const fs = require("fs"); const http = require("http"); const https = require("https"); const crypto = require("crypto"); // ── LOCAL DATABASE CLIENT (BGP, RPKI, Threat Intel) ────────────── const localDb = require('./local-db-client'); console.log('[PeerCortex] Local DB client initialized'); // Load .env file require('./src/backend/config'); const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; // bio-rd gRPC client (optional — graceful fallback if unavailable) let risClient = null; try { const { createRisClient } = require('./bio-rd-client'); const BIO_RD_HOST = process.env.BIO_RD_HOST || 'localhost'; const BIO_RD_PORT = parseInt(process.env.BIO_RD_PORT || '4321'); risClient = createRisClient(BIO_RD_HOST, BIO_RD_PORT); console.log(`[bio-rd] RIS client configured → ${BIO_RD_HOST}:${BIO_RD_PORT}`); } catch (e) { console.log('[bio-rd] RIS client not available (bio-rd-client.js missing or gRPC not installed)'); } const { PEERINGDB_API_KEY, PEERINGDB_API_URL, getPdbLocal, queryPeeringDBLocal, pdbSemaphore, fetchPeeringDB, fetchPeeringDBWithRetry, } = require("./server/services/peeringdb"); // ── SMTP / Email ────────────────────────────────────────────── const { sendFeedbackMail } = require('./src/backend/services/smtp'); // ── SMTP / Email ────────────────────────────────────────────── const { loadVisitors, trackVisitor } = require("./server/visitors"); // ═══════════════════════════════════════════════════════════════ // PEERCORTEX v0.6.1 — New Features // ═══════════════════════════════════════════════════════════════ // ── BGP Community Database ───────────────────────────────────── // Migrated to src/features/bgp-communities/bgp-community-db.ts // ── Hijack Monitoring ────────────────────────────────────────── 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"); const { aspaAdoptionDailyHistory, recordAspaAdoptionSnapshot, getAdoptionTrend, buildAspaAdoptionDashboard, } = require("./server/aspa-adoption/tracker"); const { fetchRirDelegation, fetchIpv6AdoptionStats } = require("./server/ipv6-adoption"); const { UA } = require("./server/data/constants"); const { CITY_COORDS } = require("./server/data/city-coords"); // ============================================================ const { scoreRingSvg, pdfBaseCSS, buildPdfHtml, buildComparisonPdfHtml, formatLabel, escHtml, } = require("./server/pdf-export/templates"); const { getPuppeteerBrowser, pdfCacheGet, pdfCacheSet, renderHtmlToPdf, } = require("./server/pdf-export/renderer"); 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. const { ensureManrsCache, checkManrsMembership } = require("./server/services/manrs"); // ============================================================ // 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"); // ============================================================ // 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); } // ============================================================ // HTTP Server // ============================================================ const staticRoutes = require("./server/routes/static"); const searchRoutes = require("./server/routes/search"); const feedbackRoutes = require("./server/routes/feedback"); const server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); return res.end(); } let url, reqPath; try { url = new URL(req.url, "http://localhost"); reqPath = url.pathname; } catch (_urlErr) { res.writeHead(400); return res.end('Bad Request'); } // Serve static files — host-based routing const host = (req.headers.host || '').split(':')[0]; if (reqPath === "/" || reqPath === "/index.html") { return staticRoutes.handleRoot(req, res, host, reqPath); } // Direct access to editorial version if (reqPath === "/index-editorial.html") { return staticRoutes.handleIndexEditorial(req, res); } // 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") { return searchRoutes.handleVisitors(req, res); } // OPTIONS preflight (CORS) 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") { return feedbackRoutes.handlePost(req, res); } // GET /api/feedback?token=... — admin: fetch all entries as JSON if (reqPath === "/api/feedback" && req.method === "GET") { return feedbackRoutes.handleGet(req, res, url); } // Serve favicon if (reqPath === "/favicon.ico") { return staticRoutes.handleFavicon(req, res); } // Lia'''s Atlas Paradise - Easter egg page if (reqPath === "/lia" || reqPath === "/lia/") { 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 (!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) // ============================================================ 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; } // ============================================================ // Lia's Paradise: Combined PeeringDB + Atlas coverage data // ============================================================ if (reqPath === "/api/lia/coverage") { 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; } 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 rpkiAspaLastFetch = getRpkiAspaLastFetch(); 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; } // ============================================================ // 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 })); } } // ============================================================ // 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 })); } } } // ============================================================ // 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 })); } } // ============================================================ // 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 })); } } // ============================================================ // 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, APNIC_CC, LACNIC_CC, AFRINIC_CC } = require("./server/data/rir-country-codes"); 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 } = require("./server/data/ix-city-map"); 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; } // ============================================================ // 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 })); } } // ============================================================ // 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; } // ============================================================ // Quick-IX endpoint: /api/quick-ix?asn=X // Lightweight: only IX connections from PeeringDB, 1h cached // Used by Peering Recommendations to avoid 20x full lookups // ============================================================ if (reqPath === "/api/quick-ix") { const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing asn parameter" })); } const cached = quickIxCacheGet(rawAsn); if (cached !== undefined) { res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(cached)); } try { const [pdbNetData, pdbIxlanData] = await Promise.all([ fetchJSON("https://www.peeringdb.com/api/net?asn=" + rawAsn + "&depth=0", { timeout: 5000 }).catch(() => null), fetchJSON("https://www.peeringdb.com/api/netixlan?asn=" + rawAsn + "&limit=100", { timeout: 6000 }).catch(() => null), ]); const netName = pdbNetData?.data?.[0]?.name || ""; const ixConnections = []; if (pdbIxlanData && pdbIxlanData.data) { pdbIxlanData.data.forEach((row) => { ixConnections.push({ ix_id: row.ixlan_id, name: row.name || "", speed: row.speed || 0 }); }); } // Fall back to RIPE Stat if PeeringDB returns nothing if (ixConnections.length === 0) { const rsStat = await fetchRipeStatCached("https://stat.ripe.net/data/ixs/data.json?resource=AS" + rawAsn, { timeout: 5000 }).catch(() => null); const ixs = rsStat?.data?.ixs || []; ixs.forEach((ix) => ixConnections.push({ ix_id: ix.ixp_id || 0, name: ix.name || "", speed: 0 })); } const result = { asn: parseInt(rawAsn), name: netName, ix_connections: ixConnections }; quickIxCacheSet(rawAsn, result); res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(result)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "quick-ix lookup failed", message: err.message })); } } // ============================================================ // Peer Matching endpoint: /api/peers/find?ix=NAME&policy=open&min_speed=10000 // ============================================================ 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 })); } } // ============================================================ // 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 })); } } // ============================================================ // 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 })); } } // ============================================================ // 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 })); } } // ============================================================ // 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 })); } } // 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); } // ── Changelog page ───────────────────────────────────────── if (reqPath === '/changelog') { try { const md = fs.readFileSync('/opt/peercortex-app/CHANGELOG.md', 'utf8'); const lines = md.split('\n'); let html = ''; for (const line of lines) { if (line.startsWith('## ')) { html += `

${line.slice(3)}

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

${line.slice(4)}

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

· ${m}

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

· ${line.slice(2)}

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

${line.slice(2)}

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

PeerCortex · v0.5.0 · Open Source · MIT

`; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.writeHead(200); return res.end(page); } catch(e) { res.writeHead(500); return res.end('Changelog not available'); } } // ── BGP Community Decoder ──────────────────────────────────── if (reqPath === '/api/communities') { return proxyToApiServer(req, res, req.url); } // ── IRR Audit ───────────────────────────────────────────────── if (reqPath === '/api/irr-audit') { return proxyToApiServer(req, res, req.url); } // ── AS-SET Expander ─────────────────────────────────────────── if (reqPath === '/api/asset-expand') { return proxyToApiServer(req, res, req.url); } // ── Routing History (prefix table via RIPE Stat routing-history) ── if (reqPath === '/api/rpki-history') { return proxyToApiServer(req, res, req.url); } // ── AS-PATH Visualizer (RIPE Stat looking-glass) ──────────────── if (reqPath === '/api/aspath') { return proxyToApiServer(req, res, req.url); } // ── Looking Glass (RIPE Stat) ───────────────────────────────── if (reqPath === '/api/looking-glass') { return proxyToApiServer(req, res, req.url); } // ── IXP Peering Matrix ──────────────────────────────────────── if (reqPath === '/api/ix-matrix') { return proxyToApiServer(req, res, req.url); } // ── 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(); } // ── 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; } // ── 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 })); } // ── 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 })); } // ── 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; } // ── 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 })); } // ── 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; } // ── 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; } // ── 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 })); } // ── 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); } // ── 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); res.end( JSON.stringify({ error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d, /api/ipv6-adoption-stats", }) ); }); const { atlasState, fetchAllAtlasProbes } = require("./server/atlas-probes"); const { pdbOrgState, fetchPdbOrgCountries } = require("./server/pdb-org-countries"); const PORT = process.env.PORT || 3101; // ============================================================ // Startup Sequence — load disk caches first, then fetch fresh data // ============================================================ // Phase 0: Load disk caches for fast restart (instant) roaStore.loadFromDisk("/opt/peercortex-app/.roa-cache.json"); pdbSourceCache.loadFromDisk("/opt/peercortex-app/.pdb-source-cache.json"); loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json"); // Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants 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, rpkiAspaMap); server.listen(PORT, "0.0.0.0", () => { console.log("PeerCortex v0.6.5 running on http://0.0.0.0:" + PORT); console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); console.log("PeeringDB API key: " + (PEERINGDB_API_KEY ? "configured" : "NOT configured")); console.log("RPKI ASPA objects: " + rpkiAspaMap.size); console.log("ROA store: " + roaStore.count + " entries (" + (roaStore.ready ? "ready" : "loading...") + ")"); console.log("PDB source cache: net=" + pdbSourceCache.net.size + " ix=" + pdbSourceCache.netixlan.size + " fac=" + pdbSourceCache.netfac.size); console.log("RIPE Stat cache: " + ripeStatCache.size + " entries"); }); }); // ============================================================ // bio-rd RIB WebSocket — live route streaming on /ws/rib // ============================================================ let WebSocketServer = null; try { WebSocketServer = require('ws').Server; } catch(_e) {} if (WebSocketServer) { const ribWss = new WebSocketServer({ server, path: '/ws/rib' }); ribWss.on('connection', function(ws) { let cancelStream = null; ws.on('message', function(raw) { try { const msg = JSON.parse(raw); if (msg.type === 'rib-subscribe') { if (cancelStream) { cancelStream(); cancelStream = null; } if (!risClient) { ws.send(JSON.stringify({ type: 'error', error: 'bio-rd RIS not configured' })); return; } const router = msg.router || 'default'; cancelStream = risClient.observeRib( router, 'default', function(update) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(update)); }, function(err) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: err.message })); } ); } } catch(e) { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: 'error', error: e.message })); } }); ws.on('close', function() { if (cancelStream) { cancelStream(); cancelStream = null; } }); }); console.log('[bio-rd] RIB WebSocket server listening on /ws/rib'); } else { console.log('[bio-rd] WebSocket server skipped (ws package not installed)'); } // ============================================================ // Refresh timers — jittered to avoid thundering herd // ============================================================ // RPKI feed (ASPA + ROA): every 4h ± 5min jitter setInterval(() => { fetchRpkiAspaFeed(); }, 4 * 60 * 60 * 1000 + Math.floor(Math.random() * 10 * 60 * 1000) - 5 * 60 * 1000); // Atlas probe cache: every 12h ± 10min jitter setInterval(function() { fetchAllAtlasProbes(); }, 12 * 60 * 60 * 1000 + Math.floor(Math.random() * 20 * 60 * 1000) - 10 * 60 * 1000); // Disk cache persistence: every 30 minutes setInterval(function() { pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); }, 30 * 60 * 1000); // Save caches on graceful shutdown process.on("SIGTERM", function() { console.log("[SHUTDOWN] Saving caches to disk..."); pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); process.exit(0); }); process.on("SIGINT", function() { console.log("[SHUTDOWN] Saving caches to disk..."); pdbSourceCache.saveToDisk("/opt/peercortex-app/.pdb-source-cache.json"); saveRipeStatCacheToDisk("/opt/peercortex-app/.ripe-stat-cache.json"); roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); process.exit(0); });