diff --git a/server.js b/server.js
index 3599053..5d8f940 100644
--- a/server.js
+++ b/server.js
@@ -7,50 +7,6 @@ const crypto = require("crypto");
const localDb = require('./local-db-client');
console.log('[PeerCortex] Local DB client initialized');
-// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
-let _puppeteerBrowser = null;
-let _puppeteerLoading = false;
-async function getPuppeteerBrowser() {
- if (_puppeteerBrowser) return _puppeteerBrowser;
- if (_puppeteerLoading) {
- // Wait for in-flight launch
- await new Promise(r => setTimeout(r, 2000));
- return _puppeteerBrowser;
- }
- _puppeteerLoading = true;
- try {
- const puppeteer = require("puppeteer");
- _puppeteerBrowser = await puppeteer.launch({
- headless: true,
- args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
- });
- console.log("[PDF] Puppeteer browser launched");
- _puppeteerBrowser.on("disconnected", () => {
- console.log("[PDF] Browser disconnected — will relaunch on next request");
- _puppeteerBrowser = null;
- _puppeteerLoading = false;
- });
- } catch (e) {
- console.error("[PDF] Puppeteer unavailable:", e.message);
- _puppeteerBrowser = null;
- }
- _puppeteerLoading = false;
- return _puppeteerBrowser;
-}
-
-// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
-const pdfCache = new Map();
-const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
-
-function pdfCacheGet(key) {
- const e = pdfCache.get(key);
- if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
- return null;
-}
-function pdfCacheSet(key, buf) {
- if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
- pdfCache.set(key, { buf, ts: Date.now() });
-}
// Load .env file
require('./src/backend/config');
@@ -70,196 +26,21 @@ try {
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 {
+ PEERINGDB_API_KEY,
+ PEERINGDB_API_URL,
+ getPdbLocal,
+ queryPeeringDBLocal,
+ pdbSemaphore,
+ fetchPeeringDB,
+ fetchPeeringDBWithRetry,
+} = require("./server/services/peeringdb");
// ── SMTP / Email ──────────────────────────────────────────────
const { sendFeedbackMail } = require('./src/backend/services/smtp');
// ── 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;
-}
+const { loadVisitors, trackVisitor } = require("./server/visitors");
// ═══════════════════════════════════════════════════════════════
// PEERCORTEX v0.6.1 — New Features
@@ -269,2940 +50,164 @@ function trackVisitor(req) {
// Migrated to src/features/bgp-communities/bgp-community-db.ts
// ── Hijack Monitoring ──────────────────────────────────────────
-// ============================================================
-// FEATURE 1: BGP Hijack Alerting + Webhooks
-// ============================================================
-const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
-const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
-const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
-const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
-const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
+const {
+ DATA_DIR,
+ loadHijackSubs,
+ loadHijackAlerts,
+ loadWebhookSubs,
+ saveWebhookSubs,
+ saveHijackAlerts,
+ saveHijackSubs,
+} = require("./server/hijack-monitoring/store");
+const { webhookSignature, deliverWebhook, notifyWebhooks } = require("./server/hijack-monitoring/webhooks");
+const { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck } = require("./server/hijack-monitoring/monitor");
-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 []; } }
-function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
-function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
-function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} }
-function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
+const {
+ aspaAdoptionDailyHistory,
+ recordAspaAdoptionSnapshot,
+ getAdoptionTrend,
+ buildAspaAdoptionDashboard,
+} = require("./server/aspa-adoption/tracker");
-// Generate HMAC-SHA256 signature for webhook payloads
-function webhookSignature(secret, payload) {
- return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
-}
+const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption");
-// Classify hijack severity
-function classifyHijackSeverity(unexpected, missing, rpkiInvalid) {
- if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL';
- if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH';
- if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM';
- return 'LOW';
-}
-
-// Deliver webhook with exponential backoff (max 5 retries)
-async function deliverWebhook(sub, payload, attempt) {
- attempt = attempt || 1;
- const payloadStr = JSON.stringify(payload);
- const sig = webhookSignature(sub.secret, payloadStr);
- return new Promise((resolve) => {
- try {
- const parsed = new URL(sub.endpoint_url);
- const lib = parsed.protocol === 'https:' ? https : http;
- const options = {
- hostname: parsed.hostname,
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
- path: parsed.pathname + parsed.search,
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': Buffer.byteLength(payloadStr),
- 'X-PeerCortex-Signature': sig,
- 'X-PeerCortex-Event': 'hijack_detected',
- 'User-Agent': 'PeerCortex-Webhook/1.0'
- },
- timeout: 10000
- };
- const req = lib.request(options, (res) => {
- let body = '';
- res.on('data', c => body += c);
- res.on('end', () => {
- if (res.statusCode >= 200 && res.statusCode < 300) {
- resolve({ ok: true, status: res.statusCode });
- } else if (attempt < 5) {
- const delay = Math.pow(2, attempt) * 1000;
- setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
- } else {
- resolve({ ok: false, status: res.statusCode, error: 'max_retries' });
- }
- });
- });
- req.on('error', () => {
- if (attempt < 5) {
- const delay = Math.pow(2, attempt) * 1000;
- setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
- } else {
- resolve({ ok: false, error: 'connection_failed' });
- }
- });
- req.on('timeout', () => { req.destroy(); });
- req.write(payloadStr);
- req.end();
- } catch(e) {
- resolve({ ok: false, error: e.message });
- }
- });
-}
-
-// Fire webhooks for all subscribers of an ASN when hijack detected
-async function notifyWebhooks(asn, alert) {
- const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false);
- for (const sub of subs) {
- const payload = {
- event: 'hijack_detected',
- asn: asn,
- alert: alert,
- timestamp: new Date().toISOString(),
- source: 'PeerCortex BGP Monitor'
- };
- deliverWebhook(sub, payload, 1).then(result => {
- if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`);
- });
- }
-}
-
-// Returns null (not []) when the prefix lookup itself failed -- distinct from a
-// genuine zero-prefix result. runHijackCheck must not treat these the same way:
-// setting an empty baseline from a transient failure would permanently disable
-// hijack detection for that subscription (baseline.size stays 0 forever after,
-// so "unexpected" never fires again -- see the baseline.size > 0 guard below).
-async function checkHijacksForAsn(asn) {
- try {
- const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
- if (data && data.status === 'error') return null;
- const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
- return prefixes;
- } catch (_) { return null; }
-}
-
-async function runHijackCheck() {
- const subs = loadHijackSubs();
- if (!subs.length) return;
- const alerts = loadHijackAlerts();
- let changed = false;
- for (const sub of subs) {
- const current = await checkHijacksForAsn(sub.asn);
- if (current === null) {
- // Lookup failed this cycle -- skip both anomaly detection and baseline
- // initialization; retry on the next 30-minute run rather than locking in
- // an empty baseline that would silently disable detection forever.
- continue;
- }
- 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) {
- // Dedup: only alert if no alert in last 6 hours for this ASN
- const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
- const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo);
- if (!recentAlert) {
- const severity = classifyHijackSeverity(unexpected, missing, 0);
- const alert = {
- id: crypto.randomBytes(8).toString('hex'),
- asn: sub.asn, ts: new Date().toISOString(),
- severity, unexpected, missing, resolved: false,
- msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing`
- };
- alerts.push(alert);
- changed = true;
- notifyWebhooks(sub.asn, alert);
- }
- }
- // Set baseline on first monitoring
- if (!sub.prefixes || !sub.prefixes.length) {
- sub.prefixes = current;
- changed = true;
- }
- }
- if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); }
-}
-// Run hijack check every 30 minutes
-setInterval(runHijackCheck, 30 * 60 * 1000);
+const { UA } = require("./server/data/constants");
+const { CITY_COORDS } = require("./server/data/city-coords");
// ============================================================
-// FEATURE 3: ASPA Adoption Tracker
-// ============================================================
+const {
+ scoreRingSvg,
+ pdfBaseCSS,
+ buildPdfHtml,
+ buildComparisonPdfHtml,
+ formatLabel,
+ escHtml,
+} = require("./server/pdf-export/templates");
+const {
+ getPuppeteerBrowser,
+ pdfCacheGet,
+ pdfCacheSet,
+ renderHtmlToPdf,
+} = require("./server/pdf-export/renderer");
-/**
- * Load persisted ASPA adoption history from disk.
- * Returns array of snapshot objects (up to 365 entries).
- */
-function loadAspaAdoptionHistory() {
- try {
- const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
- return Array.isArray(raw) ? raw : [];
- } catch(_) { return []; }
-}
+const {
+ responseCache,
+ cacheGet,
+ cacheSet,
+ RESULT_CACHE_TTL,
+ CACHE_TTL_LOOKUP,
+ CACHE_TTL_LOOKUP_DEGRADED,
+ CACHE_TTL_DEFAULT,
+ rdapCacheGet,
+ rdapCacheSet,
+ whoisCacheGet,
+ whoisCacheSet,
+ WHOIS_NOT_FOUND_CACHE_TTL,
+ quickIxCacheGet,
+ quickIxCacheSet,
+ bgproutesResultCache,
+ aspaResultCache,
+ validateResultCache,
+ resultCacheGet,
+ resultCacheSet,
+} = require("./server/caches/response-caches");
+// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
+// confirmed dead code, never read anywhere in the file.
-/**
- * Save ASPA adoption history to disk (keep last 365 entries).
- */
-function saveAspaAdoptionHistory(history) {
- try {
- const trimmed = history.slice(-365);
- fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
- } catch(e) {
- console.error('[ASPA-ADOPT] Save failed:', e.message);
- }
-}
-
-/** In-memory adoption history (persisted on each new snapshot) */
-let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
-
-/**
- * Take a new adoption snapshot and persist it.
- * Called whenever the RPKI feed is refreshed.
- * @param {number} aspaCount — total ASPA objects in rpkiAspaMap
- * @param {number} roaCount — total ROAs in roaStore
- */
-function recordAspaAdoptionSnapshot(aspaCount, roaCount) {
- const now = new Date();
- const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
-
- // Compute how many Atlas-visible ASNs have ASPA
- let atlasTotal = 0;
- let atlasWithAspa = 0;
- if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) {
- atlasTotal = atlasProbeCache.asns_with_probes.length;
- // rpkiAspaMap is keyed by integer ASN
- atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
- }
-
- const coveragePct = atlasTotal > 0
- ? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
- : 0;
-
- // Compute delta from previous snapshot
- const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
- const delta = prev ? aspaCount - prev.aspa_objects : 0;
-
- const snapshot = {
- date,
- ts: now.getTime(),
- aspa_objects: aspaCount,
- roa_count: roaCount,
- atlas_asns_total: atlasTotal,
- atlas_asns_with_aspa: atlasWithAspa,
- coverage_pct_atlas: coveragePct,
- aspa_delta: delta,
- method: 'rpki_cloudflare_feed',
- };
-
- // Only store one snapshot per date (overwrite if same day)
- const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
- if (existingIdx >= 0) {
- aspaAdoptionDailyHistory[existingIdx] = snapshot;
- } else {
- aspaAdoptionDailyHistory.push(snapshot);
- }
- saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
- console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
- return snapshot;
-}
-
-/**
- * Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
- */
-function getAdoptionTrend(period) {
- const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
- const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
- return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
-}
-
-/**
- * Build the ASPA adoption dashboard HTML.
- */
-function buildAspaAdoptionDashboard() {
- const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
- const trend30 = getAdoptionTrend('30d');
- const trend7 = getAdoptionTrend('7d');
-
- // Forecast: simple linear regression over last 30 days
- function linearForecast(data, daysAhead) {
- if (data.length < 2) return null;
- const n = data.length;
- const xs = data.map((_, i) => i);
- const ys = data.map(d => d.coverage_pct_atlas);
- const xMean = xs.reduce((a, b) => a + b, 0) / n;
- const yMean = ys.reduce((a, b) => a + b, 0) / n;
- const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
- const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
- if (den === 0) return yMean;
- const slope = num / den;
- return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
- }
-
- const forecast90d = linearForecast(trend30, 90);
- const change7d = trend7.length >= 2
- ? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
- : 0;
- const trendLabels = trend30.map(d => d.date);
- const trendValues = trend30.map(d => d.coverage_pct_atlas);
- const asnValues = trend30.map(d => d.aspa_objects);
-
- const coverageNow = latest?.coverage_pct_atlas ?? 0;
- const aspaNow = latest?.aspa_objects ?? 0;
- const atlasTotal = latest?.atlas_asns_total ?? 0;
-
- return `
-
-
- ✓ No critical issues found. Network health is excellent.
'
- : failedItems.map((c, i) => `
- No common IXPs found — these networks do not share any Internet Exchange Points.
';
-
- const commonFacHtml = commonFacs.length > 0
- ? `No shared colocation facilities found.
';
-
- const oppsHtml = (asns, opps) => opps.length === 0
- ? `Already maximally peered on these exchanges — or no data available.
`
- : opps.map(x => ` 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)
-let globalFacCache = null; // PeeringDB global facilities (24h)
-
-// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed
-// ============================================================
-const rpkiAspaMap = new Map(); // customer_asid -> Set
-let rpkiAspaLastFetch = 0;
-let rpkiAspaFetching = false;
-
-// ============================================================
-// Local ROA Store — validates prefixes without RIPE Stat API calls
-// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA
-// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
-// ============================================================
-const roaStore = {
- v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start
- v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
- ready: false,
- count: 0,
- lastBuild: 0,
-
- // Parse IPv4 prefix string to 32-bit unsigned integer
- _ipv4ToUint32(ip) {
- const parts = ip.split(".");
- return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
- },
-
- // Build ROA store from Cloudflare feed roas array
- build(roas) {
- const v4 = [];
- const v6 = [];
- for (let i = 0; i < roas.length; i++) {
- const r = roas[i];
- const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
- const prefix = r.prefix;
- const maxLen = r.maxLength || r.maxPrefixLength || 0;
- if (!prefix || !asn) continue;
-
- const slashIdx = prefix.indexOf("/");
- if (slashIdx < 0) continue;
- const prefixLen = parseInt(prefix.substring(slashIdx + 1));
- const addr = prefix.substring(0, slashIdx);
-
- if (prefix.indexOf(":") >= 0) {
- // IPv6 — store as zero-padded hex string for sorting
- const expanded = this._expandIPv6(addr);
- if (expanded) {
- v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
- }
- } else {
- // IPv4 — store as numeric range
- const start = this._ipv4ToUint32(addr);
- const hostBits = 32 - prefixLen;
- const end = (start | ((1 << hostBits) - 1)) >>> 0;
- v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
- }
- }
-
- // Sort for binary search
- v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
- v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);
-
- this.v4Entries = v4;
- this.v6Entries = v6;
- this.count = v4.length + v6.length;
- this.ready = true;
- this.lastBuild = Date.now();
- },
-
- // Expand IPv6 address to 32-char hex for reliable sorting
- _expandIPv6(addr) {
- try {
- let groups = addr.split("::");
- let left = groups[0] ? groups[0].split(":") : [];
- let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
- const missing = 8 - left.length - right.length;
- const mid = [];
- for (let i = 0; i < missing; i++) mid.push("0000");
- const all = [...left, ...mid, ...right];
- return all.map(g => g.padStart(4, "0")).join("");
- } catch (_e) {
- return null;
- }
- },
-
- // Validate a prefix against the local ROA store
- // Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
- validate(asn, prefix) {
- if (!this.ready) return null; // Signal caller to use fallback
-
- const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
- const slashIdx = prefix.indexOf("/");
- if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
- const prefixLen = parseInt(prefix.substring(slashIdx + 1));
- const addr = prefix.substring(0, slashIdx);
-
- if (prefix.indexOf(":") >= 0) {
- return this._validateV6(asnNum, addr, prefixLen, prefix);
- }
- return this._validateV4(asnNum, addr, prefixLen, prefix);
- },
-
- _validateV4(asn, addr, prefixLen, prefix) {
- const queryStart = this._ipv4ToUint32(addr);
- const entries = this.v4Entries;
-
- // Binary search: find rightmost entry where start <= queryStart
- let lo = 0, hi = entries.length - 1;
- let insertionPoint = -1;
- while (lo <= hi) {
- const mid = (lo + hi) >>> 1;
- if (entries[mid].start <= queryStart) {
- insertionPoint = mid;
- lo = mid + 1;
- } else {
- hi = mid - 1;
- }
- }
-
- // Scan backwards from insertion point to find covering ROAs
- // ROAs are sorted by start, so we scan back while start could still cover our prefix
- const matched = [];
- const unmatchedAs = [];
- for (let i = insertionPoint; i >= 0; i--) {
- const e = entries[i];
- // If this ROA's network start is too far left, no more matches possible
- if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
- // Check if query prefix is contained within this ROA
- if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
- if (prefixLen <= e.maxLen) {
- if (e.asn === asn) {
- matched.push(e);
- } else {
- unmatchedAs.push(e);
- }
- }
- // prefixLen > maxLen → too specific, invalid if ASN matches
- else if (e.asn === asn) {
- unmatchedAs.push(e); // ASN matches but length exceeds maxLen
- }
- }
- }
-
- if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
- if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
- return { prefix, status: "not_found", validating_roas: 0 };
- },
-
- _validateV6(asn, addr, prefixLen, prefix) {
- const queryHex = this._expandIPv6(addr);
- if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
- const entries = this.v6Entries;
-
- // Binary search for approximate position
- let lo = 0, hi = entries.length - 1;
- let insertionPoint = -1;
- while (lo <= hi) {
- const mid = (lo + hi) >>> 1;
- if (entries[mid].prefixHex <= queryHex) {
- insertionPoint = mid;
- lo = mid + 1;
- } else {
- hi = mid - 1;
- }
- }
-
- const matched = [];
- const unmatchedAs = [];
- // Scan backwards from insertion point
- for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
- const e = entries[i];
- // Check if query prefix is covered by this ROA entry
- // A covering ROA has a shorter or equal prefix length and its network prefix matches
- if (e.prefixLen <= prefixLen) {
- // Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
- const hexChars = Math.ceil(e.prefixLen / 4);
- if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
- if (prefixLen <= e.maxLen) {
- if (e.asn === asn) matched.push(e);
- else unmatchedAs.push(e);
- } else if (e.asn === asn) {
- unmatchedAs.push(e);
- }
- }
- }
- // Stop if we're too far away
- if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
- }
-
- if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
- if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
- return { prefix, status: "not_found", validating_roas: 0 };
- },
-
- // Persist to disk for fast restart
- saveToDisk(filePath) {
- try {
- const data = JSON.stringify({
- ts: this.lastBuild,
- v4Count: this.v4Entries.length,
- v6Count: this.v6Entries.length,
- v4: this.v4Entries,
- v6: this.v6Entries,
- });
- fs.writeFileSync(filePath, data);
- console.log("[ROA] Saved " + this.count + " ROAs to disk");
- } catch (e) {
- console.warn("[ROA] Disk save failed:", e.message);
- }
- },
-
- // Load from disk cache (returns true if loaded)
- loadFromDisk(filePath) {
- try {
- if (!fs.existsSync(filePath)) return false;
- const raw = fs.readFileSync(filePath, "utf8");
- const data = JSON.parse(raw);
- // Only use if less than 6 hours old
- if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
- this.v4Entries = data.v4;
- this.v6Entries = data.v6;
- this.count = data.v4Count + data.v6Count;
- this.lastBuild = data.ts;
- this.ready = true;
- console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
- return true;
- } catch (e) {
- console.warn("[ROA] Disk load failed:", e.message);
- return false;
- }
- },
-};
-
-// ============================================================
-// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
-// Eliminates redundant PDB API calls under load
-// ============================================================
-const pdbSourceCache = {
- net: new Map(), // key: asn string → {data, ts}
- netixlan: new Map(), // key: net_id string → {data, ts}
- netfac: new Map(), // key: net_id string → {data, ts}
- facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
- TTL_NET: 6 * 60 * 60 * 1000, // 6 hours
- TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours
- TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
- MAX_NET: 5000,
- MAX_IXFAC: 5000,
- MAX_COORDS: 10000,
- hits: 0,
- misses: 0,
-
- get(type, key) {
- const map = this[type];
- const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
- const entry = map.get(String(key));
- if (!entry) { this.misses++; return null; }
- if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
- this.hits++;
- return entry.data;
- },
-
- set(type, key, data) {
- const map = this[type];
- const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
- if (map.size >= max) {
- // Evict oldest entry (Map preserves insertion order)
- map.delete(map.keys().next().value);
- }
- map.set(String(key), { data, ts: Date.now() });
- },
-
- // Disk persistence
- saveToDisk(filePath) {
- try {
- const serialize = (map) => {
- const obj = {};
- for (const [k, v] of map) obj[k] = v;
- return obj;
- };
- const data = JSON.stringify({
- ts: Date.now(),
- net: serialize(this.net),
- netixlan: serialize(this.netixlan),
- netfac: serialize(this.netfac),
- facCoords: serialize(this.facCoords),
- });
- fs.writeFileSync(filePath, data);
- console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
- } catch (e) {
- console.warn("[PDB-CACHE] Disk save failed:", e.message);
- }
- },
-
- loadFromDisk(filePath) {
- try {
- if (!fs.existsSync(filePath)) return false;
- const raw = fs.readFileSync(filePath, "utf8");
- const data = JSON.parse(raw);
- const now = Date.now();
- const load = (map, obj, ttl) => {
- for (const [k, v] of Object.entries(obj || {})) {
- if (now - v.ts < ttl) map.set(k, v);
- }
- };
- load(this.net, data.net, this.TTL_NET);
- load(this.netixlan, data.netixlan, this.TTL_IXFAC);
- load(this.netfac, data.netfac, this.TTL_IXFAC);
- load(this.facCoords, data.facCoords, this.TTL_COORDS);
- console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
- return true;
- } catch (e) {
- console.warn("[PDB-CACHE] Disk load failed:", e.message);
- return false;
- }
- },
-};
-
-// ============================================================
-// RIPE Stat Source Cache + Semaphore (L2)
-// Prevents 429 rate-limiting by throttling + caching responses
-// ============================================================
-const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
-const RIPE_STAT_CACHE_MAX = 2000;
-const RIPE_STAT_TTL = {
- "announced-prefixes": 15 * 60 * 1000,
- "asn-neighbours": 15 * 60 * 1000,
- "as-overview": 60 * 60 * 1000,
- "rir-stats-country": 24 * 60 * 60 * 1000,
- "visibility": 15 * 60 * 1000,
- "prefix-size-distribution": 60 * 60 * 1000,
- "abuse-contact-finder": 24 * 60 * 60 * 1000,
- "blocklist": 60 * 60 * 1000,
- "reverse-dns-consistency": 60 * 60 * 1000,
- "routing-status": 15 * 60 * 1000,
- "bgp-updates": 15 * 60 * 1000,
- "maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
- "looking-glass": 15 * 60 * 1000,
- "whois": 24 * 60 * 60 * 1000,
- "rpki-validation": 6 * 60 * 60 * 1000,
-};
-
-// Counting semaphore — limits concurrent RIPE Stat requests
-class Semaphore {
- constructor(max) { this.max = max; this.current = 0; this.queue = []; }
- acquire() {
- if (this.current < this.max) { this.current++; return Promise.resolve(); }
- return new Promise((resolve) => this.queue.push(resolve));
- }
- release() {
- this.current--;
- if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
- }
-}
-const ripeStatSemaphore = new Semaphore(15);
-
-// Cached + throttled RIPE Stat fetch
-// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact
-// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres
-// refactor. In JS, two `function` declarations sharing a name silently collapse to
-// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle
-// implementation below was completely shadowed, and every call to
-// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours,
-// as-overview, visibility, prefix-size-distribution} was unconditionally returning
-// null instead of ever reaching this code. That silently broke blocklist,
-// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois,
-// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's
-// fallback call to this function for the fix.
-async function fetchRipeStatCachedFromApi(url, options) {
- // Extract endpoint name from URL for TTL lookup
- const match = url.match(/\/data\/([^/]+)\//);
- const endpoint = match ? match[1] : "default";
- const resourceMatch = url.match(/resource=([^&]+)/);
- const resource = resourceMatch ? resourceMatch[1] : url;
- const cacheKey = endpoint + ":" + resource;
-
- // Check cache
- const cached = ripeStatCache.get(cacheKey);
- const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
- if (cached && (Date.now() - cached.ts) < ttl) {
- return cached.data;
- }
-
- // Throttle via semaphore
- await ripeStatSemaphore.acquire();
- try {
- // Double-check cache after acquiring semaphore (another request may have filled it)
- const cached2 = ripeStatCache.get(cacheKey);
- if (cached2 && (Date.now() - cached2.ts) < ttl) {
- return cached2.data;
- }
-
- const result = await fetchJSON(url, options);
-
- // Only cache successful results — never cache null (failed/rate-limited responses)
- // Caching null causes cascading failures: retry hits cache, returns null again
- if (result !== null) {
- if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
- ripeStatCache.delete(ripeStatCache.keys().next().value);
- }
- ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
- }
- return result;
- } finally {
- ripeStatSemaphore.release();
- }
-}
-
-// Cached + throttled RIPE Stat with one retry on failure
-async function fetchRipeStatCachedWithRetry(url, options) {
- const result = await fetchRipeStatCached(url, options);
- if (result !== null) return result;
- await new Promise(r => setTimeout(r, 1500));
- return fetchRipeStatCached(url, options);
-}
-
-// RIPE Stat cache disk persistence (skip null entries)
-function saveRipeStatCacheToDisk(filePath) {
- try {
- const obj = {};
- for (const [k, v] of ripeStatCache) {
- if (v.data !== null) obj[k] = v;
- }
- fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
- console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
- } catch (e) {
- console.warn("[RIPE-CACHE] Disk save failed:", e.message);
- }
-}
-
-function loadRipeStatCacheFromDisk(filePath) {
- try {
- if (!fs.existsSync(filePath)) return false;
- const raw = fs.readFileSync(filePath, "utf8");
- const data = JSON.parse(raw);
- const now = Date.now();
- for (const [k, v] of Object.entries(data.entries || {})) {
- const match = k.match(/^([^:]+):/);
- const endpoint = match ? match[1] : "default";
- const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
- if (now - v.ts < ttl) {
- ripeStatCache.set(k, v);
- }
- }
- console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
- return true;
- } catch (e) {
- console.warn("[RIPE-CACHE] Disk load failed:", e.message);
- return false;
- }
-}
-
-function fetchRpkiAspaFeed() {
- if (rpkiAspaFetching) return Promise.resolve();
- rpkiAspaFetching = true;
- console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)...");
- return new Promise((resolve) => {
- const options = {
- headers: { "User-Agent": UA },
- timeout: 120000,
- };
- https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
- let data = "";
- res.on("data", (chunk) => (data += chunk));
- res.on("end", () => {
- try {
- const parsed = JSON.parse(data);
-
- // Load ASPA objects
- const aspas = parsed.aspas || [];
- rpkiAspaMap.clear();
- aspas.forEach((a) => {
- const customerAsid = Number(a.customer_asid);
- const providers = (a.providers || []).map(Number);
- rpkiAspaMap.set(customerAsid, new Set(providers));
- });
-
- // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls)
- const roas = parsed.roas || [];
- roaStore.build(roas);
- roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
-
- // Track ASPA adoption — persist to disk + update in-memory trend
- recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
-
- rpkiAspaLastFetch = Date.now();
- console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
- } catch (e) {
- console.error("[RPKI] Failed to parse RPKI feed:", e.message);
- }
- rpkiAspaFetching = false;
- resolve();
- });
- }).on("error", (e) => {
- console.error("[RPKI] Fetch failed:", e.message);
- rpkiAspaFetching = false;
- resolve();
- });
- });
-}
-
-// Ensure ASPA + ROA cache is fresh
-async function ensureAspaCache() {
- if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) {
- await fetchRpkiAspaFeed();
- }
-}
-
-// Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded
-// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object"
-// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" --
-// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so
-// 0 means every attempt so far has failed and exists:false here is not trustworthy.
-function lookupAspaFromRpki(asn) {
- const asnNum = Number(asn);
- const feedLoaded = rpkiAspaLastFetch > 0;
- if (rpkiAspaMap.has(asnNum)) {
- const providers = rpkiAspaMap.get(asnNum);
- return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded };
- }
- return { exists: false, providers: [], feedLoaded };
-}
-
-
-
-// PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits
-const pdbSemaphore = new Semaphore(5);
-
-// 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) {
- headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY;
- }
- await pdbSemaphore.acquire();
- try {
- return await fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } });
- } finally {
- pdbSemaphore.release();
- }
-}
-
-// PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load).
-// Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted.
-async function fetchPeeringDBWithRetry(path, options) {
- const delays = [2000, 5000];
- let result = await fetchPeeringDB(path, options);
- for (let i = 0; i < delays.length && result === null; i++) {
- await new Promise(r => setTimeout(r, delays[i]));
- result = await fetchPeeringDB(path, options);
- }
- return result;
-}
-
-// Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas)
-async function fetchJSONWithRetry(url, options) {
- const result = await fetchJSON(url, options);
- if (result !== null) return result;
- await new Promise(r => setTimeout(r, 1000));
- return fetchJSON(url, options);
-}
-
-// bgproutes.io visibility fallback helper
-// Queries the RIB endpoint to estimate prefix visibility across vantage points
-function fetchBgproutesVisibility(prefix) {
- if (!BGPROUTES_API_KEY) return Promise.resolve(null);
- const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact";
- return fetchJSON(url, {
- timeout: 15000,
- headers: {
- "Authorization": "Bearer " + BGPROUTES_API_KEY,
- "User-Agent": UA,
- },
- }).then(function(data) {
- if (!data || !data.data) return null;
- // data.data should be an array of RIB entries from different vantage points
- var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []);
- var vpSet = new Set();
- entries.forEach(function(e) {
- if (e.vantage_point || e.vp || e.collector || e.peer_asn) {
- vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn);
- }
- });
- return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" };
- }).catch(function() { return null; });
-}
-
-// Rate limiting: max 60 requests per minute per IP
-const rateLimitMap = new Map();
-const RATE_LIMIT_WINDOW = 60 * 1000;
-const RATE_LIMIT_MAX = 60;
-
-function checkRateLimit(ip) {
- const now = Date.now();
- let entry = rateLimitMap.get(ip);
- if (!entry || now > entry.windowStart + RATE_LIMIT_WINDOW) {
- entry = { windowStart: now, count: 0 };
- rateLimitMap.set(ip, entry);
- }
- entry.count++;
- // Clean old entries periodically
- if (rateLimitMap.size > 1000) {
- for (const [k, v] of rateLimitMap) {
- if (now > v.windowStart + RATE_LIMIT_WINDOW) rateLimitMap.delete(k);
- }
- }
- return entry.count <= RATE_LIMIT_MAX;
-}
-
-function fetchJSON(url, options) {
- const timeoutMs = (options && options.timeout) || 8000;
- return new Promise((resolve) => {
- const reqOptions = {
- headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
- timeout: timeoutMs,
- };
- const timer = setTimeout(() => resolve(null), timeoutMs + 500);
- https
- .get(url, reqOptions, (res) => {
- let data = "";
- res.on("data", (chunk) => (data += chunk));
- res.on("end", () => {
- clearTimeout(timer);
- if (res.statusCode === 429) {
- console.warn("[PDB] Rate limited (429):", url.substring(0, 80));
- return resolve(null);
- }
- try {
- resolve(JSON.parse(data));
- } catch (_e) {
- resolve(null);
- }
- });
- })
- .on("timeout", () => { clearTimeout(timer); resolve(null); })
- .on("error", () => { clearTimeout(timer); resolve(null); });
- });
-}
-
-
-function fetchHTML(url, options) {
- return new Promise((resolve) => {
- const reqOptions = {
- headers: {
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
- ...(options && options.headers ? options.headers : {}),
- },
- };
- const lib = url.startsWith("https") ? require("https") : require("http");
- lib
- .get(url, reqOptions, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- return fetchHTML(res.headers.location, options).then(resolve);
- }
- let data = "";
- res.on("data", (chunk) => (data += chunk));
- res.on("end", () => resolve(data));
- })
- .on("error", () => resolve(null));
- });
-}
-
-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,
- path: parsed.pathname + parsed.search,
- method: "POST",
- headers: {
- "User-Agent": UA,
- "Content-Type": "application/json",
- "Content-Length": Buffer.byteLength(data),
- ...(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) {
- resolve(null);
- }
- });
- });
- req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
- req.write(data);
- req.end();
- });
-}
-
-async function resolveASNames(providers) {
- // Batch resolve AS names via RIPE Stat AS overview API
- const batchSize = 10;
- for (let i = 0; i < providers.length; i += batchSize) {
- const batch = providers.slice(i, i + batchSize);
- const results = await Promise.all(
- batch.map(p =>
- fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
- .then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
- .catch(() => ({ asn: p.asn, name: "" }))
- )
- );
- results.forEach(r => {
- const provider = providers.find(p => p.asn === r.asn);
- if (provider && r.name) provider.name = r.name;
- });
- }
- return providers;
-}
-
-// ── MASTER RIPE Stat API wrapper (Local-first, zero external API calls) ──
-// Analyzes RIPE Stat URL and dispatches to appropriate localDb function
-async function fetchRipeStatCached(url, options = {}) {
- try {
- // Detect which RIPE Stat endpoint this is and call local DB
- if (url.includes('/announced-prefixes/')) {
- const asnMatch = url.match(/resource=AS(\d+)/);
- if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1]));
- }
- if (url.includes('/asn-neighbours/')) {
- const asnMatch = url.match(/resource=AS(\d+)/);
- if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1]));
- }
- if (url.includes('/as-overview/')) {
- const asnMatch = url.match(/resource=AS(\d+)/);
- if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1]));
- }
- if (url.includes('/visibility/')) {
- const asnMatch = url.match(/resource=AS(\d+)/);
- if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1]));
- }
- if (url.includes('/prefix-size-distribution/')) {
- const asnMatch = url.match(/resource=AS(\d+)/);
- if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
- }
-
- // For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
- // e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
- // whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the
- // real RIPE Stat API with its own cache/throttle/never-cache-null protections,
- // instead of returning null unconditionally (see fetchRipeStatCachedFromApi's
- // header comment for how this silently broke ~9 checks until 2026-07-16).
- return fetchRipeStatCachedFromApi(url, options);
- } catch (e) {
- console.error("[fetchRipeStatCached] Error:", e.message);
- return Promise.resolve(null);
- }
-}
-
-// RPKI per-prefix validation — uses local ROA store (instant, no API calls)
-// Falls back to RIPE Stat only if ROA store is not yet loaded (cold start)
-// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
-// Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N }
-// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result.
-// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know.
-// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse-
-// sounding finding). Callers computing coverage percentages should exclude "unavailable".
-async function validateRPKIWithCache(asn, prefix) {
- try {
- const result = await localDb.validateRpki(prefix, asn);
- if (result.status === 'valid') {
- return { prefix, status: "valid", validating_roas: 1 };
- } else if (result.status === 'invalid') {
- return { prefix, status: "invalid", validating_roas: 1 };
- } else if (result.status === 'unknown') {
- return { prefix, status: "unavailable", validating_roas: 0 };
- } else {
- // 'not-found': genuinely no covering ROA
- return { prefix, status: "not_found", validating_roas: 0 };
- }
- } catch (_e) {
- console.error("[RPKI] Error validating " + prefix + ":", _e.message);
- return { prefix, status: "unavailable", validating_roas: 0 };
- }
-}
-
-
-// ============================================================
-// RFC-Compliant ASPA Verification Engine
-// ============================================================
-
-// Check if AS path contains AS_SET segments (curly braces indicate sets)
-function hasAsSet(asPath) {
- if (typeof asPath === "string") {
- return asPath.includes("{") || asPath.includes("}");
- }
- return false;
-}
-
-// Hop Check function (core of ASPA verification)
-// aspaStore = Map> (CAS -> provider set)
-// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
-// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
-// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
-function hopCheck(asI, asJ, aspaStore) {
- const providers = aspaStore.get(asI);
- if (!providers) return "NoAttestation";
- return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
-}
-
-// Collapse AS path prepends (remove consecutive duplicates)
-function collapsePrepends(path) {
- return path.filter((as, i) => i === 0 || as !== path[i - 1]);
-}
-
-// Upstream Verification (RFC Section 6.1)
-function verifyUpstream(asPath, aspaStore, rawPathStr) {
- if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
- const collapsed = collapsePrepends(asPath);
- if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
-
- const hops = [];
- let hasNoAttestation = false;
-
- for (let i = 1; i < collapsed.length; i++) {
- // collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
- // the validator) is the provider it's declaring -- customer goes first in hopCheck.
- const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
- hops.push({
- from: collapsed[i - 1],
- to: collapsed[i],
- result: check,
- });
- if (check === "NotProviderPlus") {
- return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
- }
- if (check === "NoAttestation") hasNoAttestation = true;
- }
-
- return {
- result: hasNoAttestation ? "Unknown" : "Valid",
- reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
- hops,
- };
-}
-
-// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
-// Unlike upstream verification, a downstream path may legitimately contain both
-// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
-// (provider->customer hops, near the validator), transitioning at most once --
-// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
-// validator. Section 5.3 defines four ramp lengths computed from two independent
-// scans, verified 2026-07-16 against a worked example fetched from the draft
-// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
-// to Valid, and a path with definite failures on both ends correctly resolves
-// to Invalid, matching this implementation).
-function verifyDownstream(asPath, aspaStore, rawPathStr) {
- if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
- const collapsed = collapsePrepends(asPath);
- const N = collapsed.length;
- if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
-
- const hops = [];
- for (let i = 1; i < N; i++) {
- hops.push({
- from: collapsed[i - 1],
- to: collapsed[i],
- result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
- });
- }
-
- // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
- // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
- // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
- // minUpRamp stops at the first hop that isn't confirmed ProviderPlus
- // (including unattested "NoAttestation" hops, which don't disprove a leak
- // but also don't prove the path is clean).
- let maxUpRamp = N;
- let minUpRamp = N;
- for (let k = N - 1; k >= 1; k--) {
- const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
- const I = N - k;
- if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
- if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
- if (maxUpRamp !== N && minUpRamp !== N) break;
- }
-
- // Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
- // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
- let maxDownRamp = N;
- let minDownRamp = N;
- for (let k = 0; k <= N - 2; k++) {
- const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
- const rampLen = k + 1; // = N - J + 1 for the corresponding J
- if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
- if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
- if (maxDownRamp !== N && minDownRamp !== N) break;
- }
-
- if (maxUpRamp + maxDownRamp < N) {
- return {
- result: "Invalid",
- reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
- hops,
- };
- }
- if (minUpRamp + minDownRamp < N) {
- return {
- result: "Unknown",
- reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
- hops,
- };
- }
- return {
- result: "Valid",
- reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
- hops,
- };
-}
-
-// Valley Detection: scan path for up-down-up pattern (route leak indicator)
-function detectValleys(asPath, aspaStore) {
- const collapsed = collapsePrepends(asPath);
- if (collapsed.length < 4) return [];
-
- const valleys = [];
- // Walk the path and look at relationship transitions
- const relationships = [];
- for (let i = 1; i < collapsed.length; i++) {
- const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
- const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
- let rel = "unknown";
- if (fwd === "ProviderPlus") rel = "customer-to-provider";
- else if (rev === "ProviderPlus") rel = "provider-to-customer";
- else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
- relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
- }
-
- // Detect c2p -> p2c -> c2p pattern
- for (let i = 0; i < relationships.length - 2; i++) {
- if (
- relationships[i].rel === "customer-to-provider" &&
- relationships[i + 1].rel === "provider-to-customer" &&
- relationships[i + 2].rel === "customer-to-provider"
- ) {
- valleys.push({
- position: i,
- path_segment: [
- relationships[i].from,
- relationships[i].to,
- relationships[i + 1].to,
- relationships[i + 2].to,
- ].map((a) => "AS" + a),
- description:
- "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
- " (c2p) -> AS" + relationships[i + 1].to +
- " (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
- });
- }
- }
-
- return valleys;
-}
-
-// Build ASPA store from detected provider relationships
-function buildAspaStore(detectedProviders, targetAsn) {
- const store = new Map();
- // Add the target ASN's providers
- if (detectedProviders.length > 0) {
- const providerSet = new Set(detectedProviders.map((p) => p.asn));
- store.set(targetAsn, providerSet);
- }
- return store;
-}
-
-// Calculate ASPA Readiness Score (0-100)
-function calculateAspaReadinessScore(params) {
- const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
-
- // ROA coverage (0-25 points)
- const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
-
- // ASPA object exists (0-25 points)
- const aspaScore = aspaObjectExists ? 25 : 0;
-
- // Provider completeness (0-25 points)
- const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
-
- // Path validation results (0-25 points)
- const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
-
- return {
- total: roaScore + aspaScore + provScore + pathScore,
- breakdown: {
- roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
- aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
- provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
- path_validation: { score: pathScore, max: 25, value: pathValidationPct },
- },
- };
-}
-
-
-// ============================================================
-// Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes)
-// ============================================================
-async function fetchRipeRpkiValidator(asn, prefix) {
- try {
- const encoded = encodeURIComponent(prefix);
- const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded;
- const result = await fetchJSON(url, { timeout: 5000 });
- if (result && result.validated_route) {
- return {
- prefix: prefix,
- validity: result.validated_route.validity || {},
- state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown",
- };
- }
- return { prefix: prefix, state: "unknown", error: "no_data" };
- } catch (_e) {
- return { prefix: prefix, state: "error", error: "timeout_or_unavailable" };
- }
-}
-
-// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel).
-// agreement_pct only counts pairs where BOTH sources actually returned a real
-// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct:
-// null (not a fabricated 100), since nothing was actually cross-checked.
-async function crossCheckRpki(asn, prefixes, localResults) {
- const sample = prefixes.slice(0, 5);
- if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
-
- const ripeResults = await Promise.all(
- sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
- );
-
- const localMap = new Map();
- for (const lr of localResults) {
- localMap.set(lr.prefix, lr.status);
- }
-
- let cloudflareValid = 0;
- let ripeValid = 0;
- let agreements = 0;
- let comparable = 0;
- const disagreements = [];
-
- for (let i = 0; i < sample.length; i++) {
- const pfx = sample[i];
- const cfStatus = localMap.get(pfx) || "not_found";
- const ripeState = ripeResults[i].state;
-
- const cfIsValid = cfStatus === "valid";
- const ripeIsValid = ripeState === "valid" || ripeState === "VALID";
-
- if (cfIsValid) cloudflareValid++;
- if (ripeIsValid) ripeValid++;
-
- // A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from
- // the agreement percentage instead of counting it as a silent "agreement".
- if (ripeState === "error" || ripeState === "unknown") {
- continue;
- }
-
- comparable++;
- if (cfIsValid === ripeIsValid) {
- agreements++;
- } else {
- disagreements.push({
- prefix: pfx,
- cloudflare: cfStatus,
- ripe: ripeState,
- });
- }
- }
-
- const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null;
- return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable };
-}
-
-// ============================================================
-// Feature 24: bgp.he.net Integration
-// ============================================================
-async function fetchBgpHeNet(asn) {
- try {
- const html = await fetchHTML("https://bgp.he.net/AS" + asn);
- if (!html) return null;
- const result = {};
- const titleMatch = html.match(/([^<]+)<\/title>/i);
- if (titleMatch) result.title = titleMatch[1].trim();
- const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
- 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);
- if (descMatch) result.description = descMatch[1].trim();
- const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
- if (irrMatch) result.irr_record = irrMatch[1].trim();
- // bgp.he.net format: "Prefixes Originated (v4): 147 " or "Prefixes v4 ... 147"
- const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
- if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
- const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
- if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
- const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
- if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
- result.source_url = "https://bgp.he.net/AS" + asn;
- return result;
- } catch (_e) {
- return null;
- }
-}
-
-// ============================================================
-// Feature 25: Topology / AS-Relationships
-// ============================================================
-async function fetchTopology(targetAsn, depth) {
- const maxDepth = Math.min(depth || 2, 3);
- const nodes = new Map();
- const edges = [];
- async function fetchNeighboursForAsn(asn, currentDepth) {
- if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
- const [data, overview] = await Promise.all([
- fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
- fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
- ]);
- const name = overview?.data?.holder || "";
- const neighbours = data?.data?.neighbours || [];
- const upstreams = neighbours.filter((n) => n.type === "left");
- const downstreams = neighbours.filter((n) => n.type === "right");
- const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
- const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
- nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
- upstreams.forEach((n) => {
- if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
- edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
- });
- downstreams.forEach((n) => {
- if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
- edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
- });
- peers.slice(0, 10).forEach((n) => {
- if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
- edges.push({ from: asn, to: n.asn, relationship: "peer" });
- });
- if (currentDepth < maxDepth && upstreams.length > 0) {
- const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
- await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
- }
- }
- await fetchNeighboursForAsn(targetAsn, 0);
- const edgeSet = new Set();
- const uniqueEdges = edges.filter((e) => {
- const key = e.from + "-" + e.to + "-" + e.relationship;
- if (edgeSet.has(key)) return false;
- edgeSet.add(key);
- return true;
- });
- return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
-}
-
-// ============================================================
-// 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 {
- const trimmed = resource.trim();
- if (/^(AS)?\d+$/i.test(trimmed)) {
- 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) {
- const obj = ripeData.objects.object[0];
- const attrs = obj.attributes?.attribute || [];
- const parsed = {};
- attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
- result.data = {
- aut_num: (parsed["aut-num"] || [])[0] || "",
- as_name: (parsed["as-name"] || [])[0] || "",
- descr: parsed["descr"] || [],
- org: (parsed["org"] || [])[0] || "",
- admin_c: parsed["admin-c"] || [],
- tech_c: parsed["tech-c"] || [],
- mnt_by: parsed["mnt-by"] || [],
- status: (parsed["status"] || [])[0] || "",
- created: (parsed["created"] || [])[0] || "",
- last_modified: (parsed["last-modified"] || [])[0] || "",
- source: (parsed["source"] || [])[0] || "",
- import: parsed["import"] || [],
- export: parsed["export"] || [],
- remarks: parsed["remarks"] || [],
- };
- whoisCacheSet(asn, result.data);
- }
-
- // 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 },
- { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
- { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
- { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
- ];
- const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
- fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
- if (!d || d.errorCode || !d.handle) return null;
- return { source: ep.name, data: d };
- }).catch(() => null)
- ));
- const found = rdapResults.find((r) => r !== null);
- if (found) {
- const d = found.data;
- const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
- const entities = d.entities || [];
- const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
- const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
- const events = d.events || [];
- const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
- const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
- result.data = {
- aut_num: "AS" + asn,
- as_name: d.name || "",
- descr: remarks,
- org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
- admin_c: adminContacts,
- tech_c: techContacts,
- mnt_by: [],
- status: (d.status || []).join(", "),
- created: created,
- last_modified: lastMod,
- source: found.source + " (RDAP)",
- import: [],
- export: [],
- remarks: remarks,
- };
- whoisCacheSet(asn, result.data);
- } else {
- result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
- // Short TTL: this "not found" likely reflects a transient failure across
- // all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
- whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
- }
- }
- } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
- result.type = "inetnum";
- const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
- if (ripeData && ripeData.objects && ripeData.objects.object) {
- const results = ripeData.objects.object.map((obj) => {
- const attrs = obj.attributes?.attribute || [];
- const parsed = {};
- attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
- return {
- inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
- netname: (parsed["netname"] || [])[0] || "",
- descr: parsed["descr"] || [],
- country: (parsed["country"] || [])[0] || "",
- org: (parsed["org"] || [])[0] || "",
- admin_c: parsed["admin-c"] || [],
- tech_c: parsed["tech-c"] || [],
- mnt_by: parsed["mnt-by"] || [],
- status: (parsed["status"] || [])[0] || "",
- created: (parsed["created"] || [])[0] || "",
- last_modified: (parsed["last-modified"] || [])[0] || "",
- source: (parsed["source"] || [])[0] || "",
- };
- });
- result.data = results.length === 1 ? results[0] : results;
- } else { result.error = "Not found in RIPE DB"; }
- } else {
- result.type = "domain";
- const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
- if (ripeData && ripeData.objects && ripeData.objects.object) {
- const obj = ripeData.objects.object[0];
- const attrs = obj.attributes?.attribute || [];
- const parsed = {};
- attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
- result.data = {
- domain: (parsed["domain"] || [])[0] || "",
- descr: parsed["descr"] || [],
- admin_c: parsed["admin-c"] || [],
- tech_c: parsed["tech-c"] || [],
- zone_c: parsed["zone-c"] || [],
- nserver: parsed["nserver"] || [],
- mnt_by: parsed["mnt-by"] || [],
- created: (parsed["created"] || [])[0] || "",
- last_modified: (parsed["last-modified"] || [])[0] || "",
- source: (parsed["source"] || [])[0] || "",
- };
- } else { result.error = "Not found in RIPE DB"; }
- }
- } catch (err) { result.error = err.message; }
- return result;
-}
-
-// ============================================================
-// Internal API Server Proxy
-// ============================================================
-// Several routes were extracted into src/api/ + src/features/*/routes.ts
-// (Fastify, dist/api/index.js, run as a separate PM2 process on
-// API_SERVER_PORT) but were never re-wired here after that migration —
-// they just silently 404'd. This forwards those specific paths through
-// rather than reimplementing them inline a second time.
-const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10);
-
-function proxyToApiServer(req, res, targetUrl) {
- const proxyReq = http.request(
- {
- hostname: "127.0.0.1",
- port: API_SERVER_PORT,
- path: targetUrl,
- method: req.method,
- headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }),
- },
- (proxyRes) => {
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
- proxyRes.pipe(res);
- }
- );
- proxyReq.on("error", (err) => {
- console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message);
- if (!res.headersSent) {
- res.writeHead(502, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "API server unavailable", detail: err.message }));
- }
- });
- req.pipe(proxyReq);
-}
+// subCableCache/globalFacCache removed 2026-07-16: confirmed dead code,
+// never read anywhere in the file.
+
+const { roaStore } = require("./server/caches/roa-store");
+
+const {
+ rpkiAspaMap,
+ getRpkiAspaLastFetch,
+ fetchRpkiAspaFeed,
+ ensureAspaCache,
+ lookupAspaFromRpki,
+ validateRPKIWithCache,
+ fetchRipeRpkiValidator,
+ crossCheckRpki,
+} = require("./server/services/rpki");
+
+const { pdbSourceCache } = require("./server/caches/pdb-source-cache");
+const {
+ ripeStatCache,
+ fetchRipeStatCached,
+ fetchRipeStatCachedFromApi,
+ fetchRipeStatCachedWithRetry,
+ saveRipeStatCacheToDisk,
+ loadRipeStatCacheFromDisk,
+ Semaphore,
+} = require("./server/services/ripe-stat");
+
+
+const {
+ fetchJSON,
+ fetchJSONWithRetry,
+ fetchHTML,
+ postJSON,
+ fetchBgproutesVisibility,
+} = require("./server/services/http-helpers");
+// checkRateLimit/rateLimitMap removed 2026-07-16: dead code, never called by
+// the request handler (confirmed via the server.js structural exploration).
+
+const { resolveASNames } = require("./server/resolve-as-names");
+
+
+
+const {
+ hasAsSet,
+ hopCheck,
+ collapsePrepends,
+ verifyUpstream,
+ verifyDownstream,
+ detectValleys,
+ buildAspaStore,
+ calculateAspaReadinessScore,
+} = require("./server/aspa-verification/engine");
+
+
+const { fetchBgpHeNet } = require("./server/bgp-he-net");
+
+const { fetchTopology } = require("./server/topology");
+
+const { computeResilienceScore } = require("./server/resilience-score");
+const { computeRouteLeakDetection } = require("./server/route-leak");
+const { fetchWhois } = require("./server/whois");
// ============================================================
// HTTP Server
// ============================================================
+const staticRoutes = require("./server/routes/static");
+const searchRoutes = require("./server/routes/search");
+const feedbackRoutes = require("./server/routes/feedback");
+const liaRoutes = require("./server/routes/lia");
+const healthRoute = require("./server/routes/health");
+const aspaVerifyRoute = require("./server/routes/aspa-verify");
+const aspaLegacyRoute = require("./server/routes/aspa-legacy");
+const bgpRoute = require("./server/routes/bgp");
+const validateRoute = require("./server/routes/validate");
+const lookupRoute = require("./server/routes/lookup");
+const relationshipsRoute = require("./server/routes/relationships");
+const compareRoute = require("./server/routes/compare");
+const quickIxRoute = require("./server/routes/quick-ix");
+const peersFindRoute = require("./server/routes/peers-find");
+const prefixDetailRoute = require("./server/routes/prefix-detail");
+const ixDetailRoute = require("./server/routes/ix-detail");
+const topologyRoute = require("./server/routes/topology");
+const whoisRoute = require("./server/routes/whois");
+const enrichRoute = require("./server/routes/enrich");
+const proxyStubsRoute = require("./server/routes/proxy-stubs");
+const changelogRoute = require("./server/routes/changelog");
+const hijackAlertsRoute = require("./server/routes/hijack-alerts");
+const pdfExportRoute = require("./server/routes/pdf-export");
+const aspaAdoptionRoute = require("./server/routes/aspa-adoption");
+
const server = http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
@@ -3226,3358 +231,231 @@ const server = http.createServer(async (req, res) => {
const host = (req.headers.host || '').split(':')[0];
if (reqPath === "/" || reqPath === "/index.html") {
- // shell.peercortex.org → admin feedback terminal (check first)
- if (host === 'shell.peercortex.org') {
- try {
- const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8');
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
- return res.end(html);
- } catch (_e) {
- res.writeHead(500);
- return res.end('shell.html not found');
- }
- }
- // 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");
- return res.end(html);
- } catch (_e) {
- res.writeHead(500);
- return res.end(htmlFile + " not found");
- }
+ return staticRoutes.handleRoot(req, res, host, reqPath);
}
// Direct access to editorial version
if (reqPath === "/index-editorial.html") {
- try {
- const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8");
- res.setHeader("Content-Type", "text/html; charset=utf-8");
- return res.end(html);
- } catch (_e) {
- res.writeHead(500);
- return res.end("index-editorial.html not found");
- }
+ return staticRoutes.handleIndexEditorial(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}));
- }
+ // Name Search (RIPE Stat + PeeringDB combined)
+ if (reqPath === "/api/search") {
+ return searchRoutes.handleSearch(req, res);
}
// 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 }));
+ return searchRoutes.handleVisitors(req, res);
}
// OPTIONS preflight (CORS)
- if (reqPath === '/api/feedback' && req.method === 'OPTIONS') {
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
- res.writeHead(204);
- return res.end();
+ if (reqPath === "/api/feedback" && req.method === "OPTIONS") {
+ return feedbackRoutes.handleOptions(req, res);
}
// POST /api/feedback — submit feedback entry
- if (reqPath === '/api/feedback' && req.method === 'POST') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- let body = '';
- req.on('data', function(chunk) { body += chunk; });
- req.on('end', function() {
- try {
- const data = JSON.parse(body);
- if (!data.message || String(data.message).trim().length < 3) {
- res.writeHead(400);
- return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
- }
- const entry = {
- id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
- timestamp: new Date().toISOString(),
- category: String(data.category || 'General').slice(0, 50),
- message: String(data.message || '').slice(0, 2000),
- name: String(data.name || 'Anonymous').slice(0, 100),
- asn: data.asn ? String(data.asn).slice(0, 20) : null,
- ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
- ua: String(req.headers['user-agent'] || '').slice(0, 200)
- };
- let entries = [];
- 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);
- return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
- }
- });
- return;
+ if (reqPath === "/api/feedback" && req.method === "POST") {
+ return feedbackRoutes.handlePost(req, res);
}
// GET /api/feedback?token=... — admin: fetch all entries as JSON
- if (reqPath === '/api/feedback' && req.method === 'GET') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const token = url.searchParams.get('token');
- if (!token || token !== FEEDBACK_TOKEN) {
- res.writeHead(401);
- return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
- }
- try {
- const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
- return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
- } catch (_e) {
- return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
- }
+ if (reqPath === "/api/feedback" && req.method === "GET") {
+ return feedbackRoutes.handleGet(req, res, url);
}
// Serve favicon
if (reqPath === "/favicon.ico") {
- res.writeHead(204);
- return res.end();
+ return staticRoutes.handleFavicon(req, res);
}
// Lia's Atlas Paradise - Easter egg page
if (reqPath === "/lia" || reqPath === "/lia/") {
- try {
- const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8");
- res.setHeader("Content-Type", "text/html; charset=utf-8");
- return res.end(liaHtml);
- } catch (_e) {
- res.writeHead(500);
- return res.end("lia.html not found");
- }
+ return staticRoutes.handleLia(req, res);
}
-
- // ============================================================
// Lia's Atlas Paradise: Atlas probe coverage endpoint
- // ============================================================
if (reqPath === "/api/atlas/coverage") {
- res.setHeader("Content-Type", "application/json");
- if (!atlasProbeCache) {
- res.writeHead(503);
- return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
- }
- return res.end(JSON.stringify(atlasProbeCache, null, 2));
+ return liaRoutes.handleAtlasCoverage(req, res);
}
- // ============================================================
// Lia's Paradise: File parsing endpoint (for binary uploads)
- // ============================================================
if (reqPath === "/api/lia/parse-file" && req.method === "POST") {
- res.setHeader("Content-Type", "application/json");
- let body = "";
- req.on("data", function(chunk) { body += chunk; });
- req.on("end", function() {
- try {
- var parsed = JSON.parse(body);
- var filename = parsed.filename || "";
- var ext = filename.split(".").pop().toLowerCase();
- // For text-based formats, decode base64 and extract text
- if (ext === "csv" || ext === "txt") {
- var text = Buffer.from(parsed.data, "base64").toString("utf8");
- return res.end(JSON.stringify({ text: text }));
- }
- // For binary formats (PDF, XLS, DOC), we can't parse server-side without
- // heavy dependencies. Return helpful error.
- return res.end(JSON.stringify({
- error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
- suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
- }));
- } catch(e) {
- return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
- }
- });
- return;
+ return liaRoutes.handleParseFile(req, res);
}
- // ============================================================
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
- // ============================================================
if (reqPath === "/api/lia/coverage") {
- res.setHeader("Content-Type", "application/json");
- if (!atlasProbeCache) {
- res.writeHead(503);
- return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
- }
-
- // Cache this expensive response for 30 min
- var liaCacheKey = "lia_coverage";
- var liaCached = cacheGet(liaCacheKey);
- if (liaCached) return res.end(liaCached);
-
- // Fetch PeeringDB network list (all networks with status "ok")
- // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
- fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
- if (!pdbData || !pdbData.data) {
- return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
- }
-
- var probeAsns = new Set(atlasProbeCache.asns_with_probes || []);
-
- var enriched = pdbData.data.map(function(n) {
- var org = pdbOrgCountryMap.get(n.org_id) || {};
- var cc = org.country || "";
- return {
- asn: n.asn,
- name: n.name || "",
- org_name: org.name || "",
- country: cc,
- country_name: cc,
- info_type: n.info_type || "",
- has_probe: probeAsns.has(n.asn),
- };
- }).filter(function(n) { return n.asn > 0 && n.country; });
-
- var result = JSON.stringify({
- networks: enriched,
- total: enriched.length,
- with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
- without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
- atlas_unique_asns: probeAsns.size,
- org_countries_loaded: pdbOrgCountryMap.size,
- fetched_at: new Date().toISOString(),
- });
-
- cacheSet(liaCacheKey, result, 30 * 60 * 1000);
- res.end(result);
- }).catch(function(e) {
- res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
- });
- return;
+ return liaRoutes.handleLiaCoverage(req, res);
}
res.setHeader("Content-Type", "application/json");
// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
if (reqPath === "/api/health") {
- const mem = process.memoryUsage();
- const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
- const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
-
- // Query local DB stats (async, but return partial if needed)
- localDb.getLocalDbStats().then(function(dbStats) {
- // Determine health status based on local DB data availability
- const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
- const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
- const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
-
- const healthResponse = {
- status,
- service: "PeerCortex",
- version: "0.6.9",
- timestamp: new Date().toISOString(),
- uptime_seconds: Math.floor(process.uptime()),
- memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
- bgproutes_configured: !!BGPROUTES_API_KEY,
- caches: {
- aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
- pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
- pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
- pdb_netfac: { entries: pdbSourceCache.netfac.size },
- ripe_stat: { entries: ripeStatCache.size },
- response_cache: { entries: responseCache.size },
- },
- local_db: dbStats ? {
- bgp_routes: dbStats.bgp_routes,
- rpki_roas: dbStats.rpki_roas,
- threat_intel: dbStats.threat_intel,
- rdap_cache_entries: dbStats.rdap_cache_entries,
- source: "PostgreSQL (local)",
- healthy: hasLocalBgp && hasLocalRpki,
- } : null,
- aspa_adoption: {
- total_objects: rpkiAspaMap.size,
- roa_count: roaStore.count,
- history_samples: aspaAdoptionDailyHistory.length,
- delta_last: aspaAdoptionDailyHistory.length >= 2
- ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
- : 0,
- },
- };
- return res.end(JSON.stringify(healthResponse, null, 2));
- }).catch(function(e) {
- console.error('[/api/health] Local DB stats error:', e.message);
- // Return health without local DB stats on error
- return res.end(
- JSON.stringify({
- status,
- service: "PeerCortex",
- version: "0.6.9",
- timestamp: new Date().toISOString(),
- uptime_seconds: Math.floor(process.uptime()),
- memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
- bgproutes_configured: !!BGPROUTES_API_KEY,
- caches: {
- roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
- aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
- pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
- pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
- pdb_netfac: { entries: pdbSourceCache.netfac.size },
- ripe_stat: { entries: ripeStatCache.size },
- response_cache: { entries: responseCache.size },
- },
- local_db: { error: "Could not fetch local DB stats", message: e.message },
- aspa_adoption: {
- total_objects: rpkiAspaMap.size,
- roa_count: roaStore.count,
- history_samples: aspaAdoptionDailyHistory.length,
- delta_last: aspaAdoptionDailyHistory.length >= 2
- ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
- : 0,
- },
- }, null, 2)
- );
- });
- return;
+ return healthRoute.handleHealth(req, res);
}
+
// ============================================================
// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
- // ============================================================
if (reqPath === "/api/aspa/verify") {
- 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 cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
- if (cachedVerify !== undefined) {
- res.writeHead(200, { "Content-Type": "application/json" });
- return res.end(JSON.stringify(cachedVerify));
- }
- const targetAsn = parseInt(rawAsn);
- const start = Date.now();
-
- try {
- // Fetch neighbour and prefix data first
- const [neighbourData, prefixData] = await Promise.all([
- fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
- fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
- ]);
-
- // Use looking-glass with actual prefixes to get BGP paths
- const announcedPrefixes = prefixData?.data?.prefixes || [];
- const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3
-
- // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
- const lgResults = await Promise.all(
- samplePrefixes.map((pfx) =>
- fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
- )
- );
-
- // Extract AS paths from looking glass results
- const allPaths = [];
- const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths
-
- lgResults.forEach((lgData) => {
- const rrcs = lgData?.data?.rrcs || [];
- rrcs.forEach((rrc) => {
- const peers = rrc.peers || [];
- peers.forEach((peer) => {
- const rawPath = peer.as_path || "";
- const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
- if (pathArr.length > 1) {
- allPaths.push({
- rrc: rrc.rrc,
- path: pathArr,
- rawPath: rawPath,
- prefix: peer.prefix || "",
- hasAsSet: hasAsSet(rawPath),
- });
- const idx = pathArr.indexOf(targetAsn);
- if (idx > 0) {
- const neighbour = pathArr[idx - 1];
- pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
- }
- }
- });
- });
- });
-
- // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
- // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
- const neighbours = neighbourData?.data?.neighbours || [];
- const leftNeighbours = neighbours.filter((n) => n.type === "left");
- const upstreamSet = new Set();
- leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
-
- // Classify left neighbours: high-power = likely upstream, low-power = likely peer
- const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
- const detectedProviders = [...upstreamSet].map((asn) => {
- const nb = leftNeighbours.find((n) => n.asn === asn);
- const power = nb ? (nb.power || 0) : 0;
- const powerPct = Math.round((power / maxPower) * 100);
- const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
- return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
- });
-
- await resolveASNames(detectedProviders);
-
- // Count how often each provider appears in paths
- const providerFrequency = new Map();
- allPaths.forEach((p) => {
- const idx = p.path.indexOf(targetAsn);
- if (idx > 0) {
- const prov = p.path[idx - 1];
- providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
- }
- });
-
- // Check Cloudflare RPKI feed for ASPA object
- await ensureAspaCache();
- const aspaLookup = lookupAspaFromRpki(targetAsn);
- const aspaObjectExists = aspaLookup.exists;
- const aspaDeclaredProviders = aspaLookup.providers;
-
- // Build ASPA store from RPKI feed data (real ASPA objects only). If the
- // target has no real ASPA object, its hops must show "NoAttestation" --
- // NOT a fabricated declaration built from heuristically-detected BGP
- // neighbours, which would contradict the aspa_object_exists:false this
- // same response reports and falsely present unattested hops as
- // "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
- // any ASN with no entry in aspaStore, so simply not setting one here is
- // correct and sufficient -- no fallback needed.
- const aspaStore = new Map();
- if (aspaObjectExists) {
- aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
- }
- // Also populate store with all known ASPA objects from the RPKI feed
- // for providers that have their own ASPA objects (enables full path verification)
- for (const [cas, provSet] of rpkiAspaMap) {
- if (!aspaStore.has(cas)) {
- aspaStore.set(cas, provSet);
- }
- }
- // Deliberately NOT adding empty Set() entries for detected-but-unattested
- // providers here: aspaStore.get() returning undefined for them makes
- // hopCheck() correctly report "NoAttestation". An empty (but present) Set
- // would make providers.has(x) return false, misreporting "NotProviderPlus"
- // (implying a real ASPA object that denies this hop) instead of "we simply
- // don't know" -- see the 2026-07-16 audit for how this previously caused
- // clean, unattested paths to be misclassified as route leaks.
-
- // Sample paths for verification (up to 50)
- const samplePaths = allPaths.slice(0, 50);
- const pathResults = samplePaths.map((p) => {
- const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
- const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
- const valleys = detectValleys(p.path, aspaStore);
-
- return {
- rrc: p.rrc,
- prefix: p.prefix,
- path: p.path.map((a) => "AS" + a).join(" "),
- collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
- has_as_set: p.hasAsSet,
- upstream_verification: upstream,
- downstream_verification: downstream,
- valleys: valleys,
- overall: p.hasAsSet
- ? "Invalid"
- : upstream.result === "Valid" && downstream.result === "Valid"
- ? "Valid"
- : upstream.result === "Invalid" || downstream.result === "Invalid"
- ? "Invalid"
- : "Unknown",
- };
- });
-
- // Calculate statistics
- const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
- const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
- const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
- const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
- const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;
-
- // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
- // only Invalid actually indicates problems
- const pathNotInvalidPct = pathResults.length > 0
- ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
- : 0;
- const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;
-
- // Provider audit: compare detected vs declared
- const detectedSet = new Set(detectedProviders.map((p) => p.asn));
- const declaredSet = new Set(aspaDeclaredProviders);
-
- const missingFromAspa = detectedProviders
- .filter((p) => !declaredSet.has(p.asn))
- .map((p) => ({
- asn: p.asn,
- name: p.name,
- frequency: providerFrequency.get(p.asn) || 0,
- frequency_pct: allPaths.length > 0
- ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
- : 0,
- }))
- .sort((a, b) => b.frequency - a.frequency);
-
- const extraInAspa = aspaDeclaredProviders
- .filter((asn) => !detectedSet.has(asn))
- .map((asn) => ({
- asn,
- name: "",
- seen_in_paths: false,
- }));
-
- const providerCompleteness = detectedProviders.length > 0
- ? Math.round(
- (detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
- detectedProviders.length) *
- 100
- )
- : aspaObjectExists ? 100 : 0;
-
- // Get RPKI coverage for readiness score
- // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
- await ensureAspaCache();
- const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
- const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
- // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
- // must not lower a network's displayed ASPA readiness score.
- const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
- const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
- const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
-
- // Calculate readiness score
- const readinessScore = calculateAspaReadinessScore({
- rpkiCoverage,
- aspaObjectExists,
- providerCompleteness,
- pathValidationPct: pathNotInvalidPct,
- });
-
- const duration = Date.now() - start;
- const verifyResult = {
- meta: {
- query: "AS" + rawAsn,
- duration_ms: duration,
- timestamp: new Date().toISOString(),
- paths_analyzed: pathResults.length,
- total_paths_seen: allPaths.length,
- },
- asn: targetAsn,
- readiness_score: readinessScore,
- aspa_object_exists: aspaObjectExists,
- aspa_feed_healthy: aspaLookup.feedLoaded,
- detected_providers: detectedProviders.map((p) => ({
- ...p,
- frequency: providerFrequency.get(p.asn) || 0,
- frequency_pct: allPaths.length > 0
- ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
- : 0,
- })),
- provider_audit: {
- declared_count: aspaDeclaredProviders.length,
- detected_count: detectedProviders.length,
- completeness_pct: providerCompleteness,
- missing_from_aspa: missingFromAspa,
- extra_in_aspa: extraInAspa,
- },
- path_verification: {
- total: pathResults.length,
- valid: validPaths,
- invalid: invalidPaths,
- unknown: unknownPaths,
- as_set_flagged: asSetPaths,
- valley_detected: valleyPaths,
- valid_pct: pathValidPct,
- not_invalid_pct: pathNotInvalidPct,
- results: pathResults,
- },
- rpki_coverage: rpkiCoverage,
- };
- resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
- return res.end(JSON.stringify(verifyResult, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
- }
+ return aspaVerifyRoute.handleAspaVerify(req, res, url);
}
- // ============================================================
// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
- // ============================================================
if (reqPath === "/api/aspa") {
- 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 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(() => {
- if (!_aspaDone) {
- _aspaDone = true;
- res.writeHead(200, { "Content-Type": "application/json" });
- res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
- }
- }, 12000);
- try {
- const [lgData, neighbourData] = await Promise.all([
- 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 || [];
- const asPaths = [];
-
- rrcs.forEach((rrc) => {
- const peers = rrc.peers || [];
- peers.forEach((peer) => {
- const path = peer.as_path || "";
- const pathArr = path.split(" ").map(Number).filter(Boolean);
- if (pathArr.length > 1) {
- asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
- }
- });
- });
-
- // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
- const neighbours = neighbourData?.data?.neighbours || [];
- const leftNeighbours = neighbours.filter((n) => n.type === "left");
- const upstreamSet = new Set();
- leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
-
- // Classify left neighbours: high-power = likely upstream, low-power = likely peer
- const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
- const detectedProviders = [...upstreamSet].map((asn) => {
- const nb = leftNeighbours.find((n) => n.asn === asn);
- const power = nb ? (nb.power || 0) : 0;
- const powerPct = Math.round((power / maxPower) * 100);
- const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
- return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
- });
-
- await resolveASNames(detectedProviders);
-
- // Check Cloudflare RPKI feed for ASPA object
- await ensureAspaCache();
- const aspaLookup = lookupAspaFromRpki(rawAsn);
- const aspaObjectExists = aspaLookup.exists;
- const aspaDeclaredProviders = aspaLookup.providers;
-
- const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
- let recommendedAspa =
- "aut-num: AS" + rawAsn + "\n" +
- "# Recommended ASPA object:\n" +
- "# customer: AS" + rawAsn + "\n" +
- "# provider-set: " + providerList + "\n" +
- "# AFI: ipv4, ipv6\n" +
- "#\n" +
- "# Detected providers from BGP path analysis:\n" +
- detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
-
- // If ASPA object exists, show RPKI-declared providers
- if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
- recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
- aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
- }
-
- const samplePaths = asPaths.slice(0, 10).map((p) => {
- const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
- const idx = p.path.indexOf(parseInt(rawAsn));
- const provider = idx > 0 ? p.path[idx - 1] : null;
- return {
- rrc: p.rrc,
- prefix: p.prefix,
- path: pathStr,
- detected_provider: provider ? "AS" + provider : null,
- provider_in_set: provider ? upstreamSet.has(provider) : false,
- };
- });
-
- if (_aspaDone) return; // hard timeout already responded
- _aspaDone = true;
- clearTimeout(_aspaTimer);
- const duration = Date.now() - start;
- 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_feed_healthy: aspaLookup.feedLoaded,
- 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;
- clearTimeout(_aspaTimer);
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
- }
- }
+ return aspaLegacyRoute.handleAspaLegacy(req, res, url);
}
- // ============================================================
// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
- // Queries local PostgreSQL bgp_routes table — zero external API calls
- // ============================================================
if (reqPath === "/api/bgp") {
- const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
- const prefix = url.searchParams.get("prefix") || "";
- if (!rawAsn && !prefix) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
- }
- const cacheKey = rawAsn || prefix;
- const cached = resultCacheGet(bgproutesResultCache, cacheKey);
- if (cached !== undefined) {
- res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
- return res.end(JSON.stringify(cached));
- }
- const start = Date.now();
- try {
- const result = {
- meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
- bgp_status: null,
- threat_intel: null,
- };
-
- // ---- BGP Status (local DB lookup) ----
- if (prefix) {
- // Prefix lookup: Get BGP status for this prefix
- const bgpStatus = await localDb.getBgpStatus(prefix);
- if (bgpStatus) {
- result.bgp_status = {
- prefix,
- announced: bgpStatus.announced,
- origin_asns: bgpStatus.origin_asns,
- visibility_percent: bgpStatus.visibility_percent,
- last_seen: bgpStatus.last_seen,
- source: "local_bgp",
- };
- // Check for hijack (multiple origin ASNs). null means the check itself
- // failed (DB error) -- distinct from a genuine single/no-origin result.
- const hijackAsns = await localDb.checkBgpHijack(prefix);
- if (hijackAsns === null) {
- result.bgp_status.hijack_check_unavailable = true;
- } else if (hijackAsns.length > 1) {
- result.bgp_status.hijack_warning = {
- detected: true,
- origin_asns: hijackAsns,
- message: `Multiple origin ASNs detected for ${prefix}`,
- };
- }
- }
- } else if (rawAsn) {
- // ASN lookup: Get all announced prefixes for this ASN
- const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
- if (prefixes && prefixes.length > 0) {
- result.bgp_status = {
- asn: rawAsn,
- announced_count: prefixes.length,
- prefixes: prefixes.slice(0, 50).map((p) => ({
- prefix: p.prefix,
- origin_asn: p.origin_asn,
- visibility_percent: p.visibility_percent,
- last_seen: p.last_seen,
- })),
- source: "local_bgp",
- };
- } else {
- result.bgp_status = {
- asn: rawAsn,
- announced: false,
- announced_count: 0,
- message: "No prefixes found for this ASN in local BGP table",
- source: "local_bgp",
- };
- }
- }
-
- // ---- Threat Intelligence (local cache lookup) ----
- // If we have an IP context, look up threat intel
- if (prefix && prefix.includes(".")) {
- // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
- const ipAddr = prefix.split("/")[0];
- const threat = await localDb.getThreatIntel(ipAddr);
- if (threat) {
- result.threat_intel = {
- ip_address: threat.ip_address,
- threat_level: threat.threat_level,
- confidence_score: threat.confidence_score,
- source: threat.source,
- cached_at: threat.cached_at,
- };
- }
- }
-
- result.meta.duration_ms = Date.now() - start;
- resultCacheSet(bgproutesResultCache, cacheKey, result);
- res.writeHead(200, { "Content-Type": "application/json" });
- return res.end(JSON.stringify(result, null, 2));
- } catch (err) {
- console.error("[/api/bgp] Error:", err.message);
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
- }
+ return bgpRoute.handleBgp(req, res, url);
}
- // ============================================================
+
// Unified Validation endpoint: /api/validate?asn=X
- // Runs ALL validations in parallel, returns comprehensive report
- // ============================================================
if (reqPath === "/api/validate") {
- 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 cachedValidate = resultCacheGet(validateResultCache, rawAsn);
- if (cachedValidate !== undefined) {
- res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
- return res.end(JSON.stringify(cachedValidate));
- }
- const start = Date.now();
- const targetAsn = parseInt(rawAsn);
-
- try {
- // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2
- const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
- fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
- fetchPeeringDB("/net?asn=" + rawAsn),
- fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
- fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
- ]);
-
- const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; });
- // Use all prefixes for RPKI validation (local lookup is fast, no API calls)
- const samplePrefixes = allPrefixes;
- const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {};
- const netId = net.id;
- const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : [];
-
- // ---- 11. Bogon Detection (local check) ----
- function checkBogonPrefix(prefix) {
- var bogonV4 = [
- { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
- { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
- { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
- { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
- { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
- { net: "240.0.0.0", mask: 4 },
- ];
- if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
- var split = prefix.split("/");
- var addr = split[0];
- var mask = parseInt(split[1] || "0");
- var parts = addr.split(".").map(Number);
- var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
- for (var bi = 0; bi < bogonV4.length; bi++) {
- var b = bogonV4[bi];
- var bParts = b.net.split(".").map(Number);
- var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
- var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
- if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
- return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
- }
- }
- return { prefix: prefix, is_bogon: false };
- }
-
- function checkBogonAsn(asnNum) {
- if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
- if (asnNum >= 64496 && asnNum <= 64511) return true;
- if (asnNum >= 64512 && asnNum <= 65534) return true;
- return false;
- }
-
- var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
- var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
- var asnInPaths = neighbours.map(function(n) { return n.asn; });
- var bogonAsns = asnInPaths.filter(checkBogonAsn);
- // "pass" must mean prefixes/neighbours were actually fetched and checked clean --
- // not that the upstream source failed and left nothing to check. A source is
- // unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a
- // truthy object with status:"error" (local-db-client.js's DB-error signal --
- // see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check
- // alone misses this case and would show "pass" on 0 checked prefixes).
- function isSourceUnavailable(x) { return !x || x.status === "error"; }
- var prefixesUnavailable = isSourceUnavailable(prefixData);
- var neighboursUnavailable = isSourceUnavailable(neighbourData);
- var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable;
- var bogonResult = {
- status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
- bogon_prefixes: bogonPrefixes,
- bogon_asns_in_paths: bogonAsns,
- total_prefixes_checked: allPrefixes.length,
- prefixes_source_unavailable: prefixesUnavailable,
- neighbours_source_unavailable: neighboursUnavailable,
- };
-
- // Phase 2: All API-dependent validations in parallel
- var validationPromises = {};
-
- // 12. IRR Validation
- // fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
- // rejecting, so the .catch() below never sees the common failure mode -- a
- // dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
- validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
- if (irrData === null) {
- return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
- }
- var entries = Array.isArray(irrData) ? irrData : [];
- var mismatches = entries.filter(function(e) {
- if (!e.bgpOrigins && !e.bgp_origins) return false;
- if (!e.irrRoutes && !e.irr_origins) return false;
- var bgpArr = e.bgpOrigins || e.bgp_origins || [];
- var irrArr = e.irrRoutes || e.irr_origins || [];
- var bgpSet = {};
- bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
- var match = false;
- irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
- return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
- });
- return {
- status: mismatches.length === 0 ? "pass" : "warning",
- total_entries: entries.length,
- mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
- mismatch_count: mismatches.length,
- };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
- await ensureAspaCache(); // Ensure ROA data is loaded
- validationPromises.rpki_completeness = Promise.all(
- allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
- ).then(function(rpkiResults) {
- var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
- var unavailableCount = rpkiResults.length - checkedResults.length;
- var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
- var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
- var overSpecific = checkedResults.filter(function(r) {
- var mask = parseInt((r.prefix || "").split("/")[1] || "0");
- return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
- });
- // If the local RPKI DB was unreachable for every prefix, we haven't actually
- // checked anything -- say so instead of reporting a misleading pass/fail.
- var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
- return {
- status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
- coverage_pct: coverage,
- total_checked: checkedResults.length,
- db_unavailable_count: unavailableCount,
- with_roa: withRoa.length,
- over_specific: overSpecific.map(function(r) { return r.prefix; }),
- details: rpkiResults,
- };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 14. Abuse Contact Validation
- validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
- if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
- var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
- var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
- return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 15. Spamhaus DROP/Blocklist
- validationPromises.blocklist = Promise.all(
- samplePrefixes.slice(0, 5).map(function(pfx) {
- return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
- // fetchRipeStatCached resolves(null) on timeout/network-error rather than
- // throwing, so the .catch() below never sees that case -- distinguish it
- // explicitly instead of letting a failed lookup read as "not listed".
- if (data === null) return { prefix: pfx, listed: false, checked: false };
- var sources = data && data.data && data.data.sources ? data.data.sources : [];
- var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
- return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
- }).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
- })
- ).then(function(results) {
- var checkedResults = results.filter(function(r) { return r.checked; });
- var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
- var allUnchecked = results.length > 0 && checkedResults.length === 0;
- return {
- status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
- checked: checkedResults.length,
- unavailable: results.length - checkedResults.length,
- listed_prefixes: listedPrefixes,
- };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 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 (3 prefix sample — more causes semaphore starvation on large ASNs)
- var rdnsSampleSize = Math.min(3, samplePrefixes.length);
- validationPromises.rdns = Promise.all(
- samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
- return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
- if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
- var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
- var hasDelegation = false;
- var details = [];
- // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
- ["ipv4", "ipv6"].forEach(function(af) {
- var afData = pfxData[af] || {};
- Object.keys(afData).forEach(function(p) {
- var entry = afData[p];
- if (entry && entry.complete) hasDelegation = true;
- if (entry && entry.domains) {
- entry.domains.forEach(function(d) {
- if (d.found) hasDelegation = true;
- details.push({ domain: d.domain, found: !!d.found });
- });
- }
- });
- });
- // Fallback: old array format
- if (Array.isArray(pfxData)) {
- pfxData.forEach(function(p) {
- if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
- });
- }
- return { prefix: pfx, has_rdns: hasDelegation, details: details };
- }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
- })
- ).then(function(results) {
- var checkedResults = results.filter(function(r) { return !r.error; });
- var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
- var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
- var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
- var allUnavailable = results.length > 0 && checkedResults.length === 0;
- return {
- status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
- coverage_pct: coverage,
- checked: checkedResults.length,
- unavailable: results.length - checkedResults.length,
- results: results,
- failed_prefixes: failedPrefixes,
- };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
- validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
- var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
- var v4 = vis.v4 || {};
- var v6 = vis.v6 || {};
- var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
- var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
- var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
- var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
- // If routing-status returned no data, try bgproutes.io
- if (totalPeers === 0 && samplePrefixes[0]) {
- return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
- if (bgprFb && bgprFb.vps_seeing > 0) {
- seeingPeers = bgprFb.vps_seeing;
- totalPeers = Math.max(bgprFb.vps_seeing, 300);
- score = Math.round((seeingPeers / totalPeers) * 100);
- }
- return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
- }).catch(function() {
- // Neither RIPE routing-status nor the bgproutes.io fallback returned data --
- // this network hasn't actually been measured, not confirmed at 0% visibility.
- return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
- });
- }
- return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 19. BGP Communities Analysis
- validationPromises.communities = (samplePrefixes.length > 0
- ? (function() {
- var now = new Date();
- var end = now.toISOString().replace(/\.\d+Z/, "");
- var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
- return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
- })()
- : Promise.resolve(null)
- ).then(function(data) {
- var updates = data && data.data && data.data.updates ? data.data.updates : [];
- var communityMap = {};
- var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
- updates.forEach(function(u) {
- var attrs = u.attrs || {};
- var communities = attrs.community || [];
- communities.forEach(function(c) {
- var key = Array.isArray(c) ? c.join(":") : String(c);
- if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
- communityMap[key].count++;
- });
- });
- var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
- var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
- return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 20. Geolocation Verification
- validationPromises.geolocation = (samplePrefixes.length > 0
- ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
- : Promise.resolve(null)
- ).then(function(data) {
- var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
- var countries = {};
- locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
- return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
- validationPromises.rpsl = (function() {
- // Try RIPE first (has richest policy data), then RDAP for other RIRs
- var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
- var objects = data && data.objects && data.objects.object ? data.objects.object : [];
- if (objects.length === 0) return null;
- var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
- var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
- var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
- var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
- return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
- }).catch(function() { return null; });
-
- var rdapEndpoints = [
- { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
- { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
- { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
- { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
- ];
- var rdapPromises = rdapEndpoints.map(function(ep) {
- return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
- if (!data || data.errorCode || !data.handle) return null;
- var hasRemarks = !!(data.remarks && data.remarks.length > 0);
- var name = data.name || "";
- return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
- }).catch(function() { return null; });
- });
-
- return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
- // Take first successful result
- for (var ri = 0; ri < results.length; ri++) {
- if (results[ri] !== null) return results[ri];
- }
- return { status: "warning", exists: false, has_policy: false };
- });
- })();
-
- // 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
- // Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
- var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
- {
- validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
- var connections = ixData && ixData.data ? ixData.data : [];
- var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
- var totalIx = connections.length;
- var rsCount = rsParticipants.length;
- var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
- var status, note;
-
- if (totalIx > 0 && rsCount > 0) {
- // Using route servers - good
- status = "pass";
- note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
- } else if (totalIx >= 10 && rsCount === 0) {
- // Network with 10+ IX connections but no RS = deliberate bilateral peering policy
- status = "pass";
- note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
- } else if (totalIx < 3 && rsCount === 0) {
- // Very small IX presence and no RS
- status = "warning";
- note = "Only " + totalIx + " IX connection(s) and no route server usage";
- } else {
- // Small-medium network (3-9 IX) without RS - informational
- status = "info";
- note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
- }
-
- return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
- }
-
- // 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
- validationPromises.resource_cert = Promise.all(
- allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
- ).then(function(results) {
- var checked = results.filter(function(r) { return r.status !== "unavailable"; });
- var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
- var allUnavailable = results.length > 0 && checked.length === 0;
- return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
- }).catch(function(e) { return { status: "error", error: String(e) }; });
-
- // Geolocation cross-ref with PeeringDB facilities
- var facCountriesPromise = netId
- ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
- return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
- }).catch(function() { return []; })
- : Promise.resolve([]);
-
- // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s
- var keys = Object.keys(validationPromises);
- var promises = keys.map(function(k) {
- return Promise.race([
- validationPromises[k],
- new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }),
- ]);
- });
- var settled = await Promise.allSettled(promises);
- var facCountries = await facCountriesPromise;
-
- var validations = {};
- keys.forEach(function(key, i) {
- if (settled[i].status === "fulfilled") {
- validations[key] = settled[i].value;
- } else {
- validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" };
- }
- });
-
- // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
- if (validations.geolocation && validations.geolocation.status !== "error") {
- var uniqueFacCountries = {};
- facCountries.forEach(function(c) { uniqueFacCountries[c] = true; });
- var facCountryCount = Object.keys(uniqueFacCountries).length;
- validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries);
- var geoSet = {};
- (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; });
- var geoCountryCount = Object.keys(geoSet).length;
- var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; });
- validations.geolocation.country_mismatches = mismatches;
-
- // Detect global/anycast networks: 5+ facility countries OR Content/NSP type
- var netInfoType = (net.info_type || "").toLowerCase();
- var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp";
- if (isGlobalNetwork) {
- // Global/anycast/CDN network: geo mismatches are expected, not anomalies
- validations.geolocation.status = "pass";
- if (geoCountryCount === 0) {
- validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available";
- } else {
- validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")";
- }
- validations.geolocation.country_mismatches = [];
- } else if (facCountryCount <= 2 && geoCountryCount >= 10) {
- // Actual anomaly: small network appearing in many countries
- validations.geolocation.status = "warning";
- validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration";
- }
- }
-
- validations.bogon = bogonResult;
-
- // Calculate overall health score (0-100)
- var checks = [
- { key: "bogon", weight: 15 },
- { key: "irr", weight: 10 },
- { key: "rpki_completeness", weight: 15 },
- { key: "abuse_contact", weight: 5 },
- { key: "blocklist", weight: 15 },
- { key: "manrs", weight: 5 },
- { key: "rdns", weight: 5 },
- { key: "visibility", weight: 10 },
- { key: "rpsl", weight: 5 },
- { key: "ix_route_server", weight: 5 },
- { key: "resource_cert", weight: 10 },
- ];
-
- var totalWeight = 0;
- var earnedScore = 0;
- var checkResults = [];
-
- checks.forEach(function(c) {
- var v = validations[c.key];
- var points = 0;
- if (v && v.status === "info") {
- // "info" = unable to verify (e.g. API auth required) — exclude from scoring
- checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" });
- return;
- }
- if (v && v.status === "pass") points = c.weight;
- else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
- totalWeight += c.weight;
- earnedScore += points;
- checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" });
- });
-
- 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 }; });
-
- const validateResult = {
- meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length },
- asn: targetAsn,
- name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown",
- 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.",
- },
- };
- // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures
- // Full results with prefixes are cached for the standard 15 minutes
- const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL;
- resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL);
- return res.end(JSON.stringify(validateResult, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "Validation failed", message: err.message }));
- }
+ return validateRoute.handleValidate(req, res, url);
}
- // ============================================================
// Main lookup endpoint: /api/lookup?asn=X
- // ============================================================
if (reqPath === "/api/lookup") {
- 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 asn = rawAsn;
- const cacheKey = "lookup:" + asn;
- 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 {
- // Phase 0: Get PDB net first — check L2 cache, then API with retry
- let pdbNet = pdbSourceCache.get("net", asn);
- if (!pdbNet) {
- pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
- if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
- }
- const net = pdbNet?.data?.[0] || {};
- const netId = net.id;
-
- // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
- const ixQuery = netId
- ? "/netixlan?net_id=" + netId + "&limit=1000"
- : "/netixlan?asn=" + asn + "&limit=1000";
- const ixCacheKey = netId ? String(netId) : "asn:" + asn;
-
- // Check PDB source cache for IX/Fac data
- let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
- let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
-
- // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
- const sourceTiming = {};
- function timedFetch(name, promise) {
- const ts = Date.now();
- return Promise.race([
- Promise.resolve(promise),
- new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
- ])
- .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
- .catch(function() { 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 = [
- timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
- timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
- timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
- timedFetch("RIPE Stat RIR", Promise.resolve(null)),
- timedFetch("RIPE Atlas", Promise.resolve(null)),
- timedFetch("bgp.he.net", Promise.resolve(null)),
- timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
- timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
- 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, pocData, rdapData] = await Promise.all(promises);
-
- // Store PDB results in L2 source cache for future lookups
- if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
- if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
-
- // local-db-client's getRipeStat* functions return status:"error" (not "ok") when
- // the local Postgres DB query itself failed -- track which sources actually
- // failed so we don't cache a DB hiccup's fake-empty result as if it were a
- // genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
- const degradedSources = [
- ["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
- ["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
- ["RIPE Stat PrefixSize", prefixSizeData],
- ].filter(([, v]) => v && v.status === "error").map(([name]) => name);
-
- const prefixes = prefixData?.data?.prefixes || [];
- const neighbours = neighbourData?.data?.neighbours || [];
- const overview = overviewData?.data || {};
- const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
-
- // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
- const atlasProbes = atlasProbeData?.results || [];
- const atlasConnected = atlasProbes.filter(p => {
- const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
- return sName === "connected";
- });
- const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
-
- // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
- await ensureAspaCache();
- const allPrefixes = prefixes.map((p) => p.prefix);
- const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
-
- const ixConnections = (ixlanData?.data || [])
- .map((ix) => ({
- ix_name: ix.name || "",
- ix_id: ix.ix_id,
- speed_mbps: ix.speed || 0,
- 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);
-
- const facilitiesRaw = (facData?.data || []).map((f) => ({
- fac_id: f.fac_id,
- name: f.name || "",
- city: f.city || "",
- country: f.country || "",
- }));
-
- // Batch-fetch facility coordinates for map (max 50 facilities)
- const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
- let facCoordMap = {};
- if (facIds.length > 0) {
- try {
- const chunks = [];
- for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
- const coordResults = await Promise.race([
- Promise.all(chunks.map(chunk =>
- fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
- )),
- new Promise(r => setTimeout(() => r([]), 5000))
- ]);
- (coordResults || []).forEach(res => {
- (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
- });
- } catch(e) { /* graceful degradation */ }
- }
- const facilities = facilitiesRaw.map(f => ({
- ...f,
- latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
- longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
- }));
-
- // Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
- const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
- let ixLocations = [];
- if (uniqueIxIds.length > 0) {
- try {
- const ixFacData = await Promise.race([
- fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
- new Promise(r => setTimeout(() => r(null), 5000))
- ]);
- const ixFacs = ixFacData?.data || [];
- // Collect unique fac_ids we don't already have coords for
- const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
- if (extraFacIds.length > 0) {
- const extraChunks = [];
- for (let i = 0; i < extraFacIds.length; i += 25) extraChunks.push(extraFacIds.slice(i, i + 25));
- const extraRes = await Promise.race([
- Promise.all(extraChunks.map(chunk =>
- fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
- )),
- new Promise(r => setTimeout(() => r([]), 4000))
- ]);
- (extraRes || []).forEach(res => {
- (res?.data || []).forEach(f => { if (f.latitude && f.longitude) facCoordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
- });
- }
- // Build IX locations: pick first facility with coords per IX
- const ixNameMap = {};
- ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
- const seenIx = {};
- ixFacs.forEach(f => {
- if (seenIx[f.ix_id]) return;
- const coords = facCoordMap[f.fac_id];
- if (coords) {
- seenIx[f.ix_id] = true;
- ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
- }
- });
- } catch(e) { /* graceful degradation */ }
- }
-
- const rpkiStatuses = rpkiAllResults;
- const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
- const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
- const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
- const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
- // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
- // must not lower a network's displayed RPKI coverage percentage.
- const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
- const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
-
- let upstreams = neighbours
- .filter((n) => n.type === "left")
- .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
- .sort((a, b) => b.power - a.power);
- let downstreams = neighbours
- .filter((n) => n.type === "right")
- .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
- .sort((a, b) => b.power - a.power);
- let peers = neighbours
- .filter((n) => n.type === "uncertain" || n.type === "peer")
- .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
- .sort((a, b) => b.power - a.power);
-
- // Resolve empty AS names — all in parallel, with 3s timeout
- const emptyNameNeighbours = [...upstreams, ...downstreams, ...peers].filter(n => !n.name);
- if (emptyNameNeighbours.length > 0) {
- const resolvePromise = Promise.all(
- emptyNameNeighbours.map(n =>
- fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
- .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
- .catch(() => {})
- )
- );
- await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
- }
-
- // ---- Threat Intelligence Enrichment for Neighbors ----
- // Enrich neighbor data with threat status from local threat_intel table
- const threatEnrichNeighbors = async (neighbors) => {
- const allNeighbors = [...neighbors];
- const threatMap = {};
-
- // Batch threat intel lookups (cap at 50 to avoid overwhelming DB)
- const toCheck = allNeighbors.slice(0, 50);
- const threatPromises = toCheck.map(async (n) => {
- try {
- // Try to get threat intel by AS number or typical AS IP pattern
- // For now, we'll mark neighbors without direct IP threat data
- const asNum = String(n.asn);
- const threat = await localDb.getThreatIntel(asNum);
- if (threat) {
- threatMap[n.asn] = {
- threat_level: threat.threat_level,
- confidence_score: threat.confidence_score,
- source: threat.source,
- cached_at: threat.cached_at,
- };
- }
- } catch (e) {
- // Gracefully skip on error
- console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
- }
- });
-
- // Run threat lookups with 4s timeout
- await Promise.race([
- Promise.all(threatPromises),
- new Promise(r => setTimeout(r, 4000)),
- ]);
-
- return threatMap;
- };
-
- const threatMap = await threatEnrichNeighbors([...upstreams, ...downstreams, ...peers]);
-
- // Attach threat status to neighbor objects
- const addThreatToNeighbor = (n) => ({
- ...n,
- threat_level: threatMap[n.asn]?.threat_level || null,
- threat_confidence: threatMap[n.asn]?.confidence_score || null,
- threat_source: threatMap[n.asn]?.source || null,
- });
-
- upstreams = upstreams.map(addThreatToNeighbor);
- downstreams = downstreams.map(addThreatToNeighbor);
- peers = peers.map(addThreatToNeighbor);
-
- 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 || "";
- }
- 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;
-
- // Compute routing visibility and prefix size distribution
- const routingInfo = await (async function() {
- const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
- const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
- var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
-
- // Visibility API returns per-RIS-collector data
- // Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
- // Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
- var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
- var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
- var visTimedOut = !visibilityData || !visibilityData.data;
- visibilities.forEach(function(v) {
- if (!v || !v.probe) return;
- var v4PeerCount = v.ipv4_full_table_peer_count || 0;
- var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
- var v6PeerCount = v.ipv6_full_table_peer_count || 0;
- var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
- v4Total += v4PeerCount;
- v4Seeing += (v4PeerCount - v4NotSeeing);
- v6Total += v6PeerCount;
- v6Seeing += (v6PeerCount - v6NotSeeing);
- });
- if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
- if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
- // If visibility API timed out but we have prefixes, try bgproutes.io fallback
- if (visTimedOut && prefixes.length > 0) {
- var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
- if (!fallbackPrefix) fallbackPrefix = prefixes[0];
- if (fallbackPrefix) {
- var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
- if (bgprFallback && bgprFallback.vps_seeing > 0) {
- // Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
- var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
- ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
- ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
- totalRisPeersV4 = bgprFallback.vps_seeing;
- console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
- } else {
- ipv4VisAvg = -1;
- ipv6VisAvg = -1;
- console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
- }
- } else {
- ipv4VisAvg = -1;
- ipv6VisAvg = -1;
- }
- }
- totalRisPeersV4 = v4Total;
- totalRisPeersV6 = v6Total;
-
- // Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
- var psdData = (prefixSizeData && prefixSizeData.data) || {};
- var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
- var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
-
- return {
- ipv4_prefixes: ipv4Prefixes.length,
- ipv6_prefixes: ipv6Prefixes.length,
- ipv4_visibility_avg: ipv4VisAvg,
- ipv6_visibility_avg: ipv6VisAvg,
- total_ris_peers_v4: totalRisPeersV4,
- total_ris_peers_v6: totalRisPeersV6,
- prefix_sizes_v4: psV4,
- prefix_sizes_v6: psV6,
- };
- })();
-
- // ============================================================
- // Multi-source cross-checks (run in parallel, non-blocking)
- // ============================================================
- let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
- let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
- let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
-
- try {
- // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
- const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
- const rpkiCrossResult = await Promise.race([
- rpkiCrossPromise,
- new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
- ]);
- if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
- } catch (_e) { /* cross-check failed, keep defaults */ }
-
- // bgp.he.net fetching is deliberately disabled (see the hardcoded
- // Promise.resolve(null) a few hundred lines up, "bgp.he.net" timedFetch) --
- // bgpHeData is therefore always null and this whole branch, along with the
- // prefix/neighbour cross-checks below, never actually runs. Left in place
- // (not deleted) in case bgp.he.net fetching is re-enabled later, but
- // overall_confidence below must not claim a 2-source agreement that never
- // happened -- see the sources:1/2 handling further down.
- // Prefix count cross-check: compare RIPE Stat vs bgp.he.net
- if (bgpHeData) {
- const heV4 = bgpHeData.prefixes_v4 || 0;
- const heV6 = bgpHeData.prefixes_v6 || 0;
- const heTotal = heV4 + heV6;
- if (heTotal > 0) {
- prefixCrossCheck.bgp_he_net = heTotal;
- const ripeStat = prefixes.length;
- if (ripeStat > 0 && heTotal > 0) {
- const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
- prefixCrossCheck.agreement = ratio >= 0.9;
- const diff = Math.abs(ripeStat - heTotal);
- prefixCrossCheck.note = diff === 0
- ? "Exact match"
- : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
- }
- } else {
- prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
- }
-
- // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
- if (bgpHeData.peer_count != null) {
- neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
- }
- } else {
- prefixCrossCheck.note = "bgp.he.net data unavailable";
- }
-
- // Compute overall data quality. Only push a score for a cross-check that
- // actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
- // not silently count as a perfect 100 in the average, and confidence must
- // reflect "nothing was actually cross-checked" rather than defaulting high.
- const crossCheckScores = [];
- if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
- // Prefix agreement: convert to percentage
- if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
- const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
- crossCheckScores.push(Math.round(pfxRatio * 100));
- }
- // Neighbour agreement
- if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
- const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
- crossCheckScores.push(Math.round(nbrRatio * 100));
- }
- const avgAgreement = crossCheckScores.length > 0
- ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
- : null;
- const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
-
- const dataQuality = {
- sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
- cross_checks: {
- rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
- prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
- neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
- },
- overall_confidence: overallConfidence,
- overall_agreement_pct: avgAgreement,
- };
-
- // === IX Location Geocode Fallback ===
- // Some IXPs have no facility coordinates in PeeringDB.
- // Use ix_name city extraction + hard-coded IX→city map as fallback.
- var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
- ixConnections.forEach(function(conn) {
- if (ixIdsWithCoords.has(conn.ix_id)) return;
- var name = conn.ix_name || "";
- if (name) {
- var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
- for (var w = 0; w < words.length; w++) {
- if (CITY_COORDS[words[w]]) {
- ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
- ixIdsWithCoords.add(conn.ix_id);
- return;
- }
- if (w < words.length - 1) {
- var tw = words[w] + " " + words[w + 1];
- if (CITY_COORDS[tw]) {
- ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
- ixIdsWithCoords.add(conn.ix_id);
- return;
- }
- }
- }
- }
- });
- // Hard-coded IX ID → city for well-known IXPs whose names don't contain city
- var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
- ixConnections.forEach(function(conn) {
- if (ixIdsWithCoords.has(conn.ix_id)) return;
- var city = IX_CITY_MAP[conn.ix_id];
- if (city && CITY_COORDS[city]) {
- ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
- }
- });
-
- const result = {
- meta: {
- service: "PeerCortex",
- version: "0.6.9",
- query: "AS" + asn,
- duration_ms: duration,
- sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
- timestamp: new Date().toISOString(),
- rpki_prefixes_checked: rpkiTotal,
- total_prefixes: prefixes.length,
- degraded_sources: degradedSources,
- },
- network: {
- asn: parseInt(asn),
- 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 || "",
- type: net.info_type || "",
- policy: net.policy_general || "",
- traffic: net.info_traffic || "",
- ratio: net.info_ratio || "",
- scope: net.info_scope || "",
- notes: net.notes ? net.notes.substring(0, 500) : "",
- 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,
- ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
- ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
- list: prefixes.map((p) => p.prefix),
- cross_check: prefixCrossCheck,
- },
- rpki: {
- coverage_percent: rpkiCoverage,
- valid: rpkiValid,
- invalid: rpkiInvalid,
- not_found: rpkiNotFound,
- unavailable: rpkiUnavailable,
- checked: rpkiTotal,
- details: rpkiStatuses,
- cross_check: rpkiCrossCheck,
- },
- neighbours: {
- total: neighbours.length,
- upstream_count: upstreams.length,
- downstream_count: downstreams.length,
- peer_count: peers.length,
- upstreams: upstreams.slice(0, 20),
- downstreams: downstreams.slice(0, 20),
- peers: peers.slice(0, 20),
- cross_check: neighbourCrossCheck,
- },
- ix_presence: {
- total_connections: ixConnections.length,
- unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
- connections: ixConnections,
- },
- ix_locations: ixLocations,
- facilities: {
- total: facilities.length,
- 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,
- connected: atlasConnected.length,
- disconnected: atlasProbes.length - atlasConnected.length,
- anchors: atlasAnchors.length,
- probes: atlasProbes.slice(0, 100).map(p => ({
- id: p.id,
- status: p.status_name || p.status || "Unknown",
- is_anchor: p.is_anchor || false,
- country: p.country_code || "",
- prefix_v4: p.prefix_v4 || "",
- prefix_v6: p.prefix_v6 || "",
- description: p.description || "",
- })),
- },
- 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
- result.meta.duration_ms = Date.now() - start;
-
- // A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
- // retry soon instead of repeating a degraded result to every visitor.
- cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
- res.end(JSON.stringify(result, null, 2));
- } catch (err) {
- const duration = Date.now() - start;
- res.writeHead(500);
- res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
- }
- return;
+ return lookupRoute.handleLookup(req, res, url);
}
- // ============================================================
- // 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: 8000 }
- );
- 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)),
- ]);
-
- // ---- Threat Intelligence Enrichment ----
- // Enrich neighbors with threat status from local threat_intel table
- const threatMap = {};
- const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
- const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
- try {
- const asNum = String(n.asn);
- const threat = await localDb.getThreatIntel(asNum);
- if (threat) {
- threatMap[n.asn] = {
- threat_level: threat.threat_level,
- confidence_score: threat.confidence_score,
- source: threat.source,
- };
- }
- } catch (e) {
- console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
- }
- });
- await Promise.race([
- Promise.all(threatPromises),
- new Promise(r => setTimeout(r, 3000)),
- ]);
-
- 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,
- threat_level: threatMap[n.asn]?.threat_level || null,
- threat_confidence: threatMap[n.asn]?.confidence_score || null,
- threat_source: threatMap[n.asn]?.source || null,
- });
-
- 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,
- };
-
- // A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
- // otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
- // match the existing /api/validate precedent (90s TTL for suspicious zero counts).
- cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
- 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 }));
- }
+ return relationshipsRoute.handleRelationships(req, res, url);
}
- // ============================================================
// Compare endpoint: /api/compare?asn1=X&asn2=Y
- // ============================================================
if (reqPath === "/api/compare") {
- const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
- const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
- if (!asn1 || !asn2) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
- }
-
- const compareCacheKey = "compare:" + asn1 + ":" + asn2;
- const compareCached = cacheGet(compareCacheKey);
- if (compareCached) {
- res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
- return res.end(JSON.stringify(compareCached));
- }
- const start = Date.now();
- try {
- // ALL calls in parallel — single batch
- // Phase 1: Get PDB net objects + RIPE data
- const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
- fetchPeeringDB("/net?asn=" + asn1),
- fetchPeeringDB("/net?asn=" + asn2),
- fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
- fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
- fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
- fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
- ]);
-
- const net1 = pdb1?.data?.[0] || {};
- const net2 = pdb2?.data?.[0] || {};
- const netId1 = net1.id;
- const netId2 = net2.id;
-
- // Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
- const ixFacPromises = [];
- ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
- ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
- ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
- ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
- const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
-
- const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
- const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
- const ix1Names = {};
- (ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
- const ix2Names = {};
- (ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
-
- const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
- const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
- const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
-
- const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
- const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
- const fac1Names = {};
- (fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
- const fac2Names = {};
- (fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
-
- const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
-
- const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
- const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
- const up1Set = new Set(nb1.map((n) => n.asn));
- const up2Set = new Set(nb2.map((n) => n.asn));
- const nb1Map = {};
- nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
- const nb2Map = {};
- nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
-
- const commonUpstreams = [...up1Set]
- .filter((a) => up2Set.has(a))
- .map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
-
- // ---- Threat Intelligence Enrichment for Common Upstreams ----
- const threatMap = {};
- const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
- try {
- const asNum = String(n.asn);
- const threat = await localDb.getThreatIntel(asNum);
- if (threat) {
- threatMap[n.asn] = {
- threat_level: threat.threat_level,
- confidence_score: threat.confidence_score,
- source: threat.source,
- };
- }
- } catch (e) {
- console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
- }
- });
- await Promise.race([
- Promise.all(threatPromises),
- new Promise(r => setTimeout(r, 2000)),
- ]);
-
- // Attach threat status to upstream objects
- commonUpstreams.forEach((n) => {
- if (threatMap[n.asn]) {
- n.threat_level = threatMap[n.asn].threat_level;
- n.threat_confidence = threatMap[n.asn].confidence_score;
- n.threat_source = threatMap[n.asn].source;
- }
- });
-
- // Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
- const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
- const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
- const [, rpki1Results, rpki2Results] = await Promise.race([
- Promise.all([
- commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
- fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
- .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
- .catch(() => {})
- )) : Promise.resolve([]),
- Promise.all(pfx1.map((p) => fetchRPKIPerPrefix(asn1, p))),
- Promise.all(pfx2.map((p) => fetchRPKIPerPrefix(asn2, p))),
- ]),
- new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
- ]);
-
- const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
- const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
- const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
- const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
-
- const duration = Date.now() - start;
- const compareResult = {
- meta: { duration_ms: duration, timestamp: new Date().toISOString() },
- asn1: {
- asn: parseInt(asn1),
- name: net1.name || "Unknown",
- ix_count: ix1Set.size,
- fac_count: fac1Set.size,
- upstream_count: up1Set.size,
- rpki_coverage: rpki1Pct,
- },
- asn2: {
- asn: parseInt(asn2),
- name: net2.name || "Unknown",
- ix_count: ix2Set.size,
- fac_count: fac2Set.size,
- upstream_count: up2Set.size,
- rpki_coverage: rpki2Pct,
- },
- common_ixps: commonIX,
- only_asn1_ixps: only1IX,
- only_asn2_ixps: only2IX,
- common_facilities: commonFac,
- common_upstreams: commonUpstreams,
- rpki_comparison: {
- asn1_coverage: rpki1Pct,
- asn2_coverage: rpki2Pct,
- asn1_checked: rpki1Results.length,
- asn2_checked: rpki2Results.length,
- better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
- },
- };
- cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
- res.end(JSON.stringify(compareResult, null, 2));
- } catch (err) {
- res.writeHead(500);
- res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
- }
- return;
+ return compareRoute.handleCompare(req, res, url);
}
-
- // ============================================================
// 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 }));
- }
+ return quickIxRoute.handleQuickIx(req, res, url);
}
- // ============================================================
// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
- // ============================================================
if (reqPath === "/api/peers/find") {
- const ixName = url.searchParams.get("ix") || "";
- const policy = url.searchParams.get("policy") || "";
- const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
- const netType = url.searchParams.get("type") || "";
-
- if (!ixName) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
- }
-
- const start = Date.now();
- try {
- // Search for IX by name
- const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
- const ixResults = ixSearch?.data || [];
- if (ixResults.length === 0) {
- return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
- }
-
- // Use first matching IX
- const ix = ixResults[0];
- const ixId = ix.id;
-
- // Get ixlan for this IX
- const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
- const ixlans = ixlanData?.data || [];
- if (ixlans.length === 0) {
- return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
- }
-
- const ixlanId = ixlans[0].id;
-
- // Get all networks at this IX
- const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
- const netixlans = netixlanData?.data || [];
-
- // Get unique net_ids
- const netIds = [...new Set(netixlans.map(n => n.net_id))];
-
- // Fetch network details in batches
- const networks = [];
- const batchSize = 20;
- for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
- const batch = netIds.slice(i, i + batchSize);
- const batchResults = await Promise.all(
- batch.map(nid => fetchPeeringDB("/net/" + nid))
- );
- batchResults.forEach(r => {
- if (r?.data?.[0]) networks.push(r.data[0]);
- });
- }
-
- // Filter and rank
- let filtered = networks.map(net => {
- const nix = netixlans.filter(n => n.net_id === net.id);
- const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
- return {
- asn: net.asn,
- name: net.name || "",
- policy: net.policy_general || "",
- type: net.info_type || "",
- speed_mbps: maxSpeed,
- speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
- traffic: net.info_traffic || "",
- website: net.website || "",
- peeringdb_id: net.id,
- ipv4: nix[0]?.ipaddr4 || null,
- ipv6: nix[0]?.ipaddr6 || null,
- };
- });
-
- // Apply filters
- if (policy) {
- filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
- }
- if (minSpeed > 0) {
- filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
- }
- if (netType) {
- filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
- }
-
- // Sort by speed desc
- filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
-
- // Also find common IXPs for each match (check if they share other IXPs)
- const duration = Date.now() - start;
- return res.end(JSON.stringify({
- meta: { duration_ms: duration, timestamp: new Date().toISOString() },
- ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
- total_members: netixlans.length,
- filtered_count: filtered.length,
- matches: filtered.slice(0, 50),
- }, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
- }
+ return peersFindRoute.handlePeersFind(req, res, url);
}
- // ============================================================
// Prefix Detail endpoint: /api/prefix/detail?prefix=X
- // ============================================================
if (reqPath === "/api/prefix/detail") {
- const prefix = url.searchParams.get("prefix") || "";
- if (!prefix) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
- }
- const start = Date.now();
- try {
- const [routingStatus, visibility] = await Promise.all([
- fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
- fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
- ]);
-
- const origins = routingStatus?.data?.origins || [];
- const firstSeen = routingStatus?.data?.first_seen?.time || null;
-
- // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
- let rpkiStatus = "unknown";
- let rpkiRoas = [];
- const originAsn = origins.length > 0 ? origins[0].asn : null;
- if (originAsn) {
- try {
- const localRpki = await validateRPKIWithCache(originAsn, prefix);
- rpkiStatus = localRpki.status;
- rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
- } catch (e) {
- console.error("[Prefix Detail] RPKI validation error:", e.message);
- rpkiStatus = "unknown";
- rpkiRoas = [];
- }
- }
- var visData = visibility?.data?.visibilities || [];
- var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
- var visibilitySource = "ripe_stat";
- // bgproutes.io fallback if RIPE Stat visibility returned no data
- if (visData.length === 0 && BGPROUTES_API_KEY) {
- var bgprVis = await fetchBgproutesVisibility(prefix);
- if (bgprVis && bgprVis.vps_seeing > 0) {
- risPeersSeeingIt = bgprVis.vps_seeing;
- visData = []; // keep empty, use risPeersSeeingIt
- visibilitySource = "bgproutes.io";
- }
- }
-
- // Try to get IRR data
- let irrStatus = "unknown";
- try {
- const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
- const records = whoisData?.data?.records || [];
- if (records.length > 0) irrStatus = "found";
- } catch(_e) {}
-
- const duration = Date.now() - start;
- return res.end(JSON.stringify({
- meta: { duration_ms: duration, timestamp: new Date().toISOString() },
- prefix: prefix,
- origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
- rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
- irr_status: irrStatus,
- visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
- first_seen: firstSeen,
- }, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
- }
+ return prefixDetailRoute.handlePrefixDetail(req, res, url);
}
- // ============================================================
// IX Detail endpoint: /api/ix/detail?ix_id=X
- // ============================================================
if (reqPath === "/api/ix/detail") {
- const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
- if (!ixId) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
- }
- const start = Date.now();
- try {
- const [ixData, ixlanData] = await Promise.all([
- fetchPeeringDB("/ix/" + ixId),
- fetchPeeringDB("/ixlan?ix_id=" + ixId),
- ]);
-
- const ix = ixData?.data?.[0] || {};
- const ixlans = ixlanData?.data || [];
- const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
-
- let members = [];
- if (ixlanId) {
- const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
- members = (netixlanData?.data || []).map(m => ({
- asn: m.asn,
- name: m.name || "",
- speed_mbps: m.speed || 0,
- speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
- ipv4: m.ipaddr4 || null,
- ipv6: m.ipaddr6 || null,
- }));
- }
-
- // Sort by speed desc for top members
- const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
-
- const duration = Date.now() - start;
- return res.end(JSON.stringify({
- meta: { duration_ms: duration, timestamp: new Date().toISOString() },
- ix: {
- id: parseInt(ixId),
- name: ix.name || "",
- city: ix.city || "",
- country: ix.country || "",
- website: ix.website || "",
- peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
- },
- total_members: members.length,
- top_members_by_speed: sorted.slice(0, 20),
- all_members: sorted,
- }, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
- }
+ return ixDetailRoute.handleIxDetail(req, res, url);
}
-
- // ============================================================
// Feature 25: Topology endpoint
- // ============================================================
if (reqPath === "/api/topology") {
- const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
- const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
- if (!rawAsn) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
- }
- const start = Date.now();
- try {
- const topology = await fetchTopology(parseInt(rawAsn), depth);
-
- // ---- Threat Intelligence Enrichment for Topology Nodes ----
- const threatMap = {};
- const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
- try {
- const asNum = String(node.asn);
- const threat = await localDb.getThreatIntel(asNum);
- if (threat) {
- threatMap[node.asn] = {
- threat_level: threat.threat_level,
- confidence_score: threat.confidence_score,
- source: threat.source,
- };
- }
- } catch (e) {
- console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
- }
- });
- await Promise.race([
- Promise.all(threatPromises),
- new Promise(r => setTimeout(r, 3000)),
- ]);
-
- // Attach threat status to node objects
- topology.nodes.forEach((node) => {
- if (threatMap[node.asn]) {
- node.threat_level = threatMap[node.asn].threat_level;
- node.threat_confidence = threatMap[node.asn].confidence_score;
- node.threat_source = threatMap[node.asn].source;
- }
- });
-
- topology.meta = {
- query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
- timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
- };
- return res.end(JSON.stringify(topology, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
- }
+ return topologyRoute.handleTopology(req, res, url);
}
- // ============================================================
// Feature 27: WHOIS endpoint
- // ============================================================
if (reqPath === "/api/whois") {
- const resource = url.searchParams.get("resource") || "";
- if (!resource) {
- res.writeHead(400);
- return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
- }
- const start = Date.now();
- try {
- const whoisResult = await fetchWhois(resource);
- if (!whoisResult || typeof whoisResult !== "object") {
- res.writeHead(503);
- return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
- }
- whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
- return res.end(JSON.stringify(whoisResult, null, 2));
- } catch (err) {
- res.writeHead(500);
- return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
- }
+ return whoisRoute.handleWhoisRoute(req, res, url);
}
// Feature 28b: Company enrichment via Wikipedia + website meta scraping
if (reqPath === "/api/enrich") {
- 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, "");
- const companyName = (url.searchParams.get("name") || "").trim();
- const website = (url.searchParams.get("website") || "").trim();
- const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
-
- let description = null;
- let wikiUrl = null;
-
- try {
- // 1. Direct Wikipedia lookup by company name
- if (companyName) {
- const nameLower = companyName.toLowerCase();
- const isRelevant = (title, extract) => {
- const titleLower = (title || "").toLowerCase();
- const extractLower = (extract || "").toLowerCase();
- const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
- const titleMatch = nameWords.some(w => titleLower.includes(w));
- const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
- const hasNetContext = netTerms.some(t => extractLower.includes(t));
- return titleMatch && (hasNetContext || titleMatch);
- };
-
- // Direct title lookup — try full name first, then first word as fallback
- const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
- const firstName = cleanName.split(/\s+/)[0];
- const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
- for (const tryName of namesToTry) {
- const wikiDirect = await fetchJSON(
- "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
- { timeout: 5000 }
- );
- if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
- if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
- description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
- wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
- break;
- }
- }
-
- // 2. Wikipedia search if direct lookup didn't match
- if (!description) {
- const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
- const searchData = await fetchJSON(
- "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
- { timeout: 5000 }
- );
- if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
- const topTitle = searchData[1][0];
- const wikiSearch = await fetchJSON(
- "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
- { timeout: 5000 }
- );
- if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
- if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
- description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
- wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
- }
- }
- }
- }
- }
-
- // 3. Fallback: scrape website meta description (follows up to 3 redirects)
- function fetchPage(pageUrl, hops) {
- if (hops <= 0) return Promise.resolve(null);
- return new Promise((resolve) => {
- const mod = pageUrl.startsWith("https") ? https : http;
- const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
- res.resume(); // drain to free socket
- const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
- return resolve(fetchPage(next, hops - 1));
- }
- let data = "";
- res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
- res.on("end", () => resolve(data));
- });
- req.on("error", () => resolve(null));
- req.on("timeout", () => { req.destroy(); resolve(null); });
- });
- }
- if (!description && website) {
- let wsUrl = website;
- if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
- const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
- const tryUrls = [aboutUrl, wsUrl];
- for (const tryUrl of tryUrls) {
- const resp = await fetchPage(tryUrl, 3);
- if (!resp) continue;
- const metaPatterns = [
- / ]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
- / ]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
- / ]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
- / ]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
- ];
- let matched = false;
- for (const pat of metaPatterns) {
- const m = resp.match(pat);
- if (m && m[1]) {
- description = m[1].replace(/
|✓|&/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
- matched = true;
- break;
- }
- }
- if (matched) break;
- }
- }
- } catch (_e) {
- // Return whatever we have
- }
-
- return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
- }
-
- // Feature 28: Submarine Cable overlay (TeleGeography proxy)
- // ── Submarine Cables map data ─────────────────────────────────
- if (reqPath === '/api/submarine-cables') {
- return proxyToApiServer(req, res, req.url);
- }
-
- // ── Global datacenter/IXP map (PeeringDB proxy) ───────────────
- if (reqPath === '/api/global-infra') {
- return proxyToApiServer(req, res, req.url);
+ return enrichRoute.handleEnrich(req, res, url);
}
- // ── 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');
- }
+ // Feature 28: Submarine Cable overlay + other already-migrated routes,
+ // proxied through to the separate Fastify API server.
+ if (proxyStubsRoute.isProxyStubPath(reqPath)) {
+ return proxyStubsRoute.handleProxyStub(req, res);
}
- // ── BGP Community Decoder ────────────────────────────────────
- if (reqPath === '/api/communities') {
- return proxyToApiServer(req, res, req.url);
+ // Changelog page
+ if (reqPath === "/changelog") {
+ return changelogRoute.handleChangelog(req, res);
}
- // ── IRR Audit ─────────────────────────────────────────────────
- if (reqPath === '/api/irr-audit') {
- return proxyToApiServer(req, res, req.url);
+ // CORS preflight for all /api/webhooks + /api/hijacks
+ if (hijackAlertsRoute.matchesHijackGroup(reqPath) && req.method === "OPTIONS") {
+ return hijackAlertsRoute.handleOptions(req, res);
}
- // ── AS-SET Expander ───────────────────────────────────────────
- if (reqPath === '/api/asset-expand') {
- return proxyToApiServer(req, res, req.url);
+ // Webhook: Register. POST /api/webhooks?asn=X
+ if (reqPath === "/api/webhooks" && req.method === "POST") {
+ return hijackAlertsRoute.handleWebhookRegister(req, res);
}
- // ── Routing History (prefix table via RIPE Stat routing-history) ──
- if (reqPath === '/api/rpki-history') {
- return proxyToApiServer(req, res, req.url);
+ // Webhook: List. GET /api/webhooks?asn=X
+ if (reqPath === "/api/webhooks" && req.method === "GET") {
+ return hijackAlertsRoute.handleWebhookList(req, res);
}
- // ── AS-PATH Visualizer (RIPE Stat looking-glass) ────────────────
- if (reqPath === '/api/aspath') {
- return proxyToApiServer(req, res, req.url);
+ // Webhook: Delete. DELETE /api/webhooks/:id
+ if (reqPath.startsWith("/api/webhooks/") && req.method === "DELETE" && !reqPath.includes("/test")) {
+ return hijackAlertsRoute.handleWebhookDelete(req, res, reqPath);
}
- // ── Looking Glass (RIPE Stat) ─────────────────────────────────
- if (reqPath === '/api/looking-glass') {
- return proxyToApiServer(req, res, req.url);
+ // Webhook: Test. POST /api/webhooks/:id/test
+ if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === "POST") {
+ return hijackAlertsRoute.handleWebhookTest(req, res, reqPath);
}
- // ── IXP Peering Matrix ────────────────────────────────────────
- if (reqPath === '/api/ix-matrix') {
- return proxyToApiServer(req, res, req.url);
+ // Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false
+ if (reqPath === "/api/hijacks" && req.method === "GET") {
+ return hijackAlertsRoute.handleHijacksList(req, res);
}
-
- // ── CORS preflight for all /api/webhooks + /api/hijacks ──────
- if ((reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe') && req.method === 'OPTIONS') {
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
- res.writeHead(204); return res.end();
+ // Hijack Events: Resolve. POST /api/hijacks/:id/resolve
+ if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === "POST") {
+ return hijackAlertsRoute.handleHijackResolve(req, res, reqPath);
}
- // ── Webhook: Register ─────────────────────────────────────────
- // POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
- if (reqPath === '/api/webhooks' && req.method === 'POST') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const params = new URL(req.url, 'http://localhost').searchParams;
- const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
- if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
- let body = '';
- req.on('data', c => body += c);
- req.on('end', () => {
- try {
- const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
- if (!endpoint_url || !endpoint_url.startsWith('http')) {
- res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
- }
- const subs = loadWebhookSubs();
- const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
- if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
- const webhookToken = crypto.randomBytes(32).toString('hex');
- const entry = {
- id: crypto.randomBytes(8).toString('hex'),
- asn, endpoint_url,
- secret: webhookToken,
- timeout_ms: timeout_ms || 10000,
- max_retries: max_retries || 5,
- active: true,
- created_at: new Date().toISOString(),
- last_triggered_at: null,
- failure_count: 0
- };
- subs.push(entry);
- saveWebhookSubs(subs);
- // Also ensure this ASN is in hijack monitoring
- const hijackSubs = loadHijackSubs();
- if (!hijackSubs.find(s => s.asn === asn)) {
- checkHijacksForAsn(asn).then(prefixes => {
- // null (lookup failed) becomes [] here so this record's shape stays
- // consistent; runHijackCheck retries the real baseline next cycle.
- hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
- saveHijackSubs(hijackSubs);
- });
- }
- res.writeHead(201);
- res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
- } catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
- });
- return;
+ // Hijack Subscribe (legacy)
+ if (reqPath === "/api/hijack-subscribe" && req.method === "POST") {
+ return hijackAlertsRoute.handleHijackSubscribe(req, res);
}
- // ── Webhook: List ─────────────────────────────────────────────
- // GET /api/webhooks?asn=X
- if (reqPath === '/api/webhooks' && req.method === 'GET') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const params = new URL(req.url, 'http://localhost').searchParams;
- const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
- const subs = loadWebhookSubs();
- const result = (asn ? subs.filter(s => s.asn === asn) : subs)
- .map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
- res.writeHead(200);
- return res.end(JSON.stringify({ webhooks: result, total: result.length }));
+ // Hijack Alerts (legacy endpoint)
+ if (reqPath.startsWith("/api/hijack-alerts")) {
+ return hijackAlertsRoute.handleHijackAlertsLegacy(req, res);
}
- // ── Webhook: Delete ───────────────────────────────────────────
- // DELETE /api/webhooks/:id
- if (reqPath.startsWith('/api/webhooks/') && req.method === 'DELETE' && !reqPath.includes('/test')) {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const id = reqPath.split('/')[3];
- const subs = loadWebhookSubs();
- const idx = subs.findIndex(s => s.id === id);
- if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
- subs.splice(idx, 1);
- saveWebhookSubs(subs);
- res.writeHead(200);
- return res.end(JSON.stringify({ deleted: true, id }));
+ // PDF Export
+ if (reqPath === "/api/export/pdf" && req.method === "GET") {
+ return pdfExportRoute.handleExportPdf(req, res, url);
}
- // ── Webhook: Test ─────────────────────────────────────────────
- // POST /api/webhooks/:id/test
- if (reqPath.match(/^\/api\/webhooks\/[^/]+\/test$/) && req.method === 'POST') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const id = reqPath.split('/')[3];
- const subs = loadWebhookSubs();
- const sub = subs.find(s => s.id === id);
- if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
- const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
- const start = Date.now();
- deliverWebhook(sub, testPayload, 1).then(result => {
- res.writeHead(result.ok ? 200 : 502);
- res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
- });
- return;
+ if (reqPath === "/api/export/pdf/compare" && req.method === "GET") {
+ return pdfExportRoute.handleExportPdfCompare(req, res, url);
}
- // ── Hijack Events: List ───────────────────────────────────────
- // GET /api/hijacks?asn=X&limit=50&resolved=false
- if (reqPath === '/api/hijacks' && req.method === 'GET') {
- const params = new URL(req.url, 'http://localhost').searchParams;
- const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
- const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
- const resolvedFilter = params.get('resolved');
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- res.setHeader('Cache-Control', 'no-store');
- let events = loadHijackAlerts();
- if (asn) events = events.filter(a => String(a.asn) === asn);
- if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
- if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
- const total = events.length;
- events = events.slice(-limit).reverse();
- res.writeHead(200);
- return res.end(JSON.stringify({ total, events }));
+ if (reqPath.startsWith("/api/export/") && req.method === "OPTIONS") {
+ return pdfExportRoute.handleExportOptions(req, res);
}
- // ── Hijack Events: Resolve ────────────────────────────────────
- // POST /api/hijacks/:id/resolve body: { resolution_notes? }
- if (reqPath.match(/^\/api\/hijacks\/[^/]+\/resolve$/) && req.method === 'POST') {
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Access-Control-Allow-Origin', '*');
- const id = reqPath.split('/')[3];
- let body = '';
- req.on('data', c => body += c);
- req.on('end', () => {
- const alerts = loadHijackAlerts();
- const alert = alerts.find(a => a.id === id);
- if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
- alert.resolved = true;
- alert.resolved_at = new Date().toISOString();
- try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
- saveHijackAlerts(alerts);
- res.writeHead(200);
- res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
- });
- return;
+ // ASPA Adoption Tracker
+ if (reqPath === "/aspa-adoption") {
+ return aspaAdoptionRoute.handleDashboard(req, res);
}
- // ── Hijack Subscribe (legacy, kept for backwards compatibility) ─
- 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 } = 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);
- // prefixes may be null if the lookup failed just now -- store an empty
- // array so this record shape stays consistent; runHijackCheck's own
- // "!sub.prefixes.length" check will retry establishing the real
- // baseline on its next cycle rather than treating this as permanent.
- subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
- saveHijackSubs(subs);
- }
- const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
- res.writeHead(200);
- res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
- } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
- });
- return;
+ if (reqPath === "/api/aspa-adoption-stats" && req.method === "GET") {
+ return aspaAdoptionRoute.handleStats(req, res, url);
}
- // ── Hijack Alerts (legacy endpoint, kept for backwards compatibility) ─
- 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 => String(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 }));
+ if (reqPath === "/api/aspa-adoption-stats/export" && req.method === "GET") {
+ return aspaAdoptionRoute.handleStatsExport(req, res, url);
}
-
- // ── Hijack Alerts (legacy read) ───────────────────────────────
- // src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
- // the same paths this file already serves above (file-backed, live).
- // Deliberately NOT proxied here — that would shadow working data with an
- // empty Postgres-backed implementation. Not a gap, just two feature
- // branches that ended up naming their routes the same thing.
-
- // ── Changelog JSON API ────────────────────────────────────────
- if (reqPath === '/changelog-data') {
- return proxyToApiServer(req, res, req.url);
+ if (reqPath === "/api/ipv6-adoption-stats" && req.method === "GET") {
+ return aspaAdoptionRoute.handleIpv6Stats(req, res, url);
}
- // ── bio-rd RIB routes ─────────────────────────────────────────
- if (reqPath.startsWith('/api/rib/')) {
- return proxyToApiServer(req, res, req.url);
- }
-
- // ── Prefix Changes ──────────────────────────────────────────────
- if (reqPath === '/api/prefix-changes') {
- return proxyToApiServer(req, res, req.url);
- }
-
- // ============================================================
- // FEATURE 2: PDF Export
- // GET /api/export/pdf?asn=X&format=report|executive|technical
- // GET /api/export/pdf/compare?asn1=X&asn2=Y
- // ============================================================
-
- // Helper: fetch JSON from internal endpoint
- async function fetchInternal(path) {
- return new Promise((resolve, reject) => {
- const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
- const req2 = http.request(opts, res2 => {
- let body = '';
- res2.on('data', c => body += c);
- res2.on('end', () => {
- try { resolve(JSON.parse(body)); }
- catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
- });
- });
- req2.on('error', reject);
- req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
- req2.end();
- });
- }
-
- if (reqPath === '/api/export/pdf' && req.method === 'GET') {
- const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
- const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
- ? url.searchParams.get('format') : 'report';
- if (!rawAsn) {
- res.writeHead(400, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
- }
- const cacheKey = `pdf:${rawAsn}:${format}`;
- const cached = pdfCacheGet(cacheKey);
- if (cached) {
- res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
- return res.end(cached);
- }
- try {
- const [validateData, aspaData] = await Promise.all([
- fetchInternal(`/api/validate?asn=${rawAsn}`),
- fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
- ]);
- if (validateData.error) {
- res.writeHead(400, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: validateData.error }));
- }
- const html = buildPdfHtml(validateData, aspaData, format);
- const pdfBuf = await renderHtmlToPdf(html);
- pdfCacheSet(cacheKey, pdfBuf);
- res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
- return res.end(pdfBuf);
- } catch (err) {
- console.error('[PDF] Error:', err.message);
- res.writeHead(503, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
- }
- }
-
- if (reqPath === '/api/export/pdf/compare' && req.method === 'GET') {
- const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
- const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
- if (!asn1 || !asn2) {
- res.writeHead(400, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
- }
- if (asn1 === asn2) {
- res.writeHead(400, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
- }
- const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
- const cached = pdfCacheGet(cacheKey);
- if (cached) {
- res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
- return res.end(cached);
- }
- try {
- const [v1, a1, l1, v2, a2, l2] = await Promise.all([
- fetchInternal(`/api/validate?asn=${asn1}`),
- fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
- fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
- fetchInternal(`/api/validate?asn=${asn2}`),
- fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
- fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
- ]);
- if (v1.error || v2.error) {
- res.writeHead(400, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: v1.error || v2.error }));
- }
- const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
- const pdfBuf = await renderHtmlToPdf(html);
- pdfCacheSet(cacheKey, pdfBuf);
- res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
- return res.end(pdfBuf);
- } catch (err) {
- console.error('[PDF] Error:', err.message);
- res.writeHead(503, {'Content-Type':'application/json'});
- return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
- }
- }
-
- // CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
- if (reqPath.startsWith('/api/export/') && req.method === 'OPTIONS') {
- res.writeHead(204);
- return res.end();
- }
-
- // ============================================================
- // FEATURE 3: ASPA Adoption Tracker — API + Dashboard
- // GET /aspa-adoption → dashboard HTML
- // GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend
- // GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
- // ============================================================
-
- if (reqPath === '/aspa-adoption') {
- res.setHeader('Content-Type', 'text/html; charset=utf-8');
- res.writeHead(200);
- return res.end(buildAspaAdoptionDashboard());
- }
-
- if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') {
- const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
- ? url.searchParams.get('period') : '30d';
- const trend = getAdoptionTrend(period);
- const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
- const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
-
- // Linear regression forecast
- function forecast(data, daysAhead) {
- if (data.length < 2) return null;
- const n = data.length;
- const xs = data.map((_, i) => i);
- const ys = data.map(d => d.coverage_pct_atlas);
- const xMean = xs.reduce((a,b)=>a+b,0)/n;
- const yMean = ys.reduce((a,b)=>a+b,0)/n;
- const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
- const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
- if (den===0) return yMean;
- const slope = num/den;
- return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
- }
-
- const trend30 = getAdoptionTrend('30d');
- const result = {
- period,
- current: {
- date: latest?.date,
- aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
- roa_count: latest?.roa_count,
- atlas_asns_total: latest?.atlas_asns_total ?? 0,
- atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
- coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
- delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
- },
- trend: trend.map(s => ({
- date: s.date,
- aspa_objects: s.aspa_objects,
- coverage_pct_atlas: s.coverage_pct_atlas,
- atlas_asns_total: s.atlas_asns_total,
- })),
- forecast: {
- predicted_90d: forecast(trend30, 90),
- confidence: trend30.length >= 10 ? 0.75 : 0.5,
- method: 'linear_regression',
- based_on_samples: trend30.length,
- },
- meta: {
- total_snapshots: aspaAdoptionDailyHistory.length,
- last_updated: latest?.date ?? null,
- denominator: 'atlas_visible_asns',
- source: 'rpki_cloudflare_feed',
- }
- };
- res.setHeader('Content-Type', 'application/json');
- res.writeHead(200);
- return res.end(JSON.stringify(result, null, 2));
- }
-
- if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') {
- const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
- const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
- ? url.searchParams.get('period') : '30d';
- const data = getAdoptionTrend(period);
-
- if (fmt === 'csv') {
- const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
- const rows = data.map(s =>
- `${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
- ).join('\n');
- res.setHeader('Content-Type', 'text/csv');
- res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
- res.writeHead(200);
- return res.end(header + rows);
- }
-
- res.setHeader('Content-Type', 'application/json');
- res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
- res.writeHead(200);
- return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
- }
-
- // GET /api/ipv6-adoption-stats?refresh=1
- if (reqPath === '/api/ipv6-adoption-stats' && req.method === 'GET') {
- const force = url.searchParams.get('refresh') === '1';
- try {
- const data = await fetchIpv6AdoptionStats(force);
- res.setHeader('Content-Type', 'application/json');
- res.writeHead(200);
- return res.end(JSON.stringify(data, null, 2));
- } catch (err) {
- res.writeHead(503);
- return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
- }
- }
// 404
res.writeHead(404);
@@ -6589,147 +467,9 @@ ${html}
});
-// ============================================================
-// Atlas Probe Cache (for Lia's Atlas Paradise)
-// ============================================================
-let atlasProbeCache = null;
-let atlasProbeFetching = false;
+const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes");
-function fetchAllAtlasProbes() {
- if (atlasProbeFetching) return Promise.resolve();
- atlasProbeFetching = true;
- console.log("[ATLAS] Fetching all Atlas probes...");
-
- return new Promise(function(resolve) {
- var allAsns = new Set();
- var byCountry = {};
- var pageCount = 0;
- var maxPages = 40;
-
- function fetchPage(pageUrl) {
- if (pageCount >= maxPages) return finish();
- pageCount++;
-
- fetchJSON(pageUrl).then(function(data) {
- if (!data || !data.results) return finish();
-
- data.results.forEach(function(probe) {
- var asn4 = probe.asn_v4;
- var asn6 = probe.asn_v6;
- var cc = probe.country_code || "XX";
-
- if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
- byCountry[cc].total++;
- if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
- if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
- if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
- });
-
- if (data.next) {
- fetchPage(data.next);
- } else {
- finish();
- }
- }).catch(function() { finish(); });
- }
-
- function finish() {
- var byCountryOut = {};
- Object.keys(byCountry).forEach(function(cc) {
- var info = byCountry[cc];
- byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
- });
-
- atlasProbeCache = {
- total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
- total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
- unique_asns_with_probes: allAsns.size,
- asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
- by_country: byCountryOut,
- fetched_at: new Date().toISOString(),
- pages_fetched: pageCount,
- };
-
- console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
- atlasProbeFetching = false;
- resolve();
- }
-
- fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
- });
-}
-
-// ============================================================
-// PeeringDB Org → Country Cache (for Lia's Paradise)
-// ============================================================
-let pdbOrgCountryMap = new Map(); // org_id → { country, name }
-
-function fetchPdbOrgCountries() {
- var cacheFile = require("path").join(__dirname, ".pdb-org-cache.json");
- var fs = require("fs");
-
- // Try disk cache first (valid for 24h)
- try {
- var stat = fs.statSync(cacheFile);
- var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
- if (ageHours < 24) {
- var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
- pdbOrgCountryMap = new Map(Object.entries(cached));
- console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
- return Promise.resolve();
- }
- } catch (_) { /* no cache or invalid */ }
-
- console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
- return new Promise(function(resolve) {
- var chunks = [];
- var req = require("https").get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
- headers: {
- "User-Agent": UA,
- "Authorization": PEERINGDB_API_KEY ? "Api-Key " + PEERINGDB_API_KEY : undefined,
- },
- timeout: 120000,
- }, function(res) {
- if (res.statusCode !== 200) {
- console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
- resolve();
- return;
- }
- res.on("data", function(chunk) { chunks.push(chunk); });
- res.on("end", function() {
- try {
- var body = Buffer.concat(chunks).toString("utf8");
- var data = JSON.parse(body);
- if (data && data.data) {
- pdbOrgCountryMap = new Map();
- var cacheObj = {};
- data.data.forEach(function(o) {
- if (o.id && o.country) {
- pdbOrgCountryMap.set(o.id, { country: o.country, name: o.name || "" });
- cacheObj[o.id] = { country: o.country, name: o.name || "" };
- }
- });
- // Save to disk cache
- try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
- console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " org→country mappings (cached to disk)");
- }
- } catch (e) {
- console.error("[PDB-ORG] Parse error:", e.message);
- }
- resolve();
- });
- });
- req.on("error", function(e) {
- console.error("[PDB-ORG] Fetch error:", e.message);
- resolve();
- });
- req.on("timeout", function() {
- console.error("[PDB-ORG] Timeout after 120s");
- req.destroy();
- resolve();
- });
- });
-}
+const { pdbOrgState, fetchPdbOrgCountries } = require("./server/pdb-org-countries");
const PORT = process.env.PORT || 3101;
@@ -6746,7 +486,7 @@ loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
ensureManrsCache(); // fire-and-forget, 24h cache
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
// Re-record ASPA adoption snapshot now that Atlas data is fully loaded
- recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
+ recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap);
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT);
diff --git a/server/__smoke__/baseline/baseline.json b/server/__smoke__/baseline/baseline.json
new file mode 100644
index 0000000..8788df7
--- /dev/null
+++ b/server/__smoke__/baseline/baseline.json
@@ -0,0 +1,3505 @@
+{
+ "root": {
+ "status": 500,
+ "body": {
+ "__non_json__": true,
+ "status": 500,
+ "bodyLength": 20
+ }
+ },
+ "search": {
+ "status": 200,
+ "body": {
+ "q": "cloudflare",
+ "results": [
+ {
+ "asn": "13335",
+ "label": "CLOUDFLARENET - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "14789",
+ "label": "CLOUDFLARENET - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "132892",
+ "label": "CLOUDFLARE - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "133877",
+ "label": "CLOUDFLARE - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "202623",
+ "label": "CLOUDFLARENET-CORE Cloudflare Inc",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "203898",
+ "label": "CLOUDFLARENET-UK Cloudflare Inc",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "209242",
+ "label": "CLOUDFLARESPECTRUM Cloudflare London, LLC",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "394536",
+ "label": "CLOUDFLARENET-SFO - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "395747",
+ "label": "CLOUDFLARENET-SFO05 - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "400095",
+ "label": "CLOUDFLARENET - Cloudflare, Inc.",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "402542",
+ "label": "CLOUDFLARENET - Cloudflare,",
+ "description": "",
+ "source": "RIPE Stat"
+ },
+ {
+ "asn": "4436",
+ "label": "GTT Communications (AS4436)",
+ "description": "NSP",
+ "source": "PeeringDB"
+ }
+ ]
+ }
+ },
+ "visitors": {
+ "status": 200,
+ "body": {
+ "visitors": 1
+ }
+ },
+ "health": {
+ "status": 200,
+ "body": {
+ "status": "degraded",
+ "service": "PeerCortex",
+ "version": "0.6.9",
+ "bgproutes_configured": false,
+ "caches": {
+ "aspa_map": {},
+ "pdb_net": {
+ "entries": 0,
+ "hit_rate_pct": 0
+ },
+ "pdb_netixlan": {
+ "entries": 0
+ },
+ "pdb_netfac": {
+ "entries": 0
+ },
+ "ripe_stat": {
+ "entries": 0
+ },
+ "response_cache": {
+ "entries": 0
+ }
+ },
+ "local_db": null,
+ "aspa_adoption": {}
+ }
+ },
+ "aspa-verify": {
+ "status": 200,
+ "body": {
+ "meta": {
+ "query": "AS13335",
+ "paths_analyzed": 0,
+ "total_paths_seen": 0
+ },
+ "asn": 13335,
+ "readiness_score": {
+ "total": 0,
+ "breakdown": {
+ "roa_coverage": {
+ "score": 0,
+ "max": 25,
+ "value": 0
+ },
+ "aspa_object": {
+ "score": 0,
+ "max": 25,
+ "value": false
+ },
+ "provider_completeness": {
+ "score": 0,
+ "max": 25,
+ "value": 0
+ },
+ "path_validation": {
+ "score": 0,
+ "max": 25,
+ "value": 0
+ }
+ }
+ },
+ "aspa_object_exists": false,
+ "aspa_feed_healthy": true,
+ "detected_providers": [],
+ "provider_audit": {
+ "declared_count": 0,
+ "detected_count": 0,
+ "completeness_pct": 0,
+ "missing_from_aspa": [],
+ "extra_in_aspa": []
+ },
+ "path_verification": {
+ "total": 0,
+ "valid": 0,
+ "invalid": 0,
+ "unknown": 0,
+ "as_set_flagged": 0,
+ "valley_detected": 0,
+ "valid_pct": 0,
+ "not_invalid_pct": 0,
+ "results": []
+ },
+ "rpki_coverage": 0
+ }
+ },
+ "aspa-legacy": {
+ "status": 200,
+ "body": {
+ "meta": {
+ "query": "AS13335"
+ },
+ "asn": 13335,
+ "detected_providers": [],
+ "provider_count": 0,
+ "aspa_object_exists": false,
+ "aspa_feed_healthy": true,
+ "aspa_declared_providers": [],
+ "aspa_declared_count": 0,
+ "recommended_aspa": "aut-num: AS13335\n# Recommended ASPA object:\n# customer: AS13335\n# provider-set: \n# AFI: ipv4, ipv6\n#\n# Detected providers from BGP path analysis:\n",
+ "path_analysis": {
+ "total_paths_seen": 0,
+ "sample_paths": []
+ }
+ }
+ },
+ "bgp": {
+ "status": 200,
+ "body": {
+ "meta": {
+ "source": "local_bgp_db"
+ },
+ "bgp_status": {
+ "asn": "13335",
+ "announced": false,
+ "announced_count": 0,
+ "message": "No prefixes found for this ASN in local BGP table",
+ "source": "local_bgp"
+ },
+ "threat_intel": null
+ }
+ },
+ "validate": {
+ "status": 200,
+ "body": {
+ "meta": {
+ "query": "AS13335",
+ "total_prefixes": 0,
+ "prefixes_sampled": 0
+ },
+ "asn": 13335,
+ "name": "Unknown",
+ "validations": {
+ "irr": {
+ "status": "info",
+ "message": "timed out"
+ },
+ "rpki_completeness": {
+ "status": "fail",
+ "coverage_pct": 0,
+ "total_checked": 0,
+ "db_unavailable_count": 0,
+ "with_roa": 0,
+ "over_specific": [],
+ "details": []
+ },
+ "abuse_contact": {
+ "status": "pass",
+ "contacts": [
+ "abuse@cloudflare.com"
+ ],
+ "has_valid_email": true
+ },
+ "blocklist": {
+ "status": "pass",
+ "checked": 0,
+ "unavailable": 0,
+ "listed_prefixes": []
+ },
+ "manrs": {
+ "status": "info",
+ "participant": "unknown",
+ "message": "MANRS data not yet loaded",
+ "note": "https://www.manrs.org/netops/participants/"
+ },
+ "rdns": {
+ "status": "fail",
+ "coverage_pct": 0,
+ "checked": 0,
+ "unavailable": 0,
+ "results": [],
+ "failed_prefixes": []
+ },
+ "communities": {
+ "status": "pass",
+ "total_updates": 0,
+ "unique_communities": 0,
+ "top_communities": [],
+ "well_known_detected": []
+ },
+ "geolocation": {
+ "status": "warning",
+ "geo_countries": [],
+ "sample_prefix": null,
+ "located_resources": 0,
+ "pdb_facility_countries": [],
+ "country_mismatches": []
+ },
+ "rpsl": {
+ "status": "pass",
+ "exists": true,
+ "has_import": false,
+ "has_export": false,
+ "has_remarks": true,
+ "has_policy": false,
+ "source": "ARIN",
+ "rdap_name": "CLOUDFLARENET",
+ "rdap_handle": "AS13335"
+ },
+ "ix_route_server": {
+ "status": "warning",
+ "total_ix_connections": 0,
+ "rs_peer_count": 0,
+ "rs_peer_pct": 0,
+ "note": "Only 0 IX connection(s) and no route server usage"
+ },
+ "resource_cert": {
+ "status": "fail",
+ "has_roas": false,
+ "checked": 0
+ },
+ "bogon": {
+ "status": "pass",
+ "bogon_prefixes": [],
+ "bogon_asns_in_paths": [],
+ "total_prefixes_checked": 0,
+ "prefixes_source_unavailable": false,
+ "neighbours_source_unavailable": true
+ }
+ },
+ "relationships": {
+ "counts": {
+ "upstreams": 0,
+ "downstreams": 0,
+ "peers": 0,
+ "uncertain": 0
+ },
+ "upstreams": [],
+ "downstreams": [],
+ "top_peers": [],
+ "source": "RIPE Stat asn-neighbours",
+ "note": "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score."
+ }
+ }
+ },
+ "lookup": {
+ "status": 200,
+ "body": {
+ "meta": {
+ "service": "PeerCortex",
+ "version": "0.6.9",
+ "query": "AS13335",
+ "sources": [
+ "PeeringDB",
+ "RIPE Stat",
+ "bgp.he.net",
+ "Cloudflare RPKI",
+ "RIPE RPKI Validator",
+ "Route Views"
+ ],
+ "rpki_prefixes_checked": 0,
+ "total_prefixes": 0,
+ "degraded_sources": [
+ "RIPE Stat Neighbours",
+ "RIPE Stat Overview",
+ "RIPE Stat Visibility",
+ "RIPE Stat PrefixSize"
+ ]
+ },
+ "network": {
+ "asn": 13335,
+ "name": "Unknown",
+ "aka": "",
+ "org_name": "",
+ "website": "",
+ "type": "",
+ "policy": "",
+ "traffic": "",
+ "ratio": "",
+ "scope": "",
+ "notes": "",
+ "peeringdb_id": null,
+ "rir": "ARIN",
+ "country": "",
+ "city": "",
+ "latitude": null,
+ "longitude": null,
+ "looking_glass": "",
+ "route_server": "",
+ "info_prefixes4": 0,
+ "info_prefixes6": 0,
+ "status": "",
+ "peeringdb_created": "",
+ "peeringdb_updated": ""
+ },
+ "prefixes": {
+ "total": 0,
+ "ipv4": 0,
+ "ipv6": 0,
+ "list": [],
+ "cross_check": {
+ "ripe_stat": 0,
+ "bgp_he_net": null,
+ "agreement": null,
+ "note": "bgp.he.net data unavailable"
+ }
+ },
+ "rpki": {
+ "coverage_percent": 0,
+ "valid": 0,
+ "invalid": 0,
+ "not_found": 0,
+ "unavailable": 0,
+ "checked": 0,
+ "details": [],
+ "cross_check": {
+ "cloudflare_valid": 0,
+ "ripe_valid": 0,
+ "agreement_pct": null,
+ "disagreements": [],
+ "sample_size": 0,
+ "comparable_size": 0
+ }
+ },
+ "neighbours": {
+ "total": 0,
+ "upstream_count": 0,
+ "downstream_count": 0,
+ "peer_count": 0,
+ "upstreams": [],
+ "downstreams": [],
+ "peers": [],
+ "cross_check": {
+ "ripe_stat_total": 0,
+ "bgp_he_net_total": null
+ }
+ },
+ "ix_presence": {
+ "total_connections": 0,
+ "unique_ixps": 0,
+ "connections": []
+ },
+ "ix_locations": [],
+ "facilities": {
+ "total": 0,
+ "list": []
+ },
+ "routing": {
+ "ipv4_prefixes": 0,
+ "ipv6_prefixes": 0,
+ "ipv4_visibility_avg": 0,
+ "ipv6_visibility_avg": 0,
+ "total_ris_peers_v4": 0,
+ "total_ris_peers_v6": 0,
+ "prefix_sizes_v4": [],
+ "prefix_sizes_v6": []
+ },
+ "resilience_score": null,
+ "route_leak": {
+ "detected": false,
+ "patterns": [],
+ "tier1_upstream_count": 0,
+ "tier1_downstream_count": 0,
+ "_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."
+ }
+ },
+ "bgp_he_net": null,
+ "atlas": {
+ "connected": 0,
+ "disconnected": 0,
+ "anchors": 0,
+ "probes": []
+ },
+ "data_quality": {
+ "sources_queried": [
+ "PeeringDB",
+ "RIPE Stat",
+ "bgp.he.net",
+ "Cloudflare RPKI",
+ "RIPE RPKI Validator"
+ ],
+ "cross_checks": {
+ "rpki": {
+ "sources": 1,
+ "agreement_pct": null,
+ "sample_size": 0,
+ "comparable_size": 0,
+ "disagreements": []
+ },
+ "prefixes": {
+ "sources": 1,
+ "agreement_pct": null,
+ "ripe_stat": 0,
+ "bgp_he_net": null,
+ "note": "bgp.he.net data unavailable"
+ },
+ "neighbours": {
+ "sources": 1,
+ "agreement_pct": null,
+ "ripe_stat_total": 0,
+ "bgp_he_net_total": null
+ }
+ },
+ "overall_confidence": "unknown",
+ "overall_agreement_pct": null
+ },
+ "contacts": [],
+ "registration": {
+ "created": "2010-07-14",
+ "last_modified": "2017-02-17",
+ "rir": "ARIN",
+ "handle": "AS13335",
+ "rdap_source": "whois.arin.net"
+ },
+ "_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"
+ }
+ }
+ }
+ },
+ "relationships": {
+ "status": 200,
+ "body": {
+ "asn": 13335,
+ "counts": {
+ "upstreams": 0,
+ "downstreams": 0,
+ "peers_total": 0,
+ "uncertain": 0
+ },
+ "upstreams": [],
+ "downstreams": [],
+ "peers": [],
+ "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=AS13335"
+ }
+ },
+ "compare": {
+ "status": 200,
+ "body": {
+ "meta": {},
+ "asn1": {
+ "asn": 13335,
+ "name": "Unknown",
+ "ix_count": 0,
+ "fac_count": 0,
+ "upstream_count": 0,
+ "rpki_coverage": 0
+ },
+ "asn2": {
+ "asn": 15169,
+ "name": "Unknown",
+ "ix_count": 0,
+ "fac_count": 0,
+ "upstream_count": 0,
+ "rpki_coverage": 0
+ },
+ "common_ixps": [],
+ "only_asn1_ixps": [],
+ "only_asn2_ixps": [],
+ "common_facilities": [],
+ "common_upstreams": [],
+ "rpki_comparison": {
+ "asn1_coverage": 0,
+ "asn2_coverage": 0,
+ "asn1_checked": 0,
+ "asn2_checked": 0,
+ "better": "equal"
+ }
+ }
+ },
+ "quick-ix": {
+ "status": 200,
+ "body": {
+ "asn": 13335
+ }
+ },
+ "peers-find": {
+ "status": 400,
+ "body": {
+ "error": "Missing ix parameter (IX name)"
+ }
+ },
+ "prefix-detail": {
+ "status": 200,
+ "body": {
+ "meta": {},
+ "prefix": "1.1.1.0/24",
+ "rpki": {
+ "status": "unknown",
+ "validating_roas": 0
+ },
+ "irr_status": "found",
+ "visibility": {
+ "ris_peers_seeing": 0,
+ "source": "ripe_stat"
+ }
+ }
+ },
+ "ix-detail": {
+ "status": 400,
+ "body": {
+ "error": "Missing ix_id parameter"
+ }
+ },
+ "topology": {
+ "status": 200,
+ "body": {
+ "nodes": [
+ {
+ "asn": 13335,
+ "name": "",
+ "type": "target",
+ "depth": 0
+ }
+ ],
+ "edges": [],
+ "target_asn": 13335,
+ "depth": 2,
+ "meta": {
+ "query": "AS13335",
+ "depth": 2,
+ "node_count": 1,
+ "edge_count": 0
+ }
+ }
+ },
+ "whois": {
+ "status": 400,
+ "body": {
+ "error": "Missing resource parameter (ASN, prefix, or domain)"
+ }
+ },
+ "enrich": {
+ "status": 200,
+ "body": {
+ "asn": "13335",
+ "description": null,
+ "wiki_url": null
+ }
+ },
+ "changelog": {
+ "status": 500,
+ "body": {
+ "__non_json__": true,
+ "status": 500,
+ "bodyLength": 23
+ }
+ },
+ "webhooks-list": {
+ "status": 200,
+ "body": {
+ "webhooks": [],
+ "total": 0
+ }
+ },
+ "hijacks-list": {
+ "status": 200,
+ "body": {
+ "total": 1000,
+ "events": [
+ {
+ "id": "c37eb29c5c54c5a1",
+ "asn": "132372",
+ "ts": "2026-07-16T06:29:59.070Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "5d4e90a8549f3771",
+ "asn": "22616",
+ "ts": "2026-07-16T04:59:53.845Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.24.0/23",
+ "137.31.101.0/24",
+ "205.220.14.0/23",
+ "137.31.120.0/23",
+ "137.31.114.0/23",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "137.31.26.0/23",
+ "137.31.126.0/23",
+ "137.31.122.0/23",
+ "140.232.189.0/24",
+ "137.31.128.0/23",
+ "140.232.190.0/24",
+ "137.31.124.0/23",
+ "155.95.86.0/24",
+ "165.225.99.0/24",
+ "205.220.12.0/23",
+ "140.232.188.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "6dd656f19f6a1924",
+ "asn": "396986",
+ "ts": "2026-07-16T04:29:51.725Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.18.0/24",
+ "101.45.199.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.66.0/24",
+ "101.45.15.0/24",
+ "71.18.44.0/24",
+ "101.45.6.0/24",
+ "71.18.45.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "71.18.67.0/24",
+ "71.18.65.0/24",
+ "2605:340:f049::/48",
+ "101.45.14.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "9088627d62eade29",
+ "asn": "34702",
+ "ts": "2026-07-16T03:59:52.690Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "496424b0fc064555",
+ "asn": "3491",
+ "ts": "2026-07-16T02:59:52.248Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "63.219.192.0/24",
+ "2400:8800:f8fa::/48",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "8b02d291a1af8c3d",
+ "asn": "15290",
+ "ts": "2026-07-16T01:59:53.019Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "b91f3e602fd81ca1",
+ "asn": "138699",
+ "ts": "2026-07-16T01:59:52.148Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "6daaace7b94cbf39",
+ "asn": "24940",
+ "ts": "2026-07-16T01:59:51.622Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "193.105.82.0/24",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "db1240cf335bd1fb",
+ "asn": "132372",
+ "ts": "2026-07-16T00:29:53.114Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "abf9968f49c729b7",
+ "asn": "22616",
+ "ts": "2026-07-15T22:29:53.837Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "137.31.114.0/23",
+ "155.95.86.0/24",
+ "165.225.99.0/24",
+ "137.31.124.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "137.31.119.0/24",
+ "205.220.14.0/23",
+ "137.31.128.0/23",
+ "137.31.26.0/23",
+ "137.31.120.0/23",
+ "137.31.24.0/23",
+ "140.232.190.0/24",
+ "140.232.188.0/24",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "3497eb91f0a1804b",
+ "asn": "396986",
+ "ts": "2026-07-15T22:29:51.711Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2605:340:f04a::/48",
+ "101.45.199.0/24",
+ "71.18.65.0/24",
+ "71.18.66.0/24",
+ "71.18.71.0/24",
+ "71.18.67.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "101.45.6.0/24",
+ "101.45.202.0/24",
+ "2605:340:f049::/48",
+ "101.45.14.0/24",
+ "101.45.18.0/24",
+ "71.18.45.0/24",
+ "71.18.64.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "176f637eb9f4b8a0",
+ "asn": "34702",
+ "ts": "2026-07-15T21:29:53.213Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "0739880a09b2e60e",
+ "asn": "3491",
+ "ts": "2026-07-15T20:59:51.838Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fb::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fa::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "341a4a5416d53fb4",
+ "asn": "24940",
+ "ts": "2026-07-15T19:59:51.372Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "193.105.82.0/24",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "bc655a6f3f565c2b",
+ "asn": "15290",
+ "ts": "2026-07-15T19:29:53.248Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "fe4018f500c85f61",
+ "asn": "138699",
+ "ts": "2026-07-15T19:29:52.219Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "3c915ae1bf8a755e",
+ "asn": "132372",
+ "ts": "2026-07-15T18:29:53.011Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "14d4c48ce7317e03",
+ "asn": "22616",
+ "ts": "2026-07-15T16:29:53.752Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "140.232.190.0/24",
+ "155.95.86.0/24",
+ "140.232.188.0/24",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "137.31.128.0/23",
+ "137.31.24.0/23",
+ "165.225.99.0/24",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "137.31.120.0/23",
+ "137.31.124.0/23",
+ "137.31.26.0/23",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "137.31.114.0/23",
+ "205.220.14.0/23",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "ef7fe7860371b6c7",
+ "asn": "396986",
+ "ts": "2026-07-15T16:29:51.704Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.6.0/24",
+ "71.18.71.0/24",
+ "71.18.65.0/24",
+ "71.18.64.0/24",
+ "101.45.18.0/24",
+ "2605:340:f049::/48",
+ "71.18.45.0/24",
+ "101.45.15.0/24",
+ "71.18.66.0/24",
+ "101.45.202.0/24",
+ "2605:340:f04a::/48",
+ "71.18.44.0/24",
+ "101.45.199.0/24",
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "8df39b422cdb9693",
+ "asn": "34702",
+ "ts": "2026-07-15T14:59:52.749Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "1275ffa07a209ece",
+ "asn": "3491",
+ "ts": "2026-07-15T14:29:51.794Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8f0::/48",
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "11d64812f7d25748",
+ "asn": "24940",
+ "ts": "2026-07-15T13:59:51.366Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "2.28.0.0/16",
+ "193.105.82.0/24"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "5943d1de345838ac",
+ "asn": "15290",
+ "ts": "2026-07-15T13:29:52.431Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "8fdaa6a50cb01891",
+ "asn": "138699",
+ "ts": "2026-07-15T13:29:51.615Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.198.0/24",
+ "2404:9dc0:c002::/48",
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "4a2318801159ada4",
+ "asn": "132372",
+ "ts": "2026-07-15T11:59:53.119Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "b72903d54d8d8705",
+ "asn": "396986",
+ "ts": "2026-07-15T10:29:51.682Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "71.18.45.0/24",
+ "71.18.66.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "101.45.6.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.18.0/24",
+ "71.18.65.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "32a6ba0b811278bb",
+ "asn": "22616",
+ "ts": "2026-07-15T09:59:53.724Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.128.0/23",
+ "140.232.189.0/24",
+ "137.31.122.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "137.31.119.0/24",
+ "140.232.188.0/24",
+ "137.31.126.0/23",
+ "205.220.14.0/23",
+ "137.31.24.0/23",
+ "155.95.86.0/24",
+ "205.220.12.0/23",
+ "140.232.190.0/24",
+ "137.31.114.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "9fde972e84297ad9",
+ "asn": "34702",
+ "ts": "2026-07-15T08:59:52.292Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "67491773ba454e26",
+ "asn": "3491",
+ "ts": "2026-07-15T07:59:53.053Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8f0::/48",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "a46b8ca977e0cfc5",
+ "asn": "24940",
+ "ts": "2026-07-15T07:59:51.363Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.29.0.0/16",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "23031dac4ec96573",
+ "asn": "15290",
+ "ts": "2026-07-15T06:59:52.674Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "f50fd3d9b019f60f",
+ "asn": "138699",
+ "ts": "2026-07-15T06:59:51.698Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "101.45.198.0/24",
+ "2404:9dc0:c002::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "5e648fa861caabcb",
+ "asn": "132372",
+ "ts": "2026-07-15T05:59:52.969Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "aba848e0c0078785",
+ "asn": "396986",
+ "ts": "2026-07-15T03:59:51.702Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "71.18.45.0/24",
+ "71.18.66.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "101.45.6.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.18.0/24",
+ "71.18.65.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "44d63311918b4fd7",
+ "asn": "22616",
+ "ts": "2026-07-15T03:29:54.203Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.128.0/23",
+ "140.232.189.0/24",
+ "137.31.122.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "137.31.119.0/24",
+ "140.232.188.0/24",
+ "137.31.126.0/23",
+ "205.220.14.0/23",
+ "137.31.24.0/23",
+ "155.95.86.0/24",
+ "205.220.12.0/23",
+ "140.232.190.0/24",
+ "137.31.114.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "a9658646462e1416",
+ "asn": "34702",
+ "ts": "2026-07-15T02:29:52.810Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "7e19c9cf7db18df2",
+ "asn": "3491",
+ "ts": "2026-07-15T01:59:52.321Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "366e6cbbbc89340c",
+ "asn": "24940",
+ "ts": "2026-07-15T01:59:51.361Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.28.0.0/16",
+ "2.29.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "268febdf4f8b8be3",
+ "asn": "15290",
+ "ts": "2026-07-15T00:59:52.476Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "0b0a20947ca1b4a1",
+ "asn": "138699",
+ "ts": "2026-07-15T00:59:51.661Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24",
+ "101.45.200.0/23"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "9a4fa58e438b1a35",
+ "asn": "132372",
+ "ts": "2026-07-14T23:29:56.722Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "acedee638859fec5",
+ "asn": "396986",
+ "ts": "2026-07-14T21:59:51.677Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.15.0/24",
+ "101.45.6.0/24",
+ "71.18.66.0/24",
+ "2605:340:f04a::/48",
+ "71.18.64.0/24",
+ "2605:340:f049::/48",
+ "71.18.67.0/24",
+ "101.45.199.0/24",
+ "101.45.14.0/24",
+ "101.45.18.0/24",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "71.18.45.0/24",
+ "71.18.65.0/24",
+ "71.18.71.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "3bbe047e16df0480",
+ "asn": "22616",
+ "ts": "2026-07-14T20:59:54.362Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "155.95.86.0/24",
+ "140.232.190.0/24",
+ "137.31.24.0/23",
+ "137.31.128.0/23",
+ "140.232.188.0/24",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "205.220.14.0/23",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "137.31.114.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "ac981860008fae42",
+ "asn": "34702",
+ "ts": "2026-07-14T19:59:52.842Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "e3fca5a660644a47",
+ "asn": "3491",
+ "ts": "2026-07-14T19:59:52.246Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "9f602b507e053fe9",
+ "asn": "24940",
+ "ts": "2026-07-14T19:59:51.360Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.28.0.0/16",
+ "2.29.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "9a45eded7bbde30e",
+ "asn": "15290",
+ "ts": "2026-07-14T18:29:52.554Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "8169c1fdd2bff25f",
+ "asn": "138699",
+ "ts": "2026-07-14T18:29:51.705Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2404:9dc0:cd04::/48",
+ "101.45.198.0/24",
+ "101.45.200.0/23",
+ "2404:9dc0:c002::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "8ea2453d73ab95a8",
+ "asn": "132372",
+ "ts": "2026-07-14T16:59:53.436Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "7156c0cd3d01adeb",
+ "asn": "396986",
+ "ts": "2026-07-14T15:29:52.006Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.14.0/24",
+ "71.18.45.0/24",
+ "101.45.6.0/24",
+ "71.18.65.0/24",
+ "71.18.67.0/24",
+ "71.18.64.0/24",
+ "101.45.202.0/24",
+ "101.45.18.0/24",
+ "2605:340:f04a::/48",
+ "71.18.71.0/24",
+ "71.18.66.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ }
+ ]
+ }
+ },
+ "hijack-alerts": {
+ "status": 200,
+ "body": {
+ "asn": "",
+ "alerts": [
+ {
+ "id": "7156c0cd3d01adeb",
+ "asn": "396986",
+ "ts": "2026-07-14T15:29:52.006Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.14.0/24",
+ "71.18.45.0/24",
+ "101.45.6.0/24",
+ "71.18.65.0/24",
+ "71.18.67.0/24",
+ "71.18.64.0/24",
+ "101.45.202.0/24",
+ "101.45.18.0/24",
+ "2605:340:f04a::/48",
+ "71.18.71.0/24",
+ "71.18.66.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "8ea2453d73ab95a8",
+ "asn": "132372",
+ "ts": "2026-07-14T16:59:53.436Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "8169c1fdd2bff25f",
+ "asn": "138699",
+ "ts": "2026-07-14T18:29:51.705Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2404:9dc0:cd04::/48",
+ "101.45.198.0/24",
+ "101.45.200.0/23",
+ "2404:9dc0:c002::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "9a45eded7bbde30e",
+ "asn": "15290",
+ "ts": "2026-07-14T18:29:52.554Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "9f602b507e053fe9",
+ "asn": "24940",
+ "ts": "2026-07-14T19:59:51.360Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.28.0.0/16",
+ "2.29.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "e3fca5a660644a47",
+ "asn": "3491",
+ "ts": "2026-07-14T19:59:52.246Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "ac981860008fae42",
+ "asn": "34702",
+ "ts": "2026-07-14T19:59:52.842Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "3bbe047e16df0480",
+ "asn": "22616",
+ "ts": "2026-07-14T20:59:54.362Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "155.95.86.0/24",
+ "140.232.190.0/24",
+ "137.31.24.0/23",
+ "137.31.128.0/23",
+ "140.232.188.0/24",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "205.220.14.0/23",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "137.31.114.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "acedee638859fec5",
+ "asn": "396986",
+ "ts": "2026-07-14T21:59:51.677Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.15.0/24",
+ "101.45.6.0/24",
+ "71.18.66.0/24",
+ "2605:340:f04a::/48",
+ "71.18.64.0/24",
+ "2605:340:f049::/48",
+ "71.18.67.0/24",
+ "101.45.199.0/24",
+ "101.45.14.0/24",
+ "101.45.18.0/24",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "71.18.45.0/24",
+ "71.18.65.0/24",
+ "71.18.71.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "9a4fa58e438b1a35",
+ "asn": "132372",
+ "ts": "2026-07-14T23:29:56.722Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "0b0a20947ca1b4a1",
+ "asn": "138699",
+ "ts": "2026-07-15T00:59:51.661Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24",
+ "101.45.200.0/23"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "268febdf4f8b8be3",
+ "asn": "15290",
+ "ts": "2026-07-15T00:59:52.476Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "366e6cbbbc89340c",
+ "asn": "24940",
+ "ts": "2026-07-15T01:59:51.361Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.28.0.0/16",
+ "2.29.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "7e19c9cf7db18df2",
+ "asn": "3491",
+ "ts": "2026-07-15T01:59:52.321Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "a9658646462e1416",
+ "asn": "34702",
+ "ts": "2026-07-15T02:29:52.810Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "44d63311918b4fd7",
+ "asn": "22616",
+ "ts": "2026-07-15T03:29:54.203Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.128.0/23",
+ "140.232.189.0/24",
+ "137.31.122.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "137.31.119.0/24",
+ "140.232.188.0/24",
+ "137.31.126.0/23",
+ "205.220.14.0/23",
+ "137.31.24.0/23",
+ "155.95.86.0/24",
+ "205.220.12.0/23",
+ "140.232.190.0/24",
+ "137.31.114.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "aba848e0c0078785",
+ "asn": "396986",
+ "ts": "2026-07-15T03:59:51.702Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "71.18.45.0/24",
+ "71.18.66.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "101.45.6.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.18.0/24",
+ "71.18.65.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "5e648fa861caabcb",
+ "asn": "132372",
+ "ts": "2026-07-15T05:59:52.969Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "f50fd3d9b019f60f",
+ "asn": "138699",
+ "ts": "2026-07-15T06:59:51.698Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "101.45.198.0/24",
+ "2404:9dc0:c002::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "23031dac4ec96573",
+ "asn": "15290",
+ "ts": "2026-07-15T06:59:52.674Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "a46b8ca977e0cfc5",
+ "asn": "24940",
+ "ts": "2026-07-15T07:59:51.363Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "193.105.82.0/24",
+ "2.29.0.0/16",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "67491773ba454e26",
+ "asn": "3491",
+ "ts": "2026-07-15T07:59:53.053Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8f0::/48",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "9fde972e84297ad9",
+ "asn": "34702",
+ "ts": "2026-07-15T08:59:52.292Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "32a6ba0b811278bb",
+ "asn": "22616",
+ "ts": "2026-07-15T09:59:53.724Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.128.0/23",
+ "140.232.189.0/24",
+ "137.31.122.0/23",
+ "137.31.124.0/23",
+ "137.31.120.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "137.31.119.0/24",
+ "140.232.188.0/24",
+ "137.31.126.0/23",
+ "205.220.14.0/23",
+ "137.31.24.0/23",
+ "155.95.86.0/24",
+ "205.220.12.0/23",
+ "140.232.190.0/24",
+ "137.31.114.0/23",
+ "137.31.26.0/23",
+ "165.225.99.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "b72903d54d8d8705",
+ "asn": "396986",
+ "ts": "2026-07-15T10:29:51.682Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "71.18.45.0/24",
+ "71.18.66.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "101.45.6.0/24",
+ "101.45.199.0/24",
+ "2605:340:f049::/48",
+ "101.45.18.0/24",
+ "71.18.65.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "4a2318801159ada4",
+ "asn": "132372",
+ "ts": "2026-07-15T11:59:53.119Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "8fdaa6a50cb01891",
+ "asn": "138699",
+ "ts": "2026-07-15T13:29:51.615Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.198.0/24",
+ "2404:9dc0:c002::/48",
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "5943d1de345838ac",
+ "asn": "15290",
+ "ts": "2026-07-15T13:29:52.431Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "11d64812f7d25748",
+ "asn": "24940",
+ "ts": "2026-07-15T13:59:51.366Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "2.28.0.0/16",
+ "193.105.82.0/24"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "1275ffa07a209ece",
+ "asn": "3491",
+ "ts": "2026-07-15T14:29:51.794Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8f0::/48",
+ "2400:8800:f8fa::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fb::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "8df39b422cdb9693",
+ "asn": "34702",
+ "ts": "2026-07-15T14:59:52.749Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "ef7fe7860371b6c7",
+ "asn": "396986",
+ "ts": "2026-07-15T16:29:51.704Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.6.0/24",
+ "71.18.71.0/24",
+ "71.18.65.0/24",
+ "71.18.64.0/24",
+ "101.45.18.0/24",
+ "2605:340:f049::/48",
+ "71.18.45.0/24",
+ "101.45.15.0/24",
+ "71.18.66.0/24",
+ "101.45.202.0/24",
+ "2605:340:f04a::/48",
+ "71.18.44.0/24",
+ "101.45.199.0/24",
+ "71.18.67.0/24",
+ "101.45.14.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "14d4c48ce7317e03",
+ "asn": "22616",
+ "ts": "2026-07-15T16:29:53.752Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "140.232.190.0/24",
+ "155.95.86.0/24",
+ "140.232.188.0/24",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "137.31.128.0/23",
+ "137.31.24.0/23",
+ "165.225.99.0/24",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "137.31.120.0/23",
+ "137.31.124.0/23",
+ "137.31.26.0/23",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "137.31.114.0/23",
+ "205.220.14.0/23",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "3c915ae1bf8a755e",
+ "asn": "132372",
+ "ts": "2026-07-15T18:29:53.011Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "fe4018f500c85f61",
+ "asn": "138699",
+ "ts": "2026-07-15T19:29:52.219Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "bc655a6f3f565c2b",
+ "asn": "15290",
+ "ts": "2026-07-15T19:29:53.248Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "341a4a5416d53fb4",
+ "asn": "24940",
+ "ts": "2026-07-15T19:59:51.372Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "193.105.82.0/24",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "0739880a09b2e60e",
+ "asn": "3491",
+ "ts": "2026-07-15T20:59:51.838Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2400:8800:f8fb::/48",
+ "63.219.192.0/24",
+ "2400:8800:f8fa::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "176f637eb9f4b8a0",
+ "asn": "34702",
+ "ts": "2026-07-15T21:29:53.213Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "3497eb91f0a1804b",
+ "asn": "396986",
+ "ts": "2026-07-15T22:29:51.711Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "2605:340:f04a::/48",
+ "101.45.199.0/24",
+ "71.18.65.0/24",
+ "71.18.66.0/24",
+ "71.18.71.0/24",
+ "71.18.67.0/24",
+ "71.18.44.0/24",
+ "101.45.15.0/24",
+ "101.45.6.0/24",
+ "101.45.202.0/24",
+ "2605:340:f049::/48",
+ "101.45.14.0/24",
+ "101.45.18.0/24",
+ "71.18.45.0/24",
+ "71.18.64.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "abf9968f49c729b7",
+ "asn": "22616",
+ "ts": "2026-07-15T22:29:53.837Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "140.232.189.0/24",
+ "137.31.114.0/23",
+ "155.95.86.0/24",
+ "165.225.99.0/24",
+ "137.31.124.0/23",
+ "2605:4300:e500::/40",
+ "137.31.101.0/24",
+ "205.220.12.0/23",
+ "137.31.119.0/24",
+ "205.220.14.0/23",
+ "137.31.128.0/23",
+ "137.31.26.0/23",
+ "137.31.120.0/23",
+ "137.31.24.0/23",
+ "140.232.190.0/24",
+ "140.232.188.0/24",
+ "137.31.122.0/23",
+ "137.31.126.0/23",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "db1240cf335bd1fb",
+ "asn": "132372",
+ "ts": "2026-07-16T00:29:53.114Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2a0d:9842::/48",
+ "103.91.194.0/24"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "6daaace7b94cbf39",
+ "asn": "24940",
+ "ts": "2026-07-16T01:59:51.622Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "2.29.0.0/16",
+ "193.105.82.0/24",
+ "2.28.0.0/16"
+ ],
+ "missing": [
+ "2a11:e980::/29"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS24940: 3 unexpected prefix(es), 1 missing"
+ },
+ {
+ "id": "b91f3e602fd81ca1",
+ "asn": "138699",
+ "ts": "2026-07-16T01:59:52.148Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "101.45.200.0/23",
+ "2404:9dc0:cd04::/48",
+ "2404:9dc0:c002::/48",
+ "101.45.198.0/24"
+ ],
+ "missing": [
+ "118.26.132.0/24",
+ "103.136.222.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS138699: 4 unexpected prefix(es), 2 missing"
+ },
+ {
+ "id": "8b02d291a1af8c3d",
+ "asn": "15290",
+ "ts": "2026-07-16T01:59:53.019Z",
+ "severity": "HIGH",
+ "unexpected": [],
+ "missing": [
+ "2001:4c8:1005:1::/64",
+ "209.112.10.0/24",
+ "2001:4c8:107f:100::/56",
+ "2001:4c8:1097::/48",
+ "2001:4c8:1092::/48",
+ "192.135.87.0/24",
+ "2001:4c8:4:1::/64",
+ "216.123.66.0/24",
+ "2001:4c8:1089::/48",
+ "216.123.80.0/24",
+ "203.186.117.0/24",
+ "206.191.100.0/24",
+ "2001:4c8:107f:8000::/56",
+ "216.129.75.0/24",
+ "203.186.119.0/24",
+ "74.216.180.0/24",
+ "209.82.49.0/24",
+ "2001:4c8:108e::/48",
+ "159.18.219.0/24",
+ "209.112.42.0/24",
+ "2001:4c8:100f:2::/64",
+ "216.13.190.0/24",
+ "2001:4c8:108a::/48",
+ "2001:4c8:1080::/48",
+ "2001:4c8:101f::/48",
+ "204.138.183.0/24",
+ "2001:4c8:e:10::/64",
+ "216.13.26.0/24",
+ "2001:4c8:1090::/48",
+ "216.13.36.0/24",
+ "216.13.115.0/24",
+ "74.216.227.0/24",
+ "2001:4c8:107f:8200::/56",
+ "2001:4c8:107f:200::/56",
+ "203.186.116.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS15290: 0 unexpected prefix(es), 35 missing"
+ },
+ {
+ "id": "496424b0fc064555",
+ "asn": "3491",
+ "ts": "2026-07-16T02:59:52.248Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "63.219.192.0/24",
+ "2400:8800:f8fa::/48",
+ "2400:8800:f8fb::/48",
+ "2400:8800:f8f0::/48",
+ "209.8.154.0/23"
+ ],
+ "missing": [
+ "72.57.243.0/24",
+ "45.195.111.0/24",
+ "72.57.240.0/24",
+ "72.57.250.0/24",
+ "72.57.244.0/24",
+ "72.57.249.0/24",
+ "72.57.246.0/24",
+ "72.57.248.0/24",
+ "72.57.241.0/24",
+ "72.57.253.0/24",
+ "154.194.34.0/24",
+ "101.60.254.0/24",
+ "72.57.247.0/24",
+ "72.57.254.0/24",
+ "2400:8800:f827::/48",
+ "207.176.31.0/24",
+ "72.57.252.0/24",
+ "72.57.245.0/24",
+ "72.57.255.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS3491: 5 unexpected prefix(es), 19 missing"
+ },
+ {
+ "id": "9088627d62eade29",
+ "asn": "34702",
+ "ts": "2026-07-16T03:59:52.690Z",
+ "severity": "MEDIUM",
+ "unexpected": [
+ "185.200.196.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS34702: 1 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "6dd656f19f6a1924",
+ "asn": "396986",
+ "ts": "2026-07-16T04:29:51.725Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "101.45.18.0/24",
+ "101.45.199.0/24",
+ "2605:340:f04a::/48",
+ "101.45.202.0/24",
+ "71.18.66.0/24",
+ "101.45.15.0/24",
+ "71.18.44.0/24",
+ "101.45.6.0/24",
+ "71.18.45.0/24",
+ "71.18.64.0/24",
+ "71.18.71.0/24",
+ "71.18.67.0/24",
+ "71.18.65.0/24",
+ "2605:340:f049::/48",
+ "101.45.14.0/24",
+ "101.45.23.0/24"
+ ],
+ "missing": [
+ "71.18.138.0/23",
+ "2605:340:f082::/48",
+ "71.18.64.0/21",
+ "71.18.233.0/24",
+ "2605:340:f051::/48"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS396986: 16 unexpected prefix(es), 5 missing"
+ },
+ {
+ "id": "5d4e90a8549f3771",
+ "asn": "22616",
+ "ts": "2026-07-16T04:59:53.845Z",
+ "severity": "CRITICAL",
+ "unexpected": [
+ "137.31.24.0/23",
+ "137.31.101.0/24",
+ "205.220.14.0/23",
+ "137.31.120.0/23",
+ "137.31.114.0/23",
+ "137.31.119.0/24",
+ "2605:4300:e500::/40",
+ "137.31.26.0/23",
+ "137.31.126.0/23",
+ "137.31.122.0/23",
+ "140.232.189.0/24",
+ "137.31.128.0/23",
+ "140.232.190.0/24",
+ "137.31.124.0/23",
+ "155.95.86.0/24",
+ "165.225.99.0/24",
+ "205.220.12.0/23",
+ "140.232.188.0/24",
+ "89.18.89.0/24",
+ "89.18.90.0/24"
+ ],
+ "missing": [],
+ "resolved": false,
+ "msg": "BGP anomaly for AS22616: 20 unexpected prefix(es), 0 missing"
+ },
+ {
+ "id": "c37eb29c5c54c5a1",
+ "asn": "132372",
+ "ts": "2026-07-16T06:29:59.070Z",
+ "severity": "HIGH",
+ "unexpected": [
+ "103.91.194.0/24",
+ "2a0d:9842::/48"
+ ],
+ "missing": [
+ "172.94.65.0/24",
+ "172.94.34.0/24",
+ "172.94.97.0/24",
+ "172.94.29.0/24",
+ "172.94.99.0/24"
+ ],
+ "resolved": false,
+ "msg": "BGP anomaly for AS132372: 2 unexpected prefix(es), 5 missing"
+ }
+ ],
+ "monitoring": false,
+ "prefix_count": 0
+ }
+ },
+ "aspa-adoption-page": {
+ "status": 200,
+ "body": {
+ "__non_json__": true,
+ "status": 200,
+ "bodyLength": 7059
+ }
+ },
+ "aspa-adoption-stats": {
+ "status": 200,
+ "body": {
+ "period": "30d",
+ "current": {
+ "date": "2026-07-17"
+ },
+ "trend": [
+ {
+ "date": "2026-06-17"
+ },
+ {
+ "date": "2026-06-18"
+ },
+ {
+ "date": "2026-06-19"
+ },
+ {
+ "date": "2026-06-20"
+ },
+ {
+ "date": "2026-06-21"
+ },
+ {
+ "date": "2026-06-22"
+ },
+ {
+ "date": "2026-06-23"
+ },
+ {
+ "date": "2026-06-24"
+ },
+ {
+ "date": "2026-06-25"
+ },
+ {
+ "date": "2026-06-26"
+ },
+ {
+ "date": "2026-06-27"
+ },
+ {
+ "date": "2026-06-28"
+ },
+ {
+ "date": "2026-06-29"
+ },
+ {
+ "date": "2026-06-30"
+ },
+ {
+ "date": "2026-07-01"
+ },
+ {
+ "date": "2026-07-02"
+ },
+ {
+ "date": "2026-07-03"
+ },
+ {
+ "date": "2026-07-04"
+ },
+ {
+ "date": "2026-07-05"
+ },
+ {
+ "date": "2026-07-06"
+ },
+ {
+ "date": "2026-07-07"
+ },
+ {
+ "date": "2026-07-08"
+ },
+ {
+ "date": "2026-07-09"
+ },
+ {
+ "date": "2026-07-10"
+ },
+ {
+ "date": "2026-07-11"
+ },
+ {
+ "date": "2026-07-12"
+ },
+ {
+ "date": "2026-07-13"
+ },
+ {
+ "date": "2026-07-14"
+ },
+ {
+ "date": "2026-07-15"
+ },
+ {
+ "date": "2026-07-16"
+ },
+ {
+ "date": "2026-07-17"
+ }
+ ],
+ "meta": {
+ "total_snapshots": 80,
+ "denominator": "atlas_visible_asns",
+ "source": "rpki_cloudflare_feed"
+ }
+ }
+ },
+ "ipv6-adoption-stats": {
+ "status": 200,
+ "body": {}
+ },
+ "atlas-coverage": {
+ "status": 200,
+ "body": {
+ "pages_fetched": 30
+ }
+ },
+ "submarine-cables": {
+ "status": 502,
+ "body": {
+ "error": "API server unavailable",
+ "detail": "connect ECONNREFUSED 127.0.0.1:39102"
+ }
+ },
+ "communities": {
+ "status": 502,
+ "body": {
+ "error": "API server unavailable",
+ "detail": "connect ECONNREFUSED 127.0.0.1:39102"
+ }
+ }
+}
\ No newline at end of file
diff --git a/server/__smoke__/compare-responses.js b/server/__smoke__/compare-responses.js
new file mode 100644
index 0000000..1522b0d
--- /dev/null
+++ b/server/__smoke__/compare-responses.js
@@ -0,0 +1,294 @@
+#!/usr/bin/env node
+// Regression gate for the server.js module-extraction refactor (see
+// /Users/renefichtmueller/.claude/plans/linked-riding-newell.md). Not shipped
+// to prod -- local dev tool only.
+//
+// Usage:
+// node server/__smoke__/compare-responses.js capture # save baseline (run once, before Phase A)
+// node server/__smoke__/compare-responses.js diff # run after every extraction step
+
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+const { spawn, execFileSync } = require("child_process");
+
+// server.js writes real snapshots into these git-tracked files at startup
+// (ASPA adoption history) and at runtime (hijack alerts, webhook subs) --
+// snapshot + restore around every local boot so smoke-testing never leaves
+// lasting mutations on data that's supposed to hold genuine production
+// history, not local test noise.
+const REPO_ROOT = path.join(__dirname, "..", "..");
+const MUTABLE_TRACKED_FILES = ["aspa-adoption-history.json", "hijack-alerts.json", "webhook-subs.json"];
+
+function restoreTrackedFiles() {
+ try {
+ execFileSync("git", ["checkout", "--", ...MUTABLE_TRACKED_FILES], { cwd: REPO_ROOT, stdio: "ignore" });
+ } catch {
+ // fine if a file has no uncommitted changes (git checkout on a clean path is a no-op anyway)
+ }
+}
+
+const PORT = 3199;
+const BASE = `http://127.0.0.1:${PORT}`;
+const BASELINE_DIR = path.join(__dirname, "baseline");
+const ASN = 13335; // Cloudflare -- stable, public, always has real data
+const ASN2 = 15169; // Google -- for compare/relationships
+
+// One representative, read-only (GET) request per route from the exploration
+// map. POST/DELETE mutating routes (feedback POST, webhooks POST/DELETE,
+// hijacks resolve) are intentionally excluded -- not idempotent-safe to
+// re-run repeatedly across every extraction step.
+const ENDPOINTS = [
+ ["root", "/"],
+ ["search", `/api/search?q=cloudflare`],
+ ["visitors", "/api/visitors"],
+ ["health", "/api/health"],
+ ["aspa-verify", `/api/aspa/verify?asn=${ASN}`],
+ ["aspa-legacy", `/api/aspa?asn=${ASN}`],
+ ["bgp", `/api/bgp?asn=${ASN}`],
+ ["validate", `/api/validate?asn=${ASN}`],
+ ["lookup", `/api/lookup?asn=${ASN}`],
+ ["relationships", `/api/relationships?asn=${ASN}`],
+ ["compare", `/api/compare?asn1=${ASN}&asn2=${ASN2}`],
+ ["quick-ix", `/api/quick-ix?asn=${ASN}`],
+ ["peers-find", `/api/peers/find?asn=${ASN}`],
+ ["prefix-detail", "/api/prefix/detail?prefix=1.1.1.0/24"],
+ ["ix-detail", "/api/ix/detail?asn=${ASN}"],
+ ["topology", `/api/topology?asn=${ASN}`],
+ ["whois", `/api/whois?asn=${ASN}`],
+ ["enrich", `/api/enrich?asn=${ASN}`],
+ ["changelog", "/changelog"],
+ ["webhooks-list", "/api/webhooks"],
+ ["hijacks-list", "/api/hijacks"],
+ ["hijack-alerts", "/api/hijack-alerts"],
+ ["aspa-adoption-page", "/aspa-adoption"],
+ ["aspa-adoption-stats", "/api/aspa-adoption-stats?period=30d"],
+ ["ipv6-adoption-stats", "/api/ipv6-adoption-stats"],
+ ["atlas-coverage", "/api/atlas/coverage"],
+ // proxy stubs -- expected to fail identically (peercortex-api not running
+ // locally either); still worth diffing so the *error shape* stays stable
+ ["submarine-cables", "/api/submarine-cables"],
+ ["communities", "/api/communities?asn=" + ASN],
+];
+
+// Fields that legitimately change between runs -- excluded from the diff.
+// Two sources of real (non-bug) drift observed empirically by diffing an
+// unmodified server.js against itself: (1) process-local counters (uptime,
+// memory), and (2) live third-party data that changes minute-to-minute
+// (RIPE Atlas probe counts/IDs, Cloudflare RPKI feed size, health_score
+// insofar as it's derived from live external checks like MANRS that can
+// time out differently run to run).
+const NONDETERMINISTIC_KEY_PATTERN =
+ /^(generated_at|timestamp|checked_at|fetched_at|query_time|cache_age|age_seconds|age_minutes|uptime_seconds|memory_mb|response_time_ms|duration_ms|last_updated|health_score|delta_last|history_samples|total_probes|total_connected|unique_asns_with_probes|atlas_asns_total|atlas_asns_with_aspa|coverage_pct_atlas|asns_with_probes|roa_count|aspa_objects)$/i;
+
+// Whole sub-trees excluded by dotted path (relative to the endpoint's body),
+// found empirically by diffing an unmodified server.js against itself twice.
+// Each of these depends on a live third-party call that can legitimately
+// time out or return fresh-but-different data run to run (RIPE Stat routing
+// status, RIS prefix history, PeeringDB unauthenticated rate limits, RIPE
+// Atlas probe churn, source-fetch timing diagnostics) -- none of it reflects
+// server.js's own logic, so a diff here is noise, not a regression signal.
+const VOLATILE_PATHS = new Set([
+ "lookup.source_timing",
+ "validate.validations.visibility",
+ // redundant summary of validations.* (already diffed in detail) that also
+ // embeds the visibility check's flaky pass/info status
+ "validate.score_breakdown",
+ "prefix-detail.origins",
+ "prefix-detail.first_seen",
+ "aspa-adoption-stats.forecast",
+ "quick-ix.ix_connections",
+ "quick-ix.name",
+ "atlas-coverage.by_country",
+ // live Cloudflare RPKI feed object count ticks between runs; date-rollover
+ // (midnight) shifts the adoption tracker's day-over-day delta too
+ "health.caches.aspa_map.entries",
+ "health.aspa_adoption.total_objects",
+ "aspa-adoption-stats.current.delta_from_previous",
+ // RIR delegation files (RIPE/ARIN/APNIC/AFRINIC/LACNIC) update near-real-time
+ "ipv6-adoption-stats.global_ipv6_pct",
+ "ipv6-adoption-stats.total_ipv4_records",
+ "ipv6-adoption-stats.total_ipv6_records",
+ "ipv6-adoption-stats.by_rir",
+]);
+
+function stripNondeterministic(obj, pathPrefix) {
+ if (Array.isArray(obj)) return obj.map((v) => stripNondeterministic(v, pathPrefix));
+ if (obj && typeof obj === "object") {
+ const out = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (NONDETERMINISTIC_KEY_PATTERN.test(k)) continue;
+ const fullPath = pathPrefix ? `${pathPrefix}.${k}` : k;
+ if (VOLATILE_PATHS.has(fullPath)) continue;
+ out[k] = stripNondeterministic(v, fullPath);
+ }
+ return out;
+ }
+ return obj;
+}
+
+function fetchOne(name, pathAndQuery) {
+ return new Promise((resolve) => {
+ const req = http.get(BASE + pathAndQuery, { timeout: 20000 }, (res) => {
+ let body = "";
+ res.on("data", (c) => (body += c));
+ res.on("end", () => {
+ let parsed;
+ try {
+ parsed = JSON.parse(body);
+ } catch {
+ parsed = { __non_json__: true, status: res.statusCode, bodyLength: body.length };
+ }
+ resolve({ status: res.statusCode, body: stripNondeterministic(parsed, name) });
+ });
+ });
+ req.on("timeout", () => {
+ req.destroy();
+ resolve({ status: 0, body: { __timeout__: true } });
+ });
+ req.on("error", (err) => resolve({ status: 0, body: { __error__: err.message } }));
+ });
+}
+
+function waitForServer(maxWaitMs = 100000) {
+ // Startup does real, sequential network fetches (Cloudflare RPKI/ROA feed
+ // ~972k entries, full paginated RIPE Atlas probe list, IPv6 RIR delegation
+ // stats) before calling server.listen() -- observed ~60s locally.
+ const start = Date.now();
+ return new Promise((resolve, reject) => {
+ const tryOnce = () => {
+ const req = http.get(`${BASE}/api/health`, { timeout: 2000 }, (res) => {
+ res.resume();
+ resolve();
+ });
+ req.on("error", () => {
+ if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
+ setTimeout(tryOnce, 500);
+ });
+ req.on("timeout", () => {
+ req.destroy();
+ if (Date.now() - start > maxWaitMs) return reject(new Error("server didn't come up in time"));
+ setTimeout(tryOnce, 500);
+ });
+ };
+ tryOnce();
+ });
+}
+
+async function runAgainstLiveServer() {
+ const results = {};
+ for (const [name, pathAndQuery] of ENDPOINTS) {
+ results[name] = await fetchOne(name, pathAndQuery);
+ }
+ return results;
+}
+
+function leafDiffs(a, b, pathPrefix, out) {
+ if (JSON.stringify(a) === JSON.stringify(b)) return;
+ const bothPlainObjects =
+ a && b && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b);
+ if (bothPlainObjects) {
+ for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
+ leafDiffs(a[k], b[k], pathPrefix + "." + k, out);
+ }
+ } else {
+ out.push(`${pathPrefix}: ${JSON.stringify(a)} -> ${JSON.stringify(b)}`);
+ }
+}
+
+function diffResults(baseline, current) {
+ const diffs = [];
+ for (const [name] of ENDPOINTS) {
+ const b = JSON.stringify(baseline[name]);
+ const c = JSON.stringify(current[name]);
+ if (b !== c) {
+ const leaves = [];
+ leafDiffs(baseline[name], current[name], name, leaves);
+ diffs.push({ name, leaves });
+ }
+ }
+ return diffs;
+}
+
+async function main() {
+ const mode = process.argv[2];
+ if (mode !== "capture" && mode !== "diff") {
+ console.error("Usage: node compare-responses.js ");
+ process.exit(1);
+ }
+
+ restoreTrackedFiles();
+
+ const serverEntry = path.join(__dirname, "..", "..", "server.js");
+ const child = spawn(process.execPath, [serverEntry], {
+ cwd: path.join(__dirname, "..", ".."),
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ // Node 22's stricter header validation throws on Authorization:
+ // undefined, which server.js's fetchPdbOrgCountries() produces when
+ // PEERINGDB_API_KEY is unset (line ~6689). This is a real bug (tracked
+ // separately, fixed during the pdb-org-countries.js extraction in
+ // Phase B) but it blocks the server from even booting for local
+ // testing -- work around it here at the test-harness level only, so
+ // the baseline reflects real behavior rather than a crash.
+ PEERINGDB_API_KEY: process.env.PEERINGDB_API_KEY || "smoke-test-placeholder",
+ // Force the proxy-to-peercortex-api path to genuinely fail closed
+ // instead of cross-talking with whatever unrelated dev server happens
+ // to be listening on the real default port (3102) on this machine --
+ // observed proxying straight into an unrelated Next.js app's 404 page.
+ API_SERVER_PORT: "39102",
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let stderrBuf = "";
+ child.stderr.on("data", (c) => (stderrBuf += c));
+
+ const cleanup = () => {
+ try {
+ child.kill("SIGTERM");
+ } catch {}
+ };
+ process.on("exit", cleanup);
+
+ try {
+ await waitForServer();
+ console.log(`Server up on port ${PORT}, running ${ENDPOINTS.length} requests...`);
+ const results = await runAgainstLiveServer();
+
+ if (mode === "capture") {
+ fs.mkdirSync(BASELINE_DIR, { recursive: true });
+ fs.writeFileSync(path.join(BASELINE_DIR, "baseline.json"), JSON.stringify(results, null, 2));
+ console.log(`Baseline captured: ${ENDPOINTS.length} endpoints -> ${BASELINE_DIR}/baseline.json`);
+ } else {
+ const baselinePath = path.join(BASELINE_DIR, "baseline.json");
+ if (!fs.existsSync(baselinePath)) {
+ console.error("No baseline found -- run `capture` first.");
+ process.exit(1);
+ }
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
+ const diffs = diffResults(baseline, results);
+ if (diffs.length === 0) {
+ console.log(`PASS -- all ${ENDPOINTS.length} endpoints match baseline.`);
+ } else {
+ console.log(`FAIL -- ${diffs.length}/${ENDPOINTS.length} endpoint(s) differ from baseline:`);
+ for (const d of diffs) {
+ console.log(`\n=== ${d.name} (${d.leaves.length} leaf change(s)) ===`);
+ for (const line of d.leaves.slice(0, 20)) console.log(" " + line);
+ if (d.leaves.length > 20) console.log(` ... and ${d.leaves.length - 20} more`);
+ }
+ process.exitCode = 1;
+ }
+ }
+ } catch (err) {
+ console.error("Smoke test failed to run:", err.message);
+ if (stderrBuf) console.error("--- server stderr ---\n" + stderrBuf.slice(0, 3000));
+ process.exitCode = 1;
+ } finally {
+ cleanup();
+ restoreTrackedFiles();
+ }
+}
+
+main();
diff --git a/server/aspa-adoption/tracker.js b/server/aspa-adoption/tracker.js
new file mode 100644
index 0000000..29d6cc8
--- /dev/null
+++ b/server/aspa-adoption/tracker.js
@@ -0,0 +1,291 @@
+const fs = require("fs");
+const { DATA_DIR } = require("../hijack-monitoring/store");
+const { atlasState } = require("../atlas-probes");
+
+const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
+
+// Load persisted ASPA adoption history from disk.
+// Returns array of snapshot objects (up to 365 entries).
+function loadAspaAdoptionHistory() {
+ try {
+ const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
+ return Array.isArray(raw) ? raw : [];
+ } catch(_) { return []; }
+}
+
+// Save ASPA adoption history to disk (keep last 365 entries).
+function saveAspaAdoptionHistory(history) {
+ try {
+ const trimmed = history.slice(-365);
+ fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
+ } catch(e) {
+ console.error('[ASPA-ADOPT] Save failed:', e.message);
+ }
+}
+
+// In-memory adoption history (persisted on each new snapshot). Mutated
+// in place (push / index-assign), never reassigned -- safe to export as a
+// live array reference, same pattern as roaStore/pdbSourceCache.
+let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
+
+// Take a new adoption snapshot and persist it. Called whenever the RPKI feed
+// is refreshed.
+// @param {number} aspaCount — total ASPA objects in rpkiAspaMap
+// @param {number} roaCount — total ROAs in roaStore
+// @param {Map} rpkiAspaMap — customer_asid -> Set, owned by
+// server/services/rpki.js; passed in explicitly (rather than required here)
+// to avoid a circular require between this module and rpki.js.
+function recordAspaAdoptionSnapshot(aspaCount, roaCount, rpkiAspaMap) {
+ const now = new Date();
+ const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
+
+ // Compute how many Atlas-visible ASNs have ASPA
+ let atlasTotal = 0;
+ let atlasWithAspa = 0;
+ if (atlasState.cache?.asns_with_probes) {
+ atlasTotal = atlasState.cache.asns_with_probes.length;
+ // rpkiAspaMap is keyed by integer ASN
+ atlasWithAspa = atlasState.cache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
+ }
+
+ const coveragePct = atlasTotal > 0
+ ? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
+ : 0;
+
+ // Compute delta from previous snapshot
+ const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const delta = prev ? aspaCount - prev.aspa_objects : 0;
+
+ const snapshot = {
+ date,
+ ts: now.getTime(),
+ aspa_objects: aspaCount,
+ roa_count: roaCount,
+ atlas_asns_total: atlasTotal,
+ atlas_asns_with_aspa: atlasWithAspa,
+ coverage_pct_atlas: coveragePct,
+ aspa_delta: delta,
+ method: 'rpki_cloudflare_feed',
+ };
+
+ // Only store one snapshot per date (overwrite if same day)
+ const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
+ if (existingIdx >= 0) {
+ aspaAdoptionDailyHistory[existingIdx] = snapshot;
+ } else {
+ aspaAdoptionDailyHistory.push(snapshot);
+ }
+ saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
+ console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
+ return snapshot;
+}
+
+// Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
+function getAdoptionTrend(period) {
+ const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
+ return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
+}
+
+// Build the ASPA adoption dashboard HTML.
+function buildAspaAdoptionDashboard() {
+ const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const trend30 = getAdoptionTrend('30d');
+ const trend7 = getAdoptionTrend('7d');
+
+ // Forecast: simple linear regression over last 30 days
+ function linearForecast(data, daysAhead) {
+ if (data.length < 2) return null;
+ const n = data.length;
+ const xs = data.map((_, i) => i);
+ const ys = data.map(d => d.coverage_pct_atlas);
+ const xMean = xs.reduce((a, b) => a + b, 0) / n;
+ const yMean = ys.reduce((a, b) => a + b, 0) / n;
+ const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
+ const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
+ if (den === 0) return yMean;
+ const slope = num / den;
+ return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
+ }
+
+ const forecast90d = linearForecast(trend30, 90);
+ const change7d = trend7.length >= 2
+ ? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
+ : 0;
+ const trendLabels = trend30.map(d => d.date);
+ const trendValues = trend30.map(d => d.coverage_pct_atlas);
+ const asnValues = trend30.map(d => d.aspa_objects);
+
+ const coverageNow = latest?.coverage_pct_atlas ?? 0;
+ const aspaNow = latest?.aspa_objects ?? 0;
+ const atlasTotal = latest?.atlas_asns_total ?? 0;
+
+ return `
+
+
+
+
+ ASPA Adoption Tracker · PeerCortex
+
+
+
+
+
+ ⬡ PeerCortex Network Intelligence
+ Dashboard
+ ASPA Tracker
+
+
+
ASPA Adoption Tracker
+
Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours
+
+
+
+
Atlas Coverage
+
${coverageNow.toFixed(1)}%
+
of Atlas-visible ASNs
+
+
+
ASPA Objects
+
${aspaNow.toLocaleString()}
+
total in RPKI
+
+
+
Atlas ASNs
+
${atlasTotal.toLocaleString()}
+
sampled denominator
+
+
+
7-Day Delta
+
${change7d > 0 ? '+' : ''}${change7d}
+
new ASPA objects
+
+
+
Data Points
+
${aspaAdoptionDailyHistory.length}
+
history snapshots
+
+
+
+
+
+
+
+
Atlas Coverage % (last 30 days)
+
+
+
+
90-Day Forecast
+
${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}
+
linear projection from 30-day trend
+
+ Based on ${trend30.length} data points.
+ Current: ${coverageNow.toFixed(1)}%
+
+
+
+
+
+
Total ASPA Objects in RPKI (last 30 days)
+
+
+
+
+
+
+
+
+`;
+}
+
+module.exports = {
+ aspaAdoptionDailyHistory,
+ loadAspaAdoptionHistory,
+ saveAspaAdoptionHistory,
+ recordAspaAdoptionSnapshot,
+ getAdoptionTrend,
+ buildAspaAdoptionDashboard,
+};
diff --git a/server/aspa-verification/engine.js b/server/aspa-verification/engine.js
new file mode 100644
index 0000000..b4767da
--- /dev/null
+++ b/server/aspa-verification/engine.js
@@ -0,0 +1,224 @@
+// RFC-Compliant ASPA Verification Engine
+// Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification.
+
+// Check if AS path contains AS_SET segments (curly braces indicate sets)
+function hasAsSet(asPath) {
+ if (typeof asPath === "string") {
+ return asPath.includes("{") || asPath.includes("}");
+ }
+ return false;
+}
+
+// Hop Check function (core of ASPA verification)
+// aspaStore = Map> (CAS -> provider set)
+// hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass
+// the customer first, provider second -- see draft-ietf-sidrops-aspa-verification
+// section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side.
+function hopCheck(asI, asJ, aspaStore) {
+ const providers = aspaStore.get(asI);
+ if (!providers) return "NoAttestation";
+ return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus";
+}
+
+// Collapse AS path prepends (remove consecutive duplicates)
+function collapsePrepends(path) {
+ return path.filter((as, i) => i === 0 || as !== path[i - 1]);
+}
+
+// Upstream Verification (RFC Section 6.1)
+function verifyUpstream(asPath, aspaStore, rawPathStr) {
+ if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
+ const collapsed = collapsePrepends(asPath);
+ if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" };
+
+ const hops = [];
+ let hasNoAttestation = false;
+
+ for (let i = 1; i < collapsed.length; i++) {
+ // collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to
+ // the validator) is the provider it's declaring -- customer goes first in hopCheck.
+ const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
+ hops.push({
+ from: collapsed[i - 1],
+ to: collapsed[i],
+ result: check,
+ });
+ if (check === "NotProviderPlus") {
+ return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops };
+ }
+ if (check === "NoAttestation") hasNoAttestation = true;
+ }
+
+ return {
+ result: hasNoAttestation ? "Unknown" : "Valid",
+ reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus",
+ hops,
+ };
+}
+
+// Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5.
+// Unlike upstream verification, a downstream path may legitimately contain both
+// an up-ramp (customer->provider hops, near the origin) AND a down-ramp
+// (provider->customer hops, near the validator), transitioning at most once --
+// e.g. origin -> its providers -> a peering exchange -> validator's providers ->
+// validator. Section 5.3 defines four ramp lengths computed from two independent
+// scans, verified 2026-07-16 against a worked example fetched from the draft
+// (N=4 path with a single unattested/no-attestation "kink" correctly resolves
+// to Valid, and a path with definite failures on both ends correctly resolves
+// to Invalid, matching this implementation).
+function verifyDownstream(asPath, aspaStore, rawPathStr) {
+ if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" };
+ const collapsed = collapsePrepends(asPath);
+ const N = collapsed.length;
+ if (N <= 2) return { result: "Valid", reason: "Path length <= 2" };
+
+ const hops = [];
+ for (let i = 1; i < N; i++) {
+ hops.push({
+ from: collapsed[i - 1],
+ to: collapsed[i],
+ result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore),
+ });
+ }
+
+ // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator
+ // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending.
+ // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus");
+ // minUpRamp stops at the first hop that isn't confirmed ProviderPlus
+ // (including unattested "NoAttestation" hops, which don't disprove a leak
+ // but also don't prove the path is clean).
+ let maxUpRamp = N;
+ let minUpRamp = N;
+ for (let k = N - 1; k >= 1; k--) {
+ const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore);
+ const I = N - k;
+ if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I;
+ if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I;
+ if (maxUpRamp !== N && minUpRamp !== N) break;
+ }
+
+ // Down-ramp scan: the mirror image, walking from the validator (collapsed[0])
+ // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending.
+ let maxDownRamp = N;
+ let minDownRamp = N;
+ for (let k = 0; k <= N - 2; k++) {
+ const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore);
+ const rampLen = k + 1; // = N - J + 1 for the corresponding J
+ if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen;
+ if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen;
+ if (maxDownRamp !== N && minDownRamp !== N) break;
+ }
+
+ if (maxUpRamp + maxDownRamp < N) {
+ return {
+ result: "Invalid",
+ reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path",
+ hops,
+ };
+ }
+ if (minUpRamp + minDownRamp < N) {
+ return {
+ result: "Unknown",
+ reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation",
+ hops,
+ };
+ }
+ return {
+ result: "Valid",
+ reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N,
+ hops,
+ };
+}
+
+// Valley Detection: scan path for up-down-up pattern (route leak indicator)
+function detectValleys(asPath, aspaStore) {
+ const collapsed = collapsePrepends(asPath);
+ if (collapsed.length < 4) return [];
+
+ const valleys = [];
+ // Walk the path and look at relationship transitions
+ const relationships = [];
+ for (let i = 1; i < collapsed.length; i++) {
+ const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore);
+ const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore);
+ let rel = "unknown";
+ if (fwd === "ProviderPlus") rel = "customer-to-provider";
+ else if (rev === "ProviderPlus") rel = "provider-to-customer";
+ else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer";
+ relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel });
+ }
+
+ // Detect c2p -> p2c -> c2p pattern
+ for (let i = 0; i < relationships.length - 2; i++) {
+ if (
+ relationships[i].rel === "customer-to-provider" &&
+ relationships[i + 1].rel === "provider-to-customer" &&
+ relationships[i + 2].rel === "customer-to-provider"
+ ) {
+ valleys.push({
+ position: i,
+ path_segment: [
+ relationships[i].from,
+ relationships[i].to,
+ relationships[i + 1].to,
+ relationships[i + 2].to,
+ ].map((a) => "AS" + a),
+ description:
+ "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to +
+ " (c2p) -> AS" + relationships[i + 1].to +
+ " (p2c) -> AS" + relationships[i + 2].to + " (c2p)",
+ });
+ }
+ }
+
+ return valleys;
+}
+
+// Build ASPA store from detected provider relationships
+function buildAspaStore(detectedProviders, targetAsn) {
+ const store = new Map();
+ // Add the target ASN's providers
+ if (detectedProviders.length > 0) {
+ const providerSet = new Set(detectedProviders.map((p) => p.asn));
+ store.set(targetAsn, providerSet);
+ }
+ return store;
+}
+
+// Calculate ASPA Readiness Score (0-100)
+function calculateAspaReadinessScore(params) {
+ const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params;
+
+ // ROA coverage (0-25 points)
+ const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25);
+
+ // ASPA object exists (0-25 points)
+ const aspaScore = aspaObjectExists ? 25 : 0;
+
+ // Provider completeness (0-25 points)
+ const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25);
+
+ // Path validation results (0-25 points)
+ const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25);
+
+ return {
+ total: roaScore + aspaScore + provScore + pathScore,
+ breakdown: {
+ roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage },
+ aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists },
+ provider_completeness: { score: provScore, max: 25, value: providerCompleteness },
+ path_validation: { score: pathScore, max: 25, value: pathValidationPct },
+ },
+ };
+}
+
+module.exports = {
+ hasAsSet,
+ hopCheck,
+ collapsePrepends,
+ verifyUpstream,
+ verifyDownstream,
+ detectValleys,
+ buildAspaStore,
+ calculateAspaReadinessScore,
+};
diff --git a/server/atlas-probes.js b/server/atlas-probes.js
new file mode 100644
index 0000000..1576906
--- /dev/null
+++ b/server/atlas-probes.js
@@ -0,0 +1,77 @@
+const { fetchJSON } = require("./services/http-helpers");
+
+// Atlas Probe Cache (for Lia's Atlas Paradise). Exposed as a mutable state
+// object (not a bare reassigned export) so every requirer always sees the
+// current value after fetchAllAtlasProbes() resolves -- a plain
+// `module.exports = { atlasProbeCache }` would only capture the value (null)
+// at require time, since CommonJS destructuring doesn't track reassignment.
+const atlasState = {
+ cache: null,
+ fetching: false,
+};
+
+function fetchAllAtlasProbes() {
+ if (atlasState.fetching) return Promise.resolve();
+ atlasState.fetching = true;
+ console.log("[ATLAS] Fetching all Atlas probes...");
+
+ return new Promise(function(resolve) {
+ var allAsns = new Set();
+ var byCountry = {};
+ var pageCount = 0;
+ var maxPages = 40;
+
+ function fetchPage(pageUrl) {
+ if (pageCount >= maxPages) return finish();
+ pageCount++;
+
+ fetchJSON(pageUrl).then(function(data) {
+ if (!data || !data.results) return finish();
+
+ data.results.forEach(function(probe) {
+ var asn4 = probe.asn_v4;
+ var asn6 = probe.asn_v6;
+ var cc = probe.country_code || "XX";
+
+ if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
+ byCountry[cc].total++;
+ if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
+ if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
+ if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
+ });
+
+ if (data.next) {
+ fetchPage(data.next);
+ } else {
+ finish();
+ }
+ }).catch(function() { finish(); });
+ }
+
+ function finish() {
+ var byCountryOut = {};
+ Object.keys(byCountry).forEach(function(cc) {
+ var info = byCountry[cc];
+ byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
+ });
+
+ atlasState.cache = {
+ total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
+ total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
+ unique_asns_with_probes: allAsns.size,
+ asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
+ by_country: byCountryOut,
+ fetched_at: new Date().toISOString(),
+ pages_fetched: pageCount,
+ };
+
+ console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
+ atlasState.fetching = false;
+ resolve();
+ }
+
+ fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
+ });
+}
+
+module.exports = { atlasState, fetchAllAtlasProbes };
diff --git a/server/bgp-he-net.js b/server/bgp-he-net.js
new file mode 100644
index 0000000..709c5de
--- /dev/null
+++ b/server/bgp-he-net.js
@@ -0,0 +1,53 @@
+const { fetchHTML } = require("./services/http-helpers");
+
+// Feature 24: bgp.he.net Integration.
+// NOTE: currently dead code in practice -- /api/lookup's only call site
+// (`timedFetch("bgp.he.net", ...)`) is hardcoded to `Promise.resolve(null)`
+// rather than actually invoking this. Moved as-is (not removed) since that's
+// a "disabled pending re-enable" state, not a provably-unreachable one --
+// removing a whole scraping feature is a bigger decision than this
+// behavior-preserving extraction pass should make unilaterally.
+async function fetchBgpHeNet(asn) {
+ try {
+ const html = await fetchHTML("https://bgp.he.net/AS" + asn);
+ if (!html) return null;
+ const result = {};
+ const titleMatch = html.match(/([^<]+)<\/title>/i);
+ if (titleMatch) result.title = titleMatch[1].trim();
+ const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i);
+ 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);
+ if (descMatch) result.description = descMatch[1].trim();
+ const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i);
+ if (irrMatch) result.irr_record = irrMatch[1].trim();
+ // bgp.he.net format: "Prefixes Originated (v4): 147 " or "Prefixes v4 ... 147"
+ const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i);
+ if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, ''));
+ const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i);
+ if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, ''));
+ const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i);
+ if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, ''));
+ result.source_url = "https://bgp.he.net/AS" + asn;
+ return result;
+ } catch (_e) {
+ return null;
+ }
+}
+
+module.exports = { fetchBgpHeNet };
diff --git a/server/caches/pdb-source-cache.js b/server/caches/pdb-source-cache.js
new file mode 100644
index 0000000..23881d4
--- /dev/null
+++ b/server/caches/pdb-source-cache.js
@@ -0,0 +1,85 @@
+const fs = require("fs");
+
+// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
+// Eliminates redundant PDB API calls under load
+const pdbSourceCache = {
+ net: new Map(), // key: asn string → {data, ts}
+ netixlan: new Map(), // key: net_id string → {data, ts}
+ netfac: new Map(), // key: net_id string → {data, ts}
+ facCoords: new Map(), // key: fac_id string → {lat, lon, ts}
+ TTL_NET: 6 * 60 * 60 * 1000, // 6 hours
+ TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours
+ TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days
+ MAX_NET: 5000,
+ MAX_IXFAC: 5000,
+ MAX_COORDS: 10000,
+ hits: 0,
+ misses: 0,
+
+ get(type, key) {
+ const map = this[type];
+ const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC);
+ const entry = map.get(String(key));
+ if (!entry) { this.misses++; return null; }
+ if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; }
+ this.hits++;
+ return entry.data;
+ },
+
+ set(type, key, data) {
+ const map = this[type];
+ const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC);
+ if (map.size >= max) {
+ // Evict oldest entry (Map preserves insertion order)
+ map.delete(map.keys().next().value);
+ }
+ map.set(String(key), { data, ts: Date.now() });
+ },
+
+ // Disk persistence
+ saveToDisk(filePath) {
+ try {
+ const serialize = (map) => {
+ const obj = {};
+ for (const [k, v] of map) obj[k] = v;
+ return obj;
+ };
+ const data = JSON.stringify({
+ ts: Date.now(),
+ net: serialize(this.net),
+ netixlan: serialize(this.netixlan),
+ netfac: serialize(this.netfac),
+ facCoords: serialize(this.facCoords),
+ });
+ fs.writeFileSync(filePath, data);
+ console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
+ } catch (e) {
+ console.warn("[PDB-CACHE] Disk save failed:", e.message);
+ }
+ },
+
+ loadFromDisk(filePath) {
+ try {
+ if (!fs.existsSync(filePath)) return false;
+ const raw = fs.readFileSync(filePath, "utf8");
+ const data = JSON.parse(raw);
+ const now = Date.now();
+ const load = (map, obj, ttl) => {
+ for (const [k, v] of Object.entries(obj || {})) {
+ if (now - v.ts < ttl) map.set(k, v);
+ }
+ };
+ load(this.net, data.net, this.TTL_NET);
+ load(this.netixlan, data.netixlan, this.TTL_IXFAC);
+ load(this.netfac, data.netfac, this.TTL_IXFAC);
+ load(this.facCoords, data.facCoords, this.TTL_COORDS);
+ console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")");
+ return true;
+ } catch (e) {
+ console.warn("[PDB-CACHE] Disk load failed:", e.message);
+ return false;
+ }
+ },
+};
+
+module.exports = { pdbSourceCache };
diff --git a/server/caches/response-caches.js b/server/caches/response-caches.js
new file mode 100644
index 0000000..7565c59
--- /dev/null
+++ b/server/caches/response-caches.js
@@ -0,0 +1,113 @@
+// Task 6: In-memory cache with TTL + Rate Limiting
+const responseCache = new Map();
+
+function cacheGet(key) {
+ const entry = responseCache.get(key);
+ if (!entry) return null;
+ if (Date.now() > entry.expires) {
+ responseCache.delete(key);
+ return null;
+ }
+ return entry.data;
+}
+
+function cacheSet(key, data, ttlMs) {
+ responseCache.set(key, { data, expires: Date.now() + ttlMs });
+ // Evict old entries periodically (keep cache under 500 entries)
+ if (responseCache.size > 500) {
+ const now = Date.now();
+ for (const [k, v] of responseCache) {
+ if (now > v.expires) responseCache.delete(k);
+ }
+ }
+}
+
+const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes
+const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts
+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, ttl }
+const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000;
+// A "not found" result after all 5 RIR sources failed is far more often a shared
+// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real
+// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't
+// tell timeout/network-error apart from a genuine 404, so cache that specific
+// conclusion much more briefly than a confirmed, parsed WHOIS record.
+const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000;
+function whoisCacheGet(asn) {
+ const e = whoisCache.get(String(asn));
+ if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data;
+ return undefined;
+}
+function whoisCacheSet(asn, data, ttl) {
+ if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value);
+ whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL });
+}
+
+// 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 + ASPA result caches — 15min TTL, prevent re-hitting slow APIs
+const bgproutesResultCache = new Map();
+const aspaResultCache = new Map();
+const validateResultCache = new Map();
+const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes
+function resultCacheGet(map, key) {
+ const e = map.get(String(key));
+ const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL;
+ if (e && (Date.now() - e.ts) < ttl) return e.data;
+ return undefined;
+}
+function resultCacheSet(map, key, data, ttlMs) {
+ if (map.size > 2000) map.delete(map.keys().next().value);
+ map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL });
+}
+
+module.exports = {
+ responseCache,
+ cacheGet,
+ cacheSet,
+ RESULT_CACHE_TTL,
+ CACHE_TTL_LOOKUP,
+ CACHE_TTL_LOOKUP_DEGRADED,
+ CACHE_TTL_DEFAULT,
+ rdapCacheGet,
+ rdapCacheSet,
+ whoisCacheGet,
+ whoisCacheSet,
+ WHOIS_NOT_FOUND_CACHE_TTL,
+ quickIxCacheGet,
+ quickIxCacheSet,
+ bgproutesResultCache,
+ aspaResultCache,
+ validateResultCache,
+ resultCacheGet,
+ resultCacheSet,
+};
diff --git a/server/caches/roa-store.js b/server/caches/roa-store.js
new file mode 100644
index 0000000..c2a03a8
--- /dev/null
+++ b/server/caches/roa-store.js
@@ -0,0 +1,225 @@
+const fs = require("fs");
+
+// Local ROA Store — validates prefixes without RIPE Stat API calls.
+// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA.
+// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix)
+const roaStore = {
+ v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start
+ v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex
+ ready: false,
+ count: 0,
+ lastBuild: 0,
+
+ // Parse IPv4 prefix string to 32-bit unsigned integer
+ _ipv4ToUint32(ip) {
+ const parts = ip.split(".");
+ return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
+ },
+
+ // Build ROA store from Cloudflare feed roas array
+ build(roas) {
+ const v4 = [];
+ const v6 = [];
+ for (let i = 0; i < roas.length; i++) {
+ const r = roas[i];
+ const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn);
+ const prefix = r.prefix;
+ const maxLen = r.maxLength || r.maxPrefixLength || 0;
+ if (!prefix || !asn) continue;
+
+ const slashIdx = prefix.indexOf("/");
+ if (slashIdx < 0) continue;
+ const prefixLen = parseInt(prefix.substring(slashIdx + 1));
+ const addr = prefix.substring(0, slashIdx);
+
+ if (prefix.indexOf(":") >= 0) {
+ // IPv6 — store as zero-padded hex string for sorting
+ const expanded = this._expandIPv6(addr);
+ if (expanded) {
+ v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen });
+ }
+ } else {
+ // IPv4 — store as numeric range
+ const start = this._ipv4ToUint32(addr);
+ const hostBits = 32 - prefixLen;
+ const end = (start | ((1 << hostBits) - 1)) >>> 0;
+ v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen });
+ }
+ }
+
+ // Sort for binary search
+ v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen);
+ v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen);
+
+ this.v4Entries = v4;
+ this.v6Entries = v6;
+ this.count = v4.length + v6.length;
+ this.ready = true;
+ this.lastBuild = Date.now();
+ },
+
+ // Expand IPv6 address to 32-char hex for reliable sorting
+ _expandIPv6(addr) {
+ try {
+ let groups = addr.split("::");
+ let left = groups[0] ? groups[0].split(":") : [];
+ let right = groups.length > 1 && groups[1] ? groups[1].split(":") : [];
+ const missing = 8 - left.length - right.length;
+ const mid = [];
+ for (let i = 0; i < missing; i++) mid.push("0000");
+ const all = [...left, ...mid, ...right];
+ return all.map(g => g.padStart(4, "0")).join("");
+ } catch (_e) {
+ return null;
+ }
+ },
+
+ // Validate a prefix against the local ROA store
+ // Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N}
+ validate(asn, prefix) {
+ if (!this.ready) return null; // Signal caller to use fallback
+
+ const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn);
+ const slashIdx = prefix.indexOf("/");
+ if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 };
+ const prefixLen = parseInt(prefix.substring(slashIdx + 1));
+ const addr = prefix.substring(0, slashIdx);
+
+ if (prefix.indexOf(":") >= 0) {
+ return this._validateV6(asnNum, addr, prefixLen, prefix);
+ }
+ return this._validateV4(asnNum, addr, prefixLen, prefix);
+ },
+
+ _validateV4(asn, addr, prefixLen, prefix) {
+ const queryStart = this._ipv4ToUint32(addr);
+ const entries = this.v4Entries;
+
+ // Binary search: find rightmost entry where start <= queryStart
+ let lo = 0, hi = entries.length - 1;
+ let insertionPoint = -1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ if (entries[mid].start <= queryStart) {
+ insertionPoint = mid;
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+
+ // Scan backwards from insertion point to find covering ROAs
+ // ROAs are sorted by start, so we scan back while start could still cover our prefix
+ const matched = [];
+ const unmatchedAs = [];
+ for (let i = insertionPoint; i >= 0; i--) {
+ const e = entries[i];
+ // If this ROA's network start is too far left, no more matches possible
+ if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away
+ // Check if query prefix is contained within this ROA
+ if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) {
+ if (prefixLen <= e.maxLen) {
+ if (e.asn === asn) {
+ matched.push(e);
+ } else {
+ unmatchedAs.push(e);
+ }
+ }
+ // prefixLen > maxLen → too specific, invalid if ASN matches
+ else if (e.asn === asn) {
+ unmatchedAs.push(e); // ASN matches but length exceeds maxLen
+ }
+ }
+ }
+
+ if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
+ if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
+ return { prefix, status: "not_found", validating_roas: 0 };
+ },
+
+ _validateV6(asn, addr, prefixLen, prefix) {
+ const queryHex = this._expandIPv6(addr);
+ if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 };
+ const entries = this.v6Entries;
+
+ // Binary search for approximate position
+ let lo = 0, hi = entries.length - 1;
+ let insertionPoint = -1;
+ while (lo <= hi) {
+ const mid = (lo + hi) >>> 1;
+ if (entries[mid].prefixHex <= queryHex) {
+ insertionPoint = mid;
+ lo = mid + 1;
+ } else {
+ hi = mid - 1;
+ }
+ }
+
+ const matched = [];
+ const unmatchedAs = [];
+ // Scan backwards from insertion point
+ for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) {
+ const e = entries[i];
+ // Check if query prefix is covered by this ROA entry
+ // A covering ROA has a shorter or equal prefix length and its network prefix matches
+ if (e.prefixLen <= prefixLen) {
+ // Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up)
+ const hexChars = Math.ceil(e.prefixLen / 4);
+ if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) {
+ if (prefixLen <= e.maxLen) {
+ if (e.asn === asn) matched.push(e);
+ else unmatchedAs.push(e);
+ } else if (e.asn === asn) {
+ unmatchedAs.push(e);
+ }
+ }
+ }
+ // Stop if we're too far away
+ if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break;
+ }
+
+ if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length };
+ if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length };
+ return { prefix, status: "not_found", validating_roas: 0 };
+ },
+
+ // Persist to disk for fast restart
+ saveToDisk(filePath) {
+ try {
+ const data = JSON.stringify({
+ ts: this.lastBuild,
+ v4Count: this.v4Entries.length,
+ v6Count: this.v6Entries.length,
+ v4: this.v4Entries,
+ v6: this.v6Entries,
+ });
+ fs.writeFileSync(filePath, data);
+ console.log("[ROA] Saved " + this.count + " ROAs to disk");
+ } catch (e) {
+ console.warn("[ROA] Disk save failed:", e.message);
+ }
+ },
+
+ // Load from disk cache (returns true if loaded)
+ loadFromDisk(filePath) {
+ try {
+ if (!fs.existsSync(filePath)) return false;
+ const raw = fs.readFileSync(filePath, "utf8");
+ const data = JSON.parse(raw);
+ // Only use if less than 6 hours old
+ if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false;
+ this.v4Entries = data.v4;
+ this.v6Entries = data.v6;
+ this.count = data.v4Count + data.v6Count;
+ this.lastBuild = data.ts;
+ this.ready = true;
+ console.log("[ROA] Loaded " + this.count + " ROAs from disk cache");
+ return true;
+ } catch (e) {
+ console.warn("[ROA] Disk load failed:", e.message);
+ return false;
+ }
+ },
+};
+
+module.exports = { roaStore };
diff --git a/server/data/city-coords.js b/server/data/city-coords.js
new file mode 100644
index 0000000..89686ca
--- /dev/null
+++ b/server/data/city-coords.js
@@ -0,0 +1,26 @@
+// Static geocode cache for major networking cities (fallback when PDB facility coords missing)
+const CITY_COORDS = {
+ "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821],
+ "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417],
+ "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820],
+ "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122],
+ "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384],
+ "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393],
+ "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025],
+ "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784],
+ "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198],
+ "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093],
+ "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298],
+ "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874],
+ "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832],
+ "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940],
+ "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735],
+ "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052],
+ "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903],
+ "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207],
+ "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780],
+ "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456],
+ "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883],
+};
+
+module.exports = { CITY_COORDS };
diff --git a/server/data/constants.js b/server/data/constants.js
new file mode 100644
index 0000000..a2e7a7b
--- /dev/null
+++ b/server/data/constants.js
@@ -0,0 +1,3 @@
+const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
+
+module.exports = { UA };
diff --git a/server/data/ix-city-map.js b/server/data/ix-city-map.js
new file mode 100644
index 0000000..74856ac
--- /dev/null
+++ b/server/data/ix-city-map.js
@@ -0,0 +1,4 @@
+// Small ASN/IX-id → city fallback map used by the /api/lookup facility geocoder
+const IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" };
+
+module.exports = { IX_CITY_MAP };
diff --git a/server/data/rir-country-codes.js b/server/data/rir-country-codes.js
new file mode 100644
index 0000000..28a825a
--- /dev/null
+++ b/server/data/rir-country-codes.js
@@ -0,0 +1,6 @@
+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"]);
+
+module.exports = { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC };
diff --git a/server/data/rir-delegation-urls.js b/server/data/rir-delegation-urls.js
new file mode 100644
index 0000000..44160db
--- /dev/null
+++ b/server/data/rir-delegation-urls.js
@@ -0,0 +1,9 @@
+const RIR_DELEGATION_URLS = {
+ RIPE: 'https://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-extended-latest',
+ ARIN: 'https://ftp.arin.net/pub/stats/arin/delegated-arin-extended-latest',
+ APNIC: 'https://ftp.apnic.net/stats/apnic/delegated-apnic-extended-latest',
+ AFRINIC: 'https://ftp.afrinic.net/stats/afrinic/delegated-afrinic-extended-latest',
+ LACNIC: 'https://ftp.lacnic.net/pub/stats/lacnic/delegated-lacnic-extended-latest',
+};
+
+module.exports = { RIR_DELEGATION_URLS };
diff --git a/server/data/tier1-asns.js b/server/data/tier1-asns.js
new file mode 100644
index 0000000..21f1fac
--- /dev/null
+++ b/server/data/tier1-asns.js
@@ -0,0 +1,26 @@
+// 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
+]);
+
+module.exports = { TIER1_ASNS };
diff --git a/server/hijack-monitoring/monitor.js b/server/hijack-monitoring/monitor.js
new file mode 100644
index 0000000..0b60456
--- /dev/null
+++ b/server/hijack-monitoring/monitor.js
@@ -0,0 +1,72 @@
+const crypto = require("crypto");
+const localDb = require("../../local-db-client");
+const { loadHijackSubs, loadHijackAlerts, saveHijackAlerts, saveHijackSubs } = require("./store");
+const { notifyWebhooks } = require("./webhooks");
+
+// Classify hijack severity
+function classifyHijackSeverity(unexpected, missing, rpkiInvalid) {
+ if (rpkiInvalid > 0 || unexpected.length >= 5) return 'CRITICAL';
+ if (unexpected.length >= 2 || missing.length >= 3) return 'HIGH';
+ if (unexpected.length >= 1 || missing.length >= 1) return 'MEDIUM';
+ return 'LOW';
+}
+
+// Returns null (not []) when the prefix lookup itself failed -- distinct from a
+// genuine zero-prefix result. runHijackCheck must not treat these the same way:
+// setting an empty baseline from a transient failure would permanently disable
+// hijack detection for that subscription (baseline.size stays 0 forever after,
+// so "unexpected" never fires again -- see the baseline.size > 0 guard below).
+async function checkHijacksForAsn(asn) {
+ try {
+ const data = await localDb.getRipeStatAnnouncedPrefixes(asn);
+ if (data && data.status === 'error') return null;
+ const prefixes = (data && data.data && data.data.prefixes || []).map(p => p.prefix);
+ return prefixes;
+ } catch (_) { return null; }
+}
+
+async function runHijackCheck() {
+ const subs = loadHijackSubs();
+ if (!subs.length) return;
+ const alerts = loadHijackAlerts();
+ let changed = false;
+ for (const sub of subs) {
+ const current = await checkHijacksForAsn(sub.asn);
+ if (current === null) {
+ // Lookup failed this cycle -- skip both anomaly detection and baseline
+ // initialization; retry on the next 30-minute run rather than locking in
+ // an empty baseline that would silently disable detection forever.
+ continue;
+ }
+ 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) {
+ // Dedup: only alert if no alert in last 6 hours for this ASN
+ const sixHoursAgo = Date.now() - 6 * 60 * 60 * 1000;
+ const recentAlert = alerts.find(a => a.asn === sub.asn && new Date(a.ts).getTime() > sixHoursAgo);
+ if (!recentAlert) {
+ const severity = classifyHijackSeverity(unexpected, missing, 0);
+ const alert = {
+ id: crypto.randomBytes(8).toString('hex'),
+ asn: sub.asn, ts: new Date().toISOString(),
+ severity, unexpected, missing, resolved: false,
+ msg: `BGP anomaly for AS${sub.asn}: ${unexpected.length} unexpected prefix(es), ${missing.length} missing`
+ };
+ alerts.push(alert);
+ changed = true;
+ notifyWebhooks(sub.asn, alert);
+ }
+ }
+ // Set baseline on first monitoring
+ if (!sub.prefixes || !sub.prefixes.length) {
+ sub.prefixes = current;
+ changed = true;
+ }
+ }
+ if (changed) { saveHijackAlerts(alerts); saveHijackSubs(subs); }
+}
+// Run hijack check every 30 minutes
+setInterval(runHijackCheck, 30 * 60 * 1000);
+
+module.exports = { classifyHijackSeverity, checkHijacksForAsn, runHijackCheck };
diff --git a/server/hijack-monitoring/store.js b/server/hijack-monitoring/store.js
new file mode 100644
index 0000000..cc84257
--- /dev/null
+++ b/server/hijack-monitoring/store.js
@@ -0,0 +1,31 @@
+const fs = require("fs");
+const path = require("path");
+
+// Repo root (where server.js itself lives) -- NOT this module's own directory.
+// Preserves the original server.js `__dirname` fallback behavior (only ever
+// exercised locally; Erik always has /opt/peercortex-app and takes that branch).
+const REPO_ROOT = path.join(__dirname, "..", "..");
+const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : REPO_ROOT);
+const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
+const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
+const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.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 []; } }
+function loadWebhookSubs() { try { return JSON.parse(fs.readFileSync(WEBHOOK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
+function saveWebhookSubs(s) { try { fs.writeFileSync(WEBHOOK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
+function saveHijackAlerts(a) { try { fs.writeFileSync(HIJACK_ALERTS_FILE, JSON.stringify(a.slice(-1000), null, 2)); } catch(_){} }
+function saveHijackSubs(s) { try { fs.writeFileSync(HIJACK_SUBS_FILE, JSON.stringify(s, null, 2)); } catch(_){} }
+
+module.exports = {
+ DATA_DIR,
+ HIJACK_SUBS_FILE,
+ HIJACK_ALERTS_FILE,
+ WEBHOOK_SUBS_FILE,
+ loadHijackSubs,
+ loadHijackAlerts,
+ loadWebhookSubs,
+ saveWebhookSubs,
+ saveHijackAlerts,
+ saveHijackSubs,
+};
diff --git a/server/hijack-monitoring/webhooks.js b/server/hijack-monitoring/webhooks.js
new file mode 100644
index 0000000..938a4b9
--- /dev/null
+++ b/server/hijack-monitoring/webhooks.js
@@ -0,0 +1,82 @@
+const http = require("http");
+const https = require("https");
+const crypto = require("crypto");
+const { loadWebhookSubs } = require("./store");
+
+// Generate HMAC-SHA256 signature for webhook payloads
+function webhookSignature(secret, payload) {
+ return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex');
+}
+
+// Deliver webhook with exponential backoff (max 5 retries)
+async function deliverWebhook(sub, payload, attempt) {
+ attempt = attempt || 1;
+ const payloadStr = JSON.stringify(payload);
+ const sig = webhookSignature(sub.secret, payloadStr);
+ return new Promise((resolve) => {
+ try {
+ const parsed = new URL(sub.endpoint_url);
+ const lib = parsed.protocol === 'https:' ? https : http;
+ const options = {
+ hostname: parsed.hostname,
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
+ path: parsed.pathname + parsed.search,
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Content-Length': Buffer.byteLength(payloadStr),
+ 'X-PeerCortex-Signature': sig,
+ 'X-PeerCortex-Event': 'hijack_detected',
+ 'User-Agent': 'PeerCortex-Webhook/1.0'
+ },
+ timeout: 10000
+ };
+ const req = lib.request(options, (res) => {
+ let body = '';
+ res.on('data', c => body += c);
+ res.on('end', () => {
+ if (res.statusCode >= 200 && res.statusCode < 300) {
+ resolve({ ok: true, status: res.statusCode });
+ } else if (attempt < 5) {
+ const delay = Math.pow(2, attempt) * 1000;
+ setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
+ } else {
+ resolve({ ok: false, status: res.statusCode, error: 'max_retries' });
+ }
+ });
+ });
+ req.on('error', () => {
+ if (attempt < 5) {
+ const delay = Math.pow(2, attempt) * 1000;
+ setTimeout(() => deliverWebhook(sub, payload, attempt + 1).then(resolve), delay);
+ } else {
+ resolve({ ok: false, error: 'connection_failed' });
+ }
+ });
+ req.on('timeout', () => { req.destroy(); });
+ req.write(payloadStr);
+ req.end();
+ } catch(e) {
+ resolve({ ok: false, error: e.message });
+ }
+ });
+}
+
+// Fire webhooks for all subscribers of an ASN when hijack detected
+async function notifyWebhooks(asn, alert) {
+ const subs = loadWebhookSubs().filter(s => String(s.asn) === String(asn) && s.active !== false);
+ for (const sub of subs) {
+ const payload = {
+ event: 'hijack_detected',
+ asn: asn,
+ alert: alert,
+ timestamp: new Date().toISOString(),
+ source: 'PeerCortex BGP Monitor'
+ };
+ deliverWebhook(sub, payload, 1).then(result => {
+ if (!result.ok) console.warn(`[WEBHOOK] Delivery failed for AS${asn} → ${sub.endpoint_url}: ${result.error || result.status}`);
+ });
+ }
+}
+
+module.exports = { webhookSignature, deliverWebhook, notifyWebhooks };
diff --git a/server/ipv6-adoption.js b/server/ipv6-adoption.js
new file mode 100644
index 0000000..9c55dfb
--- /dev/null
+++ b/server/ipv6-adoption.js
@@ -0,0 +1,80 @@
+const { RIR_DELEGATION_URLS } = require("./data/rir-delegation-urls");
+
+// FEATURE 4: IPv6 Adoption per RIR
+
+// In-memory cache: { data, ts }
+let ipv6StatsCache = null;
+const IPV6_STATS_TTL = 24 * 60 * 60 * 1000; // 24h
+
+// Fetch and parse one RIR delegation file.
+// Returns { ipv4, ipv6, asns } count objects.
+function fetchRirDelegation(rir, url) {
+ return new Promise((resolve) => {
+ const result = { rir, ipv4: 0, ipv6: 0, asns: 0, ipv4_countries: new Set(), ipv6_countries: new Set() };
+ const mod = url.startsWith('https') ? require('https') : require('http');
+ const req = mod.get(url, { headers: { 'User-Agent': 'PeerCortex/1.0 (+https://peercortex.org)' }, timeout: 20000 }, res => {
+ if (res.statusCode !== 200) { resolve(result); return; }
+ let buf = '';
+ res.on('data', d => { buf += d; });
+ res.on('end', () => {
+ for (const line of buf.split('\n')) {
+ if (line.startsWith('#') || line.startsWith(rir.toLowerCase() + '|*') || !line.includes('|')) continue;
+ const parts = line.split('|');
+ if (parts.length < 7) continue;
+ const type = parts[2];
+ const status = parts[6].trim();
+ const cc = parts[1];
+ if (status !== 'allocated' && status !== 'assigned') continue;
+ if (type === 'ipv4') { result.ipv4++; result.ipv4_countries.add(cc); }
+ else if (type === 'ipv6') { result.ipv6++; result.ipv6_countries.add(cc); }
+ else if (type === 'asn') { result.asns++; }
+ }
+ resolve(result);
+ });
+ });
+ req.on('error', () => resolve(result));
+ req.on('timeout', () => { req.destroy(); resolve(result); });
+ });
+}
+
+// Fetch all 5 RIR delegation files and aggregate IPv6 stats.
+// Cached for 24 hours.
+async function fetchIpv6AdoptionStats(force) {
+ if (!force && ipv6StatsCache && (Date.now() - ipv6StatsCache.ts) < IPV6_STATS_TTL) {
+ return ipv6StatsCache.data;
+ }
+ console.log('[IPv6] Fetching RIR delegation stats...');
+ const results = await Promise.all(
+ Object.entries(RIR_DELEGATION_URLS).map(([rir, url]) => fetchRirDelegation(rir, url))
+ );
+ const rirs = results.map(r => ({
+ rir: r.rir,
+ ipv4_records: r.ipv4,
+ ipv6_records: r.ipv6,
+ asn_records: r.asns,
+ ipv6_pct: (r.ipv4 + r.ipv6) > 0
+ ? Math.round((r.ipv6 / (r.ipv4 + r.ipv6)) * 10000) / 100
+ : 0,
+ countries_with_ipv6: r.ipv6_countries.size,
+ countries_with_ipv4: r.ipv4_countries.size,
+ }));
+ const total_ipv4 = rirs.reduce((s, r) => s + r.ipv4_records, 0);
+ const total_ipv6 = rirs.reduce((s, r) => s + r.ipv6_records, 0);
+ const data = {
+ fetched_at: new Date().toISOString(),
+ global_ipv6_pct: total_ipv4 + total_ipv6 > 0
+ ? Math.round((total_ipv6 / (total_ipv4 + total_ipv6)) * 10000) / 100
+ : 0,
+ total_ipv4_records: total_ipv4,
+ total_ipv6_records: total_ipv6,
+ by_rir: rirs,
+ };
+ ipv6StatsCache = { data, ts: Date.now() };
+ console.log(`[IPv6] Done. Global IPv6%: ${data.global_ipv6_pct}% across ${rirs.length} RIRs`);
+ return data;
+}
+
+// Pre-fetch on startup (non-blocking)
+fetchIpv6AdoptionStats().catch(() => {});
+
+module.exports = { fetchRirDelegation, fetchIpv6AdoptionStats };
diff --git a/server/pdb-org-countries.js b/server/pdb-org-countries.js
new file mode 100644
index 0000000..8e6e8c5
--- /dev/null
+++ b/server/pdb-org-countries.js
@@ -0,0 +1,92 @@
+const fs = require("fs");
+const https = require("https");
+const path = require("path");
+const { UA } = require("./data/constants");
+const { PEERINGDB_API_KEY } = require("./services/peeringdb");
+
+// Repo root -- NOT this module's own directory. See hijack-monitoring/store.js
+// for the same __dirname-fallback fix applied for the same reason.
+const REPO_ROOT = path.join(__dirname, "..");
+
+// PeeringDB Org → Country Cache (for Lia's Paradise). Exposed via a mutable
+// state object (not a bare reassigned export) for the same reason as
+// atlas-probes.js's atlasState -- the Map itself is reassigned wholesale on
+// refresh, not just mutated in place.
+const pdbOrgState = { map: new Map() }; // org_id → { country, name }
+
+function fetchPdbOrgCountries() {
+ var cacheFile = path.join(REPO_ROOT, ".pdb-org-cache.json");
+
+ // Try disk cache first (valid for 24h)
+ try {
+ var stat = fs.statSync(cacheFile);
+ var ageHours = (Date.now() - stat.mtimeMs) / 3600000;
+ if (ageHours < 24) {
+ var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
+ pdbOrgState.map = new Map(Object.entries(cached));
+ console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)");
+ return Promise.resolve();
+ }
+ } catch (_) { /* no cache or invalid */ }
+
+ console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)...");
+ return new Promise(function(resolve) {
+ var chunks = [];
+ var req = https.get("https://www.peeringdb.com/api/org?status=ok&depth=0", {
+ headers: {
+ "User-Agent": UA,
+ // Bug fix 2026-07-16: a bare `PEERINGDB_API_KEY ? ... : undefined`
+ // sets the Authorization header value to `undefined` when no key is
+ // configured. Node 22's stricter header validation throws
+ // synchronously on that (ERR_HTTP_INVALID_HEADER_VALUE) instead of
+ // omitting the header, crashing the whole process at startup via
+ // this function's unhandled rejection (found empirically while
+ // trying to boot server.js locally without a real PeeringDB key).
+ // Conditionally spread the key in instead so the header key itself
+ // is simply absent when unconfigured.
+ ...(PEERINGDB_API_KEY ? { "Authorization": "Api-Key " + PEERINGDB_API_KEY } : {}),
+ },
+ timeout: 120000,
+ }, function(res) {
+ if (res.statusCode !== 200) {
+ console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty");
+ resolve();
+ return;
+ }
+ res.on("data", function(chunk) { chunks.push(chunk); });
+ res.on("end", function() {
+ try {
+ var body = Buffer.concat(chunks).toString("utf8");
+ var data = JSON.parse(body);
+ if (data && data.data) {
+ pdbOrgState.map = new Map();
+ var cacheObj = {};
+ data.data.forEach(function(o) {
+ if (o.id && o.country) {
+ pdbOrgState.map.set(o.id, { country: o.country, name: o.name || "" });
+ cacheObj[o.id] = { country: o.country, name: o.name || "" };
+ }
+ });
+ // Save to disk cache
+ try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {}
+ console.log("[PDB-ORG] Loaded " + pdbOrgState.map.size + " org→country mappings (cached to disk)");
+ }
+ } catch (e) {
+ console.error("[PDB-ORG] Parse error:", e.message);
+ }
+ resolve();
+ });
+ });
+ req.on("error", function(e) {
+ console.error("[PDB-ORG] Fetch error:", e.message);
+ resolve();
+ });
+ req.on("timeout", function() {
+ console.error("[PDB-ORG] Timeout after 120s");
+ req.destroy();
+ resolve();
+ });
+ });
+}
+
+module.exports = { pdbOrgState, fetchPdbOrgCountries };
diff --git a/server/pdf-export/renderer.js b/server/pdf-export/renderer.js
new file mode 100644
index 0000000..7b3d3d1
--- /dev/null
+++ b/server/pdf-export/renderer.js
@@ -0,0 +1,65 @@
+// PDF Export: lazy puppeteer loader + response cache + renderer
+
+// ── PDF Export: lazy puppeteer loader ────────────────────────────────────────
+let _puppeteerBrowser = null;
+let _puppeteerLoading = false;
+async function getPuppeteerBrowser() {
+ if (_puppeteerBrowser) return _puppeteerBrowser;
+ if (_puppeteerLoading) {
+ // Wait for in-flight launch
+ await new Promise(r => setTimeout(r, 2000));
+ return _puppeteerBrowser;
+ }
+ _puppeteerLoading = true;
+ try {
+ const puppeteer = require("puppeteer");
+ _puppeteerBrowser = await puppeteer.launch({
+ headless: true,
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"]
+ });
+ console.log("[PDF] Puppeteer browser launched");
+ _puppeteerBrowser.on("disconnected", () => {
+ console.log("[PDF] Browser disconnected — will relaunch on next request");
+ _puppeteerBrowser = null;
+ _puppeteerLoading = false;
+ });
+ } catch (e) {
+ console.error("[PDF] Puppeteer unavailable:", e.message);
+ _puppeteerBrowser = null;
+ }
+ _puppeteerLoading = false;
+ return _puppeteerBrowser;
+}
+
+// PDF response cache: key = "asn:format" or "compare:asn1:asn2" → {buf, ts}
+const pdfCache = new Map();
+const PDF_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+function pdfCacheGet(key) {
+ const e = pdfCache.get(key);
+ if (e && (Date.now() - e.ts) < PDF_CACHE_TTL) return e.buf;
+ return null;
+}
+function pdfCacheSet(key, buf) {
+ if (pdfCache.size > 100) pdfCache.delete(pdfCache.keys().next().value);
+ pdfCache.set(key, { buf, ts: Date.now() });
+}
+
+async function renderHtmlToPdf(html) {
+ const browser = await getPuppeteerBrowser();
+ if (!browser) throw new Error('PDF renderer unavailable (puppeteer not installed)');
+ const page = await browser.newPage();
+ try {
+ await page.setContent(html, { waitUntil: 'domcontentloaded', timeout: 15000 });
+ const buf = await page.pdf({
+ format: 'A4',
+ printBackground: true,
+ margin: { top: '18mm', bottom: '18mm', left: '16mm', right: '16mm' },
+ });
+ return buf;
+ } finally {
+ await page.close();
+ }
+}
+
+module.exports = { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf };
diff --git a/server/pdf-export/templates.js b/server/pdf-export/templates.js
new file mode 100644
index 0000000..14159df
--- /dev/null
+++ b/server/pdf-export/templates.js
@@ -0,0 +1,661 @@
+// FEATURE 2: PDF Export -- pure HTML/CSS template generators, no I/O
+
+// FEATURE 2: PDF Export — template generators + puppeteer renderer
+// ============================================================
+
+/**
+ * Generate a health score ring SVG (inline, no external deps)
+ * @param {number} score 0–100
+ * @param {string} color CSS color
+ */
+function scoreRingSvg(score, color) {
+ const r = 54, cx = 64, cy = 64;
+ const circ = 2 * Math.PI * r;
+ const fill = circ * (1 - score / 100);
+ const clr = color || (score >= 80 ? '#22c55e' : score >= 60 ? '#f59e0b' : '#ef4444');
+ return `
+
+
+ ${score}
+ /100
+ `;
+}
+
+/** Shared CSS for all PDF templates */
+function pdfBaseCSS() {
+ return `
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+ body { font-family: -apple-system, 'Segoe UI', system-ui, Arial, sans-serif; color: #1e1b4b; background: #fff; font-size: 11pt; line-height: 1.5; }
+ @page { size: A4; margin: 18mm 16mm; }
+ .page { page-break-after: always; min-height: 247mm; padding: 0; }
+ .page:last-child { page-break-after: avoid; }
+ h1 { font-size: 22pt; font-weight: 700; color: #4f46e5; }
+ h2 { font-size: 14pt; font-weight: 600; color: #3730a3; margin-top: 1.4em; margin-bottom: 0.5em; border-bottom: 2px solid #e0e7ff; padding-bottom: 4px; }
+ h3 { font-size: 11pt; font-weight: 600; color: #4338ca; margin-top: 1em; margin-bottom: 0.3em; }
+ p { margin-bottom: 0.5em; }
+ table { width: 100%; border-collapse: collapse; margin: 0.6em 0; font-size: 9.5pt; }
+ th { background: #4f46e5; color: #fff; padding: 6px 10px; text-align: left; font-weight: 600; }
+ td { padding: 5px 10px; border-bottom: 1px solid #e0e7ff; vertical-align: top; }
+ tr:nth-child(even) td { background: #f5f3ff; }
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8.5pt; font-weight: 600; }
+ .badge-green { background: #dcfce7; color: #166534; }
+ .badge-red { background: #fee2e2; color: #991b1b; }
+ .badge-yellow{ background: #fef9c3; color: #854d0e; }
+ .badge-gray { background: #f3f4f6; color: #374151; }
+ .badge-indigo{ background: #e0e7ff; color: #3730a3; }
+ .cover { display: flex; flex-direction: column; justify-content: center; align-items: flex-start; padding: 40px 0 20px; min-height: 247mm; }
+ .cover-logo { font-size: 11pt; font-weight: 700; color: #4f46e5; letter-spacing: -0.5px; margin-bottom: 60px; display: flex; align-items: center; gap: 8px; }
+ .cover-logo span { color: #1e1b4b; }
+ .cover-asn { font-size: 11pt; font-weight: 500; color: #6b7280; margin-bottom: 6px; }
+ .cover-name { font-size: 28pt; font-weight: 700; color: #1e1b4b; margin-bottom: 8px; line-height: 1.2; }
+ .cover-sub { font-size: 13pt; color: #4f46e5; font-weight: 500; margin-bottom: 30px; }
+ .cover-meta { font-size: 9pt; color: #9ca3af; margin-top: auto; padding-top: 20px; border-top: 1px solid #e0e7ff; width: 100%; }
+ .cover-stripe { position: fixed; right: -8mm; top: 0; width: 28mm; height: 100vh; background: linear-gradient(180deg, #4f46e5 0%, #6366f1 50%, #818cf8 100%); opacity: 0.08; pointer-events: none; }
+ .metric-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 1em 0; }
+ .metric-card { background: #f5f3ff; border: 1px solid #e0e7ff; border-radius: 8px; padding: 12px 14px; }
+ .metric-card .label { font-size: 8pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
+ .metric-card .value { font-size: 18pt; font-weight: 700; color: #4f46e5; }
+ .metric-card .unit { font-size: 8pt; color: #9ca3af; }
+ .score-row { display: flex; align-items: center; gap: 20px; margin: 0.8em 0; }
+ .check-list { list-style: none; padding: 0; }
+ .check-list li { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid #f3f4f6; font-size: 9.5pt; }
+ .check-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; }
+ .check-pass { color: #16a34a; }
+ .check-fail { color: #dc2626; }
+ .check-warn { color: #d97706; }
+ .section-intro { font-size: 9.5pt; color: #6b7280; margin-bottom: 1em; font-style: italic; }
+ footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8pt; color: #9ca3af; padding: 6px 16mm; border-top: 1px solid #e0e7ff; display: flex; justify-content: space-between; }
+ `;
+}
+
+/**
+ * Build the PDF HTML for a single ASN.
+ * @param {object} data Full /api/validate response
+ * @param {object} aspa /api/aspa response (may be null)
+ * @param {string} format 'report' | 'executive' | 'technical'
+ */
+function buildPdfHtml(data, aspa, format) {
+ const asn = data.asn || '?';
+ const name = data.name || 'Unknown Network';
+ const score = Math.round(data.health_score || 0);
+ const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+ const checks = (data.score_breakdown || []);
+ const rel = data.relationships || {};
+ const counts = rel.counts || {};
+ // validations is an object keyed by check name; rpki details live in rpki_completeness
+ const validations = (data.validations && typeof data.validations === 'object' && !Array.isArray(data.validations)) ? data.validations : {};
+ const rpkiDetails = (validations.rpki_completeness?.details || []);
+ const totalPfx = data.meta?.total_prefixes || 0;
+
+ // Derived stats — use rpki_completeness for RPKI metrics
+ const rpki = validations.rpki_completeness || {};
+ const rpkiCoverage = rpki.coverage_pct ?? 0;
+ const rpkiTotal = rpki.total_checked || rpkiDetails.length || 0;
+ const rpkiValid = rpki.with_roa || rpkiDetails.filter(v => v.status === 'valid').length;
+ const rpkiInvalid = rpkiDetails.filter(v => v.status === 'invalid').length;
+ const rpkiNotFound = rpkiDetails.filter(v => v.status === 'not_found').length;
+
+ const passedChecks = checks.filter(c => c.status === 'pass').length;
+ const failedChecks = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
+ const scoreColor = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
+ const aspaDirect = aspa?.direct?.has_aspa ?? null;
+ const aspaProviders= aspa?.direct?.provider_asns?.length || 0;
+
+ // Check list HTML
+ function checkIcon(passed) {
+ if (passed) return ' ';
+ return ' ';
+ }
+
+ const checksHtml = checks.map(c => {
+ const isPassed = c.status === 'pass';
+ const isInfo = c.status === 'info';
+ const icon = checkIcon(isPassed || isInfo);
+ const statusBadge = isPassed ? 'badge-green' : isInfo ? 'badge-indigo' : 'badge-red';
+ const statusLabel = isPassed ? 'PASS' : isInfo ? 'INFO' : 'FAIL';
+ return `
+
+ ${icon}
+
+ ${escHtml(c.check || '')}
+ ${statusLabel}
+ ${c.earned !== undefined ? `${c.earned}/${c.weight}pt ` : ''}
+
+ `;
+ }).join('');
+
+ // Upstream providers table
+ const upstreamRows = (rel.upstreams || []).slice(0, 10).map(u => `
+ AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'}
+ `).join('');
+
+ // Prefix validations table (sample from rpki_completeness.details)
+ const pfxRows = rpkiDetails.slice(0, 20).map(v => {
+ const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
+ return `${escHtml(v.prefix)}
+ ${v.status}
+ ${v.validating_roas ?? '-'}
+ — `;
+ }).join('');
+
+ // Recommendations
+ const failedItems = checks.filter(c => c.status !== 'pass' && c.status !== 'info');
+ const recHtml = failedItems.length === 0
+ ? '✓ No critical issues found. Network health is excellent.
'
+ : failedItems.map((c, i) => `
+
+
${i+1}. ${escHtml(c.check || '')}
+ ${c.detail ? `
${escHtml(c.detail)}
` : ''}
+
`).join('');
+
+ // ── Executive pages (always present) ───────────────────────────────────────
+ const coverPage = `
+
+
+
⬡ PeerCortex Network Intelligence
+
AS${asn}
+
${escHtml(name)}
+
${formatLabel(format)} Report
+
+ ${scoreRingSvg(score)}
+
+
Health Score
+
${score}/100
+
${passedChecks} passed · ${failedChecks} failed
+
+
+
+ Generated by PeerCortex · ${ts} · peercortex.org
+ | ${totalPfx} prefixes analysed
+ | RPKI coverage ${rpkiCoverage}%
+
+
`;
+
+ const summaryPage = `
+
+
Executive Summary
+
Key performance indicators for AS${asn} (${escHtml(name)}).
+
+
Health Score
${score}
out of 100
+
RPKI Coverage
${rpkiCoverage}%
${rpkiValid}/${rpkiTotal} prefixes
+
Upstreams
${counts.upstreams || 0}
providers
+
Downstreams
${counts.downstreams || 0}
customers
+
Peers
${counts.peers || 0}
BGP sessions
+
ASPA Status
+
+
+ ${aspaDirect === true ? 'Deployed' : aspaDirect === false ? 'Not Deployed' : 'Unknown'}
+
+
+
${aspaProviders} providers
+
+
+
Status Overview
+
+
`;
+
+ // ── Recommendations page ────────────────────────────────────────────────────
+ const recsPage = `
+
+
Recommendations
+
Action items to improve network health and security posture.
+ ${recHtml}
+ ${failedItems.length > 0 ? `
+
Remediation Priority
+
+ # Check Impact Effort
+ ${failedItems.map((c, i) => `
+
+ ${i+1}
+ ${escHtml(c.check || '')}
+ High
+ Medium
+ `).join('')}
+
` : ''}
+
`;
+
+ // ── Detail pages (included for 'report' and 'technical') ───────────────────
+ const rpkiPage = `
+
+
RPKI Compliance
+
Route Origin Authorization (ROA) validation results for announced prefixes.
+
+
Coverage
${rpkiCoverage}%
${rpkiValid} valid ROAs
+
Invalid
${rpkiInvalid}
prefix${rpkiInvalid !== 1 ? 'es' : ''}
+
Not Found
${rpkiNotFound}
no ROA
+
+
ASPA (Autonomous System Provider Authorization)
+
+ Attribute Value
+ ASPA Object Present ${aspaDirect ? 'Yes' : 'No'}
+ Provider Count ${aspaProviders}
+ Providers ${(aspa?.direct?.provider_asns || []).slice(0, 8).map(a => `AS${a}`).join(', ') || '—'}
+
+ ${pfxRows ? `
+
Prefix Validation Sample (first 20)
+
+ Prefix Status Max Length ROA Origin
+ ${pfxRows}
+
` : ''}
+
`;
+
+ const bgpPage = `
+
+
BGP Topology
+
AS relationship overview and upstream connectivity analysis.
+
+
Upstreams
${counts.upstreams || 0}
transit providers
+
Downstreams
${counts.downstreams || 0}
customers
+
Peers
${counts.peers || 0}
BGP peers
+
+ ${upstreamRows ? `
+
Top Upstream Providers
+
+ ASN Name Power Score
+ ${upstreamRows}
+
` : '
No upstream data available.
'}
+
`;
+
+ const footer = `
+
+ PeerCortex · AS${asn} · ${escHtml(name)}
+ Generated ${ts} · peercortex.org
+ `;
+
+ // ── Extra pages for technical / report formats ─────────────────────────────
+
+ // Technical: full prefix table (no row cap) + all upstream rows + check scores
+ const allPfxRows = rpkiDetails.map(v => {
+ const badge = v.status === 'valid' ? 'badge-green' : v.status === 'invalid' ? 'badge-red' : 'badge-gray';
+ return `${escHtml(v.prefix)}
+ ${v.status}
+ ${v.validating_roas ?? '-'}
+ ${v.max_length ?? '-'} `;
+ }).join('');
+
+ const allUpstreamRows = (rel.upstreams || []).map(u =>
+ `AS${u.asn} ${escHtml(u.name || '')} ${u.power ?? '-'} `
+ ).join('');
+
+ const checkScoreRows = checks.map(c =>
+ `
+ ${escHtml(c.check || '')}
+ ${c.status?.toUpperCase()}
+ ${c.earned ?? '-'} ${c.weight ?? '-'}
+ ${escHtml(c.detail || '')}
+ `
+ ).join('');
+
+ const technicalHeaderPage = `
+
+
+
PeerCortex · Technical Analysis Report
+
AS${asn} — ${escHtml(name)}
+
Generated ${ts} · peercortex.org · ${totalPfx} prefixes · RPKI ${rpkiCoverage}%
+
+
+
Health Score
${score}/100
${passedChecks} pass · ${failedChecks} fail
+
RPKI Coverage
${rpkiCoverage}%
${rpkiValid} valid · ${rpkiInvalid} invalid · ${rpkiNotFound} missing
+
Upstreams
${counts.upstreams || 0}
transit providers
+
Peers / Downstreams
${counts.peers || 0} / ${counts.downstreams || 0}
BGP relationships
+
ASPA
+
${aspaDirect === true ? 'Deployed' : 'Not deployed'}
+
${aspaProviders} providers registered
+
+
Prefixes Analysed
${totalPfx}
${rpkiDetails.length} with RPKI data
+
+
`;
+
+ const rpkiFullPage = `
+
+
RPKI Compliance — Full Prefix Table
+
${rpkiDetails.length} prefixes with RPKI validation data. Coverage: ${rpkiCoverage}% · Valid: ${rpkiValid} · Invalid: ${rpkiInvalid} · Not Found: ${rpkiNotFound}
+
ASPA Object
+
+ Attribute Value
+ ASPA Present ${aspaDirect ? 'Yes' : 'No'}
+ Provider Count ${aspaProviders}
+ Registered Providers ${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(', ') || '—'}
+
+ ${allPfxRows ? `
+
All Prefixes
+
+ Prefix RPKI Status Validating ROAs Max Length
+ ${allPfxRows}
+
` : '
No RPKI prefix data available.
'}
+
`;
+
+ const bgpFullPage = `
+
+
BGP Topology — Full Relationship Table
+
Complete AS relationship data. Upstreams: ${counts.upstreams || 0} · Peers: ${counts.peers || 0} · Downstreams: ${counts.downstreams || 0}
+ ${allUpstreamRows ? `
+
All Upstream Providers
+
+ ASN Name Power Score
+ ${allUpstreamRows}
+
` : '
No upstream data available.
'}
+ ${(rel.peers || []).length > 0 ? `
+
Peer Sample (top 20)
+
+ ASN Name
+ ${(rel.peers || []).slice(0, 20).map(p => `AS${p.asn} ${escHtml(p.name || '')} `).join('')}
+
` : ''}
+
`;
+
+ const checkDetailPage = `
+
+
Check Score Detail
+
All ${checks.length} health checks with individual scores and diagnostic notes.
+
+ Check Status Earned Weight Detail
+ ${checkScoreRows}
+
+
+ Scoring: Total = sum of earned points across all checks. Maximum possible: ${checks.reduce((s, c) => s + (c.weight || 0), 0)} points.
+ Current score: ${checks.reduce((s, c) => s + (c.earned || 0), 0)} / ${checks.reduce((s, c) => s + (c.weight || 0), 0)} = ${score}/100
+
+
`;
+
+ // Report: add data provenance and ASPA deep-dive
+ const aspaPage = `
+
+
ASPA — Provider Authorization Analysis
+
Autonomous System Provider Authorization (ASPA) is a BGP security mechanism that cryptographically links an ASN to its upstream providers in the RPKI.
+
+
ASPA Deployed
+
${aspaDirect === true ? 'Yes' : 'No'}
+
+
Providers Listed
${aspaProviders}
in ASPA object
+
RPKI Coverage
${rpkiCoverage}%
of announced prefixes
+
+
What ASPA Protects Against
+
+ Threat ASPA Mitigates? Notes
+ Route leaks (valley-free violations) Yes Providers verify upstream path
+ BGP hijacks (origin AS spoofing) Partial Combined with ROV
+ AS-path prepending attacks Yes Invalid paths rejected
+ Forged-origin hijacks No ROA + ROV required
+
+ ${aspaDirect === false ? `
+
+
Recommendation: Deploy ASPA
+
+ 1. Identify all upstream transit providers (${counts.upstreams || 0} detected)
+ 2. Create an ASPA object in your RIR portal listing their ASNs
+ 3. Sign with your RPKI key — takes effect within hours
+ 4. Verify with: rpki-client -v or RIPE RPKI Validator
+
+
` : `
+
+
ASPA is deployed — listed providers:
+
${(aspa?.direct?.provider_asns || []).map(a => `AS${a}`).join(' · ') || '—'}
+
`}
+
`;
+
+ const provenancePage = `
+
+
Data Provenance
+
All data sources, cache ages, and methodology used to generate this report.
+
+ Data Source What it provides Update Frequency
+ Cloudflare RPKI Feed ROA + ASPA objects Every 4 hours
+ PeeringDB IXP presence, peering policy, facilities Daily (03:00 UTC)
+ RIPE Stat AS relationships, prefix visibility, BGP data Real-time / cached 15 min
+ RIPE Atlas Active measurement probes by ASN Cached 12 hours
+ bgproutes.io BGP route visibility fallback Real-time
+ bgp.he.net AS relationship graph Cached 24 hours
+
+
Report Metadata
+
+ Field Value
+ ASN AS${asn}
+ Network Name ${escHtml(name)}
+ Report Format ${formatLabel(format)}
+ Generated ${ts}
+ Platform PeerCortex · peercortex.org
+ Prefixes Analysed ${totalPfx}
+ RPKI Objects Checked ${rpkiDetails.length}
+ Health Checks Run ${checks.length}
+
+
+ This report is generated automatically based on publicly available BGP and RPKI data.
+ Data may be delayed by cache TTLs. Not a substitute for real-time monitoring.
+ MIT License · github.com/renefichtmueller/PeerCortex
+
+
`;
+
+ // ── Compose pages by format ────────────────────────────────────────────────
+ let bodyContent;
+ if (format === 'executive') {
+ // 3 pages: clean stakeholder summary — no deep technical data
+ bodyContent = coverPage + summaryPage + recsPage;
+ } else if (format === 'technical') {
+ // ~8 pages: engineer-focused, dense, no decorative cover, full data tables
+ bodyContent = technicalHeaderPage + rpkiFullPage + bgpFullPage + checkDetailPage + recsPage;
+ } else {
+ // 'report' — full stakeholder + technical, ~10 pages
+ bodyContent = coverPage + summaryPage + rpkiPage + bgpPage + aspaPage + recsPage + provenancePage;
+ }
+
+ return `
+
+
+
+ PeerCortex — AS${asn} ${formatLabel(format)}
+
+
+
+ ${bodyContent}
+ ${footer}
+
+`;
+}
+
+/**
+ * Build comparison PDF HTML for two ASNs.
+ */
+function buildComparisonPdfHtml(d1, aspa1, l1, d2, aspa2, l2) {
+ const ts = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
+
+ function col(d, aspa) {
+ const asn = d.asn || '?';
+ const name = d.name || 'Unknown';
+ const score = Math.round(d.health_score || 0);
+ const rel = d.relationships || {};
+ const counts= rel.counts || {};
+ const vals = (d.validations && !Array.isArray(d.validations)) ? d.validations : {};
+ const rpki = vals.rpki_completeness || {};
+ const rpkiV = rpki.with_roa || 0;
+ const rpkiT = rpki.total_checked || 0;
+ const rpkiC = rpki.coverage_pct ?? (rpkiT > 0 ? Math.round((rpkiV / rpkiT) * 100) : 0);
+ const sc = score >= 80 ? '#16a34a' : score >= 60 ? '#d97706' : '#dc2626';
+ const aspaDep = aspa?.direct?.has_aspa ?? null;
+ const checks= (d.score_breakdown || []);
+ const passC = checks.filter(c => c.status === 'pass').length;
+ const failC = checks.filter(c => c.status !== 'pass' && c.status !== 'info').length;
+
+ return `
+
+
+ ${scoreRingSvg(score, sc)}
+
AS${asn}
+
${escHtml(name)}
+
+
+ Key Metrics
+ Health Score ${score}/100
+ RPKI Coverage ${rpkiC}% (${rpkiV}/${rpkiT})
+ Upstreams ${counts.upstreams || 0}
+ Downstreams ${counts.downstreams || 0}
+ Peers ${counts.peers || 0}
+ ASPA ${aspaDep ? 'Deployed' : 'Not Deployed'}
+ Checks Passed ${passC} / ${checks.length}
+ Checks Failed ${failC}
+ Total Prefixes ${d.meta?.total_prefixes || 0}
+
+ `;
+ }
+
+ const colHtml = col(d1, aspa1) + col(d2, aspa2);
+
+ // ── Peering Intelligence: common IXPs + common facilities ─────────────────
+ const ixList1 = (l1?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
+ const ixList2 = (l2?.ix_presence?.connections || []).map(x => ({ id: x.ix_id, name: x.ix_name, city: x.city || '', speed: x.speed_mbps }));
+ const ixIds1 = new Set(ixList1.map(x => x.id));
+ const ixIds2 = new Set(ixList2.map(x => x.id));
+ const commonIxIds = [...ixIds1].filter(id => ixIds2.has(id));
+ const commonIxps = commonIxIds.slice(0, 20).map(id => {
+ const x1 = ixList1.find(x => x.id === id);
+ const x2 = ixList2.find(x => x.id === id);
+ return { id, name: x1?.name || 'Unknown', city: x1?.city || '', speed1: x1?.speed, speed2: x2?.speed };
+ });
+
+ const facList1 = (l1?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
+ const facList2 = (l2?.facilities?.list || []).map(f => ({ id: f.fac_id, name: f.name, city: f.city || '', country: f.country || '' }));
+ const facIds1 = new Set(facList1.map(f => f.id));
+ const facIds2 = new Set(facList2.map(f => f.id));
+ const commonFacIds = [...facIds1].filter(id => facIds2.has(id));
+ const commonFacs = commonFacIds.slice(0, 15).map(id => facList1.find(f => f.id === id));
+
+ // IXPs of ASN1 where ASN2 is NOT present (opportunity IXPs for ASN2)
+ const oppsFor2 = ixList1.filter(x => !ixIds2.has(x.id)).slice(0, 8);
+ const oppsFor1 = ixList2.filter(x => !ixIds1.has(x.id)).slice(0, 8);
+
+ const commonIxHtml = commonIxps.length > 0
+ ? `
+ IXP Name City AS${d1.asn} Speed AS${d2.asn} Speed
+ ${commonIxps.map(x => `
+
+ ${escHtml(x.name)}
+ ${escHtml(x.city)}
+ ${x.speed1 ? (x.speed1/1000).toLocaleString()+'G' : '—'}
+ ${x.speed2 ? (x.speed2/1000).toLocaleString()+'G' : '—'}
+ `).join('')}
+
`
+ : 'No common IXPs found — these networks do not share any Internet Exchange Points.
';
+
+ const commonFacHtml = commonFacs.length > 0
+ ? `
+ Datacenter / Colocation Facility City Country
+ ${commonFacs.map(f => `
+
+ ${escHtml(f?.name||'')}
+ ${escHtml(f?.city||'')}
+ ${escHtml(f?.country||'')}
+ `).join('')}
+
`
+ : 'No shared colocation facilities found.
';
+
+ const oppsHtml = (asns, opps) => opps.length === 0
+ ? `Already maximally peered on these exchanges — or no data available.
`
+ : opps.map(x => `
+ ${escHtml(x.name)}
+ ${x.city ? ` · ${escHtml(x.city)} ` : ''}
+ ${x.speed ? `${(x.speed/1000).toLocaleString()}G ` : ''}
+
`).join('');
+
+ const peeringPage = (commonIxps.length + commonFacs.length > 0 || oppsFor1.length + oppsFor2.length > 0) ? `
+
+
Peering Intelligence
+
Common Internet Exchange Points and colocation facilities — potential peering locations between AS${d1.asn} and AS${d2.asn}.
+
+
+
+
${commonIxps.length}
+
Common IXPs
+
+
+
${commonFacs.length}
+
Shared Facilities
+
+
+
+
Common Internet Exchange Points (direct peering possible)
+ ${commonIxHtml}
+
+
Shared Colocation Facilities
+ ${commonFacHtml}
+
+ ${oppsFor1.length + oppsFor2.length > 0 ? `
+
Expansion Opportunities
+
+
+
AS${d2.asn} could join (AS${d1.asn} is there)
+ ${oppsHtml(d2.asn, oppsFor2)}
+
+
+
AS${d1.asn} could join (AS${d2.asn} is there)
+ ${oppsHtml(d1.asn, oppsFor1)}
+
+
` : ''}
+
` : '';
+
+ // Side-by-side check comparison
+ const allChecks = [...new Set([
+ ...(d1.score_breakdown || []).map(c => c.check),
+ ...(d2.score_breakdown || []).map(c => c.check),
+ ])];
+ const checkRows = allChecks.map(checkName => {
+ const c1 = (d1.score_breakdown || []).find(c => c.check === checkName);
+ const c2 = (d2.score_breakdown || []).find(c => c.check === checkName);
+ const isP1 = c1?.status === 'pass';
+ const isP2 = c2?.status === 'pass';
+ const icon = p => p ? '✓' : '✗';
+ const st = p => p ? 'color:#16a34a;font-weight:700' : 'color:#dc2626;font-weight:700';
+ return `
+ ${escHtml(checkName)}
+ ${c1 ? icon(isP1) : '—'}
+ ${c2 ? icon(isP2) : '—'}
+ `;
+ }).join('');
+
+ return `
+
+
+
+ PeerCortex — AS${d1.asn} vs AS${d2.asn} Comparison
+
+
+
+
+
+
⬡ PeerCortex Network Intelligence
+
+
ASN Comparison Report
+
AS${d1.asn} vs AS${d2.asn}
+
${escHtml(d1.name)} ← vs → ${escHtml(d2.name)}
+
+
+
Generated by PeerCortex · ${ts} · peercortex.org
+
+
+
Head-to-Head Health Checks
+
+ Check AS${d1.asn} AS${d2.asn}
+ ${checkRows}
+
+
+ ${peeringPage}
+
+ PeerCortex · AS${d1.asn} vs AS${d2.asn}
+ Generated ${ts} · peercortex.org
+
+
+`;
+}
+
+function formatLabel(fmt) {
+ return fmt === 'executive' ? 'Executive Summary' : fmt === 'technical' ? 'Technical Analysis' : 'Full Report';
+}
+
+function escHtml(s) {
+ if (!s) return '';
+ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
+}
+
+module.exports = { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml };
diff --git a/server/resilience-score.js b/server/resilience-score.js
new file mode 100644
index 0000000..a31ae35
--- /dev/null
+++ b/server/resilience-score.js
@@ -0,0 +1,75 @@
+// 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",
+ },
+ };
+}
+
+module.exports = { computeResilienceScore };
diff --git a/server/resolve-as-names.js b/server/resolve-as-names.js
new file mode 100644
index 0000000..226a692
--- /dev/null
+++ b/server/resolve-as-names.js
@@ -0,0 +1,23 @@
+const { fetchRipeStatCached } = require("./services/ripe-stat");
+
+// Batch resolve AS names via RIPE Stat AS overview API
+async function resolveASNames(providers) {
+ const batchSize = 10;
+ for (let i = 0; i < providers.length; i += batchSize) {
+ const batch = providers.slice(i, i + batchSize);
+ const results = await Promise.all(
+ batch.map(p =>
+ fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn)
+ .then(r => ({ asn: p.asn, name: r?.data?.holder || "" }))
+ .catch(() => ({ asn: p.asn, name: "" }))
+ )
+ );
+ results.forEach(r => {
+ const provider = providers.find(p => p.asn === r.asn);
+ if (provider && r.name) provider.name = r.name;
+ });
+ }
+ return providers;
+}
+
+module.exports = { resolveASNames };
diff --git a/server/route-leak.js b/server/route-leak.js
new file mode 100644
index 0000000..862d771
--- /dev/null
+++ b/server/route-leak.js
@@ -0,0 +1,54 @@
+const { TIER1_ASNS } = require("./data/tier1-asns");
+
+// 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.",
+ },
+ };
+}
+
+module.exports = { computeRouteLeakDetection };
diff --git a/server/routes/aspa-adoption.js b/server/routes/aspa-adoption.js
new file mode 100644
index 0000000..5d4522d
--- /dev/null
+++ b/server/routes/aspa-adoption.js
@@ -0,0 +1,114 @@
+const { rpkiAspaMap } = require("../services/rpki");
+const { fetchIpv6AdoptionStats } = require("../ipv6-adoption");
+const {
+ aspaAdoptionDailyHistory, getAdoptionTrend, buildAspaAdoptionDashboard,
+} = require("../aspa-adoption/tracker");
+
+// GET /aspa-adoption → dashboard HTML
+function handleDashboard(req, res) {
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.writeHead(200);
+ return res.end(buildAspaAdoptionDashboard());
+}
+
+// Linear regression forecast -- duplicated near-identically in
+// buildAspaAdoptionDashboard (server/aspa-adoption/tracker.js) since both
+// use the same formula over possibly-different trend windows; kept as two
+// call sites rather than merged to avoid changing either's behavior here.
+function forecast(data, daysAhead) {
+ if (data.length < 2) return null;
+ const n = data.length;
+ const xs = data.map((_, i) => i);
+ const ys = data.map(d => d.coverage_pct_atlas);
+ const xMean = xs.reduce((a,b)=>a+b,0)/n;
+ const yMean = ys.reduce((a,b)=>a+b,0)/n;
+ const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
+ const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
+ if (den===0) return yMean;
+ const slope = num/den;
+ return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
+}
+
+// GET /api/aspa-adoption-stats?period=7d|30d|1y
+function handleStats(req, res, url) {
+ const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
+ ? url.searchParams.get('period') : '30d';
+ const trend = getAdoptionTrend(period);
+ const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
+ const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
+
+ const trend30 = getAdoptionTrend('30d');
+ const result = {
+ period,
+ current: {
+ date: latest?.date,
+ aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
+ roa_count: latest?.roa_count,
+ atlas_asns_total: latest?.atlas_asns_total ?? 0,
+ atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
+ coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
+ delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
+ },
+ trend: trend.map(s => ({
+ date: s.date,
+ aspa_objects: s.aspa_objects,
+ coverage_pct_atlas: s.coverage_pct_atlas,
+ atlas_asns_total: s.atlas_asns_total,
+ })),
+ forecast: {
+ predicted_90d: forecast(trend30, 90),
+ confidence: trend30.length >= 10 ? 0.75 : 0.5,
+ method: 'linear_regression',
+ based_on_samples: trend30.length,
+ },
+ meta: {
+ total_snapshots: aspaAdoptionDailyHistory.length,
+ last_updated: latest?.date ?? null,
+ denominator: 'atlas_visible_asns',
+ source: 'rpki_cloudflare_feed',
+ }
+ };
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ return res.end(JSON.stringify(result, null, 2));
+}
+
+// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
+function handleStatsExport(req, res, url) {
+ const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
+ const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
+ ? url.searchParams.get('period') : '30d';
+ const data = getAdoptionTrend(period);
+
+ if (fmt === 'csv') {
+ const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
+ const rows = data.map(s =>
+ `${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
+ ).join('\n');
+ res.setHeader('Content-Type', 'text/csv');
+ res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
+ res.writeHead(200);
+ return res.end(header + rows);
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
+}
+
+// GET /api/ipv6-adoption-stats?refresh=1
+async function handleIpv6Stats(req, res, url) {
+ const force = url.searchParams.get('refresh') === '1';
+ try {
+ const data = await fetchIpv6AdoptionStats(force);
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ return res.end(JSON.stringify(data, null, 2));
+ } catch (err) {
+ res.writeHead(503);
+ return res.end(JSON.stringify({ error: 'IPv6 stats unavailable', message: err.message }));
+ }
+}
+
+module.exports = { handleDashboard, handleStats, handleStatsExport, handleIpv6Stats };
diff --git a/server/routes/aspa-legacy.js b/server/routes/aspa-legacy.js
new file mode 100644
index 0000000..8164596
--- /dev/null
+++ b/server/routes/aspa-legacy.js
@@ -0,0 +1,133 @@
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { resolveASNames } = require("../resolve-as-names");
+const { ensureAspaCache, lookupAspaFromRpki } = require("../services/rpki");
+const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
+
+// ASPA Check endpoint: /api/aspa?asn=X (existing, kept for compat)
+async function handleAspaLegacy(req, res, url) {
+ 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 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(() => {
+ if (!_aspaDone) {
+ _aspaDone = true;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "ASPA data temporarily unavailable (timeout)", asn: parseInt(rawAsn) }));
+ }
+ }, 12000);
+ try {
+ const [lgData, neighbourData] = await Promise.all([
+ 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 || [];
+ const asPaths = [];
+
+ rrcs.forEach((rrc) => {
+ const peers = rrc.peers || [];
+ peers.forEach((peer) => {
+ const path = peer.as_path || "";
+ const pathArr = path.split(" ").map(Number).filter(Boolean);
+ if (pathArr.length > 1) {
+ asPaths.push({ rrc: rrc.rrc, path: pathArr, prefix: peer.prefix || "" });
+ }
+ });
+ });
+
+ // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
+ const neighbours = neighbourData?.data?.neighbours || [];
+ const leftNeighbours = neighbours.filter((n) => n.type === "left");
+ const upstreamSet = new Set();
+ leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
+
+ // Classify left neighbours: high-power = likely upstream, low-power = likely peer
+ const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
+ const detectedProviders = [...upstreamSet].map((asn) => {
+ const nb = leftNeighbours.find((n) => n.asn === asn);
+ const power = nb ? (nb.power || 0) : 0;
+ const powerPct = Math.round((power / maxPower) * 100);
+ const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
+ return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
+ });
+
+ await resolveASNames(detectedProviders);
+
+ // Check Cloudflare RPKI feed for ASPA object
+ await ensureAspaCache();
+ const aspaLookup = lookupAspaFromRpki(rawAsn);
+ const aspaObjectExists = aspaLookup.exists;
+ const aspaDeclaredProviders = aspaLookup.providers;
+
+ const providerList = detectedProviders.map((p) => "AS" + p.asn).join(", ");
+ let recommendedAspa =
+ "aut-num: AS" + rawAsn + "\n" +
+ "# Recommended ASPA object:\n" +
+ "# customer: AS" + rawAsn + "\n" +
+ "# provider-set: " + providerList + "\n" +
+ "# AFI: ipv4, ipv6\n" +
+ "#\n" +
+ "# Detected providers from BGP path analysis:\n" +
+ detectedProviders.map((p) => "# AS" + p.asn + (p.name ? " (" + p.name + ")" : "")).join("\n");
+
+ // If ASPA object exists, show RPKI-declared providers
+ if (aspaObjectExists && aspaDeclaredProviders.length > 0) {
+ recommendedAspa += "\n#\n# RPKI-declared providers (from Cloudflare RPKI feed):\n" +
+ aspaDeclaredProviders.map((a) => "# AS" + a).join("\n");
+ }
+
+ const samplePaths = asPaths.slice(0, 10).map((p) => {
+ const pathStr = p.path.map((a) => "AS" + a).join(" -> ");
+ const idx = p.path.indexOf(parseInt(rawAsn));
+ const provider = idx > 0 ? p.path[idx - 1] : null;
+ return {
+ rrc: p.rrc,
+ prefix: p.prefix,
+ path: pathStr,
+ detected_provider: provider ? "AS" + provider : null,
+ provider_in_set: provider ? upstreamSet.has(provider) : false,
+ };
+ });
+
+ if (_aspaDone) return; // hard timeout already responded
+ _aspaDone = true;
+ clearTimeout(_aspaTimer);
+ const duration = Date.now() - start;
+ 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_feed_healthy: aspaLookup.feedLoaded,
+ 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;
+ clearTimeout(_aspaTimer);
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message }));
+ }
+ }
+
+}
+
+module.exports = { handleAspaLegacy };
diff --git a/server/routes/aspa-verify.js b/server/routes/aspa-verify.js
new file mode 100644
index 0000000..9102c17
--- /dev/null
+++ b/server/routes/aspa-verify.js
@@ -0,0 +1,271 @@
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { hasAsSet, verifyUpstream, verifyDownstream, detectValleys, collapsePrepends, calculateAspaReadinessScore } = require("../aspa-verification/engine");
+const { resolveASNames } = require("../resolve-as-names");
+const { ensureAspaCache, lookupAspaFromRpki, rpkiAspaMap, validateRPKIWithCache } = require("../services/rpki");
+const { aspaResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
+
+// ASPA Deep Verification endpoint: /api/aspa/verify?asn=X
+async function handleAspaVerify(req, res, url) {
+ 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 cachedVerify = resultCacheGet(aspaResultCache, "verify:" + rawAsn);
+ if (cachedVerify !== undefined) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ return res.end(JSON.stringify(cachedVerify));
+ }
+ const targetAsn = parseInt(rawAsn);
+ const start = Date.now();
+
+ try {
+ // Fetch neighbour and prefix data first
+ const [neighbourData, prefixData] = await Promise.all([
+ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
+ fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
+ ]);
+
+ // Use looking-glass with actual prefixes to get BGP paths
+ const announcedPrefixes = prefixData?.data?.prefixes || [];
+ const samplePrefixes = announcedPrefixes.slice(0, 3).map((p) => p.prefix); // reduced 5→3
+
+ // Fetch looking-glass data for up to 3 prefixes in parallel (3s timeout each)
+ const lgResults = await Promise.all(
+ samplePrefixes.map((pfx) =>
+ fetchRipeStatCached("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx), { timeout: 3000 }).catch(() => null)
+ )
+ );
+
+ // Extract AS paths from looking glass results
+ const allPaths = [];
+ const pathNeighbourCount = new Map(); // Count how often each AS appears next to target in paths
+
+ lgResults.forEach((lgData) => {
+ const rrcs = lgData?.data?.rrcs || [];
+ rrcs.forEach((rrc) => {
+ const peers = rrc.peers || [];
+ peers.forEach((peer) => {
+ const rawPath = peer.as_path || "";
+ const pathArr = rawPath.split(" ").map(Number).filter(Boolean);
+ if (pathArr.length > 1) {
+ allPaths.push({
+ rrc: rrc.rrc,
+ path: pathArr,
+ rawPath: rawPath,
+ prefix: peer.prefix || "",
+ hasAsSet: hasAsSet(rawPath),
+ });
+ const idx = pathArr.indexOf(targetAsn);
+ if (idx > 0) {
+ const neighbour = pathArr[idx - 1];
+ pathNeighbourCount.set(neighbour, (pathNeighbourCount.get(neighbour) || 0) + 1);
+ }
+ }
+ });
+ });
+ });
+
+ // Provider detection: ONLY use RIPE Stat "left" neighbours (verified upstreams)
+ // AS-path analysis is used for frequency/confirmation, NOT as standalone provider source
+ const neighbours = neighbourData?.data?.neighbours || [];
+ const leftNeighbours = neighbours.filter((n) => n.type === "left");
+ const upstreamSet = new Set();
+ leftNeighbours.forEach((n) => upstreamSet.add(n.asn));
+
+ // Classify left neighbours: high-power = likely upstream, low-power = likely peer
+ const maxPower = leftNeighbours.reduce((m, n) => Math.max(m, n.power || 0), 1);
+ const detectedProviders = [...upstreamSet].map((asn) => {
+ const nb = leftNeighbours.find((n) => n.asn === asn);
+ const power = nb ? (nb.power || 0) : 0;
+ const powerPct = Math.round((power / maxPower) * 100);
+ const classification = powerPct >= 10 ? "likely_upstream" : "likely_peer";
+ return { asn, name: nb && nb.as_name ? nb.as_name : "", power, power_pct: powerPct, classification };
+ });
+
+ await resolveASNames(detectedProviders);
+
+ // Count how often each provider appears in paths
+ const providerFrequency = new Map();
+ allPaths.forEach((p) => {
+ const idx = p.path.indexOf(targetAsn);
+ if (idx > 0) {
+ const prov = p.path[idx - 1];
+ providerFrequency.set(prov, (providerFrequency.get(prov) || 0) + 1);
+ }
+ });
+
+ // Check Cloudflare RPKI feed for ASPA object
+ await ensureAspaCache();
+ const aspaLookup = lookupAspaFromRpki(targetAsn);
+ const aspaObjectExists = aspaLookup.exists;
+ const aspaDeclaredProviders = aspaLookup.providers;
+
+ // Build ASPA store from RPKI feed data (real ASPA objects only). If the
+ // target has no real ASPA object, its hops must show "NoAttestation" --
+ // NOT a fabricated declaration built from heuristically-detected BGP
+ // neighbours, which would contradict the aspa_object_exists:false this
+ // same response reports and falsely present unattested hops as
+ // "ProviderPlus"/Valid. hopCheck() already returns "NoAttestation" for
+ // any ASN with no entry in aspaStore, so simply not setting one here is
+ // correct and sufficient -- no fallback needed.
+ const aspaStore = new Map();
+ if (aspaObjectExists) {
+ aspaStore.set(targetAsn, new Set(aspaDeclaredProviders));
+ }
+ // Also populate store with all known ASPA objects from the RPKI feed
+ // for providers that have their own ASPA objects (enables full path verification)
+ for (const [cas, provSet] of rpkiAspaMap) {
+ if (!aspaStore.has(cas)) {
+ aspaStore.set(cas, provSet);
+ }
+ }
+ // Deliberately NOT adding empty Set() entries for detected-but-unattested
+ // providers here: aspaStore.get() returning undefined for them makes
+ // hopCheck() correctly report "NoAttestation". An empty (but present) Set
+ // would make providers.has(x) return false, misreporting "NotProviderPlus"
+ // (implying a real ASPA object that denies this hop) instead of "we simply
+ // don't know" -- see the 2026-07-16 audit for how this previously caused
+ // clean, unattested paths to be misclassified as route leaks.
+
+ // Sample paths for verification (up to 50)
+ const samplePaths = allPaths.slice(0, 50);
+ const pathResults = samplePaths.map((p) => {
+ const upstream = verifyUpstream(p.path, aspaStore, p.rawPath);
+ const downstream = verifyDownstream(p.path, aspaStore, p.rawPath);
+ const valleys = detectValleys(p.path, aspaStore);
+
+ return {
+ rrc: p.rrc,
+ prefix: p.prefix,
+ path: p.path.map((a) => "AS" + a).join(" "),
+ collapsed_path: collapsePrepends(p.path).map((a) => "AS" + a).join(" "),
+ has_as_set: p.hasAsSet,
+ upstream_verification: upstream,
+ downstream_verification: downstream,
+ valleys: valleys,
+ overall: p.hasAsSet
+ ? "Invalid"
+ : upstream.result === "Valid" && downstream.result === "Valid"
+ ? "Valid"
+ : upstream.result === "Invalid" || downstream.result === "Invalid"
+ ? "Invalid"
+ : "Unknown",
+ };
+ });
+
+ // Calculate statistics
+ const validPaths = pathResults.filter((p) => p.overall === "Valid").length;
+ const invalidPaths = pathResults.filter((p) => p.overall === "Invalid").length;
+ const unknownPaths = pathResults.filter((p) => p.overall === "Unknown").length;
+ const asSetPaths = pathResults.filter((p) => p.has_as_set).length;
+ const valleyPaths = pathResults.filter((p) => p.valleys.length > 0).length;
+
+ // For readiness scoring: Valid = full credit, Unknown = partial (no ASPA data is normal),
+ // only Invalid actually indicates problems
+ const pathNotInvalidPct = pathResults.length > 0
+ ? Math.round(((validPaths + unknownPaths) / pathResults.length) * 100)
+ : 0;
+ const pathValidPct = pathResults.length > 0 ? Math.round((validPaths / pathResults.length) * 100) : 0;
+
+ // Provider audit: compare detected vs declared
+ const detectedSet = new Set(detectedProviders.map((p) => p.asn));
+ const declaredSet = new Set(aspaDeclaredProviders);
+
+ const missingFromAspa = detectedProviders
+ .filter((p) => !declaredSet.has(p.asn))
+ .map((p) => ({
+ asn: p.asn,
+ name: p.name,
+ frequency: providerFrequency.get(p.asn) || 0,
+ frequency_pct: allPaths.length > 0
+ ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
+ : 0,
+ }))
+ .sort((a, b) => b.frequency - a.frequency);
+
+ const extraInAspa = aspaDeclaredProviders
+ .filter((asn) => !detectedSet.has(asn))
+ .map((asn) => ({
+ asn,
+ name: "",
+ seen_in_paths: false,
+ }));
+
+ const providerCompleteness = detectedProviders.length > 0
+ ? Math.round(
+ (detectedProviders.filter((p) => declaredSet.has(p.asn)).length /
+ detectedProviders.length) *
+ 100
+ )
+ : aspaObjectExists ? 100 : 0;
+
+ // Get RPKI coverage for readiness score
+ // Validate ALL prefixes using local RPKI data (Cloudflare feed - all 5 RIRs)
+ await ensureAspaCache();
+ const rpkiBatch = announcedPrefixes.map((p) => p.prefix);
+ const rpkiResults = await Promise.all(rpkiBatch.map((pfx) => validateRPKIWithCache(rawAsn, pfx)));
+ // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
+ // must not lower a network's displayed ASPA readiness score.
+ const rpkiChecked = rpkiResults.filter((r) => r.status !== "unavailable");
+ const rpkiValid = rpkiChecked.filter((r) => r.status === "valid").length;
+ const rpkiCoverage = rpkiChecked.length > 0 ? Math.round((rpkiValid / rpkiChecked.length) * 100) : 0;
+
+ // Calculate readiness score
+ const readinessScore = calculateAspaReadinessScore({
+ rpkiCoverage,
+ aspaObjectExists,
+ providerCompleteness,
+ pathValidationPct: pathNotInvalidPct,
+ });
+
+ const duration = Date.now() - start;
+ const verifyResult = {
+ meta: {
+ query: "AS" + rawAsn,
+ duration_ms: duration,
+ timestamp: new Date().toISOString(),
+ paths_analyzed: pathResults.length,
+ total_paths_seen: allPaths.length,
+ },
+ asn: targetAsn,
+ readiness_score: readinessScore,
+ aspa_object_exists: aspaObjectExists,
+ aspa_feed_healthy: aspaLookup.feedLoaded,
+ detected_providers: detectedProviders.map((p) => ({
+ ...p,
+ frequency: providerFrequency.get(p.asn) || 0,
+ frequency_pct: allPaths.length > 0
+ ? Math.round(((providerFrequency.get(p.asn) || 0) / allPaths.length) * 100)
+ : 0,
+ })),
+ provider_audit: {
+ declared_count: aspaDeclaredProviders.length,
+ detected_count: detectedProviders.length,
+ completeness_pct: providerCompleteness,
+ missing_from_aspa: missingFromAspa,
+ extra_in_aspa: extraInAspa,
+ },
+ path_verification: {
+ total: pathResults.length,
+ valid: validPaths,
+ invalid: invalidPaths,
+ unknown: unknownPaths,
+ as_set_flagged: asSetPaths,
+ valley_detected: valleyPaths,
+ valid_pct: pathValidPct,
+ not_invalid_pct: pathNotInvalidPct,
+ results: pathResults,
+ },
+ rpki_coverage: rpkiCoverage,
+ };
+ resultCacheSet(aspaResultCache, "verify:" + rawAsn, verifyResult);
+ return res.end(JSON.stringify(verifyResult, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "ASPA verification failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handleAspaVerify };
diff --git a/server/routes/bgp.js b/server/routes/bgp.js
new file mode 100644
index 0000000..17a0dd8
--- /dev/null
+++ b/server/routes/bgp.js
@@ -0,0 +1,108 @@
+const localDb = require("../../local-db-client");
+const { bgproutesResultCache, resultCacheGet, resultCacheSet } = require("../caches/response-caches");
+
+// BGP endpoint (LOCAL DB): /api/bgp?asn=X (or prefix=X)
+// Queries local PostgreSQL bgp_routes table — zero external API calls
+async function handleBgp(req, res, url) {
+ const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
+ const prefix = url.searchParams.get("prefix") || "";
+ if (!rawAsn && !prefix) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Need asn or prefix parameter" }));
+ }
+ const cacheKey = rawAsn || prefix;
+ const cached = resultCacheGet(bgproutesResultCache, cacheKey);
+ if (cached !== undefined) {
+ res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
+ return res.end(JSON.stringify(cached));
+ }
+ const start = Date.now();
+ try {
+ const result = {
+ meta: { timestamp: new Date().toISOString(), source: "local_bgp_db" },
+ bgp_status: null,
+ threat_intel: null,
+ };
+
+ // ---- BGP Status (local DB lookup) ----
+ if (prefix) {
+ // Prefix lookup: Get BGP status for this prefix
+ const bgpStatus = await localDb.getBgpStatus(prefix);
+ if (bgpStatus) {
+ result.bgp_status = {
+ prefix,
+ announced: bgpStatus.announced,
+ origin_asns: bgpStatus.origin_asns,
+ visibility_percent: bgpStatus.visibility_percent,
+ last_seen: bgpStatus.last_seen,
+ source: "local_bgp",
+ };
+ // Check for hijack (multiple origin ASNs). null means the check itself
+ // failed (DB error) -- distinct from a genuine single/no-origin result.
+ const hijackAsns = await localDb.checkBgpHijack(prefix);
+ if (hijackAsns === null) {
+ result.bgp_status.hijack_check_unavailable = true;
+ } else if (hijackAsns.length > 1) {
+ result.bgp_status.hijack_warning = {
+ detected: true,
+ origin_asns: hijackAsns,
+ message: `Multiple origin ASNs detected for ${prefix}`,
+ };
+ }
+ }
+ } else if (rawAsn) {
+ // ASN lookup: Get all announced prefixes for this ASN
+ const prefixes = await localDb.getAnnouncedPrefixes(rawAsn);
+ if (prefixes && prefixes.length > 0) {
+ result.bgp_status = {
+ asn: rawAsn,
+ announced_count: prefixes.length,
+ prefixes: prefixes.slice(0, 50).map((p) => ({
+ prefix: p.prefix,
+ origin_asn: p.origin_asn,
+ visibility_percent: p.visibility_percent,
+ last_seen: p.last_seen,
+ })),
+ source: "local_bgp",
+ };
+ } else {
+ result.bgp_status = {
+ asn: rawAsn,
+ announced: false,
+ announced_count: 0,
+ message: "No prefixes found for this ASN in local BGP table",
+ source: "local_bgp",
+ };
+ }
+ }
+
+ // ---- Threat Intelligence (local cache lookup) ----
+ // If we have an IP context, look up threat intel
+ if (prefix && prefix.includes(".")) {
+ // Extract IP from prefix (e.g., "1.1.1.0/24" → "1.1.1.0")
+ const ipAddr = prefix.split("/")[0];
+ const threat = await localDb.getThreatIntel(ipAddr);
+ if (threat) {
+ result.threat_intel = {
+ ip_address: threat.ip_address,
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ cached_at: threat.cached_at,
+ };
+ }
+ }
+
+ result.meta.duration_ms = Date.now() - start;
+ resultCacheSet(bgproutesResultCache, cacheKey, result);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ return res.end(JSON.stringify(result, null, 2));
+ } catch (err) {
+ console.error("[/api/bgp] Error:", err.message);
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "BGP query failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handleBgp };
diff --git a/server/routes/changelog.js b/server/routes/changelog.js
new file mode 100644
index 0000000..4cc5401
--- /dev/null
+++ b/server/routes/changelog.js
@@ -0,0 +1,46 @@
+const fs = require("fs");
+
+// Changelog page: renders CHANGELOG.md as HTML
+function handleChangelog(req, res) {
+ 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');
+ }
+}
+
+module.exports = { handleChangelog };
diff --git a/server/routes/compare.js b/server/routes/compare.js
new file mode 100644
index 0000000..fe2f13e
--- /dev/null
+++ b/server/routes/compare.js
@@ -0,0 +1,175 @@
+const localDb = require("../../local-db-client");
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { fetchPeeringDB } = require("../services/peeringdb");
+const { validateRPKIWithCache } = require("../services/rpki");
+const { cacheGet, cacheSet, CACHE_TTL_DEFAULT } = require("../caches/response-caches");
+
+// Compare endpoint: /api/compare?asn1=X&asn2=Y
+async function handleCompare(req, res, url) {
+ const asn1 = (url.searchParams.get("asn1") || "").replace(/[^0-9]/g, "");
+ const asn2 = (url.searchParams.get("asn2") || "").replace(/[^0-9]/g, "");
+ if (!asn1 || !asn2) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Need asn1 and asn2 parameters" }));
+ }
+
+ const compareCacheKey = "compare:" + asn1 + ":" + asn2;
+ const compareCached = cacheGet(compareCacheKey);
+ if (compareCached) {
+ res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
+ return res.end(JSON.stringify(compareCached));
+ }
+ const start = Date.now();
+ try {
+ // ALL calls in parallel — single batch
+ // Phase 1: Get PDB net objects + RIPE data
+ const [pdb1, pdb2, nb1Data, nb2Data, pfx1Data, pfx2Data] = await Promise.all([
+ fetchPeeringDB("/net?asn=" + asn1),
+ fetchPeeringDB("/net?asn=" + asn2),
+ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 8000 }),
+ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 8000 }),
+ fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 8000 }),
+ fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 8000 }),
+ ]);
+
+ const net1 = pdb1?.data?.[0] || {};
+ const net2 = pdb2?.data?.[0] || {};
+ const netId1 = net1.id;
+ const netId2 = net2.id;
+
+ // Phase 2: IX + Facility using net_id (Bug 1 fix: netfac requires net_id, not asn)
+ const ixFacPromises = [];
+ ixFacPromises.push(netId1 ? fetchPeeringDB("/netixlan?net_id=" + netId1) : Promise.resolve(null));
+ ixFacPromises.push(netId2 ? fetchPeeringDB("/netixlan?net_id=" + netId2) : Promise.resolve(null));
+ ixFacPromises.push(netId1 ? fetchPeeringDB("/netfac?net_id=" + netId1) : Promise.resolve(null));
+ ixFacPromises.push(netId2 ? fetchPeeringDB("/netfac?net_id=" + netId2) : Promise.resolve(null));
+ const [ix1Data, ix2Data, fac1Data, fac2Data] = await Promise.all(ixFacPromises);
+
+ const ix1Set = new Set((ix1Data?.data || []).map((ix) => ix.ix_id));
+ const ix2Set = new Set((ix2Data?.data || []).map((ix) => ix.ix_id));
+ const ix1Names = {};
+ (ix1Data?.data || []).forEach((ix) => (ix1Names[ix.ix_id] = ix.name));
+ const ix2Names = {};
+ (ix2Data?.data || []).forEach((ix) => (ix2Names[ix.ix_id] = ix.name));
+
+ const commonIX = [...ix1Set].filter((id) => ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || ix2Names[id] || "" }));
+ const only1IX = [...ix1Set].filter((id) => !ix2Set.has(id)).map((id) => ({ ix_id: id, name: ix1Names[id] || "" }));
+ const only2IX = [...ix2Set].filter((id) => !ix1Set.has(id)).map((id) => ({ ix_id: id, name: ix2Names[id] || "" }));
+
+ const fac1Set = new Set((fac1Data?.data || []).map((f) => f.fac_id));
+ const fac2Set = new Set((fac2Data?.data || []).map((f) => f.fac_id));
+ const fac1Names = {};
+ (fac1Data?.data || []).forEach((f) => (fac1Names[f.fac_id] = f.name));
+ const fac2Names = {};
+ (fac2Data?.data || []).forEach((f) => (fac2Names[f.fac_id] = f.name));
+
+ const commonFac = [...fac1Set].filter((id) => fac2Set.has(id)).map((id) => ({ fac_id: id, name: fac1Names[id] || fac2Names[id] || "" }));
+
+ const nb1 = (nb1Data?.data?.neighbours || []).filter((n) => n.type === "left");
+ const nb2 = (nb2Data?.data?.neighbours || []).filter((n) => n.type === "left");
+ const up1Set = new Set(nb1.map((n) => n.asn));
+ const up2Set = new Set(nb2.map((n) => n.asn));
+ const nb1Map = {};
+ nb1.forEach((n) => (nb1Map[n.asn] = n.as_name || ""));
+ const nb2Map = {};
+ nb2.forEach((n) => (nb2Map[n.asn] = n.as_name || ""));
+
+ const commonUpstreams = [...up1Set]
+ .filter((a) => up2Set.has(a))
+ .map((a) => ({ asn: a, name: nb1Map[a] || nb2Map[a] || "" }));
+
+ // ---- Threat Intelligence Enrichment for Common Upstreams ----
+ const threatMap = {};
+ const threatPromises = commonUpstreams.slice(0, 50).map(async (n) => {
+ try {
+ const asNum = String(n.asn);
+ const threat = await localDb.getThreatIntel(asNum);
+ if (threat) {
+ threatMap[n.asn] = {
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ };
+ }
+ } catch (e) {
+ console.error(`[Compare Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
+ }
+ });
+ await Promise.race([
+ Promise.all(threatPromises),
+ new Promise(r => setTimeout(r, 2000)),
+ ]);
+
+ // Attach threat status to upstream objects
+ commonUpstreams.forEach((n) => {
+ if (threatMap[n.asn]) {
+ n.threat_level = threatMap[n.asn].threat_level;
+ n.threat_confidence = threatMap[n.asn].confidence_score;
+ n.threat_source = threatMap[n.asn].source;
+ }
+ });
+
+ // Resolve names + RPKI sample (max 3+3 prefixes) all in parallel with 5s timeout
+ const pfx1 = (pfx1Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
+ const pfx2 = (pfx2Data?.data?.prefixes || []).slice(0, 3).map((p) => p.prefix);
+ const [, rpki1Results, rpki2Results] = await Promise.race([
+ Promise.all([
+ commonUpstreams.length > 0 ? Promise.all(commonUpstreams.map(n =>
+ fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
+ .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
+ .catch(() => {})
+ )) : Promise.resolve([]),
+ Promise.all(pfx1.map((p) => validateRPKIWithCache(asn1, p))),
+ Promise.all(pfx2.map((p) => validateRPKIWithCache(asn2, p))),
+ ]),
+ new Promise(r => setTimeout(() => r([[], [], []]), 5000)),
+ ]);
+
+ const rpki1Valid = rpki1Results.filter((r) => r.status === "valid").length;
+ const rpki2Valid = rpki2Results.filter((r) => r.status === "valid").length;
+ const rpki1Pct = rpki1Results.length > 0 ? Math.round((rpki1Valid / rpki1Results.length) * 100) : 0;
+ const rpki2Pct = rpki2Results.length > 0 ? Math.round((rpki2Valid / rpki2Results.length) * 100) : 0;
+
+ const duration = Date.now() - start;
+ const compareResult = {
+ meta: { duration_ms: duration, timestamp: new Date().toISOString() },
+ asn1: {
+ asn: parseInt(asn1),
+ name: net1.name || "Unknown",
+ ix_count: ix1Set.size,
+ fac_count: fac1Set.size,
+ upstream_count: up1Set.size,
+ rpki_coverage: rpki1Pct,
+ },
+ asn2: {
+ asn: parseInt(asn2),
+ name: net2.name || "Unknown",
+ ix_count: ix2Set.size,
+ fac_count: fac2Set.size,
+ upstream_count: up2Set.size,
+ rpki_coverage: rpki2Pct,
+ },
+ common_ixps: commonIX,
+ only_asn1_ixps: only1IX,
+ only_asn2_ixps: only2IX,
+ common_facilities: commonFac,
+ common_upstreams: commonUpstreams,
+ rpki_comparison: {
+ asn1_coverage: rpki1Pct,
+ asn2_coverage: rpki2Pct,
+ asn1_checked: rpki1Results.length,
+ asn2_checked: rpki2Results.length,
+ better: rpki1Pct > rpki2Pct ? "AS" + asn1 : rpki2Pct > rpki1Pct ? "AS" + asn2 : "equal",
+ },
+ };
+ cacheSet(compareCacheKey, compareResult, CACHE_TTL_DEFAULT);
+ res.end(JSON.stringify(compareResult, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ res.end(JSON.stringify({ error: "Compare failed", message: err.message }));
+ }
+ return;
+
+}
+
+module.exports = { handleCompare };
diff --git a/server/routes/enrich.js b/server/routes/enrich.js
new file mode 100644
index 0000000..e6f80c7
--- /dev/null
+++ b/server/routes/enrich.js
@@ -0,0 +1,125 @@
+const http = require("http");
+const https = require("https");
+const { fetchJSON } = require("../services/http-helpers");
+
+// Feature 28b: Company enrichment via Wikipedia + website meta scraping
+async function handleEnrich(req, res, url) {
+ 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, "");
+ const companyName = (url.searchParams.get("name") || "").trim();
+ const website = (url.searchParams.get("website") || "").trim();
+ const UA_SCRAPE = "Mozilla/5.0 (compatible; PeerCortex/1.0; +https://peercortex.org)";
+
+ let description = null;
+ let wikiUrl = null;
+
+ try {
+ // 1. Direct Wikipedia lookup by company name
+ if (companyName) {
+ const nameLower = companyName.toLowerCase();
+ const isRelevant = (title, extract) => {
+ const titleLower = (title || "").toLowerCase();
+ const extractLower = (extract || "").toLowerCase();
+ const nameWords = nameLower.replace(/\s+(gmbh|ag|ltd|inc|llc|bv|sa|sas|oy|ab)\s*$/i, "").split(/\s+/).filter(w => w.length > 3);
+ const titleMatch = nameWords.some(w => titleLower.includes(w));
+ const netTerms = ["internet", "network", "isp", "hosting", "provider", "transit", "data center", "datacenter", "telecommunications", "telecom", "bandwidth", "peering", "routing", "autonomous system", "colocation", "colo", "fiber", "optical", "transceiver"];
+ const hasNetContext = netTerms.some(t => extractLower.includes(t));
+ return titleMatch && (hasNetContext || titleMatch);
+ };
+
+ // Direct title lookup — try full name first, then first word as fallback
+ const cleanName = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC|BV|SA|SAS|Oy|AB)$/i, "").trim();
+ const firstName = cleanName.split(/\s+/)[0];
+ const namesToTry = cleanName === firstName ? [cleanName] : [cleanName, firstName];
+ for (const tryName of namesToTry) {
+ const wikiDirect = await fetchJSON(
+ "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(tryName),
+ { timeout: 5000 }
+ );
+ if (wikiDirect && wikiDirect.type === "disambiguation") continue; // skip disambiguation pages
+ if (wikiDirect && wikiDirect.extract && wikiDirect.extract.length > 30 && isRelevant(wikiDirect.title, wikiDirect.extract)) {
+ description = wikiDirect.extract.replace(/\s+/g, " ").trim().slice(0, 300);
+ wikiUrl = wikiDirect.content_urls && wikiDirect.content_urls.desktop && wikiDirect.content_urls.desktop.page;
+ break;
+ }
+ }
+
+ // 2. Wikipedia search if direct lookup didn't match
+ if (!description) {
+ const searchQuery = companyName.replace(/\s+(GmbH|AG|Ltd|Inc|LLC)$/i, "") + " internet service provider";
+ const searchData = await fetchJSON(
+ "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=" + encodeURIComponent(searchQuery) + "&limit=3",
+ { timeout: 5000 }
+ );
+ if (searchData && Array.isArray(searchData) && searchData[1] && searchData[1].length > 0) {
+ const topTitle = searchData[1][0];
+ const wikiSearch = await fetchJSON(
+ "https://en.wikipedia.org/api/rest_v1/page/summary/" + encodeURIComponent(topTitle),
+ { timeout: 5000 }
+ );
+ if (wikiSearch && wikiSearch.extract && wikiSearch.extract.length > 30) {
+ if (isRelevant(wikiSearch.title, wikiSearch.extract)) {
+ description = wikiSearch.extract.replace(/\s+/g, " ").trim().slice(0, 300);
+ wikiUrl = wikiSearch.content_urls && wikiSearch.content_urls.desktop && wikiSearch.content_urls.desktop.page;
+ }
+ }
+ }
+ }
+ }
+
+ // 3. Fallback: scrape website meta description (follows up to 3 redirects)
+ function fetchPage(pageUrl, hops) {
+ if (hops <= 0) return Promise.resolve(null);
+ return new Promise((resolve) => {
+ const mod = pageUrl.startsWith("https") ? https : http;
+ const req = mod.get(pageUrl, { headers: { "User-Agent": UA_SCRAPE }, timeout: 6000 }, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ res.resume(); // drain to free socket
+ const next = res.headers.location.startsWith("http") ? res.headers.location : new URL(res.headers.location, pageUrl).href;
+ return resolve(fetchPage(next, hops - 1));
+ }
+ let data = "";
+ res.on("data", (c) => { data += c; if (data.length > 40000) { req.destroy(); resolve(data); } });
+ res.on("end", () => resolve(data));
+ });
+ req.on("error", () => resolve(null));
+ req.on("timeout", () => { req.destroy(); resolve(null); });
+ });
+ }
+ if (!description && website) {
+ let wsUrl = website;
+ if (!wsUrl.startsWith("http")) wsUrl = "https://" + wsUrl;
+ const aboutUrl = wsUrl.replace(/\/$/, "") + "/about";
+ const tryUrls = [aboutUrl, wsUrl];
+ for (const tryUrl of tryUrls) {
+ const resp = await fetchPage(tryUrl, 3);
+ if (!resp) continue;
+ const metaPatterns = [
+ / ]+name=["']description["'][^>]+content=["']([^"']{20,500})["']/i,
+ / ]+content=["']([^"']{20,500})["'][^>]+name=["']description["']/i,
+ / ]+property=["']og:description["'][^>]+content=["']([^"']{20,500})["']/i,
+ / ]+content=["']([^"']{20,500})["'][^>]+property=["']og:description["']/i,
+ ];
+ let matched = false;
+ for (const pat of metaPatterns) {
+ const m = resp.match(pat);
+ if (m && m[1]) {
+ description = m[1].replace(/
|✓|&/g, " ").replace(/\s+/g, " ").trim().slice(0, 300);
+ matched = true;
+ break;
+ }
+ }
+ if (matched) break;
+ }
+ }
+ } catch (_e) {
+ // Return whatever we have
+ }
+
+ return res.end(JSON.stringify({ asn: rawAsn, description, wiki_url: wikiUrl }));
+
+}
+
+module.exports = { handleEnrich };
diff --git a/server/routes/feedback.js b/server/routes/feedback.js
new file mode 100644
index 0000000..630df32
--- /dev/null
+++ b/server/routes/feedback.js
@@ -0,0 +1,70 @@
+const fs = require("fs");
+const { sendFeedbackMail } = require("../../src/backend/services/smtp");
+
+const FEEDBACK_TOKEN = process.env.FEEDBACK_TOKEN || "changeme-set-in-env";
+const FEEDBACK_FILE = "/opt/peercortex-app/feedback.json";
+
+function handleOptions(req, res) {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.writeHead(204);
+ return res.end();
+}
+
+// POST /api/feedback — submit feedback entry
+function handlePost(req, res) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ let body = '';
+ req.on('data', function(chunk) { body += chunk; });
+ req.on('end', function() {
+ try {
+ const data = JSON.parse(body);
+ if (!data.message || String(data.message).trim().length < 3) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ ok: false, error: 'Message too short' }));
+ }
+ const entry = {
+ id: Date.now() + '-' + Math.random().toString(36).slice(2, 7),
+ timestamp: new Date().toISOString(),
+ category: String(data.category || 'General').slice(0, 50),
+ message: String(data.message || '').slice(0, 2000),
+ name: String(data.name || 'Anonymous').slice(0, 100),
+ asn: data.asn ? String(data.asn).slice(0, 20) : null,
+ ip: req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for'] || req.socket.remoteAddress || null,
+ ua: String(req.headers['user-agent'] || '').slice(0, 200)
+ };
+ let entries = [];
+ 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);
+ return res.end(JSON.stringify({ ok: false, error: 'Server error' }));
+ }
+ });
+ return;
+}
+
+// GET /api/feedback?token=... — admin: fetch all entries as JSON
+function handleGet(req, res, url) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const token = url.searchParams.get('token');
+ if (!token || token !== FEEDBACK_TOKEN) {
+ res.writeHead(401);
+ return res.end(JSON.stringify({ ok: false, error: 'Unauthorized' }));
+ }
+ try {
+ const entries = JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf8'));
+ return res.end(JSON.stringify({ ok: true, entries: entries, count: entries.length }));
+ } catch (_e) {
+ return res.end(JSON.stringify({ ok: true, entries: [], count: 0 }));
+ }
+}
+
+module.exports = { handleOptions, handlePost, handleGet };
diff --git a/server/routes/health.js b/server/routes/health.js
new file mode 100644
index 0000000..f4d0319
--- /dev/null
+++ b/server/routes/health.js
@@ -0,0 +1,104 @@
+const localDb = require("../../local-db-client");
+const { getRpkiAspaLastFetch, rpkiAspaMap } = require("../services/rpki");
+const { pdbSourceCache } = require("../caches/pdb-source-cache");
+const { roaStore } = require("../caches/roa-store");
+const { ripeStatCache } = require("../services/ripe-stat");
+const { responseCache } = require("../caches/response-caches");
+const { aspaAdoptionDailyHistory } = require("../aspa-adoption/tracker");
+
+const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
+
+function aspaAdoptionDelta() {
+ return aspaAdoptionDailyHistory.length >= 2
+ ? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
+ : 0;
+}
+
+// Health endpoint — extended with cache status, ASPA metrics, and local DB stats
+function handleHealth(req, res) {
+ const mem = process.memoryUsage();
+ const rpkiAspaLastFetch = getRpkiAspaLastFetch();
+ const aspaAge = rpkiAspaLastFetch ? Math.floor((Date.now() - rpkiAspaLastFetch) / 60000) : -1;
+ // Bug fix 2026-07-17: previously only declared inside the .then() callback
+ // below, so the .catch() branch's reference to it threw ReferenceError
+ // (never caught -- the response just hung until client timeout).
+ const roaAge = roaStore.lastBuild ? Math.floor((Date.now() - roaStore.lastBuild) / 60000) : -1;
+ const pdbTotal = pdbSourceCache.hits + pdbSourceCache.misses;
+
+ // Query local DB stats (async, but return partial if needed)
+ localDb.getLocalDbStats().then(function(dbStats) {
+ // Determine health status based on local DB data availability
+ const hasLocalBgp = dbStats && dbStats.bgp_routes > 100000; // should have >2M rows normally
+ const hasLocalRpki = dbStats && dbStats.rpki_roas > 100000; // should have >500k rows normally
+ const status = (hasLocalBgp && hasLocalRpki && aspaAge < 300) ? "ok" : "degraded";
+
+ const healthResponse = {
+ status,
+ service: "PeerCortex",
+ version: "0.6.9",
+ timestamp: new Date().toISOString(),
+ uptime_seconds: Math.floor(process.uptime()),
+ memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
+ bgproutes_configured: !!BGPROUTES_API_KEY,
+ caches: {
+ aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
+ pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
+ pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
+ pdb_netfac: { entries: pdbSourceCache.netfac.size },
+ ripe_stat: { entries: ripeStatCache.size },
+ response_cache: { entries: responseCache.size },
+ },
+ local_db: dbStats ? {
+ bgp_routes: dbStats.bgp_routes,
+ rpki_roas: dbStats.rpki_roas,
+ threat_intel: dbStats.threat_intel,
+ rdap_cache_entries: dbStats.rdap_cache_entries,
+ source: "PostgreSQL (local)",
+ healthy: hasLocalBgp && hasLocalRpki,
+ } : null,
+ aspa_adoption: {
+ total_objects: rpkiAspaMap.size,
+ roa_count: roaStore.count,
+ history_samples: aspaAdoptionDailyHistory.length,
+ delta_last: aspaAdoptionDelta(),
+ },
+ };
+ return res.end(JSON.stringify(healthResponse, null, 2));
+ }).catch(function(e) {
+ console.error('[/api/health] Local DB stats error:', e.message);
+ // Return health without local DB stats on error. Local DB stats failed
+ // to fetch, so "ok" can't be confirmed -- report degraded, matching the
+ // honest-degradation pattern used throughout this codebase.
+ const status = "degraded";
+ return res.end(
+ JSON.stringify({
+ status,
+ service: "PeerCortex",
+ version: "0.6.9",
+ timestamp: new Date().toISOString(),
+ uptime_seconds: Math.floor(process.uptime()),
+ memory_mb: Math.round(mem.heapUsed / 1024 / 1024),
+ bgproutes_configured: !!BGPROUTES_API_KEY,
+ caches: {
+ roa_store: { entries: roaStore.count, age_minutes: roaAge, ready: roaStore.ready },
+ aspa_map: { entries: rpkiAspaMap.size, age_minutes: aspaAge },
+ pdb_net: { entries: pdbSourceCache.net.size, hit_rate_pct: pdbTotal > 0 ? Math.round(pdbSourceCache.hits / pdbTotal * 100) : 0 },
+ pdb_netixlan: { entries: pdbSourceCache.netixlan.size },
+ pdb_netfac: { entries: pdbSourceCache.netfac.size },
+ ripe_stat: { entries: ripeStatCache.size },
+ response_cache: { entries: responseCache.size },
+ },
+ local_db: { error: "Could not fetch local DB stats", message: e.message },
+ aspa_adoption: {
+ total_objects: rpkiAspaMap.size,
+ roa_count: roaStore.count,
+ history_samples: aspaAdoptionDailyHistory.length,
+ delta_last: aspaAdoptionDelta(),
+ },
+ }, null, 2)
+ );
+ });
+ return;
+}
+
+module.exports = { handleHealth };
diff --git a/server/routes/hijack-alerts.js b/server/routes/hijack-alerts.js
new file mode 100644
index 0000000..a210ae0
--- /dev/null
+++ b/server/routes/hijack-alerts.js
@@ -0,0 +1,221 @@
+const crypto = require("crypto");
+const {
+ loadHijackSubs, loadHijackAlerts, loadWebhookSubs,
+ saveWebhookSubs, saveHijackAlerts, saveHijackSubs,
+} = require("../hijack-monitoring/store");
+const { deliverWebhook } = require("../hijack-monitoring/webhooks");
+const { checkHijacksForAsn } = require("../hijack-monitoring/monitor");
+
+function matchesHijackGroup(reqPath) {
+ return reqPath.startsWith('/api/webhooks') || reqPath.startsWith('/api/hijacks') || reqPath === '/api/hijack-subscribe';
+}
+
+// CORS preflight for all /api/webhooks + /api/hijacks
+function handleOptions(req, res) {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ res.writeHead(204); return res.end();
+}
+
+// Webhook: Register. POST /api/webhooks?asn=X body: { endpoint_url, timeout_ms?, max_retries? }
+function handleWebhookRegister(req, res) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const params = new URL(req.url, 'http://localhost').searchParams;
+ const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
+ if (!asn) { res.writeHead(400); return res.end(JSON.stringify({error:'asn query param required'})); }
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const { endpoint_url, timeout_ms, max_retries } = JSON.parse(body || '{}');
+ if (!endpoint_url || !endpoint_url.startsWith('http')) {
+ res.writeHead(400); return res.end(JSON.stringify({error:'endpoint_url required (http/https)'}));
+ }
+ const subs = loadWebhookSubs();
+ const exists = subs.find(s => s.asn === asn && s.endpoint_url === endpoint_url);
+ if (exists) { res.writeHead(200); return res.end(JSON.stringify({id: exists.id, already_exists: true, secret_key: exists.secret})); }
+ const webhookToken = crypto.randomBytes(32).toString('hex');
+ const entry = {
+ id: crypto.randomBytes(8).toString('hex'),
+ asn, endpoint_url,
+ secret: webhookToken,
+ timeout_ms: timeout_ms || 10000,
+ max_retries: max_retries || 5,
+ active: true,
+ created_at: new Date().toISOString(),
+ last_triggered_at: null,
+ failure_count: 0
+ };
+ subs.push(entry);
+ saveWebhookSubs(subs);
+ // Also ensure this ASN is in hijack monitoring
+ const hijackSubs = loadHijackSubs();
+ if (!hijackSubs.find(s => s.asn === asn)) {
+ checkHijacksForAsn(asn).then(prefixes => {
+ // null (lookup failed) becomes [] here so this record's shape stays
+ // consistent; runHijackCheck retries the real baseline next cycle.
+ hijackSubs.push({ asn, prefixes: prefixes || [], subscribed: new Date().toISOString() });
+ saveHijackSubs(hijackSubs);
+ });
+ }
+ res.writeHead(201);
+ // Bug fix 2026-07-17 (agreed pre-existing bug #1 of 3): responded with
+ // `secret_key: secret`, but `secret` was never declared in this scope
+ // (only `webhookToken` and `entry.secret`, the same value) -- every new
+ // webhook registration threw ReferenceError here, caught below,
+ // degrading to a 400 "secret is not defined". Use entry.secret, matching
+ // the working "already exists" branch above.
+ res.end(JSON.stringify({ id: entry.id, asn, endpoint_url, secret_key: entry.secret, created_at: entry.created_at, note: 'Store secret_key safely — it signs all webhook payloads' }));
+ } catch(e) { res.writeHead(400); res.end(JSON.stringify({error: e.message})); }
+ });
+ return;
+}
+
+// Webhook: List. GET /api/webhooks?asn=X
+function handleWebhookList(req, res) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const params = new URL(req.url, 'http://localhost').searchParams;
+ const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
+ const subs = loadWebhookSubs();
+ const result = (asn ? subs.filter(s => s.asn === asn) : subs)
+ .map(s => ({ id: s.id, asn: s.asn, endpoint_url: s.endpoint_url, active: s.active, failure_count: s.failure_count, last_triggered_at: s.last_triggered_at, created_at: s.created_at }));
+ res.writeHead(200);
+ return res.end(JSON.stringify({ webhooks: result, total: result.length }));
+}
+
+// Webhook: Delete. DELETE /api/webhooks/:id
+function handleWebhookDelete(req, res, reqPath) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const id = reqPath.split('/')[3];
+ const subs = loadWebhookSubs();
+ const idx = subs.findIndex(s => s.id === id);
+ if (idx === -1) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
+ subs.splice(idx, 1);
+ saveWebhookSubs(subs);
+ res.writeHead(200);
+ return res.end(JSON.stringify({ deleted: true, id }));
+}
+
+// Webhook: Test. POST /api/webhooks/:id/test
+function handleWebhookTest(req, res, reqPath) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const id = reqPath.split('/')[3];
+ const subs = loadWebhookSubs();
+ const sub = subs.find(s => s.id === id);
+ if (!sub) { res.writeHead(404); return res.end(JSON.stringify({error:'webhook not found'})); }
+ const testPayload = { event: 'test', asn: sub.asn, message: 'PeerCortex webhook test — if you receive this, delivery works!', timestamp: new Date().toISOString() };
+ const start = Date.now();
+ deliverWebhook(sub, testPayload, 1).then(result => {
+ res.writeHead(result.ok ? 200 : 502);
+ res.end(JSON.stringify({ ok: result.ok, response_time_ms: Date.now() - start, status: result.status, error: result.error || null }));
+ });
+ return;
+}
+
+// Hijack Events: List. GET /api/hijacks?asn=X&limit=50&resolved=false
+function handleHijacksList(req, res) {
+ const params = new URL(req.url, 'http://localhost').searchParams;
+ const asn = params.get('asn') ? String(params.get('asn')).replace(/[^0-9]/g,'') : '';
+ const limit = Math.min(parseInt(params.get('limit') || '50', 10), 200);
+ const resolvedFilter = params.get('resolved');
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Cache-Control', 'no-store');
+ let events = loadHijackAlerts();
+ if (asn) events = events.filter(a => String(a.asn) === asn);
+ if (resolvedFilter === 'false') events = events.filter(a => !a.resolved);
+ if (resolvedFilter === 'true') events = events.filter(a => a.resolved);
+ const total = events.length;
+ events = events.slice(-limit).reverse();
+ res.writeHead(200);
+ return res.end(JSON.stringify({ total, events }));
+}
+
+// Hijack Events: Resolve. POST /api/hijacks/:id/resolve body: { resolution_notes? }
+function handleHijackResolve(req, res, reqPath) {
+ res.setHeader('Content-Type', 'application/json');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ const id = reqPath.split('/')[3];
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ const alerts = loadHijackAlerts();
+ const alert = alerts.find(a => a.id === id);
+ if (!alert) { res.writeHead(404); return res.end(JSON.stringify({error:'event not found'})); }
+ alert.resolved = true;
+ alert.resolved_at = new Date().toISOString();
+ try { const { resolution_notes } = JSON.parse(body || '{}'); if (resolution_notes) alert.resolution_notes = resolution_notes; } catch(_){}
+ saveHijackAlerts(alerts);
+ res.writeHead(200);
+ res.end(JSON.stringify({ resolved: true, id, resolved_at: alert.resolved_at }));
+ });
+ return;
+}
+
+// Hijack Subscribe (legacy, kept for backwards compatibility)
+function handleHijackSubscribe(req, res) {
+ 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 } = 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);
+ // prefixes may be null if the lookup failed just now -- store an empty
+ // array so this record shape stays consistent; runHijackCheck's own
+ // "!sub.prefixes.length" check will retry establishing the real
+ // baseline on its next cycle rather than treating this as permanent.
+ subs.push({ asn: asnNum, prefixes: prefixes || [], subscribed: new Date().toISOString() });
+ saveHijackSubs(subs);
+ }
+ const sub = subs.find(s => s.asn === asnNum) || { prefixes: [] };
+ res.writeHead(200);
+ res.end(JSON.stringify({ ok: true, asn: asnNum, monitoring: true, prefix_count: (sub.prefixes || []).length }));
+ } catch(e) { res.writeHead(500); res.end(JSON.stringify({error:e.message})); }
+ });
+ return;
+}
+
+// Hijack Alerts (legacy endpoint, kept for backwards compatibility).
+// src/routes/hijack-alerts.ts registers /api/webhooks + /api/hijacks under
+// the same paths this file already serves above (file-backed, live).
+// Deliberately NOT proxied here — that would shadow working data with an
+// empty Postgres-backed implementation. Not a gap, just two feature
+// branches that ended up naming their routes the same thing.
+function handleHijackAlertsLegacy(req, res) {
+ 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 => String(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 }));
+}
+
+module.exports = {
+ matchesHijackGroup,
+ handleOptions,
+ handleWebhookRegister,
+ handleWebhookList,
+ handleWebhookDelete,
+ handleWebhookTest,
+ handleHijacksList,
+ handleHijackResolve,
+ handleHijackSubscribe,
+ handleHijackAlertsLegacy,
+};
diff --git a/server/routes/ix-detail.js b/server/routes/ix-detail.js
new file mode 100644
index 0000000..d55f8be
--- /dev/null
+++ b/server/routes/ix-detail.js
@@ -0,0 +1,59 @@
+const { fetchPeeringDB } = require("../services/peeringdb");
+
+// IX Detail endpoint: /api/ix/detail?ix_id=X
+async function handleIxDetail(req, res, url) {
+ const ixId = (url.searchParams.get("ix_id") || "").replace(/[^0-9]/g, "");
+ if (!ixId) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Missing ix_id parameter" }));
+ }
+ const start = Date.now();
+ try {
+ const [ixData, ixlanData] = await Promise.all([
+ fetchPeeringDB("/ix/" + ixId),
+ fetchPeeringDB("/ixlan?ix_id=" + ixId),
+ ]);
+
+ const ix = ixData?.data?.[0] || {};
+ const ixlans = ixlanData?.data || [];
+ const ixlanId = ixlans.length > 0 ? ixlans[0].id : null;
+
+ let members = [];
+ if (ixlanId) {
+ const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
+ members = (netixlanData?.data || []).map(m => ({
+ asn: m.asn,
+ name: m.name || "",
+ speed_mbps: m.speed || 0,
+ speed_display: (m.speed || 0) >= 1000 ? ((m.speed || 0) / 1000) + " Gbps" : (m.speed || 0) + " Mbps",
+ ipv4: m.ipaddr4 || null,
+ ipv6: m.ipaddr6 || null,
+ }));
+ }
+
+ // Sort by speed desc for top members
+ const sorted = members.slice().sort((a, b) => b.speed_mbps - a.speed_mbps);
+
+ const duration = Date.now() - start;
+ return res.end(JSON.stringify({
+ meta: { duration_ms: duration, timestamp: new Date().toISOString() },
+ ix: {
+ id: parseInt(ixId),
+ name: ix.name || "",
+ city: ix.city || "",
+ country: ix.country || "",
+ website: ix.website || "",
+ peeringdb_url: "https://www.peeringdb.com/ix/" + ixId,
+ },
+ total_members: members.length,
+ top_members_by_speed: sorted.slice(0, 20),
+ all_members: sorted,
+ }, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "IX detail failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handleIxDetail };
diff --git a/server/routes/lia.js b/server/routes/lia.js
new file mode 100644
index 0000000..45de159
--- /dev/null
+++ b/server/routes/lia.js
@@ -0,0 +1,98 @@
+const { atlasState } = require("../atlas-probes");
+const { pdbOrgState } = require("../pdb-org-countries");
+const { fetchPeeringDB } = require("../services/peeringdb");
+const { cacheGet, cacheSet } = require("../caches/response-caches");
+
+// Lia's Atlas Paradise: Atlas probe coverage endpoint
+function handleAtlasCoverage(req, res) {
+ res.setHeader("Content-Type", "application/json");
+ if (!atlasState.cache) {
+ res.writeHead(503);
+ return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
+ }
+ return res.end(JSON.stringify(atlasState.cache, null, 2));
+}
+
+// Lia's Paradise: File parsing endpoint (for binary uploads)
+function handleParseFile(req, res) {
+ res.setHeader("Content-Type", "application/json");
+ let body = "";
+ req.on("data", function(chunk) { body += chunk; });
+ req.on("end", function() {
+ try {
+ var parsed = JSON.parse(body);
+ var filename = parsed.filename || "";
+ var ext = filename.split(".").pop().toLowerCase();
+ // For text-based formats, decode base64 and extract text
+ if (ext === "csv" || ext === "txt") {
+ var text = Buffer.from(parsed.data, "base64").toString("utf8");
+ return res.end(JSON.stringify({ text: text }));
+ }
+ // For binary formats (PDF, XLS, DOC), we can't parse server-side without
+ // heavy dependencies. Return helpful error.
+ return res.end(JSON.stringify({
+ error: "Binary file parsing (" + ext.toUpperCase() + ") requires client-side extraction. Please use CSV or TXT format, or copy-paste the content.",
+ suggestion: "Export your spreadsheet as CSV first, then upload the CSV file."
+ }));
+ } catch(e) {
+ return res.end(JSON.stringify({ error: "Parse error: " + e.message }));
+ }
+ });
+ return;
+}
+
+// Lia's Paradise: Combined PeeringDB + Atlas coverage data
+function handleLiaCoverage(req, res) {
+ res.setHeader("Content-Type", "application/json");
+ if (!atlasState.cache) {
+ res.writeHead(503);
+ return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
+ }
+
+ // Cache this expensive response for 30 min
+ var liaCacheKey = "lia_coverage";
+ var liaCached = cacheGet(liaCacheKey);
+ if (liaCached) return res.end(liaCached);
+
+ // Fetch PeeringDB network list (all networks with status "ok")
+ // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory)
+ fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
+ if (!pdbData || !pdbData.data) {
+ return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
+ }
+
+ var probeAsns = new Set(atlasState.cache.asns_with_probes || []);
+
+ var enriched = pdbData.data.map(function(n) {
+ var org = pdbOrgState.map.get(n.org_id) || {};
+ var cc = org.country || "";
+ return {
+ asn: n.asn,
+ name: n.name || "",
+ org_name: org.name || "",
+ country: cc,
+ country_name: cc,
+ info_type: n.info_type || "",
+ has_probe: probeAsns.has(n.asn),
+ };
+ }).filter(function(n) { return n.asn > 0 && n.country; });
+
+ var result = JSON.stringify({
+ networks: enriched,
+ total: enriched.length,
+ with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
+ without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
+ atlas_unique_asns: probeAsns.size,
+ org_countries_loaded: pdbOrgState.map.size,
+ fetched_at: new Date().toISOString(),
+ });
+
+ cacheSet(liaCacheKey, result, 30 * 60 * 1000);
+ res.end(result);
+ }).catch(function(e) {
+ res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
+ });
+ return;
+}
+
+module.exports = { handleAtlasCoverage, handleParseFile, handleLiaCoverage };
diff --git a/server/routes/lookup/cross-check.js b/server/routes/lookup/cross-check.js
new file mode 100644
index 0000000..f9243a0
--- /dev/null
+++ b/server/routes/lookup/cross-check.js
@@ -0,0 +1,90 @@
+const { crossCheckRpki } = require("../../services/rpki");
+
+// Multi-source cross-checks (RPKI vs RIPE Validator, prefix/neighbour counts
+// vs bgp.he.net) plus the overall data-quality summary derived from them.
+async function computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData) {
+ let rpkiCrossCheck = { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
+ let prefixCrossCheck = { ripe_stat: prefixes.length, bgp_he_net: null, agreement: null, note: "" };
+ let neighbourCrossCheck = { ripe_stat_total: neighbours.length, bgp_he_net_total: null };
+
+ try {
+ // RPKI cross-check: sample up to 5 prefixes against RIPE Validator (with 8s total timeout)
+ const rpkiCrossPromise = crossCheckRpki(asn, allPrefixes, rpkiStatuses);
+ const rpkiCrossResult = await Promise.race([
+ rpkiCrossPromise,
+ new Promise((resolve) => setTimeout(() => resolve(null), 8000)),
+ ]);
+ if (rpkiCrossResult) rpkiCrossCheck = rpkiCrossResult;
+ } catch (_e) { /* cross-check failed, keep defaults */ }
+
+ // bgp.he.net fetching is deliberately disabled (see the hardcoded
+ // Promise.resolve(null) in index.js's timedFetch("bgp.he.net", ...)) --
+ // bgpHeData is therefore always null and this whole branch, along with the
+ // prefix/neighbour cross-checks below, never actually runs. Left in place
+ // (not deleted) in case bgp.he.net fetching is re-enabled later, but
+ // overall_confidence below must not claim a 2-source agreement that never
+ // happened -- see the sources:1/2 handling further down.
+ // Prefix count cross-check: compare RIPE Stat vs bgp.he.net
+ if (bgpHeData) {
+ const heV4 = bgpHeData.prefixes_v4 || 0;
+ const heV6 = bgpHeData.prefixes_v6 || 0;
+ const heTotal = heV4 + heV6;
+ if (heTotal > 0) {
+ prefixCrossCheck.bgp_he_net = heTotal;
+ const ripeStat = prefixes.length;
+ if (ripeStat > 0 && heTotal > 0) {
+ const ratio = Math.min(ripeStat, heTotal) / Math.max(ripeStat, heTotal);
+ prefixCrossCheck.agreement = ratio >= 0.9;
+ const diff = Math.abs(ripeStat - heTotal);
+ prefixCrossCheck.note = diff === 0
+ ? "Exact match"
+ : "Difference of " + diff + " prefixes (" + Math.round((1 - ratio) * 100) + "% divergence)";
+ }
+ } else {
+ prefixCrossCheck.note = "bgp.he.net prefix count unavailable";
+ }
+
+ // Neighbour cross-check: compare RIPE Stat vs bgp.he.net peer_count
+ if (bgpHeData.peer_count != null) {
+ neighbourCrossCheck.bgp_he_net_total = bgpHeData.peer_count;
+ }
+ } else {
+ prefixCrossCheck.note = "bgp.he.net data unavailable";
+ }
+
+ // Compute overall data quality. Only push a score for a cross-check that
+ // actually ran (agreement_pct !== null) -- an unrun/inconclusive check must
+ // not silently count as a perfect 100 in the average, and confidence must
+ // reflect "nothing was actually cross-checked" rather than defaulting high.
+ const crossCheckScores = [];
+ if (rpkiCrossCheck.agreement_pct !== null) crossCheckScores.push(rpkiCrossCheck.agreement_pct);
+ // Prefix agreement: convert to percentage
+ if (prefixCrossCheck.bgp_he_net != null && prefixes.length > 0) {
+ const pfxRatio = Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net);
+ crossCheckScores.push(Math.round(pfxRatio * 100));
+ }
+ // Neighbour agreement
+ if (neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0) {
+ const nbrRatio = Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total);
+ crossCheckScores.push(Math.round(nbrRatio * 100));
+ }
+ const avgAgreement = crossCheckScores.length > 0
+ ? Math.round(crossCheckScores.reduce((a, b) => a + b, 0) / crossCheckScores.length)
+ : null;
+ const overallConfidence = avgAgreement === null ? "unknown" : avgAgreement > 90 ? "high" : avgAgreement >= 70 ? "medium" : "low";
+
+ const dataQuality = {
+ sources_queried: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator"],
+ cross_checks: {
+ rpki: { sources: rpkiCrossCheck.agreement_pct !== null ? 2 : 1, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, comparable_size: rpkiCrossCheck.comparable_size, disagreements: rpkiCrossCheck.disagreements },
+ prefixes: { sources: prefixCrossCheck.bgp_he_net != null ? 2 : 1, agreement_pct: prefixCrossCheck.bgp_he_net != null ? Math.round((Math.min(prefixes.length, prefixCrossCheck.bgp_he_net) / Math.max(prefixes.length, prefixCrossCheck.bgp_he_net || 1)) * 100) : null, ripe_stat: prefixCrossCheck.ripe_stat, bgp_he_net: prefixCrossCheck.bgp_he_net, note: prefixCrossCheck.note },
+ neighbours: { sources: neighbourCrossCheck.bgp_he_net_total != null ? 2 : 1, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total },
+ },
+ overall_confidence: overallConfidence,
+ overall_agreement_pct: avgAgreement,
+ };
+
+ return { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality };
+}
+
+module.exports = { computeCrossChecks };
diff --git a/server/routes/lookup/enrich-neighbours.js b/server/routes/lookup/enrich-neighbours.js
new file mode 100644
index 0000000..1d25066
--- /dev/null
+++ b/server/routes/lookup/enrich-neighbours.js
@@ -0,0 +1,73 @@
+const localDb = require("../../../local-db-client");
+const { fetchRipeStatCached } = require("../../services/ripe-stat");
+
+// Resolve empty AS names — all in parallel, with 3s timeout. Mutates entries
+// in place (matches original control flow).
+async function resolveEmptyNames(neighbourLists) {
+ const emptyNameNeighbours = neighbourLists.flat().filter(n => !n.name);
+ if (emptyNameNeighbours.length === 0) return;
+ const resolvePromise = Promise.all(
+ emptyNameNeighbours.map(n =>
+ fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + n.asn, { timeout: 3000 })
+ .then(r => { if (r?.data?.holder) n.name = r.data.holder; })
+ .catch(() => {})
+ )
+ );
+ await Promise.race([resolvePromise, new Promise(r => setTimeout(r, 3000))]);
+}
+
+// Threat Intelligence Enrichment for Neighbors: enrich neighbor data with
+// threat status from the local threat_intel table. Batch lookups (cap at 50
+// to avoid overwhelming DB), 4s timeout.
+async function buildThreatMap(neighbors) {
+ const threatMap = {};
+ const toCheck = neighbors.slice(0, 50);
+ const threatPromises = toCheck.map(async (n) => {
+ try {
+ const asNum = String(n.asn);
+ const threat = await localDb.getThreatIntel(asNum);
+ if (threat) {
+ threatMap[n.asn] = {
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ cached_at: threat.cached_at,
+ };
+ }
+ } catch (e) {
+ // Gracefully skip on error
+ console.error(`[Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
+ }
+ });
+
+ await Promise.race([
+ Promise.all(threatPromises),
+ new Promise(r => setTimeout(r, 4000)),
+ ]);
+
+ return threatMap;
+}
+
+function attachThreat(n, threatMap) {
+ return {
+ ...n,
+ threat_level: threatMap[n.asn]?.threat_level || null,
+ threat_confidence: threatMap[n.asn]?.confidence_score || null,
+ threat_source: threatMap[n.asn]?.source || null,
+ };
+}
+
+// Full enrichment pipeline: resolve empty names, then attach threat intel to
+// upstreams/downstreams/peers. Returns new arrays (does not mutate the input
+// arrays themselves, though resolveEmptyNames does mutate entry.name).
+async function enrichNeighbours(upstreams, downstreams, peers) {
+ await resolveEmptyNames([upstreams, downstreams, peers]);
+ const threatMap = await buildThreatMap([...upstreams, ...downstreams, ...peers]);
+ return {
+ upstreams: upstreams.map(n => attachThreat(n, threatMap)),
+ downstreams: downstreams.map(n => attachThreat(n, threatMap)),
+ peers: peers.map(n => attachThreat(n, threatMap)),
+ };
+}
+
+module.exports = { enrichNeighbours };
diff --git a/server/routes/lookup/facilities.js b/server/routes/lookup/facilities.js
new file mode 100644
index 0000000..c0a6082
--- /dev/null
+++ b/server/routes/lookup/facilities.js
@@ -0,0 +1,114 @@
+const { fetchPeeringDB } = require("../../services/peeringdb");
+const { CITY_COORDS } = require("../../data/city-coords");
+const { IX_CITY_MAP } = require("../../data/ix-city-map");
+
+// Batch-fetch facility lat/lon by ID, 25 per request (PeeringDB /fac?id__in= limit),
+// bounded by an overall race timeout. Returns {fac_id: {lat, lon}}.
+async function fetchFacCoordsBatch(facIds, timeoutMs) {
+ const coordMap = {};
+ if (facIds.length === 0) return coordMap;
+ try {
+ const chunks = [];
+ for (let i = 0; i < facIds.length; i += 25) chunks.push(facIds.slice(i, i + 25));
+ const coordResults = await Promise.race([
+ Promise.all(chunks.map(chunk =>
+ fetchPeeringDB("/fac?id__in=" + chunk.join(",") + "&fields=id,latitude,longitude").catch(() => null)
+ )),
+ new Promise(r => setTimeout(() => r([]), timeoutMs)),
+ ]);
+ (coordResults || []).forEach(res => {
+ (res?.data || []).forEach(f => { if (f.latitude && f.longitude) coordMap[f.id] = { lat: f.latitude, lon: f.longitude }; });
+ });
+ } catch (e) { /* graceful degradation */ }
+ return coordMap;
+}
+
+// Resolve facility coordinates for the map, then IX locations via ixfac -> fac
+// coordinates (max 20 IXs), then the name/CITY_COORDS/IX_CITY_MAP geocode
+// fallbacks for IXs PeeringDB has no facility coordinates for.
+async function resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections) {
+ // Batch-fetch facility coordinates for map (max 50 facilities)
+ const facIds = facilitiesRaw.map(f => f.fac_id).filter(Boolean).slice(0, 50);
+ let facCoordMap = await fetchFacCoordsBatch(facIds, 5000);
+
+ const facilities = facilitiesRaw.map(f => ({
+ ...f,
+ latitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lat : null,
+ longitude: facCoordMap[f.fac_id] ? facCoordMap[f.fac_id].lon : null,
+ }));
+
+ // Get IX locations for map via ixfac -> fac coordinates (max 20 IXs)
+ const uniqueIxIds = [...new Set(ixConnections.map(c => c.ix_id))].filter(Boolean).slice(0, 20);
+ let ixLocations = [];
+ if (uniqueIxIds.length > 0) {
+ try {
+ const ixFacData = await Promise.race([
+ fetchPeeringDB("/ixfac?ix_id__in=" + uniqueIxIds.join(",")),
+ new Promise(r => setTimeout(() => r(null), 5000)),
+ ]);
+ const ixFacs = ixFacData?.data || [];
+ // Collect unique fac_ids we don't already have coords for
+ const extraFacIds = [...new Set(ixFacs.map(f => f.fac_id).filter(id => id && !facCoordMap[id]))].slice(0, 30);
+ if (extraFacIds.length > 0) {
+ const extraCoords = await fetchFacCoordsBatch(extraFacIds, 4000);
+ facCoordMap = { ...facCoordMap, ...extraCoords };
+ }
+ // Build IX locations: pick first facility with coords per IX
+ const ixNameMap = {};
+ ixConnections.forEach(c => { if (c.ix_id && c.ix_name) ixNameMap[c.ix_id] = c.ix_name; });
+ const seenIx = {};
+ ixFacs.forEach(f => {
+ if (seenIx[f.ix_id]) return;
+ const coords = facCoordMap[f.fac_id];
+ if (coords) {
+ seenIx[f.ix_id] = true;
+ ixLocations.push({ ix_id: f.ix_id, name: ixNameMap[f.ix_id] || f.name || "", city: f.city || "", country: f.country || "", latitude: coords.lat, longitude: coords.lon });
+ }
+ });
+ } catch (e) { /* graceful degradation */ }
+ }
+
+ applyIxGeocodeFallback(ixLocations, ixConnections);
+
+ return { facilities, ixLocations };
+}
+
+// === IX Location Geocode Fallback ===
+// Some IXPs have no facility coordinates in PeeringDB.
+// Use ix_name city extraction + hard-coded IX→city map as fallback.
+// Mutates ixLocations in place (matches original control flow).
+function applyIxGeocodeFallback(ixLocations, ixConnections) {
+ var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; }));
+ ixConnections.forEach(function(conn) {
+ if (ixIdsWithCoords.has(conn.ix_id)) return;
+ var name = conn.ix_name || "";
+ if (name) {
+ var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean);
+ for (var w = 0; w < words.length; w++) {
+ if (CITY_COORDS[words[w]]) {
+ ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" });
+ ixIdsWithCoords.add(conn.ix_id);
+ return;
+ }
+ if (w < words.length - 1) {
+ var tw = words[w] + " " + words[w + 1];
+ if (CITY_COORDS[tw]) {
+ ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" });
+ ixIdsWithCoords.add(conn.ix_id);
+ return;
+ }
+ }
+ }
+ }
+ });
+ // Hard-coded IX ID → city for well-known IXPs whose names don't contain city
+ ixConnections.forEach(function(conn) {
+ if (ixIdsWithCoords.has(conn.ix_id)) return;
+ var city = IX_CITY_MAP[conn.ix_id];
+ if (city && CITY_COORDS[city]) {
+ ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" });
+ }
+ });
+}
+
+module.exports = { resolveFacilitiesAndIxLocations };
diff --git a/server/routes/lookup/index.js b/server/routes/lookup/index.js
new file mode 100644
index 0000000..25be038
--- /dev/null
+++ b/server/routes/lookup/index.js
@@ -0,0 +1,337 @@
+const localDb = require("../../../local-db-client");
+const { fetchJSON } = require("../../services/http-helpers");
+const { fetchPeeringDB, fetchPeeringDBWithRetry } = require("../../services/peeringdb");
+const { ensureAspaCache, validateRPKIWithCache } = require("../../services/rpki");
+const { computeResilienceScore } = require("../../resilience-score");
+const { computeRouteLeakDetection } = require("../../route-leak");
+const { pdbSourceCache } = require("../../caches/pdb-source-cache");
+const {
+ cacheGet, cacheSet, CACHE_TTL_LOOKUP, CACHE_TTL_LOOKUP_DEGRADED,
+ rdapCacheGet, rdapCacheSet,
+} = require("../../caches/response-caches");
+const { resolveFacilitiesAndIxLocations } = require("./facilities");
+const { enrichNeighbours } = require("./enrich-neighbours");
+const { deriveRirAndCountry } = require("./rir-country");
+const { computeRoutingInfo } = require("./routing-info");
+const { computeCrossChecks } = require("./cross-check");
+
+// Main lookup endpoint: /api/lookup?asn=X
+async function handleLookup(req, res, url) {
+ 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 asn = rawAsn;
+ const cacheKey = "lookup:" + asn;
+ 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 {
+ // Phase 0: Get PDB net first — check L2 cache, then API with retry
+ let pdbNet = pdbSourceCache.get("net", asn);
+ if (!pdbNet) {
+ pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn);
+ if (pdbNet) pdbSourceCache.set("net", asn, pdbNet);
+ }
+ const net = pdbNet?.data?.[0] || {};
+ const netId = net.id;
+
+ // Phase 1: ALL calls in parallel — RIPE Stat (cached+throttled) + PDB IX/Fac (cached) + Atlas + bgp.he.net
+ const ixQuery = netId
+ ? "/netixlan?net_id=" + netId + "&limit=1000"
+ : "/netixlan?asn=" + asn + "&limit=1000";
+ const ixCacheKey = netId ? String(netId) : "asn:" + asn;
+
+ // Check PDB source cache for IX/Fac data
+ let cachedIxlan = pdbSourceCache.get("netixlan", ixCacheKey);
+ let cachedFac = netId ? pdbSourceCache.get("netfac", String(netId)) : null;
+
+ // Per-source timing tracking — 9s hard cap per source to prevent long-tail blocking
+ const sourceTiming = {};
+ function timedFetch(name, promise) {
+ const ts = Date.now();
+ return Promise.race([
+ Promise.resolve(promise),
+ new Promise(function(r) { setTimeout(function() { r(null); }, 9000); }),
+ ])
+ .then(function(r) { sourceTiming[name] = Date.now() - ts; return r; })
+ .catch(function() { 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 = [
+ timedFetch("RIPE Stat Prefixes", localDb ? localDb.getRipeStatAnnouncedPrefixes(asn) : Promise.resolve(null)),
+ timedFetch("RIPE Stat Neighbours", localDb ? localDb.getRipeStatAsnNeighbours(asn) : Promise.resolve(null)),
+ timedFetch("RIPE Stat Overview", localDb ? localDb.getRipeStatAsOverview(asn) : Promise.resolve(null)),
+ timedFetch("RIPE Stat RIR", Promise.resolve(null)),
+ timedFetch("RIPE Atlas", Promise.resolve(null)),
+ timedFetch("bgp.he.net", Promise.resolve(null)),
+ timedFetch("RIPE Stat Visibility", localDb ? localDb.getRipeStatVisibility(asn) : Promise.resolve(null)),
+ timedFetch("RIPE Stat PrefixSize", localDb ? localDb.getRipeStatPrefixSizeDistribution(asn) : Promise.resolve(null)),
+ 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, pocData, rdapData] = await Promise.all(promises);
+
+ // Store PDB results in L2 source cache for future lookups
+ if (!cachedIxlan && ixlanData) pdbSourceCache.set("netixlan", ixCacheKey, ixlanData);
+ if (!cachedFac && facData) pdbSourceCache.set("netfac", String(netId), facData);
+
+ // local-db-client's getRipeStat* functions return status:"error" (not "ok") when
+ // the local Postgres DB query itself failed -- track which sources actually
+ // failed so we don't cache a DB hiccup's fake-empty result as if it were a
+ // genuine "this ASN has 0 prefixes" answer for the full 5-minute TTL.
+ const degradedSources = [
+ ["RIPE Stat Prefixes", prefixData], ["RIPE Stat Neighbours", neighbourData],
+ ["RIPE Stat Overview", overviewData], ["RIPE Stat Visibility", visibilityData],
+ ["RIPE Stat PrefixSize", prefixSizeData],
+ ].filter(([, v]) => v && v.status === "error").map(([name]) => name);
+
+ const prefixes = prefixData?.data?.prefixes || [];
+ const neighbours = neighbourData?.data?.neighbours || [];
+ const overview = overviewData?.data || {};
+ const rirEntries = rirData?.data?.located_resources || rirData?.data?.rir_stats || [];
+
+ // Bug 6 fix: Atlas probe status uses status.name (object), not status_name (flat)
+ const atlasProbes = atlasProbeData?.results || [];
+ const atlasConnected = atlasProbes.filter(p => {
+ const sName = (p.status_name || (p.status && p.status.name) || "").toLowerCase();
+ return sName === "connected";
+ });
+ const atlasAnchors = atlasProbes.filter(p => p.is_anchor === true);
+
+ // RPKI: validate ALL prefixes using local Cloudflare RPKI data (all 5 RIRs, instant)
+ await ensureAspaCache();
+ const allPrefixes = prefixes.map((p) => p.prefix);
+ const rpkiAllResults = await Promise.all(allPrefixes.map((pfx) => validateRPKIWithCache(asn, pfx)));
+
+ const ixConnections = (ixlanData?.data || [])
+ .map((ix) => ({
+ ix_name: ix.name || "",
+ ix_id: ix.ix_id,
+ speed_mbps: ix.speed || 0,
+ 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);
+
+ const facilitiesRaw = (facData?.data || []).map((f) => ({
+ fac_id: f.fac_id,
+ name: f.name || "",
+ city: f.city || "",
+ country: f.country || "",
+ }));
+
+ const { facilities, ixLocations } = await resolveFacilitiesAndIxLocations(facilitiesRaw, ixConnections);
+
+ const rpkiStatuses = rpkiAllResults;
+ const rpkiValid = rpkiStatuses.filter((r) => r.status === "valid").length;
+ const rpkiInvalid = rpkiStatuses.filter((r) => r.status === "invalid").length;
+ const rpkiUnavailable = rpkiStatuses.filter((r) => r.status === "unavailable").length;
+ const rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid" && r.status !== "unavailable").length;
+ // Exclude "unavailable" (DB error) from the coverage denominator -- a DB hiccup
+ // must not lower a network's displayed RPKI coverage percentage.
+ const rpkiTotal = rpkiStatuses.length - rpkiUnavailable;
+ const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0;
+
+ let upstreams = neighbours
+ .filter((n) => n.type === "left")
+ .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
+ .sort((a, b) => b.power - a.power);
+ let downstreams = neighbours
+ .filter((n) => n.type === "right")
+ .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
+ .sort((a, b) => b.power - a.power);
+ let peers = neighbours
+ .filter((n) => n.type === "uncertain" || n.type === "peer")
+ .map((n) => ({ asn: n.asn, name: n.as_name || "", power: n.power || 0 }))
+ .sort((a, b) => b.power - a.power);
+
+ const enriched = await enrichNeighbours(upstreams, downstreams, peers);
+ upstreams = enriched.upstreams;
+ downstreams = enriched.downstreams;
+ peers = enriched.peers;
+
+ const { rir, country } = deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData);
+
+ const duration = Date.now() - start;
+
+ const routingInfo = await computeRoutingInfo(prefixes, visibilityData, prefixSizeData);
+
+ const { rpkiCrossCheck, prefixCrossCheck, neighbourCrossCheck, dataQuality } =
+ await computeCrossChecks(asn, allPrefixes, rpkiStatuses, prefixes, neighbours, bgpHeData);
+
+ const result = {
+ meta: {
+ service: "PeerCortex",
+ version: "0.6.9",
+ query: "AS" + asn,
+ duration_ms: duration,
+ sources: ["PeeringDB", "RIPE Stat", "bgp.he.net", "Cloudflare RPKI", "RIPE RPKI Validator", "Route Views"],
+ timestamp: new Date().toISOString(),
+ rpki_prefixes_checked: rpkiTotal,
+ total_prefixes: prefixes.length,
+ degraded_sources: degradedSources,
+ },
+ network: {
+ asn: parseInt(asn),
+ 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 || "",
+ type: net.info_type || "",
+ policy: net.policy_general || "",
+ traffic: net.info_traffic || "",
+ ratio: net.info_ratio || "",
+ scope: net.info_scope || "",
+ notes: net.notes ? net.notes.substring(0, 500) : "",
+ 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,
+ ipv4: prefixes.filter((p) => !p.prefix.includes(":")).length,
+ ipv6: prefixes.filter((p) => p.prefix.includes(":")).length,
+ list: prefixes.map((p) => p.prefix),
+ cross_check: prefixCrossCheck,
+ },
+ rpki: {
+ coverage_percent: rpkiCoverage,
+ valid: rpkiValid,
+ invalid: rpkiInvalid,
+ not_found: rpkiNotFound,
+ unavailable: rpkiUnavailable,
+ checked: rpkiTotal,
+ details: rpkiStatuses,
+ cross_check: rpkiCrossCheck,
+ },
+ neighbours: {
+ total: neighbours.length,
+ upstream_count: upstreams.length,
+ downstream_count: downstreams.length,
+ peer_count: peers.length,
+ upstreams: upstreams.slice(0, 20),
+ downstreams: downstreams.slice(0, 20),
+ peers: peers.slice(0, 20),
+ cross_check: neighbourCrossCheck,
+ },
+ ix_presence: {
+ total_connections: ixConnections.length,
+ unique_ixps: [...new Set(ixConnections.map((ix) => ix.ix_id))].length,
+ connections: ixConnections,
+ },
+ ix_locations: ixLocations,
+ facilities: {
+ total: facilities.length,
+ 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,
+ connected: atlasConnected.length,
+ disconnected: atlasProbes.length - atlasConnected.length,
+ anchors: atlasAnchors.length,
+ probes: atlasProbes.slice(0, 100).map(p => ({
+ id: p.id,
+ status: p.status_name || p.status || "Unknown",
+ is_anchor: p.is_anchor || false,
+ country: p.country_code || "",
+ prefix_v4: p.prefix_v4 || "",
+ prefix_v6: p.prefix_v6 || "",
+ description: p.description || "",
+ })),
+ },
+ 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
+ result.meta.duration_ms = Date.now() - start;
+
+ // A DB hiccup shouldn't get cached as this ASN's answer for the full 5min --
+ // retry soon instead of repeating a degraded result to every visitor.
+ cacheSet(cacheKey, result, degradedSources.length > 0 ? CACHE_TTL_LOOKUP_DEGRADED : CACHE_TTL_LOOKUP);
+ res.end(JSON.stringify(result, null, 2));
+ } catch (err) {
+ const duration = Date.now() - start;
+ res.writeHead(500);
+ res.end(JSON.stringify({ error: "Lookup failed", message: err.message, duration_ms: duration }));
+ }
+ return;
+}
+
+module.exports = { handleLookup };
diff --git a/server/routes/lookup/rir-country.js b/server/routes/lookup/rir-country.js
new file mode 100644
index 0000000..fc56f57
--- /dev/null
+++ b/server/routes/lookup/rir-country.js
@@ -0,0 +1,51 @@
+const { ARIN_CC, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("../../data/rir-country-codes");
+
+// Derive RIR and country through a cascade of fallback sources: RIPE Stat
+// rir-stats-country, RDAP port43, RDAP self-link, bgp.he.net country_code,
+// then a country-code-to-RIR table as a last resort.
+function deriveRirAndCountry(rirEntries, rirData, rdapData, bgpHeData) {
+ 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 || "";
+ }
+ 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) {
+ 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
+ }
+ return { rir, country };
+}
+
+module.exports = { deriveRirAndCountry };
diff --git a/server/routes/lookup/routing-info.js b/server/routes/lookup/routing-info.js
new file mode 100644
index 0000000..bf20c1c
--- /dev/null
+++ b/server/routes/lookup/routing-info.js
@@ -0,0 +1,71 @@
+const { fetchBgproutesVisibility } = require("../../services/http-helpers");
+
+// Compute routing visibility and prefix size distribution.
+async function computeRoutingInfo(prefixes, visibilityData, prefixSizeData) {
+ const ipv4Prefixes = prefixes.filter(function(p) { return !p.prefix.includes(":"); });
+ const ipv6Prefixes = prefixes.filter(function(p) { return p.prefix.includes(":"); });
+ var ipv4VisAvg = 0, ipv6VisAvg = 0, totalRisPeersV4 = 0, totalRisPeersV6 = 0;
+
+ // Visibility API returns per-RIS-collector data
+ // Each collector has ipv4_full_table_peer_count and ipv4_full_table_peers_not_seeing[]
+ // Bug 3 fix: visibility API may timeout for large ASNs — handle gracefully
+ var visibilities = (visibilityData && visibilityData.data && visibilityData.data.visibilities) || [];
+ var v4Seeing = 0, v4Total = 0, v6Seeing = 0, v6Total = 0;
+ var visTimedOut = !visibilityData || !visibilityData.data;
+ visibilities.forEach(function(v) {
+ if (!v || !v.probe) return;
+ var v4PeerCount = v.ipv4_full_table_peer_count || 0;
+ var v4NotSeeing = (v.ipv4_full_table_peers_not_seeing || []).length;
+ var v6PeerCount = v.ipv6_full_table_peer_count || 0;
+ var v6NotSeeing = (v.ipv6_full_table_peers_not_seeing || []).length;
+ v4Total += v4PeerCount;
+ v4Seeing += (v4PeerCount - v4NotSeeing);
+ v6Total += v6PeerCount;
+ v6Seeing += (v6PeerCount - v6NotSeeing);
+ });
+ if (v4Total > 0) ipv4VisAvg = Math.round((v4Seeing / v4Total) * 1000) / 10;
+ if (v6Total > 0) ipv6VisAvg = Math.round((v6Seeing / v6Total) * 1000) / 10;
+ // If visibility API timed out but we have prefixes, try bgproutes.io fallback
+ if (visTimedOut && prefixes.length > 0) {
+ var fallbackPrefix = prefixes.find(function(p) { return !p.prefix.includes(":"); });
+ if (!fallbackPrefix) fallbackPrefix = prefixes[0];
+ if (fallbackPrefix) {
+ var bgprFallback = await fetchBgproutesVisibility(fallbackPrefix.prefix);
+ if (bgprFallback && bgprFallback.vps_seeing > 0) {
+ // Estimate visibility: % of VPs seeing the prefix (assume ~300 total RIS-equivalent VPs)
+ var estimatedTotal = Math.max(bgprFallback.vps_seeing, 300);
+ ipv4VisAvg = Math.round((bgprFallback.vps_seeing / estimatedTotal) * 1000) / 10;
+ ipv6VisAvg = -1; // bgproutes fallback is per-prefix, not per-AF aggregate
+ totalRisPeersV4 = bgprFallback.vps_seeing;
+ console.log("[Visibility] RIPE Stat timed out, used bgproutes.io fallback for " + fallbackPrefix.prefix + ": " + bgprFallback.vps_seeing + " VPs seeing it");
+ } else {
+ ipv4VisAvg = -1;
+ ipv6VisAvg = -1;
+ console.log("[Visibility] RIPE Stat timed out and bgproutes.io fallback returned no data");
+ }
+ } else {
+ ipv4VisAvg = -1;
+ ipv6VisAvg = -1;
+ }
+ }
+ totalRisPeersV4 = v4Total;
+ totalRisPeersV6 = v6Total;
+
+ // Prefix size distribution: data.ipv4[] and data.ipv6[] arrays with {size, count}
+ var psdData = (prefixSizeData && prefixSizeData.data) || {};
+ var psV4 = (psdData.ipv4 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
+ var psV6 = (psdData.ipv6 || []).map(function(e) { return { size: e.size, count: e.count }; }).sort(function(a,b){ return a.size - b.size; });
+
+ return {
+ ipv4_prefixes: ipv4Prefixes.length,
+ ipv6_prefixes: ipv6Prefixes.length,
+ ipv4_visibility_avg: ipv4VisAvg,
+ ipv6_visibility_avg: ipv6VisAvg,
+ total_ris_peers_v4: totalRisPeersV4,
+ total_ris_peers_v6: totalRisPeersV6,
+ prefix_sizes_v4: psV4,
+ prefix_sizes_v6: psV6,
+ };
+}
+
+module.exports = { computeRoutingInfo };
diff --git a/server/routes/pdf-export.js b/server/routes/pdf-export.js
new file mode 100644
index 0000000..2f2c690
--- /dev/null
+++ b/server/routes/pdf-export.js
@@ -0,0 +1,112 @@
+const http = require("http");
+const { buildPdfHtml, buildComparisonPdfHtml } = require("../pdf-export/templates");
+const { renderHtmlToPdf, pdfCacheGet, pdfCacheSet } = require("../pdf-export/renderer");
+
+const PORT = process.env.PORT || 3101;
+
+// Fetch JSON from an internal endpoint on this same process (loopback) --
+// re-triggers the full HTTP/caching/rate-limit path for /api/validate,
+// /api/aspa, /api/lookup rather than calling their handler logic directly.
+async function fetchInternal(path) {
+ return new Promise((resolve, reject) => {
+ const opts = { hostname: '127.0.0.1', port: PORT, path, method: 'GET' };
+ const req2 = http.request(opts, res2 => {
+ let body = '';
+ res2.on('data', c => body += c);
+ res2.on('end', () => {
+ try { resolve(JSON.parse(body)); }
+ catch(e) { reject(new Error('JSON parse failed: ' + body.slice(0, 200))); }
+ });
+ });
+ req2.on('error', reject);
+ req2.setTimeout(30000, () => { req2.destroy(); reject(new Error('Internal request timeout')); });
+ req2.end();
+ });
+}
+
+// GET /api/export/pdf?asn=X&format=report|executive|technical
+async function handleExportPdf(req, res, url) {
+ const rawAsn = (url.searchParams.get('asn') || '').replace(/[^0-9]/g, '');
+ const format = ['executive', 'technical', 'report'].includes(url.searchParams.get('format'))
+ ? url.searchParams.get('format') : 'report';
+ if (!rawAsn) {
+ res.writeHead(400, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: 'Missing asn parameter' }));
+ }
+ const cacheKey = `pdf:${rawAsn}:${format}`;
+ const cached = pdfCacheGet(cacheKey);
+ if (cached) {
+ res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
+ return res.end(cached);
+ }
+ try {
+ const [validateData, aspaData] = await Promise.all([
+ fetchInternal(`/api/validate?asn=${rawAsn}`),
+ fetchInternal(`/api/aspa?asn=${rawAsn}`).catch(() => null),
+ ]);
+ if (validateData.error) {
+ res.writeHead(400, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: validateData.error }));
+ }
+ const html = buildPdfHtml(validateData, aspaData, format);
+ const pdfBuf = await renderHtmlToPdf(html);
+ pdfCacheSet(cacheKey, pdfBuf);
+ res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${rawAsn}-${format}.pdf"`, 'Content-Length': pdfBuf.length });
+ return res.end(pdfBuf);
+ } catch (err) {
+ console.error('[PDF] Error:', err.message);
+ res.writeHead(503, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
+ }
+}
+
+// GET /api/export/pdf/compare?asn1=X&asn2=Y
+async function handleExportPdfCompare(req, res, url) {
+ const asn1 = (url.searchParams.get('asn1') || '').replace(/[^0-9]/g, '');
+ const asn2 = (url.searchParams.get('asn2') || '').replace(/[^0-9]/g, '');
+ if (!asn1 || !asn2) {
+ res.writeHead(400, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: 'Missing asn1 or asn2 parameter' }));
+ }
+ if (asn1 === asn2) {
+ res.writeHead(400, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: 'asn1 and asn2 must be different' }));
+ }
+ const cacheKey = `pdf:compare:${Math.min(+asn1,+asn2)}:${Math.max(+asn1,+asn2)}`;
+ const cached = pdfCacheGet(cacheKey);
+ if (cached) {
+ res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'X-Cache': 'HIT', 'Content-Length': cached.length });
+ return res.end(cached);
+ }
+ try {
+ const [v1, a1, l1, v2, a2, l2] = await Promise.all([
+ fetchInternal(`/api/validate?asn=${asn1}`),
+ fetchInternal(`/api/aspa?asn=${asn1}`).catch(() => null),
+ fetchInternal(`/api/lookup?asn=${asn1}`).catch(() => null),
+ fetchInternal(`/api/validate?asn=${asn2}`),
+ fetchInternal(`/api/aspa?asn=${asn2}`).catch(() => null),
+ fetchInternal(`/api/lookup?asn=${asn2}`).catch(() => null),
+ ]);
+ if (v1.error || v2.error) {
+ res.writeHead(400, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: v1.error || v2.error }));
+ }
+ const html = buildComparisonPdfHtml(v1, a1, l1, v2, a2, l2);
+ const pdfBuf = await renderHtmlToPdf(html);
+ pdfCacheSet(cacheKey, pdfBuf);
+ res.writeHead(200, { ...{'Content-Type':'application/json'}, 'Content-Type': 'application/pdf', 'Content-Disposition': `inline; filename="peercortex-AS${asn1}-vs-AS${asn2}.pdf"`, 'Content-Length': pdfBuf.length });
+ return res.end(pdfBuf);
+ } catch (err) {
+ console.error('[PDF] Error:', err.message);
+ res.writeHead(503, {'Content-Type':'application/json'});
+ return res.end(JSON.stringify({ error: 'PDF generation failed', message: err.message }));
+ }
+}
+
+// CORS preflight for PDF export (already handled globally above, belt-and-suspenders)
+function handleExportOptions(req, res) {
+ res.writeHead(204);
+ return res.end();
+}
+
+module.exports = { handleExportPdf, handleExportPdfCompare, handleExportOptions };
diff --git a/server/routes/peers-find.js b/server/routes/peers-find.js
new file mode 100644
index 0000000..d9a6bef
--- /dev/null
+++ b/server/routes/peers-find.js
@@ -0,0 +1,106 @@
+const { fetchPeeringDB } = require("../services/peeringdb");
+
+// Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000
+async function handlePeersFind(req, res, url) {
+ const ixName = url.searchParams.get("ix") || "";
+ const policy = url.searchParams.get("policy") || "";
+ const minSpeed = parseInt(url.searchParams.get("min_speed") || "0");
+ const netType = url.searchParams.get("type") || "";
+
+ if (!ixName) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Missing ix parameter (IX name)" }));
+ }
+
+ const start = Date.now();
+ try {
+ // Search for IX by name
+ const ixSearch = await fetchPeeringDB("/ix?name__contains=" + encodeURIComponent(ixName));
+ const ixResults = ixSearch?.data || [];
+ if (ixResults.length === 0) {
+ return res.end(JSON.stringify({ error: "No IX found matching: " + ixName, matches: [] }));
+ }
+
+ // Use first matching IX
+ const ix = ixResults[0];
+ const ixId = ix.id;
+
+ // Get ixlan for this IX
+ const ixlanData = await fetchPeeringDB("/ixlan?ix_id=" + ixId);
+ const ixlans = ixlanData?.data || [];
+ if (ixlans.length === 0) {
+ return res.end(JSON.stringify({ ix: { id: ixId, name: ix.name }, matches: [] }));
+ }
+
+ const ixlanId = ixlans[0].id;
+
+ // Get all networks at this IX
+ const netixlanData = await fetchPeeringDB("/netixlan?ixlan_id=" + ixlanId);
+ const netixlans = netixlanData?.data || [];
+
+ // Get unique net_ids
+ const netIds = [...new Set(netixlans.map(n => n.net_id))];
+
+ // Fetch network details in batches
+ const networks = [];
+ const batchSize = 20;
+ for (let i = 0; i < Math.min(netIds.length, 200); i += batchSize) {
+ const batch = netIds.slice(i, i + batchSize);
+ const batchResults = await Promise.all(
+ batch.map(nid => fetchPeeringDB("/net/" + nid))
+ );
+ batchResults.forEach(r => {
+ if (r?.data?.[0]) networks.push(r.data[0]);
+ });
+ }
+
+ // Filter and rank
+ let filtered = networks.map(net => {
+ const nix = netixlans.filter(n => n.net_id === net.id);
+ const maxSpeed = Math.max(...nix.map(n => n.speed || 0));
+ return {
+ asn: net.asn,
+ name: net.name || "",
+ policy: net.policy_general || "",
+ type: net.info_type || "",
+ speed_mbps: maxSpeed,
+ speed_gbps: maxSpeed >= 1000 ? (maxSpeed / 1000) + " Gbps" : maxSpeed + " Mbps",
+ traffic: net.info_traffic || "",
+ website: net.website || "",
+ peeringdb_id: net.id,
+ ipv4: nix[0]?.ipaddr4 || null,
+ ipv6: nix[0]?.ipaddr6 || null,
+ };
+ });
+
+ // Apply filters
+ if (policy) {
+ filtered = filtered.filter(n => n.policy.toLowerCase().includes(policy.toLowerCase()));
+ }
+ if (minSpeed > 0) {
+ filtered = filtered.filter(n => n.speed_mbps >= minSpeed);
+ }
+ if (netType) {
+ filtered = filtered.filter(n => n.type.toLowerCase().includes(netType.toLowerCase()));
+ }
+
+ // Sort by speed desc
+ filtered.sort((a, b) => b.speed_mbps - a.speed_mbps);
+
+ // Also find common IXPs for each match (check if they share other IXPs)
+ const duration = Date.now() - start;
+ return res.end(JSON.stringify({
+ meta: { duration_ms: duration, timestamp: new Date().toISOString() },
+ ix: { id: ixId, name: ix.name, ixlan_id: ixlanId },
+ total_members: netixlans.length,
+ filtered_count: filtered.length,
+ matches: filtered.slice(0, 50),
+ }, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "Peer matching failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handlePeersFind };
diff --git a/server/routes/prefix-detail.js b/server/routes/prefix-detail.js
new file mode 100644
index 0000000..74d1011
--- /dev/null
+++ b/server/routes/prefix-detail.js
@@ -0,0 +1,77 @@
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { fetchBgproutesVisibility } = require("../services/http-helpers");
+const { validateRPKIWithCache } = require("../services/rpki");
+
+const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
+
+// Prefix Detail endpoint: /api/prefix/detail?prefix=X
+async function handlePrefixDetail(req, res, url) {
+ const prefix = url.searchParams.get("prefix") || "";
+ if (!prefix) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Missing prefix parameter" }));
+ }
+ const start = Date.now();
+ try {
+ const [routingStatus, visibility] = await Promise.all([
+ fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)),
+ fetchRipeStatCached("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)),
+ ]);
+
+ const origins = routingStatus?.data?.origins || [];
+ const firstSeen = routingStatus?.data?.first_seen?.time || null;
+
+ // RPKI validation: use local PostgreSQL database (sub-10ms, zero external API calls)
+ let rpkiStatus = "unknown";
+ let rpkiRoas = [];
+ const originAsn = origins.length > 0 ? origins[0].asn : null;
+ if (originAsn) {
+ try {
+ const localRpki = await validateRPKIWithCache(originAsn, prefix);
+ rpkiStatus = localRpki.status;
+ rpkiRoas = new Array(localRpki.validating_roas); // count only, no detail
+ } catch (e) {
+ console.error("[Prefix Detail] RPKI validation error:", e.message);
+ rpkiStatus = "unknown";
+ rpkiRoas = [];
+ }
+ }
+ var visData = visibility?.data?.visibilities || [];
+ var risPeersSeeingIt = visData.length > 0 ? visData.filter(v => v.ris_peers_seeing > 0).length : 0;
+ var visibilitySource = "ripe_stat";
+ // bgproutes.io fallback if RIPE Stat visibility returned no data
+ if (visData.length === 0 && BGPROUTES_API_KEY) {
+ var bgprVis = await fetchBgproutesVisibility(prefix);
+ if (bgprVis && bgprVis.vps_seeing > 0) {
+ risPeersSeeingIt = bgprVis.vps_seeing;
+ visData = []; // keep empty, use risPeersSeeingIt
+ visibilitySource = "bgproutes.io";
+ }
+ }
+
+ // Try to get IRR data
+ let irrStatus = "unknown";
+ try {
+ const whoisData = await fetchRipeStatCached("https://stat.ripe.net/data/whois/data.json?resource=" + encodeURIComponent(prefix));
+ const records = whoisData?.data?.records || [];
+ if (records.length > 0) irrStatus = "found";
+ } catch(_e) {}
+
+ const duration = Date.now() - start;
+ return res.end(JSON.stringify({
+ meta: { duration_ms: duration, timestamp: new Date().toISOString() },
+ prefix: prefix,
+ origins: origins.map(o => ({ asn: o.asn, prefix: o.prefix })),
+ rpki: { status: rpkiStatus, validating_roas: rpkiRoas.length },
+ irr_status: irrStatus,
+ visibility: { ris_peers_seeing: risPeersSeeingIt, total_probes: visData.length || risPeersSeeingIt, source: visibilitySource },
+ first_seen: firstSeen,
+ }, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "Prefix detail failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handlePrefixDetail };
diff --git a/server/routes/proxy-stubs.js b/server/routes/proxy-stubs.js
new file mode 100644
index 0000000..98bb879
--- /dev/null
+++ b/server/routes/proxy-stubs.js
@@ -0,0 +1,30 @@
+const { proxyToApiServer } = require("../services/api-proxy");
+
+// Routes already migrated to the separate Fastify API server (src/api/ +
+// src/features/*/routes.ts) -- forwarded through rather than reimplemented
+// inline a second time. All mechanically identical (proxyToApiServer(req,
+// res, req.url)); handled as one data-driven dispatch instead of 12
+// near-duplicate one-line route handlers.
+const EXACT_PATHS = new Set([
+ "/api/submarine-cables",
+ "/api/global-infra",
+ "/api/communities",
+ "/api/irr-audit",
+ "/api/asset-expand",
+ "/api/rpki-history",
+ "/api/aspath",
+ "/api/looking-glass",
+ "/api/ix-matrix",
+ "/changelog-data",
+ "/api/prefix-changes",
+]);
+
+function isProxyStubPath(reqPath) {
+ return EXACT_PATHS.has(reqPath) || reqPath.startsWith("/api/rib/");
+}
+
+function handleProxyStub(req, res) {
+ return proxyToApiServer(req, res, req.url);
+}
+
+module.exports = { isProxyStubPath, handleProxyStub };
diff --git a/server/routes/quick-ix.js b/server/routes/quick-ix.js
new file mode 100644
index 0000000..5647a0a
--- /dev/null
+++ b/server/routes/quick-ix.js
@@ -0,0 +1,48 @@
+const { fetchJSON } = require("../services/http-helpers");
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { quickIxCacheGet, quickIxCacheSet } = require("../caches/response-caches");
+
+// 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
+async function handleQuickIx(req, res, url) {
+ 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 }));
+ }
+
+}
+
+module.exports = { handleQuickIx };
diff --git a/server/routes/relationships.js b/server/routes/relationships.js
new file mode 100644
index 0000000..869e025
--- /dev/null
+++ b/server/routes/relationships.js
@@ -0,0 +1,110 @@
+const localDb = require("../../local-db-client");
+const { fetchRipeStatCached } = require("../services/ripe-stat");
+const { cacheGet, cacheSet } = require("../caches/response-caches");
+
+// AS Relationships endpoint: /api/relationships?asn=X
+// Returns upstream providers, downstream customers, and peers with resolved
+// names. Based on RIPE Stat asn-neighbours.
+async function handleRelationships(req, res, url) {
+ 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: 8000 }
+ );
+ 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)),
+ ]);
+
+ // ---- Threat Intelligence Enrichment ----
+ // Enrich neighbors with threat status from local threat_intel table
+ const threatMap = {};
+ const allNeighborsForThreat = [...upstreams, ...downstreams, ...peers];
+ const threatPromises = allNeighborsForThreat.slice(0, 100).map(async (n) => {
+ try {
+ const asNum = String(n.asn);
+ const threat = await localDb.getThreatIntel(asNum);
+ if (threat) {
+ threatMap[n.asn] = {
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ };
+ }
+ } catch (e) {
+ console.error(`[Relationships Threat Lookup] Error checking ASN ${n.asn}:`, e.message);
+ }
+ });
+ await Promise.race([
+ Promise.all(threatPromises),
+ new Promise(r => setTimeout(r, 3000)),
+ ]);
+
+ 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,
+ threat_level: threatMap[n.asn]?.threat_level || null,
+ threat_confidence: threatMap[n.asn]?.confidence_score || null,
+ threat_source: threatMap[n.asn]?.source || null,
+ });
+
+ 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,
+ };
+
+ // A neighbourData fetch failure (not a genuine zero-neighbour ASN) would
+ // otherwise get cached as "0 upstreams/downstreams/peers" for the full 10min --
+ // match the existing /api/validate precedent (90s TTL for suspicious zero counts).
+ cacheSet(cacheKey, result, neighbourData ? 10 * 60 * 1000 : 90 * 1000);
+ 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 }));
+ }
+
+}
+
+module.exports = { handleRelationships };
diff --git a/server/routes/search.js b/server/routes/search.js
new file mode 100644
index 0000000..4372ead
--- /dev/null
+++ b/server/routes/search.js
@@ -0,0 +1,83 @@
+const { fetchJSONWithRetry } = require("../services/http-helpers");
+const { PEERINGDB_API_KEY } = require("../services/peeringdb");
+const { trackVisitor } = require("../visitors");
+
+// Name Search (RIPE Stat + PeeringDB combined)
+async function handleSearch(req, res) {
+ 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
+function handleVisitors(req, res) {
+ 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 }));
+}
+
+module.exports = { handleSearch, handleVisitors };
diff --git a/server/routes/static.js b/server/routes/static.js
new file mode 100644
index 0000000..7364a5e
--- /dev/null
+++ b/server/routes/static.js
@@ -0,0 +1,61 @@
+const fs = require("fs");
+const path = require("path");
+
+const REPO_ROOT = path.join(__dirname, "..", "..");
+
+function handleRoot(req, res, host, reqPath) {
+ // shell.peercortex.org → admin feedback terminal (check first)
+ if (host === 'shell.peercortex.org') {
+ try {
+ const html = fs.readFileSync('/opt/peercortex-app/public/shell.html', 'utf8');
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ return res.end(html);
+ } catch (_e) {
+ res.writeHead(500);
+ return res.end('shell.html not found');
+ }
+ }
+ // 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");
+ return res.end(html);
+ } catch (_e) {
+ res.writeHead(500);
+ return res.end(htmlFile + " not found");
+ }
+}
+
+function handleIndexEditorial(req, res) {
+ try {
+ const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8");
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ return res.end(html);
+ } catch (_e) {
+ res.writeHead(500);
+ return res.end("index-editorial.html not found");
+ }
+}
+
+function handleFavicon(req, res) {
+ res.writeHead(204);
+ return res.end();
+}
+
+function handleLia(req, res) {
+ try {
+ const liaHtml = fs.readFileSync(path.join(REPO_ROOT, "public", "lia.html"), "utf8");
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
+ return res.end(liaHtml);
+ } catch (_e) {
+ res.writeHead(500);
+ return res.end("lia.html not found");
+ }
+}
+
+module.exports = { handleRoot, handleIndexEditorial, handleFavicon, handleLia };
diff --git a/server/routes/topology.js b/server/routes/topology.js
new file mode 100644
index 0000000..7f661a8
--- /dev/null
+++ b/server/routes/topology.js
@@ -0,0 +1,59 @@
+const localDb = require("../../local-db-client");
+const { fetchTopology } = require("../topology");
+
+// Feature 25: Topology endpoint
+async function handleTopology(req, res, url) {
+ const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, "");
+ const depth = parseInt(url.searchParams.get("depth") || "2") || 2;
+ if (!rawAsn) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" }));
+ }
+ const start = Date.now();
+ try {
+ const topology = await fetchTopology(parseInt(rawAsn), depth);
+
+ // ---- Threat Intelligence Enrichment for Topology Nodes ----
+ const threatMap = {};
+ const threatPromises = topology.nodes.slice(0, 100).map(async (node) => {
+ try {
+ const asNum = String(node.asn);
+ const threat = await localDb.getThreatIntel(asNum);
+ if (threat) {
+ threatMap[node.asn] = {
+ threat_level: threat.threat_level,
+ confidence_score: threat.confidence_score,
+ source: threat.source,
+ };
+ }
+ } catch (e) {
+ console.error(`[Topology Threat Lookup] Error checking ASN ${node.asn}:`, e.message);
+ }
+ });
+ await Promise.race([
+ Promise.all(threatPromises),
+ new Promise(r => setTimeout(r, 3000)),
+ ]);
+
+ // Attach threat status to node objects
+ topology.nodes.forEach((node) => {
+ if (threatMap[node.asn]) {
+ node.threat_level = threatMap[node.asn].threat_level;
+ node.threat_confidence = threatMap[node.asn].confidence_score;
+ node.threat_source = threatMap[node.asn].source;
+ }
+ });
+
+ topology.meta = {
+ query: "AS" + rawAsn, depth: depth, duration_ms: Date.now() - start,
+ timestamp: new Date().toISOString(), node_count: topology.nodes.length, edge_count: topology.edges.length,
+ };
+ return res.end(JSON.stringify(topology, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "Topology query failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handleTopology };
diff --git a/server/routes/validate/bogon.js b/server/routes/validate/bogon.js
new file mode 100644
index 0000000..0c25cb9
--- /dev/null
+++ b/server/routes/validate/bogon.js
@@ -0,0 +1,62 @@
+// 11. Bogon Detection (local check, no external API calls)
+function checkBogonPrefix(prefix) {
+ var bogonV4 = [
+ { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 },
+ { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 },
+ { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 },
+ { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 },
+ { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 },
+ { net: "240.0.0.0", mask: 4 },
+ ];
+ if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" };
+ var split = prefix.split("/");
+ var addr = split[0];
+ var mask = parseInt(split[1] || "0");
+ var parts = addr.split(".").map(Number);
+ var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
+ for (var bi = 0; bi < bogonV4.length; bi++) {
+ var b = bogonV4[bi];
+ var bParts = b.net.split(".").map(Number);
+ var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0;
+ var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0;
+ if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) {
+ return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask };
+ }
+ }
+ return { prefix: prefix, is_bogon: false };
+}
+
+function checkBogonAsn(asnNum) {
+ if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true;
+ if (asnNum >= 64496 && asnNum <= 64511) return true;
+ if (asnNum >= 64512 && asnNum <= 65534) return true;
+ return false;
+}
+
+// "pass" must mean prefixes/neighbours were actually fetched and checked clean --
+// not that the upstream source failed and left nothing to check. A source is
+// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a
+// truthy object with status:"error" (local-db-client.js's DB-error signal --
+// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check
+// alone misses this case and would show "pass" on 0 checked prefixes).
+function isSourceUnavailable(x) { return !x || x.status === "error"; }
+
+function computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData) {
+ var bogonPrefixResults = allPrefixes.map(checkBogonPrefix);
+ var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; });
+ var asnInPaths = neighbours.map(function(n) { return n.asn; });
+ var bogonAsns = asnInPaths.filter(checkBogonAsn);
+ var prefixesUnavailable = isSourceUnavailable(prefixData);
+ var neighboursUnavailable = isSourceUnavailable(neighbourData);
+ var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable;
+ return {
+ status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"),
+ bogon_prefixes: bogonPrefixes,
+ bogon_asns_in_paths: bogonAsns,
+ total_prefixes_checked: allPrefixes.length,
+ prefixes_source_unavailable: prefixesUnavailable,
+ neighbours_source_unavailable: neighboursUnavailable,
+ };
+}
+
+module.exports = { computeBogonResult };
diff --git a/server/routes/validate/checks.js b/server/routes/validate/checks.js
new file mode 100644
index 0000000..7f9aaf3
--- /dev/null
+++ b/server/routes/validate/checks.js
@@ -0,0 +1,337 @@
+const { fetchJSON, fetchBgproutesVisibility } = require("../../services/http-helpers");
+const { fetchRipeStatCached } = require("../../services/ripe-stat");
+const { fetchPeeringDB } = require("../../services/peeringdb");
+const { validateRPKIWithCache } = require("../../services/rpki");
+const { ensureManrsCache, checkManrsMembership } = require("../../services/manrs");
+
+// 12. IRR Validation
+// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than
+// rejecting, so the .catch() below never sees the common failure mode -- a
+// dead upstream would otherwise silently read as "0 entries, no mismatches, pass".
+function checkIRR(rawAsn) {
+ return fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) {
+ if (irrData === null) {
+ return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 };
+ }
+ var entries = Array.isArray(irrData) ? irrData : [];
+ var mismatches = entries.filter(function(e) {
+ if (!e.bgpOrigins && !e.bgp_origins) return false;
+ if (!e.irrRoutes && !e.irr_origins) return false;
+ var bgpArr = e.bgpOrigins || e.bgp_origins || [];
+ var irrArr = e.irrRoutes || e.irr_origins || [];
+ var bgpSet = {};
+ bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; });
+ var match = false;
+ irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; });
+ return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match;
+ });
+ return {
+ status: mismatches.length === 0 ? "pass" : "warning",
+ total_entries: entries.length,
+ mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }),
+ mismatch_count: mismatches.length,
+ };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs)
+// Caller must await ensureAspaCache() before calling this.
+function checkRpkiCompleteness(rawAsn, allPrefixes) {
+ return Promise.all(
+ allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
+ ).then(function(rpkiResults) {
+ var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; });
+ var unavailableCount = rpkiResults.length - checkedResults.length;
+ var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; });
+ var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0;
+ var overSpecific = checkedResults.filter(function(r) {
+ var mask = parseInt((r.prefix || "").split("/")[1] || "0");
+ return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid";
+ });
+ // If the local RPKI DB was unreachable for every prefix, we haven't actually
+ // checked anything -- say so instead of reporting a misleading pass/fail.
+ var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0;
+ return {
+ status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail",
+ coverage_pct: coverage,
+ total_checked: checkedResults.length,
+ db_unavailable_count: unavailableCount,
+ with_roa: withRoa.length,
+ over_specific: overSpecific.map(function(r) { return r.prefix; }),
+ details: rpkiResults,
+ };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 14. Abuse Contact Validation
+function checkAbuseContact(rawAsn) {
+ return fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) {
+ if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false };
+ var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : [];
+ var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); });
+ return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 15. Spamhaus DROP/Blocklist
+function checkBlocklist(samplePrefixes) {
+ return Promise.all(
+ samplePrefixes.slice(0, 5).map(function(pfx) {
+ return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) {
+ // fetchRipeStatCached resolves(null) on timeout/network-error rather than
+ // throwing, so the .catch() below never sees that case -- distinguish it
+ // explicitly instead of letting a failed lookup read as "not listed".
+ if (data === null) return { prefix: pfx, listed: false, checked: false };
+ var sources = data && data.data && data.data.sources ? data.data.sources : [];
+ var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); });
+ return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) };
+ }).catch(function() { return { prefix: pfx, listed: false, checked: false }; });
+ })
+ ).then(function(results) {
+ var checkedResults = results.filter(function(r) { return r.checked; });
+ var listedPrefixes = checkedResults.filter(function(r) { return r.listed; });
+ var allUnchecked = results.length > 0 && checkedResults.length === 0;
+ return {
+ status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"),
+ checked: checkedResults.length,
+ unavailable: results.length - checkedResults.length,
+ listed_prefixes: listedPrefixes,
+ };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 16. MANRS Compliance — scraped from public participants list (24h cache)
+function checkManrs(rawAsn) {
+ return 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 (3 prefix sample — more causes semaphore starvation on large ASNs)
+function checkRdns(samplePrefixes) {
+ var rdnsSampleSize = Math.min(3, samplePrefixes.length);
+ return Promise.all(
+ samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) {
+ return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) {
+ if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true };
+ var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {};
+ var hasDelegation = false;
+ var details = [];
+ // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } }
+ ["ipv4", "ipv6"].forEach(function(af) {
+ var afData = pfxData[af] || {};
+ Object.keys(afData).forEach(function(p) {
+ var entry = afData[p];
+ if (entry && entry.complete) hasDelegation = true;
+ if (entry && entry.domains) {
+ entry.domains.forEach(function(d) {
+ if (d.found) hasDelegation = true;
+ details.push({ domain: d.domain, found: !!d.found });
+ });
+ }
+ });
+ });
+ // Fallback: old array format
+ if (Array.isArray(pfxData)) {
+ pfxData.forEach(function(p) {
+ if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true;
+ });
+ }
+ return { prefix: pfx, has_rdns: hasDelegation, details: details };
+ }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; });
+ })
+ ).then(function(results) {
+ var checkedResults = results.filter(function(r) { return !r.error; });
+ var withRdns = checkedResults.filter(function(r) { return r.has_rdns; });
+ var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0;
+ var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; });
+ var allUnavailable = results.length > 0 && checkedResults.length === 0;
+ return {
+ status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"),
+ coverage_pct: coverage,
+ checked: checkedResults.length,
+ unavailable: results.length - checkedResults.length,
+ results: results,
+ failed_prefixes: failedPrefixes,
+ };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API)
+function checkVisibility(rawAsn, samplePrefixes) {
+ return fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) {
+ var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {};
+ var v4 = vis.v4 || {};
+ var v6 = vis.v6 || {};
+ var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0);
+ var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0);
+ var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0;
+ var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0;
+ // If routing-status returned no data, try bgproutes.io
+ if (totalPeers === 0 && samplePrefixes[0]) {
+ return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) {
+ if (bgprFb && bgprFb.vps_seeing > 0) {
+ seeingPeers = bgprFb.vps_seeing;
+ totalPeers = Math.max(bgprFb.vps_seeing, 300);
+ score = Math.round((seeingPeers / totalPeers) * 100);
+ }
+ return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" };
+ }).catch(function() {
+ // Neither RIPE routing-status nor the bgproutes.io fallback returned data --
+ // this network hasn't actually been measured, not confirmed at 0% visibility.
+ return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" };
+ });
+ }
+ return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 19. BGP Communities Analysis
+function checkCommunities(samplePrefixes) {
+ return (samplePrefixes.length > 0
+ ? (function() {
+ var now = new Date();
+ var end = now.toISOString().replace(/\.\d+Z/, "");
+ var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, "");
+ return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end);
+ })()
+ : Promise.resolve(null)
+ ).then(function(data) {
+ var updates = data && data.data && data.data.updates ? data.data.updates : [];
+ var communityMap = {};
+ var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" };
+ updates.forEach(function(u) {
+ var attrs = u.attrs || {};
+ var communities = attrs.community || [];
+ communities.forEach(function(c) {
+ var key = Array.isArray(c) ? c.join(":") : String(c);
+ if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null };
+ communityMap[key].count++;
+ });
+ });
+ var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; });
+ var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; });
+ return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 20. Geolocation Verification
+function checkGeolocation(samplePrefixes) {
+ return (samplePrefixes.length > 0
+ ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0]))
+ : Promise.resolve(null)
+ ).then(function(data) {
+ var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : [];
+ var countries = {};
+ locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); });
+ return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel)
+function checkRpsl(rawAsn) {
+ // Try RIPE first (has richest policy data), then RDAP for other RIRs
+ var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) {
+ var objects = data && data.objects && data.objects.object ? data.objects.object : [];
+ if (objects.length === 0) return null;
+ var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : [];
+ var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; });
+ var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; });
+ var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; });
+ return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" };
+ }).catch(function() { return null; });
+
+ var rdapEndpoints = [
+ { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn },
+ { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn },
+ { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn },
+ { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn },
+ ];
+ var rdapPromises = rdapEndpoints.map(function(ep) {
+ return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) {
+ if (!data || data.errorCode || !data.handle) return null;
+ var hasRemarks = !!(data.remarks && data.remarks.length > 0);
+ var name = data.name || "";
+ return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" };
+ }).catch(function() { return null; });
+ });
+
+ return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) {
+ // Take first successful result
+ for (var ri = 0; ri < results.length; ri++) {
+ if (results[ri] !== null) return results[ri];
+ }
+ return { status: "warning", exists: false, has_policy: false };
+ });
+}
+
+// 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering)
+// Always use asn= for netixlan (more reliable than net_id when PDB rate-limits)
+function checkIxRouteServer(rawAsn) {
+ var ixRsQueryUrl = "/netixlan?asn=" + rawAsn;
+ return fetchPeeringDB(ixRsQueryUrl).then(function(ixData) {
+ var connections = ixData && ixData.data ? ixData.data : [];
+ var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; });
+ var totalIx = connections.length;
+ var rsCount = rsParticipants.length;
+ var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0;
+ var status, note;
+
+ if (totalIx > 0 && rsCount > 0) {
+ // Using route servers - good
+ status = "pass";
+ note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)";
+ } else if (totalIx >= 10 && rsCount === 0) {
+ // Network with 10+ IX connections but no RS = deliberate bilateral peering policy
+ status = "pass";
+ note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)";
+ } else if (totalIx < 3 && rsCount === 0) {
+ // Very small IX presence and no RS
+ status = "warning";
+ note = "Only " + totalIx + " IX connection(s) and no route server usage";
+ } else {
+ // Small-medium network (3-9 IX) without RS - informational
+ status = "info";
+ note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability";
+ }
+
+ return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// 23. Resource Certification (local RPKI validation - all prefixes, all RIRs)
+function checkResourceCert(rawAsn, allPrefixes) {
+ return Promise.all(
+ allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); })
+ ).then(function(results) {
+ var checked = results.filter(function(r) { return r.status !== "unavailable"; });
+ var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; });
+ var allUnavailable = results.length > 0 && checked.length === 0;
+ return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length };
+ }).catch(function(e) { return { status: "error", error: String(e) }; });
+}
+
+// Geolocation cross-ref with PeeringDB facilities
+function fetchFacCountries(netId) {
+ return netId
+ ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) {
+ return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean);
+ }).catch(function() { return []; })
+ : Promise.resolve([]);
+}
+
+module.exports = {
+ checkIRR,
+ checkRpkiCompleteness,
+ checkAbuseContact,
+ checkBlocklist,
+ checkManrs,
+ checkRdns,
+ checkVisibility,
+ checkCommunities,
+ checkGeolocation,
+ checkRpsl,
+ checkIxRouteServer,
+ checkResourceCert,
+ fetchFacCountries,
+};
diff --git a/server/routes/validate/index.js b/server/routes/validate/index.js
new file mode 100644
index 0000000..c56039d
--- /dev/null
+++ b/server/routes/validate/index.js
@@ -0,0 +1,208 @@
+const { fetchRipeStatCached } = require("../../services/ripe-stat");
+const { fetchPeeringDB } = require("../../services/peeringdb");
+const { ensureAspaCache } = require("../../services/rpki");
+const { validateResultCache, resultCacheGet, resultCacheSet, RESULT_CACHE_TTL } = require("../../caches/response-caches");
+const { computeBogonResult } = require("./bogon");
+const checks = require("./checks");
+
+// Calculate overall health score (0-100)
+const SCORE_WEIGHTS = [
+ { key: "bogon", weight: 15 },
+ { key: "irr", weight: 10 },
+ { key: "rpki_completeness", weight: 15 },
+ { key: "abuse_contact", weight: 5 },
+ { key: "blocklist", weight: 15 },
+ { key: "manrs", weight: 5 },
+ { key: "rdns", weight: 5 },
+ { key: "visibility", weight: 10 },
+ { key: "rpsl", weight: 5 },
+ { key: "ix_route_server", weight: 5 },
+ { key: "resource_cert", weight: 10 },
+];
+
+function scoreChecks(validations) {
+ var totalWeight = 0;
+ var earnedScore = 0;
+ var checkResults = [];
+
+ SCORE_WEIGHTS.forEach(function(c) {
+ var v = validations[c.key];
+ var points = 0;
+ if (v && v.status === "info") {
+ // "info" = unable to verify (e.g. API auth required) — exclude from scoring
+ checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" });
+ return;
+ }
+ if (v && v.status === "pass") points = c.weight;
+ else if (v && v.status === "warning") points = Math.round(c.weight * 0.5);
+ totalWeight += c.weight;
+ earnedScore += points;
+ checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" });
+ });
+
+ var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0;
+ return { healthScore: healthScore, checkResults: checkResults };
+}
+
+// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks)
+function enrichGeolocation(validations, facCountries, net) {
+ if (!validations.geolocation || validations.geolocation.status === "error") return;
+ var uniqueFacCountries = {};
+ facCountries.forEach(function(c) { uniqueFacCountries[c] = true; });
+ var facCountryCount = Object.keys(uniqueFacCountries).length;
+ validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries);
+ var geoSet = {};
+ (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; });
+ var geoCountryCount = Object.keys(geoSet).length;
+ var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; });
+ validations.geolocation.country_mismatches = mismatches;
+
+ // Detect global/anycast networks: 5+ facility countries OR Content/NSP type
+ var netInfoType = (net.info_type || "").toLowerCase();
+ var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp";
+ if (isGlobalNetwork) {
+ // Global/anycast/CDN network: geo mismatches are expected, not anomalies
+ validations.geolocation.status = "pass";
+ if (geoCountryCount === 0) {
+ validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available";
+ } else {
+ validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")";
+ }
+ validations.geolocation.country_mismatches = [];
+ } else if (facCountryCount <= 2 && geoCountryCount >= 10) {
+ // Actual anomaly: small network appearing in many countries
+ validations.geolocation.status = "warning";
+ validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration";
+ }
+}
+
+// Build relationships from neighbour data
+function buildRelationships(neighbourData) {
+ 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 {
+ 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.",
+ };
+}
+
+// Unified Validation endpoint: /api/validate?asn=X
+// Runs ALL validations in parallel, returns comprehensive report
+async function handleValidate(req, res, url) {
+ 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 cachedValidate = resultCacheGet(validateResultCache, rawAsn);
+ if (cachedValidate !== undefined) {
+ res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" });
+ return res.end(JSON.stringify(cachedValidate));
+ }
+ const start = Date.now();
+ const targetAsn = parseInt(rawAsn);
+
+ try {
+ // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2
+ const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([
+ fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
+ fetchPeeringDB("/net?asn=" + rawAsn),
+ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
+ fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }),
+ ]);
+
+ const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; });
+ // Use all prefixes for RPKI validation (local lookup is fast, no API calls)
+ const samplePrefixes = allPrefixes;
+ const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {};
+ const netId = net.id;
+ const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : [];
+
+ const bogonResult = computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData);
+
+ // Phase 2: All API-dependent validations in parallel
+ await ensureAspaCache(); // Ensure ROA data is loaded before RPKI-dependent checks
+ var validationPromises = {
+ irr: checks.checkIRR(rawAsn),
+ rpki_completeness: checks.checkRpkiCompleteness(rawAsn, allPrefixes),
+ abuse_contact: checks.checkAbuseContact(rawAsn),
+ blocklist: checks.checkBlocklist(samplePrefixes),
+ manrs: checks.checkManrs(rawAsn),
+ rdns: checks.checkRdns(samplePrefixes),
+ visibility: checks.checkVisibility(rawAsn, samplePrefixes),
+ communities: checks.checkCommunities(samplePrefixes),
+ geolocation: checks.checkGeolocation(samplePrefixes),
+ rpsl: checks.checkRpsl(rawAsn),
+ ix_route_server: checks.checkIxRouteServer(rawAsn),
+ resource_cert: checks.checkResourceCert(rawAsn, allPrefixes),
+ };
+
+ const facCountriesPromise = checks.fetchFacCountries(netId);
+
+ // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s
+ var keys = Object.keys(validationPromises);
+ var promises = keys.map(function(k) {
+ return Promise.race([
+ validationPromises[k],
+ new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }),
+ ]);
+ });
+ var settled = await Promise.allSettled(promises);
+ var facCountries = await facCountriesPromise;
+
+ var validations = {};
+ keys.forEach(function(key, i) {
+ if (settled[i].status === "fulfilled") {
+ validations[key] = settled[i].value;
+ } else {
+ validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" };
+ }
+ });
+
+ enrichGeolocation(validations, facCountries, net);
+ validations.bogon = bogonResult;
+
+ const { healthScore, checkResults } = scoreChecks(validations);
+ const duration = Date.now() - start;
+ const relationships = buildRelationships(neighbourData);
+
+ const validateResult = {
+ meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length },
+ asn: targetAsn,
+ name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown",
+ health_score: healthScore,
+ score_breakdown: checkResults,
+ validations: validations,
+ relationships: relationships,
+ };
+ // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures
+ // Full results with prefixes are cached for the standard 15 minutes
+ const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL;
+ resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL);
+ return res.end(JSON.stringify(validateResult, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "Validation failed", message: err.message }));
+ }
+}
+
+module.exports = { handleValidate };
diff --git a/server/routes/whois.js b/server/routes/whois.js
new file mode 100644
index 0000000..43ad972
--- /dev/null
+++ b/server/routes/whois.js
@@ -0,0 +1,26 @@
+const { fetchWhois } = require("../whois");
+
+// Feature 27: WHOIS endpoint
+async function handleWhoisRoute(req, res, url) {
+ const resource = url.searchParams.get("resource") || "";
+ if (!resource) {
+ res.writeHead(400);
+ return res.end(JSON.stringify({ error: "Missing resource parameter (ASN, prefix, or domain)" }));
+ }
+ const start = Date.now();
+ try {
+ const whoisResult = await fetchWhois(resource);
+ if (!whoisResult || typeof whoisResult !== "object") {
+ res.writeHead(503);
+ return res.end(JSON.stringify({ error: "WHOIS data temporarily unavailable" }));
+ }
+ whoisResult.meta = { duration_ms: Date.now() - start, timestamp: new Date().toISOString() };
+ return res.end(JSON.stringify(whoisResult, null, 2));
+ } catch (err) {
+ res.writeHead(500);
+ return res.end(JSON.stringify({ error: "WHOIS lookup failed", message: err.message }));
+ }
+
+}
+
+module.exports = { handleWhoisRoute };
diff --git a/server/services/api-proxy.js b/server/services/api-proxy.js
new file mode 100644
index 0000000..499a356
--- /dev/null
+++ b/server/services/api-proxy.js
@@ -0,0 +1,34 @@
+const http = require("http");
+
+// Several routes were extracted into src/api/ + src/features/*/routes.ts
+// (Fastify, dist/api/index.js, run as a separate PM2 process on
+// API_SERVER_PORT) but were never re-wired here after that migration —
+// they just silently 404'd. This forwards those specific paths through
+// rather than reimplementing them inline a second time.
+const API_SERVER_PORT = parseInt(process.env.API_SERVER_PORT || "3102", 10);
+
+function proxyToApiServer(req, res, targetUrl) {
+ const proxyReq = http.request(
+ {
+ hostname: "127.0.0.1",
+ port: API_SERVER_PORT,
+ path: targetUrl,
+ method: req.method,
+ headers: Object.assign({}, req.headers, { host: "127.0.0.1:" + API_SERVER_PORT }),
+ },
+ (proxyRes) => {
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res);
+ }
+ );
+ proxyReq.on("error", (err) => {
+ console.error("[API Proxy] Error forwarding " + targetUrl + ":", err.message);
+ if (!res.headersSent) {
+ res.writeHead(502, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "API server unavailable", detail: err.message }));
+ }
+ });
+ req.pipe(proxyReq);
+}
+
+module.exports = { proxyToApiServer, API_SERVER_PORT };
diff --git a/server/services/http-helpers.js b/server/services/http-helpers.js
new file mode 100644
index 0000000..a4e6809
--- /dev/null
+++ b/server/services/http-helpers.js
@@ -0,0 +1,131 @@
+const https = require("https");
+const { UA } = require("../data/constants");
+
+const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || "";
+const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1";
+
+function fetchJSON(url, options) {
+ const timeoutMs = (options && options.timeout) || 8000;
+ return new Promise((resolve) => {
+ const reqOptions = {
+ headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) },
+ timeout: timeoutMs,
+ };
+ const timer = setTimeout(() => resolve(null), timeoutMs + 500);
+ https
+ .get(url, reqOptions, (res) => {
+ let data = "";
+ res.on("data", (chunk) => (data += chunk));
+ res.on("end", () => {
+ clearTimeout(timer);
+ if (res.statusCode === 429) {
+ console.warn("[PDB] Rate limited (429):", url.substring(0, 80));
+ return resolve(null);
+ }
+ try {
+ resolve(JSON.parse(data));
+ } catch (_e) {
+ resolve(null);
+ }
+ });
+ })
+ .on("timeout", () => { clearTimeout(timer); resolve(null); })
+ .on("error", () => { clearTimeout(timer); resolve(null); });
+ });
+}
+
+// Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas)
+async function fetchJSONWithRetry(url, options) {
+ const result = await fetchJSON(url, options);
+ if (result !== null) return result;
+ await new Promise(r => setTimeout(r, 1000));
+ return fetchJSON(url, options);
+}
+
+function fetchHTML(url, options) {
+ return new Promise((resolve) => {
+ const reqOptions = {
+ headers: {
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ ...(options && options.headers ? options.headers : {}),
+ },
+ };
+ const lib = url.startsWith("https") ? require("https") : require("http");
+ lib
+ .get(url, reqOptions, (res) => {
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
+ return fetchHTML(res.headers.location, options).then(resolve);
+ }
+ let data = "";
+ res.on("data", (chunk) => (data += chunk));
+ res.on("end", () => resolve(data));
+ })
+ .on("error", () => resolve(null));
+ });
+}
+
+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,
+ path: parsed.pathname + parsed.search,
+ method: "POST",
+ headers: {
+ "User-Agent": UA,
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(data),
+ ...(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) {
+ resolve(null);
+ }
+ });
+ });
+ req.on("error", () => { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
+ req.write(data);
+ req.end();
+ });
+}
+
+// bgproutes.io visibility fallback helper
+// Queries the RIB endpoint to estimate prefix visibility across vantage points
+function fetchBgproutesVisibility(prefix) {
+ if (!BGPROUTES_API_KEY) return Promise.resolve(null);
+ const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact";
+ return fetchJSON(url, {
+ timeout: 15000,
+ headers: {
+ "Authorization": "Bearer " + BGPROUTES_API_KEY,
+ "User-Agent": UA,
+ },
+ }).then(function(data) {
+ if (!data || !data.data) return null;
+ // data.data should be an array of RIB entries from different vantage points
+ var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []);
+ var vpSet = new Set();
+ entries.forEach(function(e) {
+ if (e.vantage_point || e.vp || e.collector || e.peer_asn) {
+ vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn);
+ }
+ });
+ return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" };
+ }).catch(function() { return null; });
+}
+
+module.exports = { fetchJSON, fetchJSONWithRetry, fetchHTML, postJSON, fetchBgproutesVisibility };
diff --git a/server/services/manrs.js b/server/services/manrs.js
new file mode 100644
index 0000000..6276dd1
--- /dev/null
+++ b/server/services/manrs.js
@@ -0,0 +1,64 @@
+const https = require("https");
+const { UA } = require("../data/constants");
+
+// 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;
+ for (const m of html.matchAll(re)) {
+ 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/",
+ };
+}
+
+module.exports = { ensureManrsCache, checkManrsMembership };
diff --git a/server/services/peeringdb.js b/server/services/peeringdb.js
new file mode 100644
index 0000000..0797731
--- /dev/null
+++ b/server/services/peeringdb.js
@@ -0,0 +1,216 @@
+const fs = require("fs");
+const { UA } = require("../data/constants");
+const { fetchJSON } = require("./http-helpers");
+const { Semaphore } = require("./ripe-stat");
+
+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;
+ }
+}
+
+// PeeringDB semaphore — limits concurrent PDB requests to avoid 429 rate-limits
+const pdbSemaphore = new Semaphore(5);
+
+// 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) {
+ headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY;
+ }
+ await pdbSemaphore.acquire();
+ try {
+ return await fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } });
+ } finally {
+ pdbSemaphore.release();
+ }
+}
+
+// PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load).
+// Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted.
+async function fetchPeeringDBWithRetry(path, options) {
+ const delays = [2000, 5000];
+ let result = await fetchPeeringDB(path, options);
+ for (let i = 0; i < delays.length && result === null; i++) {
+ await new Promise(r => setTimeout(r, delays[i]));
+ result = await fetchPeeringDB(path, options);
+ }
+ return result;
+}
+
+module.exports = {
+ PEERINGDB_API_KEY,
+ PEERINGDB_API_URL,
+ getPdbLocal,
+ queryPeeringDBLocal,
+ pdbSemaphore,
+ fetchPeeringDB,
+ fetchPeeringDBWithRetry,
+};
diff --git a/server/services/ripe-stat.js b/server/services/ripe-stat.js
new file mode 100644
index 0000000..00a3480
--- /dev/null
+++ b/server/services/ripe-stat.js
@@ -0,0 +1,184 @@
+const fs = require("fs");
+const { fetchJSON } = require("./http-helpers");
+const localDb = require("../../local-db-client");
+
+// RIPE Stat Source Cache + Semaphore (L2)
+// Prevents 429 rate-limiting by throttling + caching responses
+const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
+const RIPE_STAT_CACHE_MAX = 2000;
+const RIPE_STAT_TTL = {
+ "announced-prefixes": 15 * 60 * 1000,
+ "asn-neighbours": 15 * 60 * 1000,
+ "as-overview": 60 * 60 * 1000,
+ "rir-stats-country": 24 * 60 * 60 * 1000,
+ "visibility": 15 * 60 * 1000,
+ "prefix-size-distribution": 60 * 60 * 1000,
+ "abuse-contact-finder": 24 * 60 * 60 * 1000,
+ "blocklist": 60 * 60 * 1000,
+ "reverse-dns-consistency": 60 * 60 * 1000,
+ "routing-status": 15 * 60 * 1000,
+ "bgp-updates": 15 * 60 * 1000,
+ "maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
+ "looking-glass": 15 * 60 * 1000,
+ "whois": 24 * 60 * 60 * 1000,
+ "rpki-validation": 6 * 60 * 60 * 1000,
+};
+
+// Counting semaphore — limits concurrent RIPE Stat requests
+class Semaphore {
+ constructor(max) { this.max = max; this.current = 0; this.queue = []; }
+ acquire() {
+ if (this.current < this.max) { this.current++; return Promise.resolve(); }
+ return new Promise((resolve) => this.queue.push(resolve));
+ }
+ release() {
+ this.current--;
+ if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
+ }
+}
+const ripeStatSemaphore = new Semaphore(15);
+
+// Cached + throttled RIPE Stat fetch
+// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact
+// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres
+// refactor. In JS, two `function` declarations sharing a name silently collapse to
+// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle
+// implementation below was completely shadowed, and every call to
+// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours,
+// as-overview, visibility, prefix-size-distribution} was unconditionally returning
+// null instead of ever reaching this code. That silently broke blocklist,
+// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois,
+// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's
+// fallback call to this function for the fix.
+async function fetchRipeStatCachedFromApi(url, options) {
+ // Extract endpoint name from URL for TTL lookup
+ const match = url.match(/\/data\/([^/]+)\//);
+ const endpoint = match ? match[1] : "default";
+ const resourceMatch = url.match(/resource=([^&]+)/);
+ const resource = resourceMatch ? resourceMatch[1] : url;
+ const cacheKey = endpoint + ":" + resource;
+
+ // Check cache
+ const cached = ripeStatCache.get(cacheKey);
+ const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
+ if (cached && (Date.now() - cached.ts) < ttl) {
+ return cached.data;
+ }
+
+ // Throttle via semaphore
+ await ripeStatSemaphore.acquire();
+ try {
+ // Double-check cache after acquiring semaphore (another request may have filled it)
+ const cached2 = ripeStatCache.get(cacheKey);
+ if (cached2 && (Date.now() - cached2.ts) < ttl) {
+ return cached2.data;
+ }
+
+ const result = await fetchJSON(url, options);
+
+ // Only cache successful results — never cache null (failed/rate-limited responses)
+ // Caching null causes cascading failures: retry hits cache, returns null again
+ if (result !== null) {
+ if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
+ ripeStatCache.delete(ripeStatCache.keys().next().value);
+ }
+ ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
+ }
+ return result;
+ } finally {
+ ripeStatSemaphore.release();
+ }
+}
+
+// Cached + throttled RIPE Stat with one retry on failure
+async function fetchRipeStatCachedWithRetry(url, options) {
+ const result = await fetchRipeStatCached(url, options);
+ if (result !== null) return result;
+ await new Promise(r => setTimeout(r, 1500));
+ return fetchRipeStatCached(url, options);
+}
+
+// RIPE Stat cache disk persistence (skip null entries)
+function saveRipeStatCacheToDisk(filePath) {
+ try {
+ const obj = {};
+ for (const [k, v] of ripeStatCache) {
+ if (v.data !== null) obj[k] = v;
+ }
+ fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
+ console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
+ } catch (e) {
+ console.warn("[RIPE-CACHE] Disk save failed:", e.message);
+ }
+}
+
+function loadRipeStatCacheFromDisk(filePath) {
+ try {
+ if (!fs.existsSync(filePath)) return false;
+ const raw = fs.readFileSync(filePath, "utf8");
+ const data = JSON.parse(raw);
+ const now = Date.now();
+ for (const [k, v] of Object.entries(data.entries || {})) {
+ const match = k.match(/^([^:]+):/);
+ const endpoint = match ? match[1] : "default";
+ const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
+ if (now - v.ts < ttl) {
+ ripeStatCache.set(k, v);
+ }
+ }
+ console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
+ return true;
+ } catch (e) {
+ console.warn("[RIPE-CACHE] Disk load failed:", e.message);
+ return false;
+ }
+}
+
+// MASTER RIPE Stat API wrapper (Local-first, zero external API calls).
+// Analyzes RIPE Stat URL and dispatches to appropriate localDb function.
+async function fetchRipeStatCached(url, options = {}) {
+ try {
+ // Detect which RIPE Stat endpoint this is and call local DB
+ if (url.includes('/announced-prefixes/')) {
+ const asnMatch = url.match(/resource=AS(\d+)/);
+ if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1]));
+ }
+ if (url.includes('/asn-neighbours/')) {
+ const asnMatch = url.match(/resource=AS(\d+)/);
+ if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1]));
+ }
+ if (url.includes('/as-overview/')) {
+ const asnMatch = url.match(/resource=AS(\d+)/);
+ if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1]));
+ }
+ if (url.includes('/visibility/')) {
+ const asnMatch = url.match(/resource=AS(\d+)/);
+ if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1]));
+ }
+ if (url.includes('/prefix-size-distribution/')) {
+ const asnMatch = url.match(/resource=AS(\d+)/);
+ if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
+ }
+
+ // For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
+ // e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
+ // whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the
+ // real RIPE Stat API with its own cache/throttle/never-cache-null protections,
+ // instead of returning null unconditionally (see fetchRipeStatCachedFromApi's
+ // header comment for how this silently broke ~9 checks until 2026-07-16).
+ return fetchRipeStatCachedFromApi(url, options);
+ } catch (e) {
+ console.error("[fetchRipeStatCached] Error:", e.message);
+ return Promise.resolve(null);
+ }
+}
+
+module.exports = {
+ ripeStatCache,
+ fetchRipeStatCached,
+ fetchRipeStatCachedFromApi,
+ fetchRipeStatCachedWithRetry,
+ saveRipeStatCacheToDisk,
+ loadRipeStatCacheFromDisk,
+ Semaphore,
+};
diff --git a/server/services/rpki.js b/server/services/rpki.js
new file mode 100644
index 0000000..69fd6e5
--- /dev/null
+++ b/server/services/rpki.js
@@ -0,0 +1,200 @@
+const https = require("https");
+const localDb = require("../../local-db-client");
+const { UA } = require("../data/constants");
+const { fetchJSON } = require("./http-helpers");
+const { roaStore } = require("../caches/roa-store");
+const { recordAspaAdoptionSnapshot } = require("../aspa-adoption/tracker");
+
+// RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed
+const rpkiAspaMap = new Map(); // customer_asid -> Set
+let rpkiAspaLastFetch = 0;
+let rpkiAspaFetching = false;
+
+function fetchRpkiAspaFeed() {
+ if (rpkiAspaFetching) return Promise.resolve();
+ rpkiAspaFetching = true;
+ console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)...");
+ return new Promise((resolve) => {
+ const options = {
+ headers: { "User-Agent": UA },
+ timeout: 120000,
+ };
+ https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => {
+ let data = "";
+ res.on("data", (chunk) => (data += chunk));
+ res.on("end", () => {
+ try {
+ const parsed = JSON.parse(data);
+
+ // Load ASPA objects
+ const aspas = parsed.aspas || [];
+ rpkiAspaMap.clear();
+ aspas.forEach((a) => {
+ const customerAsid = Number(a.customer_asid);
+ const providers = (a.providers || []).map(Number);
+ rpkiAspaMap.set(customerAsid, new Set(providers));
+ });
+
+ // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls)
+ const roas = parsed.roas || [];
+ roaStore.build(roas);
+ roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
+
+ // Track ASPA adoption — persist to disk + update in-memory trend
+ recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap);
+
+ rpkiAspaLastFetch = Date.now();
+ console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
+ } catch (e) {
+ console.error("[RPKI] Failed to parse RPKI feed:", e.message);
+ }
+ rpkiAspaFetching = false;
+ resolve();
+ });
+ }).on("error", (e) => {
+ console.error("[RPKI] Fetch failed:", e.message);
+ rpkiAspaFetching = false;
+ resolve();
+ });
+ });
+}
+
+// Ensure ASPA + ROA cache is fresh
+async function ensureAspaCache() {
+ if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) {
+ await fetchRpkiAspaFeed();
+ }
+}
+
+// Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded
+// distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object"
+// from "the Cloudflare feed fetch has never succeeded, so we don't actually know" --
+// rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so
+// 0 means every attempt so far has failed and exists:false here is not trustworthy.
+function lookupAspaFromRpki(asn) {
+ const asnNum = Number(asn);
+ const feedLoaded = rpkiAspaLastFetch > 0;
+ if (rpkiAspaMap.has(asnNum)) {
+ const providers = rpkiAspaMap.get(asnNum);
+ return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded };
+ }
+ return { exists: false, providers: [], feedLoaded };
+}
+
+// Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls)
+// Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N }
+// "not_found" means the DB was queried and genuinely has no covering ROA -- a real result.
+// "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know.
+// Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse-
+// sounding finding). Callers computing coverage percentages should exclude "unavailable".
+async function validateRPKIWithCache(asn, prefix) {
+ try {
+ const result = await localDb.validateRpki(prefix, asn);
+ if (result.status === 'valid') {
+ return { prefix, status: "valid", validating_roas: 1 };
+ } else if (result.status === 'invalid') {
+ return { prefix, status: "invalid", validating_roas: 1 };
+ } else if (result.status === 'unknown') {
+ return { prefix, status: "unavailable", validating_roas: 0 };
+ } else {
+ // 'not-found': genuinely no covering ROA
+ return { prefix, status: "not_found", validating_roas: 0 };
+ }
+ } catch (_e) {
+ console.error("[RPKI] Error validating " + prefix + ":", _e.message);
+ return { prefix, status: "unavailable", validating_roas: 0 };
+ }
+}
+
+// Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes)
+async function fetchRipeRpkiValidator(asn, prefix) {
+ try {
+ const encoded = encodeURIComponent(prefix);
+ const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded;
+ const result = await fetchJSON(url, { timeout: 5000 });
+ if (result && result.validated_route) {
+ return {
+ prefix: prefix,
+ validity: result.validated_route.validity || {},
+ state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown",
+ };
+ }
+ return { prefix: prefix, state: "unknown", error: "no_data" };
+ } catch (_e) {
+ return { prefix: prefix, state: "error", error: "timeout_or_unavailable" };
+ }
+}
+
+// Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel).
+// agreement_pct only counts pairs where BOTH sources actually returned a real
+// verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct:
+// null (not a fabricated 100), since nothing was actually cross-checked.
+async function crossCheckRpki(asn, prefixes, localResults) {
+ const sample = prefixes.slice(0, 5);
+ if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 };
+
+ const ripeResults = await Promise.all(
+ sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx))
+ );
+
+ const localMap = new Map();
+ for (const lr of localResults) {
+ localMap.set(lr.prefix, lr.status);
+ }
+
+ let cloudflareValid = 0;
+ let ripeValid = 0;
+ let agreements = 0;
+ let comparable = 0;
+ const disagreements = [];
+
+ for (let i = 0; i < sample.length; i++) {
+ const pfx = sample[i];
+ const cfStatus = localMap.get(pfx) || "not_found";
+ const ripeState = ripeResults[i].state;
+
+ const cfIsValid = cfStatus === "valid";
+ const ripeIsValid = ripeState === "valid" || ripeState === "VALID";
+
+ if (cfIsValid) cloudflareValid++;
+ if (ripeIsValid) ripeValid++;
+
+ // A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from
+ // the agreement percentage instead of counting it as a silent "agreement".
+ if (ripeState === "error" || ripeState === "unknown") {
+ continue;
+ }
+
+ comparable++;
+ if (cfIsValid === ripeIsValid) {
+ agreements++;
+ } else {
+ disagreements.push({
+ prefix: pfx,
+ cloudflare: cfStatus,
+ ripe: ripeState,
+ });
+ }
+ }
+
+ const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null;
+ return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable };
+}
+
+// rpkiAspaLastFetch is a reassigned `let`, not a mutated object -- a bare
+// export would only capture its value (0) at require time. Expose it via a
+// getter so callers (e.g. /api/health) always see the current value.
+function getRpkiAspaLastFetch() {
+ return rpkiAspaLastFetch;
+}
+
+module.exports = {
+ rpkiAspaMap,
+ getRpkiAspaLastFetch,
+ fetchRpkiAspaFeed,
+ ensureAspaCache,
+ lookupAspaFromRpki,
+ validateRPKIWithCache,
+ fetchRipeRpkiValidator,
+ crossCheckRpki,
+};
diff --git a/server/topology.js b/server/topology.js
new file mode 100644
index 0000000..d9cbd39
--- /dev/null
+++ b/server/topology.js
@@ -0,0 +1,49 @@
+const { fetchRipeStatCached } = require("./services/ripe-stat");
+
+// Feature 25: Topology / AS-Relationships
+async function fetchTopology(targetAsn, depth) {
+ const maxDepth = Math.min(depth || 2, 3);
+ const nodes = new Map();
+ const edges = [];
+ async function fetchNeighboursForAsn(asn, currentDepth) {
+ if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return;
+ const [data, overview] = await Promise.all([
+ fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn),
+ fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn),
+ ]);
+ const name = overview?.data?.holder || "";
+ const neighbours = data?.data?.neighbours || [];
+ const upstreams = neighbours.filter((n) => n.type === "left");
+ const downstreams = neighbours.filter((n) => n.type === "right");
+ const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer");
+ const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect";
+ nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth });
+ upstreams.forEach((n) => {
+ if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 });
+ edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" });
+ });
+ downstreams.forEach((n) => {
+ if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 });
+ edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" });
+ });
+ peers.slice(0, 10).forEach((n) => {
+ if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 });
+ edges.push({ from: asn, to: n.asn, relationship: "peer" });
+ });
+ if (currentDepth < maxDepth && upstreams.length > 0) {
+ const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5);
+ await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1)));
+ }
+ }
+ await fetchNeighboursForAsn(targetAsn, 0);
+ const edgeSet = new Set();
+ const uniqueEdges = edges.filter((e) => {
+ const key = e.from + "-" + e.to + "-" + e.relationship;
+ if (edgeSet.has(key)) return false;
+ edgeSet.add(key);
+ return true;
+ });
+ return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth };
+}
+
+module.exports = { fetchTopology };
diff --git a/server/visitors.js b/server/visitors.js
new file mode 100644
index 0000000..b1ad486
--- /dev/null
+++ b/server/visitors.js
@@ -0,0 +1,21 @@
+const fs = require("fs");
+const crypto = require("crypto");
+
+const VISITORS_FILE = "/opt/peercortex-app/visitors.json";
+
+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;
+}
+
+module.exports = { loadVisitors, trackVisitor };
diff --git a/server/whois.js b/server/whois.js
new file mode 100644
index 0000000..d211453
--- /dev/null
+++ b/server/whois.js
@@ -0,0 +1,146 @@
+const { fetchJSON } = require("./services/http-helpers");
+const { whoisCacheGet, whoisCacheSet, WHOIS_NOT_FOUND_CACHE_TTL } = require("./caches/response-caches");
+
+// Feature 27: WHOIS via RIPE DB
+async function fetchWhois(resource) {
+ const result = { resource, type: null, data: null, error: null };
+ try {
+ const trimmed = resource.trim();
+ if (/^(AS)?\d+$/i.test(trimmed)) {
+ 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) {
+ const obj = ripeData.objects.object[0];
+ const attrs = obj.attributes?.attribute || [];
+ const parsed = {};
+ attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
+ result.data = {
+ aut_num: (parsed["aut-num"] || [])[0] || "",
+ as_name: (parsed["as-name"] || [])[0] || "",
+ descr: parsed["descr"] || [],
+ org: (parsed["org"] || [])[0] || "",
+ admin_c: parsed["admin-c"] || [],
+ tech_c: parsed["tech-c"] || [],
+ mnt_by: parsed["mnt-by"] || [],
+ status: (parsed["status"] || [])[0] || "",
+ created: (parsed["created"] || [])[0] || "",
+ last_modified: (parsed["last-modified"] || [])[0] || "",
+ source: (parsed["source"] || [])[0] || "",
+ import: parsed["import"] || [],
+ export: parsed["export"] || [],
+ remarks: parsed["remarks"] || [],
+ };
+ whoisCacheSet(asn, result.data);
+ }
+
+ // 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 },
+ { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn },
+ { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn },
+ { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn },
+ ];
+ const rdapResults = await Promise.all(rdapEndpoints.map((ep) =>
+ fetchJSON(ep.url, { timeout: 3000 }).then((d) => {
+ if (!d || d.errorCode || !d.handle) return null;
+ return { source: ep.name, data: d };
+ }).catch(() => null)
+ ));
+ const found = rdapResults.find((r) => r !== null);
+ if (found) {
+ const d = found.data;
+ const remarks = (d.remarks || []).map((r) => (r.description || []).join(" "));
+ const entities = d.entities || [];
+ const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || "");
+ const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || "");
+ const events = d.events || [];
+ const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || "";
+ const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || "";
+ result.data = {
+ aut_num: "AS" + asn,
+ as_name: d.name || "",
+ descr: remarks,
+ org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "",
+ admin_c: adminContacts,
+ tech_c: techContacts,
+ mnt_by: [],
+ status: (d.status || []).join(", "),
+ created: created,
+ last_modified: lastMod,
+ source: found.source + " (RDAP)",
+ import: [],
+ export: [],
+ remarks: remarks,
+ };
+ whoisCacheSet(asn, result.data);
+ } else {
+ result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)";
+ // Short TTL: this "not found" likely reflects a transient failure across
+ // all 5 sources, not a confirmed non-existent ASN -- see WHOIS_NOT_FOUND_CACHE_TTL.
+ whoisCacheSet(asn, null, WHOIS_NOT_FOUND_CACHE_TTL);
+ }
+ }
+ } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) {
+ result.type = "inetnum";
+ const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num");
+ if (ripeData && ripeData.objects && ripeData.objects.object) {
+ const results = ripeData.objects.object.map((obj) => {
+ const attrs = obj.attributes?.attribute || [];
+ const parsed = {};
+ attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
+ return {
+ inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "",
+ netname: (parsed["netname"] || [])[0] || "",
+ descr: parsed["descr"] || [],
+ country: (parsed["country"] || [])[0] || "",
+ org: (parsed["org"] || [])[0] || "",
+ admin_c: parsed["admin-c"] || [],
+ tech_c: parsed["tech-c"] || [],
+ mnt_by: parsed["mnt-by"] || [],
+ status: (parsed["status"] || [])[0] || "",
+ created: (parsed["created"] || [])[0] || "",
+ last_modified: (parsed["last-modified"] || [])[0] || "",
+ source: (parsed["source"] || [])[0] || "",
+ };
+ });
+ result.data = results.length === 1 ? results[0] : results;
+ } else { result.error = "Not found in RIPE DB"; }
+ } else {
+ result.type = "domain";
+ const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain");
+ if (ripeData && ripeData.objects && ripeData.objects.object) {
+ const obj = ripeData.objects.object[0];
+ const attrs = obj.attributes?.attribute || [];
+ const parsed = {};
+ attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); });
+ result.data = {
+ domain: (parsed["domain"] || [])[0] || "",
+ descr: parsed["descr"] || [],
+ admin_c: parsed["admin-c"] || [],
+ tech_c: parsed["tech-c"] || [],
+ zone_c: parsed["zone-c"] || [],
+ nserver: parsed["nserver"] || [],
+ mnt_by: parsed["mnt-by"] || [],
+ created: (parsed["created"] || [])[0] || "",
+ last_modified: (parsed["last-modified"] || [])[0] || "",
+ source: (parsed["source"] || [])[0] || "",
+ };
+ } else { result.error = "Not found in RIPE DB"; }
+ }
+ } catch (err) { result.error = err.message; }
+ return result;
+}
+
+module.exports = { fetchWhois };