const { fetchRipeStatCached } = require("../../services/ripe-stat"); const { fetchPeeringDB } = require("../../services/peeringdb"); const { ensureAspaCache } = require("../../services/rpki"); const { validateResultCache, resultCacheGet, resultCacheSet, RESULT_CACHE_TTL } = require("../../caches/response-caches"); const { computeBogonResult } = require("./bogon"); const checks = require("./checks"); // Calculate overall health score (0-100) const SCORE_WEIGHTS = [ { key: "bogon", weight: 15 }, { key: "irr", weight: 10 }, { key: "rpki_completeness", weight: 15 }, { key: "abuse_contact", weight: 5 }, { key: "blocklist", weight: 15 }, { key: "manrs", weight: 5 }, { key: "rdns", weight: 5 }, { key: "visibility", weight: 10 }, { key: "rpsl", weight: 5 }, { key: "ix_route_server", weight: 5 }, { key: "resource_cert", weight: 10 }, ]; function scoreChecks(validations) { var totalWeight = 0; var earnedScore = 0; var checkResults = []; SCORE_WEIGHTS.forEach(function(c) { var v = validations[c.key]; var points = 0; if (v && v.status === "info") { // "info" = unable to verify (e.g. API auth required) — exclude from scoring checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" }); return; } if (v && v.status === "pass") points = c.weight; else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); totalWeight += c.weight; earnedScore += points; checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" }); }); var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; return { healthScore: healthScore, checkResults: checkResults }; } // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) function enrichGeolocation(validations, facCountries, net) { if (!validations.geolocation || validations.geolocation.status === "error") return; var uniqueFacCountries = {}; facCountries.forEach(function(c) { uniqueFacCountries[c] = true; }); var facCountryCount = Object.keys(uniqueFacCountries).length; validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries); var geoSet = {}; (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; }); var geoCountryCount = Object.keys(geoSet).length; var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; }); validations.geolocation.country_mismatches = mismatches; // Detect global/anycast networks: 5+ facility countries OR Content/NSP type var netInfoType = (net.info_type || "").toLowerCase(); var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp"; if (isGlobalNetwork) { // Global/anycast/CDN network: geo mismatches are expected, not anomalies validations.geolocation.status = "pass"; if (geoCountryCount === 0) { validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available"; } else { validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")"; } validations.geolocation.country_mismatches = []; } else if (facCountryCount <= 2 && geoCountryCount >= 10) { // Actual anomaly: small network appearing in many countries validations.geolocation.status = "warning"; validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration"; } } // Build relationships from neighbour data function buildRelationships(neighbourData) { var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts ? neighbourData.data.neighbour_counts : {}; var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) .slice(0, 20) .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) .slice(0, 20) .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) .slice(0, 30) .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); return { counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, upstreams: relUpstreams, downstreams: relDownstreams, top_peers: relPeers, source: "RIPE Stat asn-neighbours", note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", }; } // Unified Validation endpoint: /api/validate?asn=X // Runs ALL validations in parallel, returns comprehensive report async function handleValidate(req, res, url) { const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); if (!rawAsn) { res.writeHead(400); return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); } const cachedValidate = resultCacheGet(validateResultCache, rawAsn); if (cachedValidate !== undefined) { res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); return res.end(JSON.stringify(cachedValidate)); } const start = Date.now(); const targetAsn = parseInt(rawAsn); try { // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), fetchPeeringDB("/net?asn=" + rawAsn), fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), ]); const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); // Use all prefixes for RPKI validation (local lookup is fast, no API calls) const samplePrefixes = allPrefixes; const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {}; const netId = net.id; const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; const bogonResult = computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData); // Phase 2: All API-dependent validations in parallel await ensureAspaCache(); // Ensure ROA data is loaded before RPKI-dependent checks var validationPromises = { irr: checks.checkIRR(rawAsn), rpki_completeness: checks.checkRpkiCompleteness(rawAsn, allPrefixes), abuse_contact: checks.checkAbuseContact(rawAsn), blocklist: checks.checkBlocklist(samplePrefixes), manrs: checks.checkManrs(rawAsn), rdns: checks.checkRdns(samplePrefixes), visibility: checks.checkVisibility(rawAsn, samplePrefixes), communities: checks.checkCommunities(samplePrefixes), geolocation: checks.checkGeolocation(samplePrefixes), rpsl: checks.checkRpsl(rawAsn), ix_route_server: checks.checkIxRouteServer(rawAsn), resource_cert: checks.checkResourceCert(rawAsn, allPrefixes), }; const facCountriesPromise = checks.fetchFacCountries(netId); // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s var keys = Object.keys(validationPromises); var promises = keys.map(function(k) { return Promise.race([ validationPromises[k], new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), ]); }); var settled = await Promise.allSettled(promises); var facCountries = await facCountriesPromise; var validations = {}; keys.forEach(function(key, i) { if (settled[i].status === "fulfilled") { validations[key] = settled[i].value; } else { validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" }; } }); enrichGeolocation(validations, facCountries, net); validations.bogon = bogonResult; const { healthScore, checkResults } = scoreChecks(validations); const duration = Date.now() - start; const relationships = buildRelationships(neighbourData); const validateResult = { meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length }, asn: targetAsn, name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown", health_score: healthScore, score_breakdown: checkResults, validations: validations, relationships: relationships, }; // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures // Full results with prefixes are cached for the standard 15 minutes const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL; resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL); return res.end(JSON.stringify(validateResult, null, 2)); } catch (err) { res.writeHead(500); return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); } } module.exports = { handleValidate };