const fs = require("fs"); const http = require("http"); const https = require("https"); // Load .env file const envPath = "/opt/peercortex-app/.env"; try { const envContent = fs.readFileSync(envPath, "utf8"); envContent.split("\n").forEach((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) return; const eqIdx = trimmed.indexOf("="); if (eqIdx > 0) { const key = trimmed.substring(0, eqIdx).trim(); const val = trimmed.substring(eqIdx + 1).trim(); if (!process.env[key]) process.env[key] = val; } }); } catch (_e) { console.warn("Warning: Could not read .env file at", envPath); } const BGPROUTES_API_KEY = process.env.BGPROUTES_API_KEY || ""; const BGPROUTES_API_URL = process.env.BGPROUTES_API_URL || "https://api.bgproutes.io/v1"; const PEERINGDB_API_KEY = process.env.PEERINGDB_API_KEY || ""; const PEERINGDB_API_URL = process.env.PEERINGDB_API_URL || "https://www.peeringdb.com/api"; const UA = "PeerCortex/0.5.0 (+https://peercortex.org; contact: rene.fichtmueller@flexoptix.net)"; // Static geocode cache for major networking cities (fallback when PDB facility coords missing) const CITY_COORDS = { "amsterdam": [52.3676, 4.9041], "london": [51.5074, -0.1278], "frankfurt": [50.1109, 8.6821], "paris": [48.8566, 2.3522], "stockholm": [59.3293, 18.0686], "zurich": [47.3769, 8.5417], "berlin": [52.5200, 13.4050], "hamburg": [53.5511, 9.9937], "munich": [48.1351, 11.5820], "vienna": [48.2082, 16.3738], "prague": [50.0755, 14.4378], "warsaw": [52.2297, 21.0122], "copenhagen": [55.6761, 12.5683], "oslo": [59.9139, 10.7522], "helsinki": [60.1699, 24.9384], "milan": [45.4642, 9.1900], "madrid": [40.4168, -3.7038], "lisbon": [38.7223, -9.1393], "dublin": [53.3498, -6.2603], "brussels": [50.8503, 4.3517], "bucharest": [44.4268, 26.1025], "sofia": [42.6977, 23.3219], "athens": [37.9838, 23.7275], "istanbul": [41.0082, 28.9784], "moscow": [55.7558, 37.6173], "mumbai": [19.0760, 72.8777], "singapore": [1.3521, 103.8198], "hong kong": [22.3193, 114.1694], "tokyo": [35.6762, 139.6503], "sydney": [-33.8688, 151.2093], "los angeles": [34.0522, -118.2437], "new york": [40.7128, -74.0060], "chicago": [41.8781, -87.6298], "dallas": [32.7767, -96.7970], "miami": [25.7617, -80.1918], "ashburn": [39.0438, -77.4874], "seattle": [47.6062, -122.3321], "san jose": [37.3382, -121.8863], "toronto": [43.6532, -79.3832], "sao paulo": [-23.5505, -46.6333], "johannesburg": [-26.2041, 28.0473], "meppel": [52.6966, 6.1940], "manchester": [53.4808, -2.2426], "marseille": [43.2965, 5.3698], "dusseldorf": [51.2277, 6.7735], "nuremberg": [49.4521, 11.0767], "tallinn": [59.4370, 24.7536], "riga": [56.9496, 24.1052], "auckland": [-36.8485, 174.7633], "wellington": [-41.2865, 174.7762], "denver": [39.7392, -104.9903], "atlanta": [33.7490, -84.3880], "portland": [45.5152, -122.6784], "vancouver": [49.2827, -123.1207], "montreal": [45.5017, -73.5673], "mexico city": [19.4326, -99.1332], "seoul": [37.5665, 126.9780], "taipei": [25.0330, 121.5654], "bangkok": [13.7563, 100.5018], "jakarta": [-6.2088, 106.8456], "scotland": [55.9533, -3.1883], "edinburgh": [55.9533, -3.1883], }; // ============================================================ // Task 6: In-memory cache with TTL + Rate Limiting // ============================================================ const responseCache = new Map(); function cacheGet(key) { const entry = responseCache.get(key); if (!entry) return null; if (Date.now() > entry.expires) { responseCache.delete(key); return null; } return entry.data; } function cacheSet(key, data, ttlMs) { responseCache.set(key, { data, expires: Date.now() + ttlMs }); // Evict old entries periodically (keep cache under 500 entries) if (responseCache.size > 500) { const now = Date.now(); for (const [k, v] of responseCache) { if (now > v.expires) responseCache.delete(k); } } } const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_ASPA = 4 * 60 * 60 * 1000; // 4 hours const CACHE_TTL_NEWS = 10 * 60 * 1000; // 10 minutes const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes // ============================================================ // Infrastructure overlay caches let subCableCache = null; // TeleGeography submarine cables (24h) let globalFacCache = null; // PeeringDB global facilities (24h) // RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed // ============================================================ const rpkiAspaMap = new Map(); // customer_asid -> Set let rpkiAspaLastFetch = 0; let rpkiAspaFetching = false; function fetchRpkiAspaFeed() { if (rpkiAspaFetching) return Promise.resolve(); rpkiAspaFetching = true; console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA only)..."); return new Promise((resolve) => { const options = { headers: { "User-Agent": UA }, timeout: 60000, }; https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { try { const parsed = JSON.parse(data); // Load ASPA objects const aspas = parsed.aspas || []; rpkiAspaMap.clear(); aspas.forEach((a) => { const customerAsid = Number(a.customer_asid); const providers = (a.providers || []).map(Number); rpkiAspaMap.set(customerAsid, new Set(providers)); }); rpkiAspaLastFetch = Date.now(); console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects from Cloudflare feed"); } catch (e) { console.error("[RPKI] Failed to parse RPKI feed:", e.message); } rpkiAspaFetching = false; resolve(); }); }).on("error", (e) => { console.error("[RPKI] Fetch failed:", e.message); rpkiAspaFetching = false; resolve(); }); }); } // Ensure ASPA cache is fresh async function ensureAspaCache() { if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) { await fetchRpkiAspaFeed(); } } // Lookup ASPA object for a given ASN from the RPKI feed cache function lookupAspaFromRpki(asn) { const asnNum = Number(asn); if (rpkiAspaMap.has(asnNum)) { const providers = rpkiAspaMap.get(asnNum); return { exists: true, providers: [...providers].sort((a, b) => a - b) }; } return { exists: false, providers: [] }; } // PeeringDB authenticated fetch helper function fetchPeeringDB(path, options) { const url = PEERINGDB_API_URL + path; const headers = { "User-Agent": UA }; if (PEERINGDB_API_KEY) { headers["Authorization"] = "Api-Key " + PEERINGDB_API_KEY; } return fetchJSON(url, { ...options, headers: { ...(options && options.headers || {}), ...headers } }); } // PeeringDB fetch with exponential backoff retries (handles rate-limits under concurrent load). // Up to 3 attempts: immediate → 2s → 5s. Returns null only after all attempts exhausted. async function fetchPeeringDBWithRetry(path, options) { const delays = [2000, 5000]; let result = await fetchPeeringDB(path, options); for (let i = 0; i < delays.length && result === null; i++) { await new Promise(r => setTimeout(r, delays[i])); result = await fetchPeeringDB(path, options); } return result; } // Generic JSON fetch with one retry — for sources that occasionally fail under load (RIPE Stat, Atlas) async function fetchJSONWithRetry(url, options) { const result = await fetchJSON(url, options); if (result !== null) return result; await new Promise(r => setTimeout(r, 1000)); return fetchJSON(url, options); } // bgproutes.io visibility fallback helper // Queries the RIB endpoint to estimate prefix visibility across vantage points function fetchBgproutesVisibility(prefix) { if (!BGPROUTES_API_KEY) return Promise.resolve(null); const url = BGPROUTES_API_URL + "/rib?prefix=" + encodeURIComponent(prefix) + "&prefix_match=exact"; return fetchJSON(url, { timeout: 15000, headers: { "Authorization": "Bearer " + BGPROUTES_API_KEY, "User-Agent": UA, }, }).then(function(data) { if (!data || !data.data) return null; // data.data should be an array of RIB entries from different vantage points var entries = Array.isArray(data.data) ? data.data : (data.data.entries || data.data.routes || []); var vpSet = new Set(); entries.forEach(function(e) { if (e.vantage_point || e.vp || e.collector || e.peer_asn) { vpSet.add(e.vantage_point || e.vp || e.collector || e.peer_asn); } }); return { vps_seeing: vpSet.size, total_entries: entries.length, source: "bgproutes.io" }; }).catch(function() { return null; }); } // Rate limiting: max 60 requests per minute per IP const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW = 60 * 1000; const RATE_LIMIT_MAX = 60; function checkRateLimit(ip) { const now = Date.now(); let entry = rateLimitMap.get(ip); if (!entry || now > entry.windowStart + RATE_LIMIT_WINDOW) { entry = { windowStart: now, count: 0 }; rateLimitMap.set(ip, entry); } entry.count++; // Clean old entries periodically if (rateLimitMap.size > 1000) { for (const [k, v] of rateLimitMap) { if (now > v.windowStart + RATE_LIMIT_WINDOW) rateLimitMap.delete(k); } } return entry.count <= RATE_LIMIT_MAX; } function fetchJSON(url, options) { const timeoutMs = (options && options.timeout) || 20000; return new Promise((resolve) => { const reqOptions = { headers: { "User-Agent": UA, ...(options && options.headers ? options.headers : {}) }, timeout: timeoutMs, }; const timer = setTimeout(() => resolve(null), timeoutMs + 500); https .get(url, reqOptions, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { clearTimeout(timer); if (res.statusCode === 429) { console.warn("[PDB] Rate limited (429):", url.substring(0, 80)); return resolve(null); } try { resolve(JSON.parse(data)); } catch (_e) { resolve(null); } }); }) .on("timeout", () => { clearTimeout(timer); resolve(null); }) .on("error", () => { clearTimeout(timer); resolve(null); }); }); } function fetchHTML(url, options) { return new Promise((resolve) => { const reqOptions = { headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", ...(options && options.headers ? options.headers : {}), }, }; const lib = url.startsWith("https") ? require("https") : require("http"); lib .get(url, reqOptions, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { return fetchHTML(res.headers.location, options).then(resolve); } let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => resolve(data)); }) .on("error", () => resolve(null)); }); } function postJSON(url, body, options) { return new Promise((resolve) => { const data = JSON.stringify(body); const parsed = new URL(url); const reqOptions = { hostname: parsed.hostname, port: parsed.port || 443, path: parsed.pathname + parsed.search, method: "POST", headers: { "User-Agent": UA, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), ...(options && options.headers ? options.headers : {}), }, }; const req = https.request(reqOptions, (res) => { let chunks = ""; res.on("data", (chunk) => (chunks += chunk)); res.on("end", () => { try { resolve(JSON.parse(chunks)); } catch (_e) { resolve(null); } }); }); req.on("error", () => resolve(null)); req.write(data); req.end(); }); } async function resolveASNames(providers) { // Batch resolve AS names via RIPE Stat AS overview API const batchSize = 10; for (let i = 0; i < providers.length; i += batchSize) { const batch = providers.slice(i, i + batchSize); const results = await Promise.all( batch.map(p => fetchJSON("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + p.asn) .then(r => ({ asn: p.asn, name: r?.data?.holder || "" })) .catch(() => ({ asn: p.asn, name: "" })) ) ); results.forEach(r => { const provider = providers.find(p => p.asn === r.asn); if (provider && r.name) provider.name = r.name; }); } return providers; } function fetchRPKIPerPrefix(asn, prefix) { return fetchJSON( "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS" + asn + "&prefix=" + encodeURIComponent(prefix) ).then((r) => { const status = r?.data?.status || "not_found"; const validating = r?.data?.validating_roas || []; return { prefix, status, validating_roas: validating.length }; }); } // RPKI per-prefix validation with LRU cache (replaces local 825k ROA index) // Cache: max 5000 entries, 6h TTL — covers all practical lookup patterns const _rpkiCache = new Map(); // key: "asn:prefix" -> { result, ts } const RPKI_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours const RPKI_CACHE_MAX = 5000; async function validateRPKIWithCache(asn, prefix) { const key = String(asn) + ":" + prefix; const now = Date.now(); const cached = _rpkiCache.get(key); if (cached && (now - cached.ts) < RPKI_CACHE_TTL) { return cached.result; } if (_rpkiCache.size >= RPKI_CACHE_MAX) { // Evict oldest entry (Map preserves insertion order) _rpkiCache.delete(_rpkiCache.keys().next().value); } try { const result = await fetchRPKIPerPrefix(asn, prefix); _rpkiCache.set(key, { result, ts: now }); return result; } catch (_e) { return { prefix, status: "not_found", validating_roas: 0 }; } } // ============================================================ // RFC-Compliant ASPA Verification Engine // ============================================================ // Check if AS path contains AS_SET segments (curly braces indicate sets) function hasAsSet(asPath) { if (typeof asPath === "string") { return asPath.includes("{") || asPath.includes("}"); } return false; } // Hop Check function (core of ASPA verification) // aspaStore = Map> (CAS -> provider set) function hopCheck(asI, asJ, aspaStore) { const providers = aspaStore.get(asI); if (!providers) return "NoAttestation"; return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus"; } // Collapse AS path prepends (remove consecutive duplicates) function collapsePrepends(path) { return path.filter((as, i) => i === 0 || as !== path[i - 1]); } // Upstream Verification (RFC Section 6.1) function verifyUpstream(asPath, aspaStore, rawPathStr) { if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; const collapsed = collapsePrepends(asPath); if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" }; const hops = []; let hasNoAttestation = false; for (let i = 1; i < collapsed.length; i++) { const check = hopCheck(collapsed[i - 1], collapsed[i], aspaStore); hops.push({ from: collapsed[i - 1], to: collapsed[i], result: check, }); if (check === "NotProviderPlus") { return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops }; } if (check === "NoAttestation") hasNoAttestation = true; } return { result: hasNoAttestation ? "Unknown" : "Valid", reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus", hops, }; } // Downstream Verification (RFC Section 6.2) function verifyDownstream(asPath, aspaStore, rawPathStr) { if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; const collapsed = collapsePrepends(asPath); const N = collapsed.length; if (N <= 2) return { result: "Valid", reason: "Path length <= 2" }; const hops = []; for (let i = 1; i < N; i++) { hops.push({ from: collapsed[i - 1], to: collapsed[i], result: hopCheck(collapsed[i - 1], collapsed[i], aspaStore), }); } // Find u_min: first index where forward hop is NotProviderPlus let uMin = N + 1; for (let u = 1; u < N; u++) { if (hopCheck(collapsed[u - 1], collapsed[u], aspaStore) === "NotProviderPlus") { uMin = u; break; } } // Find v_max: last index where reverse hop is NotProviderPlus let vMax = 0; for (let v = N - 2; v >= 0; v--) { if (hopCheck(collapsed[v + 1], collapsed[v], aspaStore) === "NotProviderPlus") { vMax = v; break; } } if (uMin <= vMax) { return { result: "Invalid", reason: "uMin(" + uMin + ") <= vMax(" + vMax + "): valley detected", hops }; } // Compute up-ramp K let K = 0; for (let i = 1; i < N; i++) { if (hopCheck(collapsed[i - 1], collapsed[i], aspaStore) === "ProviderPlus") { K = i; } else { break; } } // Compute down-ramp L let L = N - 1; for (let j = N - 2; j >= 0; j--) { if (hopCheck(collapsed[j + 1], collapsed[j], aspaStore) === "ProviderPlus") { L = j; } else { break; } } const gap = L - K; if (gap <= 1) { return { result: "Valid", reason: "Valid up-down path (K=" + K + ", L=" + L + ")", hops }; } return { result: "Unknown", reason: "Gap between up-ramp and down-ramp (K=" + K + ", L=" + L + ", gap=" + gap + ")", hops }; } // Valley Detection: scan path for up-down-up pattern (route leak indicator) function detectValleys(asPath, aspaStore) { const collapsed = collapsePrepends(asPath); if (collapsed.length < 4) return []; const valleys = []; // Walk the path and look at relationship transitions const relationships = []; for (let i = 1; i < collapsed.length; i++) { const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore); const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore); let rel = "unknown"; if (fwd === "ProviderPlus") rel = "customer-to-provider"; else if (rev === "ProviderPlus") rel = "provider-to-customer"; else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer"; relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel }); } // Detect c2p -> p2c -> c2p pattern for (let i = 0; i < relationships.length - 2; i++) { if ( relationships[i].rel === "customer-to-provider" && relationships[i + 1].rel === "provider-to-customer" && relationships[i + 2].rel === "customer-to-provider" ) { valleys.push({ position: i, path_segment: [ relationships[i].from, relationships[i].to, relationships[i + 1].to, relationships[i + 2].to, ].map((a) => "AS" + a), description: "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to + " (c2p) -> AS" + relationships[i + 1].to + " (p2c) -> AS" + relationships[i + 2].to + " (c2p)", }); } } return valleys; } // Build ASPA store from detected provider relationships function buildAspaStore(detectedProviders, targetAsn) { const store = new Map(); // Add the target ASN's providers if (detectedProviders.length > 0) { const providerSet = new Set(detectedProviders.map((p) => p.asn)); store.set(targetAsn, providerSet); } return store; } // Calculate ASPA Readiness Score (0-100) function calculateAspaReadinessScore(params) { const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params; // ROA coverage (0-25 points) const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25); // ASPA object exists (0-25 points) const aspaScore = aspaObjectExists ? 25 : 0; // Provider completeness (0-25 points) const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25); // Path validation results (0-25 points) const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25); return { total: roaScore + aspaScore + provScore + pathScore, breakdown: { roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage }, aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists }, provider_completeness: { score: provScore, max: 25, value: providerCompleteness }, path_validation: { score: pathScore, max: 25, value: pathValidationPct }, }, }; } // ============================================================ // Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes) // ============================================================ async function fetchRipeRpkiValidator(asn, prefix) { try { const encoded = encodeURIComponent(prefix); const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded; const result = await fetchJSON(url, { timeout: 5000 }); if (result && result.validated_route) { return { prefix: prefix, validity: result.validated_route.validity || {}, state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown", }; } return { prefix: prefix, state: "unknown", error: "no_data" }; } catch (_e) { return { prefix: prefix, state: "error", error: "timeout_or_unavailable" }; } } // Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel) async function crossCheckRpki(asn, prefixes, localResults) { const sample = prefixes.slice(0, 5); if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: 100, disagreements: [], sample_size: 0 }; const ripeResults = await Promise.all( sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx)) ); const localMap = new Map(); for (const lr of localResults) { localMap.set(lr.prefix, lr.status); } let cloudflareValid = 0; let ripeValid = 0; let agreements = 0; const disagreements = []; for (let i = 0; i < sample.length; i++) { const pfx = sample[i]; const cfStatus = localMap.get(pfx) || "not_found"; const ripeState = ripeResults[i].state; const cfIsValid = cfStatus === "valid"; const ripeIsValid = ripeState === "valid" || ripeState === "VALID"; if (cfIsValid) cloudflareValid++; if (ripeIsValid) ripeValid++; // Skip comparison if RIPE returned error/unknown if (ripeState === "error" || ripeState === "unknown") { agreements++; // Don't count failed lookups as disagreements continue; } if (cfIsValid === ripeIsValid) { agreements++; } else { disagreements.push({ prefix: pfx, cloudflare: cfStatus, ripe: ripeState, }); } } const agreementPct = sample.length > 0 ? Math.round((agreements / sample.length) * 100) : 100; return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length }; } // ============================================================ // Feature 24: bgp.he.net Integration // ============================================================ async function fetchBgpHeNet(asn) { try { const html = await fetchHTML("https://bgp.he.net/AS" + asn); if (!html) return null; const result = {}; const titleMatch = html.match(/([^<]+)<\/title>/i); if (titleMatch) result.title = titleMatch[1].trim(); const peerMatch = html.match(/BGP\s+Peers\s+Observed\s*\(all\)\s*:\s*(\d[\d,]*)/i) || html.match(/Observed\s+Peers[^<]*<[^>]*>\s*(\d+)/i); if (peerMatch) result.peer_count = parseInt(peerMatch[1].replace(/,/g, '')); const countryMatch = html.match(/Country[^<]*<[^>]*>[^<]*<[^>]*>\s*<[^>]*>([^<]+)/i); if (countryMatch) result.country = countryMatch[1].trim(); const lgMatch = html.match(/Looking\s+Glass[^<]*<[^>]*href="([^"]+)"/i); if (lgMatch) result.looking_glass = lgMatch[1]; const descMatch = html.match(/AS\s+Name[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i); if (descMatch) result.description = descMatch[1].trim(); const irrMatch = html.match(/IRR\s+Record[^<]*<[^>]*>[^<]*<[^>]*>([^<]+)/i); if (irrMatch) result.irr_record = irrMatch[1].trim(); // bgp.he.net format: "Prefixes Originated (v4): 147<br/>" or "Prefixes v4 ... <td>147" const v4Match = html.match(/Prefixes\s+Originated\s*\(v4\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v4[^<]*<[^>]*>\s*(\d+)/i); if (v4Match) result.prefixes_v4 = parseInt(v4Match[1].replace(/,/g, '')); const v6Match = html.match(/Prefixes\s+Originated\s*\(v6\)\s*:\s*(\d[\d,]*)/i) || html.match(/Prefixes\s+v6[^<]*<[^>]*>\s*(\d+)/i); if (v6Match) result.prefixes_v6 = parseInt(v6Match[1].replace(/,/g, '')); const allMatch = html.match(/Prefixes\s+Originated\s*\(all\)\s*:\s*(\d[\d,]*)/i); if (allMatch) result.prefixes_all = parseInt(allMatch[1].replace(/,/g, '')); result.source_url = "https://bgp.he.net/AS" + asn; return result; } catch (_e) { return null; } } // ============================================================ // Feature 25: Topology / AS-Relationships // ============================================================ async function fetchTopology(targetAsn, depth) { const maxDepth = Math.min(depth || 2, 3); const nodes = new Map(); const edges = []; async function fetchNeighboursForAsn(asn, currentDepth) { if (nodes.has(asn) && nodes.get(asn).depth <= currentDepth) return; const [data, overview] = await Promise.all([ fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn), fetchJSON("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), ]); const name = overview?.data?.holder || ""; const neighbours = data?.data?.neighbours || []; const upstreams = neighbours.filter((n) => n.type === "left"); const downstreams = neighbours.filter((n) => n.type === "right"); const peers = neighbours.filter((n) => n.type === "uncertain" || n.type === "peer"); const nodeType = asn === targetAsn ? "target" : currentDepth === 1 ? "direct" : "indirect"; nodes.set(asn, { asn, name, type: nodeType, depth: currentDepth }); upstreams.forEach((n) => { if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "upstream", depth: currentDepth + 1 }); edges.push({ from: n.asn, to: asn, relationship: "provider-to-customer" }); }); downstreams.forEach((n) => { if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "downstream", depth: currentDepth + 1 }); edges.push({ from: asn, to: n.asn, relationship: "provider-to-customer" }); }); peers.slice(0, 10).forEach((n) => { if (!nodes.has(n.asn)) nodes.set(n.asn, { asn: n.asn, name: n.as_name || "", type: "peer", depth: currentDepth + 1 }); edges.push({ from: asn, to: n.asn, relationship: "peer" }); }); if (currentDepth < maxDepth && upstreams.length > 0) { const top5 = upstreams.sort((a, b) => (b.power || 0) - (a.power || 0)).slice(0, 5); await Promise.all(top5.map((u) => fetchNeighboursForAsn(u.asn, currentDepth + 1))); } } await fetchNeighboursForAsn(targetAsn, 0); const edgeSet = new Set(); const uniqueEdges = edges.filter((e) => { const key = e.from + "-" + e.to + "-" + e.relationship; if (edgeSet.has(key)) return false; edgeSet.add(key); return true; }); return { nodes: [...nodes.values()], edges: uniqueEdges, target_asn: targetAsn, depth: maxDepth }; } // ============================================================ // Feature 27: WHOIS via RIPE DB // ============================================================ async function fetchWhois(resource) { const result = { resource, type: null, data: null, error: null }; try { const trimmed = resource.trim(); if (/^(AS)?\d+$/i.test(trimmed)) { result.type = "aut-num"; const asn = trimmed.replace(/^AS/i, ""); // Try RIPE first const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=AS" + asn + "&type-filter=aut-num&source=ripe", { timeout: 5000 }).catch(() => null); if (ripeData && ripeData.objects && ripeData.objects.object) { const obj = ripeData.objects.object[0]; const attrs = obj.attributes?.attribute || []; const parsed = {}; attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); result.data = { aut_num: (parsed["aut-num"] || [])[0] || "", as_name: (parsed["as-name"] || [])[0] || "", descr: parsed["descr"] || [], org: (parsed["org"] || [])[0] || "", admin_c: parsed["admin-c"] || [], tech_c: parsed["tech-c"] || [], mnt_by: parsed["mnt-by"] || [], status: (parsed["status"] || [])[0] || "", created: (parsed["created"] || [])[0] || "", last_modified: (parsed["last-modified"] || [])[0] || "", source: (parsed["source"] || [])[0] || "", import: parsed["import"] || [], export: parsed["export"] || [], remarks: parsed["remarks"] || [], }; } // If RIPE didn't find it, try all other RIRs via RDAP in parallel if (!result.data) { const rdapEndpoints = [ { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + asn }, { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + asn }, { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + asn }, { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + asn }, ]; const rdapResults = await Promise.all(rdapEndpoints.map((ep) => fetchJSON(ep.url, { timeout: 5000 }).then((d) => { if (!d || d.errorCode || !d.handle) return null; return { source: ep.name, data: d }; }).catch(() => null) )); const found = rdapResults.find((r) => r !== null); if (found) { const d = found.data; const remarks = (d.remarks || []).map((r) => (r.description || []).join(" ")); const entities = d.entities || []; const adminContacts = entities.filter((e) => (e.roles || []).includes("administrative")).map((e) => e.handle || ""); const techContacts = entities.filter((e) => (e.roles || []).includes("technical")).map((e) => e.handle || ""); const events = d.events || []; const created = (events.find((e) => e.eventAction === "registration") || {}).eventDate || ""; const lastMod = (events.find((e) => e.eventAction === "last changed") || {}).eventDate || ""; result.data = { aut_num: "AS" + asn, as_name: d.name || "", descr: remarks, org: (entities.find((e) => (e.roles || []).includes("registrant")) || {}).handle || "", admin_c: adminContacts, tech_c: techContacts, mnt_by: [], status: (d.status || []).join(", "), created: created, last_modified: lastMod, source: found.source + " (RDAP)", import: [], export: [], remarks: remarks, }; } else { result.error = "Not found in any RIR database (RIPE, APNIC, ARIN, LACNIC, AFRINIC)"; } } } else if (/[\/:]/.test(trimmed) || /^\d+\.\d+\.\d+/.test(trimmed)) { result.type = "inetnum"; const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=inetnum,inet6num"); if (ripeData && ripeData.objects && ripeData.objects.object) { const results = ripeData.objects.object.map((obj) => { const attrs = obj.attributes?.attribute || []; const parsed = {}; attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); return { inetnum: (parsed["inetnum"] || parsed["inet6num"] || [])[0] || "", netname: (parsed["netname"] || [])[0] || "", descr: parsed["descr"] || [], country: (parsed["country"] || [])[0] || "", org: (parsed["org"] || [])[0] || "", admin_c: parsed["admin-c"] || [], tech_c: parsed["tech-c"] || [], mnt_by: parsed["mnt-by"] || [], status: (parsed["status"] || [])[0] || "", created: (parsed["created"] || [])[0] || "", last_modified: (parsed["last-modified"] || [])[0] || "", source: (parsed["source"] || [])[0] || "", }; }); result.data = results.length === 1 ? results[0] : results; } else { result.error = "Not found in RIPE DB"; } } else { result.type = "domain"; const ripeData = await fetchJSON("https://rest.db.ripe.net/search.json?query-string=" + encodeURIComponent(trimmed) + "&type-filter=domain"); if (ripeData && ripeData.objects && ripeData.objects.object) { const obj = ripeData.objects.object[0]; const attrs = obj.attributes?.attribute || []; const parsed = {}; attrs.forEach((a) => { if (!parsed[a.name]) parsed[a.name] = []; parsed[a.name].push(a.value); }); result.data = { domain: (parsed["domain"] || [])[0] || "", descr: parsed["descr"] || [], admin_c: parsed["admin-c"] || [], tech_c: parsed["tech-c"] || [], zone_c: parsed["zone-c"] || [], nserver: parsed["nserver"] || [], mnt_by: parsed["mnt-by"] || [], created: (parsed["created"] || [])[0] || "", last_modified: (parsed["last-modified"] || [])[0] || "", source: (parsed["source"] || [])[0] || "", }; } else { result.error = "Not found in RIPE DB"; } } } catch (err) { result.error = err.message; } return result; } // ============================================================ // HTTP Server // ============================================================ 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 // v2.peercortex.org → editorial design const host = (req.headers.host || '').split(':')[0]; const isV2 = host === 'v2.peercortex.org'; if (reqPath === "/" || reqPath === "/index.html") { const htmlFile = isV2 ? "index-editorial.html" : "index.html"; try { const html = fs.readFileSync("/opt/peercortex-app/public/" + htmlFile, "utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.end(html); } catch (_e) { res.writeHead(500); return res.end(htmlFile + " not found"); } } // Direct access to editorial version if (reqPath === "/index-editorial.html") { try { const html = fs.readFileSync("/opt/peercortex-app/public/index-editorial.html", "utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.end(html); } catch (_e) { res.writeHead(500); return res.end("index-editorial.html not found"); } } // Serve favicon if (reqPath === "/favicon.ico") { res.writeHead(204); return res.end(); } // Lia's Atlas Paradise - Easter egg page if (reqPath === "/lia" || reqPath === "/lia/") { try { const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8"); res.setHeader("Content-Type", "text/html; charset=utf-8"); return res.end(liaHtml); } catch (_e) { res.writeHead(500); return res.end("lia.html not found"); } } // ============================================================ // Lia's Atlas Paradise: Atlas probe coverage endpoint // ============================================================ if (reqPath === "/api/atlas/coverage") { res.setHeader("Content-Type", "application/json"); if (!atlasProbeCache) { res.writeHead(503); return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); } return res.end(JSON.stringify(atlasProbeCache, null, 2)); } // ============================================================ // 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 (!atlasProbeCache) { res.writeHead(503); return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." })); } // Cache this expensive response for 30 min var liaCacheKey = "lia_coverage"; var liaCached = cacheGet(liaCacheKey); if (liaCached) return res.end(liaCached); // Fetch PeeringDB network list (all networks with status "ok") // Use pre-cached org→country map (loaded at startup, 16MB response cached in memory) fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) { if (!pdbData || !pdbData.data) { return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" })); } var probeAsns = new Set(atlasProbeCache.asns_with_probes || []); var enriched = pdbData.data.map(function(n) { var org = pdbOrgCountryMap.get(n.org_id) || {}; var cc = org.country || ""; return { asn: n.asn, name: n.name || "", org_name: org.name || "", country: cc, country_name: cc, info_type: n.info_type || "", has_probe: probeAsns.has(n.asn), }; }).filter(function(n) { return n.asn > 0 && n.country; }); var result = JSON.stringify({ networks: enriched, total: enriched.length, with_probes: enriched.filter(function(n) { return n.has_probe; }).length, without_probes: enriched.filter(function(n) { return !n.has_probe; }).length, atlas_unique_asns: probeAsns.size, org_countries_loaded: pdbOrgCountryMap.size, fetched_at: new Date().toISOString(), }); cacheSet(liaCacheKey, result, 30 * 60 * 1000); res.end(result); }).catch(function(e) { res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message })); }); return; } res.setHeader("Content-Type", "application/json"); // Health endpoint if (reqPath === "/api/health") { return res.end( JSON.stringify({ status: "ok", service: "PeerCortex", version: "0.5.0", timestamp: new Date().toISOString(), uptime_seconds: Math.floor(process.uptime()), bgproutes_configured: !!BGPROUTES_API_KEY, }) ); } // ============================================================ // 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 targetAsn = parseInt(rawAsn); const start = Date.now(); try { // Fetch neighbour and prefix data first const [neighbourData, prefixData] = await Promise.all([ fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn), fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn), ]); // Use looking-glass with actual prefixes to get BGP paths const announcedPrefixes = prefixData?.data?.prefixes || []; const samplePrefixes = announcedPrefixes.slice(0, 5).map((p) => p.prefix); // Fetch looking-glass data for up to 5 prefixes in parallel const lgResults = await Promise.all( samplePrefixes.map((pfx) => fetchJSON("https://stat.ripe.net/data/looking-glass/data.json?resource=" + encodeURIComponent(pfx)) ) ); // 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) const aspaStore = new Map(); // Add the target ASN's RPKI-declared providers if (aspaObjectExists) { aspaStore.set(targetAsn, new Set(aspaDeclaredProviders)); } else { // Fallback: use detected providers for path verification const providerSet = new Set(detectedProviders.map((p) => p.asn)); aspaStore.set(targetAsn, providerSet); } // 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); } } // Also add reverse relationships for providers we know about // (each provider has the target as customer) detectedProviders.forEach((p) => { if (!aspaStore.has(p.asn)) { aspaStore.set(p.asn, new Set()); } }); // 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))); const rpkiValid = rpkiResults.filter((r) => r.status === "valid").length; const rpkiCoverage = rpkiResults.length > 0 ? Math.round((rpkiValid / rpkiResults.length) * 100) : 0; // Calculate readiness score const readinessScore = calculateAspaReadinessScore({ rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct: pathNotInvalidPct, }); const duration = Date.now() - start; return res.end( JSON.stringify( { 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, 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, }, 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 start = Date.now(); try { const [lgData, neighbourData] = await Promise.all([ fetchJSON("https://stat.ripe.net/data/looking-glass/data.json?resource=AS" + rawAsn), fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn), ]); 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, }; }); const duration = Date.now() - start; return res.end( JSON.stringify( { meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString() }, asn: parseInt(rawAsn), detected_providers: detectedProviders, provider_count: detectedProviders.length, aspa_object_exists: aspaObjectExists, aspa_declared_providers: aspaDeclaredProviders.map((a) => ({ asn: a })), aspa_declared_count: aspaDeclaredProviders.length, recommended_aspa: recommendedAspa, path_analysis: { total_paths_seen: asPaths.length, sample_paths: samplePaths, }, }, null, 2 ) ); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "ASPA check failed", message: err.message })); } } // ============================================================ // bgproutes.io endpoint: /api/bgproutes?asn=X (or prefix=X) // ============================================================ if (reqPath === "/api/bgproutes") { 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 start = Date.now(); try { const result = { meta: { timestamp: new Date().toISOString() }, vantage_points: null, routes: null }; const vpData = await fetchJSON(BGPROUTES_API_URL + "/vantage_points", { headers: { "x-api-key": BGPROUTES_API_KEY }, }); if (vpData && !vpData.error) { const vpList = vpData?.data?.bgp || (Array.isArray(vpData) ? vpData : vpData.data || []); const readyVPs = Array.isArray(vpList) ? vpList.filter((vp) => !vp.status || (Array.isArray(vp.status) && vp.status.includes("ready"))) : []; result.vantage_points = { count: readyVPs.length, total: Array.isArray(vpList) ? vpList.length : 0, list: readyVPs.slice(0, 20).map((vp) => ({ id: vp.id, asn: vp.asn, ip: vp.ip, source: vp.source || "", org_name: vp.org_name || "", country: vp.org_country || vp.country || "", rib_v4: vp.rib_size_v4 || 0, rib_v6: vp.rib_size_v6 || 0, })), }; } else { result.vantage_points = { count: 0, error: "Could not fetch vantage points" }; } let ribSuccess = false; const readyVPsForRib = result.vantage_points && result.vantage_points.list ? result.vantage_points.list.filter((vp) => vp.rib_v4 > 500000).slice(0, 1) : []; if (readyVPsForRib.length > 0) { const vpId = readyVPsForRib[0].id; const now = new Date().toISOString().replace(/\.\d+Z$/, ""); const ribBody = { vp_bgp_ids: String(vpId), date: now, return_aspath: true, return_rov_status: true, return_aspa_status: true, }; if (prefix) { ribBody.prefix_exact_match = prefix; } else if (rawAsn) { ribBody.aspath_regexp = rawAsn + "$"; } try { const ribData = await postJSON(BGPROUTES_API_URL + "/rib", ribBody, { headers: { "x-api-key": BGPROUTES_API_KEY }, }); if (ribData && ribData.data) { const bgpData = ribData.data.bgp || {}; const vpRoutes = bgpData[String(vpId)] || {}; const routeEntries = Object.entries(vpRoutes).map(([pfx, arr]) => { const asPath = Array.isArray(arr) ? arr[0] || "" : ""; const rovStatus = Array.isArray(arr) ? arr[2] || "" : ""; const aspaStatus = Array.isArray(arr) ? arr[3] || "" : ""; return { prefix: pfx, as_path: asPath, rov_status: (function(rs) { var parts = rs.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; }); if (parts.indexOf("invalid") >= 0) return "invalid"; if (parts.indexOf("unknown") >= 0) return "unknown"; if (parts.indexOf("valid") >= 0) return "valid"; return parts[0] || "unknown"; })(rovStatus), aspa_status: (function(as) { var parts = as.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; }); if (parts.indexOf("invalid") >= 0) return "invalid"; if (parts.indexOf("unknown") >= 0) return "unknown"; if (parts.indexOf("valid") >= 0) return "valid"; return parts[0] || "unknown"; })(aspaStatus), }; }); if (routeEntries.length > 0) { result.routes = { count: routeEntries.length, vp_used: { id: vpId, org: readyVPsForRib[0].org_name, country: readyVPsForRib[0].country }, sample: routeEntries.slice(0, 20), }; ribSuccess = true; } } } catch (_e) {} } if (!ribSuccess) { result.routes = { status: "unavailable", message: readyVPsForRib.length === 0 ? "No ready VPs with sufficient RIB size found" : "bgproutes.io: VPs available but RIB query returned no data for this ASN", }; } result.meta.duration_ms = Date.now() - start; return res.end(JSON.stringify(result, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "bgproutes.io 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 start = Date.now(); const targetAsn = parseInt(rawAsn); try { // Phase 1: Fetch core data needed by multiple validations const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 30000 }), fetchPeeringDB("/net?asn=" + rawAsn), fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn), ]); 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); var bogonResult = { status: bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail", bogon_prefixes: bogonPrefixes, bogon_asns_in_paths: bogonAsns, total_prefixes_checked: allPrefixes.length, }; // Phase 2: All API-dependent validations in parallel var validationPromises = {}; // 12. IRR Validation validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { 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 withRoa = rpkiResults.filter(function(r) { return r.status === "valid"; }); var coverage = rpkiResults.length > 0 ? Math.round((withRoa.length / rpkiResults.length) * 100) : 0; var overSpecific = rpkiResults.filter(function(r) { var mask = parseInt((r.prefix || "").split("/")[1] || "0"); return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; }); return { status: coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, total_checked: rpkiResults.length, 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 = fetchJSON("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { 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 fetchJSON("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { 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, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; }).catch(function() { return { prefix: pfx, listed: false, error: true }; }); }) ).then(function(results) { var listedPrefixes = results.filter(function(r) { return r.listed; }); return { status: listedPrefixes.length === 0 ? "pass" : "fail", checked: results.length, listed_prefixes: listedPrefixes }; }).catch(function(e) { return { status: "error", error: String(e) }; }); // 16. MANRS Compliance (observatory API requires auth — use fallback indicators) validationPromises.manrs = fetchJSON("https://observatory.manrs.org/api/v2/asn/" + rawAsn + "/conformance", { timeout: 5000 }).then(function(data) { if (!data || data.error || data.detail === "Authentication credentials were not provided.") { // API unavailable — check MANRS indicators: RPKI ROA + IRR objects as proxy var hasRoa = samplePrefixes.length > 0; // will be checked by RPKI validation var hasIrr = !!(net.irr_as_set); if (hasRoa && hasIrr) { return { status: "info", participant: "unknown", message: "MANRS Observatory API requires authentication — cannot verify membership. Network has ROA + IRR objects (positive indicators).", note: "Unable to verify — MANRS API requires auth. Check https://observatory.manrs.org/asn/" + rawAsn }; } return { status: "info", participant: "unknown", message: "Unable to verify MANRS membership (API requires authentication)", note: "Check manually: https://observatory.manrs.org/asn/" + rawAsn }; } var score = data.conformance_score || data.score || 0; return { status: score >= 50 ? "pass" : "warning", participant: true, score: score, details: data }; }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable", note: "https://observatory.manrs.org/asn/" + rawAsn }; }); // 17. Reverse DNS Coverage (sample up to 20 prefixes for better coverage) var rdnsSampleSize = Math.min(20, samplePrefixes.length); validationPromises.rdns = Promise.all( samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { return fetchJSON("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 15000 }).then(function(data) { 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 withRdns = results.filter(function(r) { return r.has_rdns; }); var coverage = results.length > 0 ? Math.round((withRdns.length / results.length) * 100) : 0; // Include details of what failed var failedPrefixes = results.filter(function(r) { return !r.has_rdns && !r.error; }).map(function(r) { return r.prefix; }); return { status: coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail", coverage_pct: coverage, checked: results.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 = fetchJSON("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 20000 }).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() { return { status: "fail", 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 fetchJSON("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 ? fetchJSON("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 hasRoa = results.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); return { status: hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: results.length, roa_count: results.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 var keys = Object.keys(validationPromises); var promises = keys.map(function(k) { return validationPromises[k]; }); 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; return res.end( JSON.stringify( { 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, }, 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 (fast, <1s) to get net_id for IX/Fac queries // Use retry to handle transient rate-limits const pdbNet = await fetchPeeringDBWithRetry("/net?asn=" + asn); const net = pdbNet?.data?.[0] || {}; const netId = net.id; // Phase 1: ALL calls in parallel — RIPE Stat + PDB IX/Fac + Atlas + bgp.he.net // IX/Fac: use net_id when available (canonical), fall back to asn/local_asn filter // &limit=1000 prevents truncation for large networks (default PeeringDB limit is 250) // netixlan supports asn= filter as fallback; netfac requires net_id (local_asn= is not a valid filter) const ixQuery = netId ? "/netixlan?net_id=" + netId + "&limit=1000" : "/netixlan?asn=" + asn + "&limit=1000"; const promises = [ fetchJSONWithRetry("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn, { timeout: 30000 }), fetchJSONWithRetry("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + asn), fetchJSON("https://stat.ripe.net/data/rir-stats-country/data.json?resource=AS" + asn), fetchJSON("https://atlas.ripe.net/api/v2/probes/?asn_v4=" + asn + "&page_size=500"), fetchBgpHeNet(asn), fetchJSON("https://stat.ripe.net/data/visibility/data.json?resource=AS" + asn, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/prefix-size-distribution/data.json?resource=AS" + asn), fetchPeeringDBWithRetry(ixQuery), netId ? fetchPeeringDBWithRetry("/netfac?net_id=" + netId + "&limit=1000") : Promise.resolve(null), ]; const [prefixData, neighbourData, overviewData, rirData, atlasProbeData, bgpHeData, visibilityData, prefixSizeData, ixlanData, facData] = await Promise.all(promises); 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 || "", })) .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 rpkiNotFound = rpkiStatuses.filter((r) => r.status !== "valid" && r.status !== "invalid").length; const rpkiTotal = rpkiStatuses.length; const rpkiCoverage = rpkiTotal > 0 ? Math.round((rpkiValid / rpkiTotal) * 100) : 0; const 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); const 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); const 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 => fetchJSON("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))]); } let rir = ""; let country = ""; if (Array.isArray(rirEntries) && rirEntries.length > 0) { rir = rirEntries[0]?.rir || ""; country = rirEntries[0]?.country || ""; } if (!rir && rirData?.data) { const rirField = rirData.data.rirs || []; if (rirField.length > 0) rir = rirField[0]?.rir || ""; } 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: 100, disagreements: [], sample_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 */ } // 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 const crossCheckScores = []; // RPKI agreement 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) : 100; const overallConfidence = 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: 2, agreement_pct: rpkiCrossCheck.agreement_pct, sample_size: rpkiCrossCheck.sample_size, disagreements: rpkiCrossCheck.disagreements }, prefixes: { sources: 2, 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: 2, agreement_pct: neighbourCrossCheck.bgp_he_net_total != null && neighbours.length > 0 ? Math.round((Math.min(neighbours.length, neighbourCrossCheck.bgp_he_net_total) / Math.max(neighbours.length, neighbourCrossCheck.bgp_he_net_total)) * 100) : null, ripe_stat_total: neighbourCrossCheck.ripe_stat_total, bgp_he_net_total: neighbourCrossCheck.bgp_he_net_total }, }, overall_confidence: overallConfidence, overall_agreement_pct: avgAgreement, }; // === IX Location Geocode Fallback === // Some IXPs have no facility coordinates in PeeringDB. // Use ix_name city extraction + hard-coded IX→city map as fallback. var ixIdsWithCoords = new Set(ixLocations.map(function(l) { return l.ix_id; })); ixConnections.forEach(function(conn) { if (ixIdsWithCoords.has(conn.ix_id)) return; var name = conn.ix_name || ""; if (name) { var words = name.toLowerCase().replace(/[^a-z\s]/g, " ").split(/\s+/).filter(Boolean); for (var w = 0; w < words.length; w++) { if (CITY_COORDS[words[w]]) { ixLocations.push({ ix_id: conn.ix_id, name: name, city: words[w].charAt(0).toUpperCase() + words[w].slice(1), country: "", latitude: CITY_COORDS[words[w]][0], longitude: CITY_COORDS[words[w]][1], source: "name_geocode" }); ixIdsWithCoords.add(conn.ix_id); return; } if (w < words.length - 1) { var tw = words[w] + " " + words[w + 1]; if (CITY_COORDS[tw]) { ixLocations.push({ ix_id: conn.ix_id, name: name, city: tw, country: "", latitude: CITY_COORDS[tw][0], longitude: CITY_COORDS[tw][1], source: "name_geocode" }); ixIdsWithCoords.add(conn.ix_id); return; } } } } }); // Hard-coded IX ID → city for well-known IXPs whose names don't contain city var IX_CITY_MAP = { 60: "zurich", 2601: "meppel", 24: "london", 35: "moscow", 15: "chicago", 11: "seattle", 387: "dublin", 171: "warsaw", 168: "bucharest", 71: "milan", 66: "vienna", 62: "prague", 1: "ashburn" }; ixConnections.forEach(function(conn) { if (ixIdsWithCoords.has(conn.ix_id)) return; var city = IX_CITY_MAP[conn.ix_id]; if (city && CITY_COORDS[city]) { ixLocations.push({ ix_id: conn.ix_id, name: conn.ix_name || ("IX " + conn.ix_id), city: city.charAt(0).toUpperCase() + city.slice(1), country: "", latitude: CITY_COORDS[city][0], longitude: CITY_COORDS[city][1], source: "ix_city_map" }); } }); const result = { meta: { service: "PeerCortex", version: "0.5.0", 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, }, network: { asn: parseInt(asn), name: net.name || overview?.holder || "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, looking_glass: net.looking_glass || "", route_server: net.route_server || "", }, 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, 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, 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, }; // Update duration to include cross-check time result.meta.duration_ms = Date.now() - start; cacheSet(cacheKey, result, 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; } // ============================================================ // 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), fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn1, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + asn2, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn1, { timeout: 30000 }), fetchJSON("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + asn2, { timeout: 30000 }), ]); 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] || "" })); // 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 => fetchJSON("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; } // ============================================================ // 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, rpkiValid, visibility] = await Promise.all([ fetchJSON("https://stat.ripe.net/data/routing-status/data.json?resource=" + encodeURIComponent(prefix)), fetchJSON("https://stat.ripe.net/data/rpki-validation/data.json?resource=" + encodeURIComponent(prefix)), fetchJSON("https://stat.ripe.net/data/visibility/data.json?resource=" + encodeURIComponent(prefix)), ]); const origins = routingStatus?.data?.origins || []; const firstSeen = routingStatus?.data?.first_seen?.time || null; const rpkiStatus = rpkiValid?.data?.status || "unknown"; const rpkiRoas = rpkiValid?.data?.validating_roas || []; 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 fetchJSON("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); 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); 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 28: Submarine Cable overlay (TeleGeography proxy) if (reqPath === "/api/submarine-cables") { const CABLE_TTL = 24 * 60 * 60 * 1000; if (subCableCache && Date.now() - subCableCache.ts < CABLE_TTL) { res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Cache-Control", "public, max-age=86400"); return res.end(subCableCache.data); } const cableData = await fetchJSONWithRetry("https://www.submarinecablemap.com/api/v3/cable/cable-geo.json", { timeout: 30000 }); if (cableData) { subCableCache = { ts: Date.now(), data: JSON.stringify(cableData) }; res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Cache-Control", "public, max-age=86400"); return res.end(subCableCache.data); } res.writeHead(503); return res.end(JSON.stringify({ error: "Submarine cable data unavailable" })); } // Feature 29: Global datacenter/IXP map (PeeringDB proxy) if (reqPath === "/api/global-infra") { const FAC_TTL = 24 * 60 * 60 * 1000; if (globalFacCache && Date.now() - globalFacCache.ts < FAC_TTL) { res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Cache-Control", "public, max-age=86400"); return res.end(globalFacCache.data); } const [facData, ixData] = await Promise.all([ fetchJSONWithRetry(PEERINGDB_API_URL + "/fac?depth=1&limit=3000", { timeout: 30000 }), fetchJSONWithRetry(PEERINGDB_API_URL + "/ix?depth=1&limit=1000", { timeout: 30000 }), ]); const facs = (facData && facData.data || []) .filter(f => f.latitude && f.longitude) .map(f => ({ id: f.id, name: f.name, city: f.city, country: f.country, lat: +f.latitude, lng: +f.longitude })); const ixps = (ixData && ixData.data || []) .filter(ix => ix.city && ix.country) .map(ix => ({ id: ix.id, name: ix.name, city: ix.city, country: ix.country, website: ix.website })); const result = JSON.stringify({ facs, ixps }); globalFacCache = { ts: Date.now(), data: result }; res.setHeader("Content-Type", "application/json"); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Cache-Control", "public, max-age=86400"); return res.end(result); } // 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", }) ); }); // ============================================================ // Atlas Probe Cache (for Lia's Atlas Paradise) // ============================================================ let atlasProbeCache = null; let atlasProbeFetching = false; function fetchAllAtlasProbes() { if (atlasProbeFetching) return Promise.resolve(); atlasProbeFetching = true; console.log("[ATLAS] Fetching all Atlas probes..."); return new Promise(function(resolve) { var allAsns = new Set(); var byCountry = {}; var pageCount = 0; var maxPages = 40; function fetchPage(pageUrl) { if (pageCount >= maxPages) return finish(); pageCount++; fetchJSON(pageUrl).then(function(data) { if (!data || !data.results) return finish(); data.results.forEach(function(probe) { var asn4 = probe.asn_v4; var asn6 = probe.asn_v6; var cc = probe.country_code || "XX"; if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() }; byCountry[cc].total++; if (probe.status && probe.status.id === 1) byCountry[cc].connected++; if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); } if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); } }); if (data.next) { fetchPage(data.next); } else { finish(); } }).catch(function() { finish(); }); } function finish() { var byCountryOut = {}; Object.keys(byCountry).forEach(function(cc) { var info = byCountry[cc]; byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size }; }); atlasProbeCache = { total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0), total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0), unique_asns_with_probes: allAsns.size, asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }), by_country: byCountryOut, fetched_at: new Date().toISOString(), pages_fetched: pageCount, }; console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)"); atlasProbeFetching = false; resolve(); } fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json"); }); } // ============================================================ // PeeringDB Org → Country Cache (for Lia's Paradise) // ============================================================ let pdbOrgCountryMap = new Map(); // org_id → { country, name } function fetchPdbOrgCountries() { var cacheFile = require("path").join(__dirname, ".pdb-org-cache.json"); var fs = require("fs"); // Try disk cache first (valid for 24h) try { var stat = fs.statSync(cacheFile); var ageHours = (Date.now() - stat.mtimeMs) / 3600000; if (ageHours < 24) { var cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")); pdbOrgCountryMap = new Map(Object.entries(cached)); console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " orgs from disk cache (" + Math.round(ageHours) + "h old)"); return Promise.resolve(); } } catch (_) { /* no cache or invalid */ } console.log("[PDB-ORG] Fetching PeeringDB org countries (fresh)..."); return new Promise(function(resolve) { var chunks = []; var req = require("https").get("https://www.peeringdb.com/api/org?status=ok&depth=0", { headers: { "User-Agent": UA, "Authorization": PEERINGDB_API_KEY ? "Api-Key " + PEERINGDB_API_KEY : undefined, }, timeout: 120000, }, function(res) { if (res.statusCode !== 200) { console.error("[PDB-ORG] HTTP " + res.statusCode + " — using stale cache or empty"); resolve(); return; } res.on("data", function(chunk) { chunks.push(chunk); }); res.on("end", function() { try { var body = Buffer.concat(chunks).toString("utf8"); var data = JSON.parse(body); if (data && data.data) { pdbOrgCountryMap = new Map(); var cacheObj = {}; data.data.forEach(function(o) { if (o.id && o.country) { pdbOrgCountryMap.set(o.id, { country: o.country, name: o.name || "" }); cacheObj[o.id] = { country: o.country, name: o.name || "" }; } }); // Save to disk cache try { fs.writeFileSync(cacheFile, JSON.stringify(cacheObj)); } catch (_) {} console.log("[PDB-ORG] Loaded " + pdbOrgCountryMap.size + " org→country mappings (cached to disk)"); } } catch (e) { console.error("[PDB-ORG] Parse error:", e.message); } resolve(); }); }); req.on("error", function(e) { console.error("[PDB-ORG] Fetch error:", e.message); resolve(); }); req.on("timeout", function() { console.error("[PDB-ORG] Timeout after 120s"); req.destroy(); resolve(); }); }); } const PORT = process.env.PORT || 3101; // Fetch RPKI ASPA feed at startup and refresh every 10 minutes Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => { server.listen(PORT, "0.0.0.0", () => { console.log("PeerCortex v0.4.0 running on http://0.0.0.0:" + PORT); console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured")); console.log("RPKI ASPA objects loaded: " + rpkiAspaMap.size); }); }); // Refresh RPKI ASPA cache every 10 minutes setInterval(() => { fetchRpkiAspaFeed(); }, 4 * 60 * 60 * 1000); // Refresh Atlas probe cache every hour setInterval(function() { fetchAllAtlasProbes(); }, 12 * 60 * 60 * 1000);