diff --git a/server.js b/server.js index 656c53a..57eb240 100644 --- a/server.js +++ b/server.js @@ -225,6 +225,7 @@ const healthRoute = require("./server/routes/health"); const aspaVerifyRoute = require("./server/routes/aspa-verify"); const aspaLegacyRoute = require("./server/routes/aspa-legacy"); const bgpRoute = require("./server/routes/bgp"); +const validateRoute = require("./server/routes/validate"); const server = http.createServer(async (req, res) => { res.setHeader("Access-Control-Allow-Origin", "*"); @@ -333,530 +334,12 @@ const server = http.createServer(async (req, res) => { - // ============================================================ // Unified Validation endpoint: /api/validate?asn=X - // Runs ALL validations in parallel, returns comprehensive report - // ============================================================ if (reqPath === "/api/validate") { - const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); - if (!rawAsn) { - res.writeHead(400); - return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); - } - const cachedValidate = resultCacheGet(validateResultCache, rawAsn); - if (cachedValidate !== undefined) { - res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); - return res.end(JSON.stringify(cachedValidate)); - } - const start = Date.now(); - const targetAsn = parseInt(rawAsn); - - try { - // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 - const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ - fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - fetchPeeringDB("/net?asn=" + rawAsn), - fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), - ]); - - const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); - // Use all prefixes for RPKI validation (local lookup is fast, no API calls) - const samplePrefixes = allPrefixes; - const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {}; - const netId = net.id; - const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; - - // ---- 11. Bogon Detection (local check) ---- - function checkBogonPrefix(prefix) { - var bogonV4 = [ - { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 }, - { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 }, - { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 }, - { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 }, - { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 }, - { net: "240.0.0.0", mask: 4 }, - ]; - if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" }; - var split = prefix.split("/"); - var addr = split[0]; - var mask = parseInt(split[1] || "0"); - var parts = addr.split(".").map(Number); - var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; - for (var bi = 0; bi < bogonV4.length; bi++) { - var b = bogonV4[bi]; - var bParts = b.net.split(".").map(Number); - var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0; - var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0; - if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) { - return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask }; - } - } - return { prefix: prefix, is_bogon: false }; - } - - function checkBogonAsn(asnNum) { - if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true; - if (asnNum >= 64496 && asnNum <= 64511) return true; - if (asnNum >= 64512 && asnNum <= 65534) return true; - return false; - } - - var bogonPrefixResults = allPrefixes.map(checkBogonPrefix); - var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; }); - var asnInPaths = neighbours.map(function(n) { return n.asn; }); - var bogonAsns = asnInPaths.filter(checkBogonAsn); - // "pass" must mean prefixes/neighbours were actually fetched and checked clean -- - // not that the upstream source failed and left nothing to check. A source is - // unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a - // truthy object with status:"error" (local-db-client.js's DB-error signal -- - // see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check - // alone misses this case and would show "pass" on 0 checked prefixes). - function isSourceUnavailable(x) { return !x || x.status === "error"; } - var prefixesUnavailable = isSourceUnavailable(prefixData); - var neighboursUnavailable = isSourceUnavailable(neighbourData); - var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable; - var bogonResult = { - status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"), - bogon_prefixes: bogonPrefixes, - bogon_asns_in_paths: bogonAsns, - total_prefixes_checked: allPrefixes.length, - prefixes_source_unavailable: prefixesUnavailable, - neighbours_source_unavailable: neighboursUnavailable, - }; - - // Phase 2: All API-dependent validations in parallel - var validationPromises = {}; - - // 12. IRR Validation - // fetchJSON resolves(null) on timeout/network-error/parse-failure rather than - // rejecting, so the .catch() below never sees the common failure mode -- a - // dead upstream would otherwise silently read as "0 entries, no mismatches, pass". - validationPromises.irr = fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { - if (irrData === null) { - return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 }; - } - var entries = Array.isArray(irrData) ? irrData : []; - var mismatches = entries.filter(function(e) { - if (!e.bgpOrigins && !e.bgp_origins) return false; - if (!e.irrRoutes && !e.irr_origins) return false; - var bgpArr = e.bgpOrigins || e.bgp_origins || []; - var irrArr = e.irrRoutes || e.irr_origins || []; - var bgpSet = {}; - bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; }); - var match = false; - irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; }); - return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match; - }); - return { - status: mismatches.length === 0 ? "pass" : "warning", - total_entries: entries.length, - mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }), - mismatch_count: mismatches.length, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs) - await ensureAspaCache(); // Ensure ROA data is loaded - validationPromises.rpki_completeness = Promise.all( - allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) - ).then(function(rpkiResults) { - var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; }); - var unavailableCount = rpkiResults.length - checkedResults.length; - var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; }); - var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0; - var overSpecific = checkedResults.filter(function(r) { - var mask = parseInt((r.prefix || "").split("/")[1] || "0"); - return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; - }); - // If the local RPKI DB was unreachable for every prefix, we haven't actually - // checked anything -- say so instead of reporting a misleading pass/fail. - var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0; - return { - status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", - coverage_pct: coverage, - total_checked: checkedResults.length, - db_unavailable_count: unavailableCount, - with_roa: withRoa.length, - over_specific: overSpecific.map(function(r) { return r.prefix; }), - details: rpkiResults, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 14. Abuse Contact Validation - validationPromises.abuse_contact = fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { - if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false }; - var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : []; - var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); }); - return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 15. Spamhaus DROP/Blocklist - validationPromises.blocklist = Promise.all( - samplePrefixes.slice(0, 5).map(function(pfx) { - return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { - // fetchRipeStatCached resolves(null) on timeout/network-error rather than - // throwing, so the .catch() below never sees that case -- distinguish it - // explicitly instead of letting a failed lookup read as "not listed". - if (data === null) return { prefix: pfx, listed: false, checked: false }; - var sources = data && data.data && data.data.sources ? data.data.sources : []; - var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); }); - return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; - }).catch(function() { return { prefix: pfx, listed: false, checked: false }; }); - }) - ).then(function(results) { - var checkedResults = results.filter(function(r) { return r.checked; }); - var listedPrefixes = checkedResults.filter(function(r) { return r.listed; }); - var allUnchecked = results.length > 0 && checkedResults.length === 0; - return { - status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"), - checked: checkedResults.length, - unavailable: results.length - checkedResults.length, - listed_prefixes: listedPrefixes, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 16. MANRS Compliance — scraped from public participants list (24h cache) - validationPromises.manrs = ensureManrsCache().then(function() { - return checkManrsMembership(rawAsn); - }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); - - // 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs) - var rdnsSampleSize = Math.min(3, samplePrefixes.length); - validationPromises.rdns = Promise.all( - samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { - return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { - if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true }; - var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; - var hasDelegation = false; - var details = []; - // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } } - ["ipv4", "ipv6"].forEach(function(af) { - var afData = pfxData[af] || {}; - Object.keys(afData).forEach(function(p) { - var entry = afData[p]; - if (entry && entry.complete) hasDelegation = true; - if (entry && entry.domains) { - entry.domains.forEach(function(d) { - if (d.found) hasDelegation = true; - details.push({ domain: d.domain, found: !!d.found }); - }); - } - }); - }); - // Fallback: old array format - if (Array.isArray(pfxData)) { - pfxData.forEach(function(p) { - if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true; - }); - } - return { prefix: pfx, has_rdns: hasDelegation, details: details }; - }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); - }) - ).then(function(results) { - var checkedResults = results.filter(function(r) { return !r.error; }); - var withRdns = checkedResults.filter(function(r) { return r.has_rdns; }); - var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0; - var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; }); - var allUnavailable = results.length > 0 && checkedResults.length === 0; - return { - status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"), - coverage_pct: coverage, - checked: checkedResults.length, - unavailable: results.length - checkedResults.length, - results: results, - failed_prefixes: failedPrefixes, - }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) - validationPromises.visibility = fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) { - var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {}; - var v4 = vis.v4 || {}; - var v6 = vis.v6 || {}; - var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0); - var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0); - var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0; - var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0; - // If routing-status returned no data, try bgproutes.io - if (totalPeers === 0 && samplePrefixes[0]) { - return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { - if (bgprFb && bgprFb.vps_seeing > 0) { - seeingPeers = bgprFb.vps_seeing; - totalPeers = Math.max(bgprFb.vps_seeing, 300); - score = Math.round((seeingPeers / totalPeers) * 100); - } - return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" }; - }).catch(function() { - // Neither RIPE routing-status nor the bgproutes.io fallback returned data -- - // this network hasn't actually been measured, not confirmed at 0% visibility. - return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" }; - }); - } - return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 19. BGP Communities Analysis - validationPromises.communities = (samplePrefixes.length > 0 - ? (function() { - var now = new Date(); - var end = now.toISOString().replace(/\.\d+Z/, ""); - var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, ""); - return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end); - })() - : Promise.resolve(null) - ).then(function(data) { - var updates = data && data.data && data.data.updates ? data.data.updates : []; - var communityMap = {}; - var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" }; - updates.forEach(function(u) { - var attrs = u.attrs || {}; - var communities = attrs.community || []; - communities.forEach(function(c) { - var key = Array.isArray(c) ? c.join(":") : String(c); - if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null }; - communityMap[key].count++; - }); - }); - var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; }); - var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; }); - return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 20. Geolocation Verification - validationPromises.geolocation = (samplePrefixes.length > 0 - ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0])) - : Promise.resolve(null) - ).then(function(data) { - var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : []; - var countries = {}; - locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); }); - return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel) - validationPromises.rpsl = (function() { - // Try RIPE first (has richest policy data), then RDAP for other RIRs - var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) { - var objects = data && data.objects && data.objects.object ? data.objects.object : []; - if (objects.length === 0) return null; - var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : []; - var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; }); - var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; }); - var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; }); - return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" }; - }).catch(function() { return null; }); - - var rdapEndpoints = [ - { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn }, - { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn }, - { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn }, - { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn }, - ]; - var rdapPromises = rdapEndpoints.map(function(ep) { - return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) { - if (!data || data.errorCode || !data.handle) return null; - var hasRemarks = !!(data.remarks && data.remarks.length > 0); - var name = data.name || ""; - return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" }; - }).catch(function() { return null; }); - }); - - return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) { - // Take first successful result - for (var ri = 0; ri < results.length; ri++) { - if (results[ri] !== null) return results[ri]; - } - return { status: "warning", exists: false, has_policy: false }; - }); - })(); - - // 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering) - // Always use asn= for netixlan (more reliable than net_id when PDB rate-limits) - var ixRsQueryUrl = "/netixlan?asn=" + rawAsn; - { - validationPromises.ix_route_server = fetchPeeringDB(ixRsQueryUrl).then(function(ixData) { - var connections = ixData && ixData.data ? ixData.data : []; - var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; }); - var totalIx = connections.length; - var rsCount = rsParticipants.length; - var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0; - var status, note; - - if (totalIx > 0 && rsCount > 0) { - // Using route servers - good - status = "pass"; - note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)"; - } else if (totalIx >= 10 && rsCount === 0) { - // Network with 10+ IX connections but no RS = deliberate bilateral peering policy - status = "pass"; - note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)"; - } else if (totalIx < 3 && rsCount === 0) { - // Very small IX presence and no RS - status = "warning"; - note = "Only " + totalIx + " IX connection(s) and no route server usage"; - } else { - // Small-medium network (3-9 IX) without RS - informational - status = "info"; - note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability"; - } - - return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - } - - // 23. Resource Certification (local RPKI validation - all prefixes, all RIRs) - validationPromises.resource_cert = Promise.all( - allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) - ).then(function(results) { - var checked = results.filter(function(r) { return r.status !== "unavailable"; }); - var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); - var allUnavailable = results.length > 0 && checked.length === 0; - return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length }; - }).catch(function(e) { return { status: "error", error: String(e) }; }); - - // Geolocation cross-ref with PeeringDB facilities - var facCountriesPromise = netId - ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) { - return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean); - }).catch(function() { return []; }) - : Promise.resolve([]); - - // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s - var keys = Object.keys(validationPromises); - var promises = keys.map(function(k) { - return Promise.race([ - validationPromises[k], - new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), - ]); - }); - var settled = await Promise.allSettled(promises); - var facCountries = await facCountriesPromise; - - var validations = {}; - keys.forEach(function(key, i) { - if (settled[i].status === "fulfilled") { - validations[key] = settled[i].value; - } else { - validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" }; - } - }); - - // Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) - if (validations.geolocation && validations.geolocation.status !== "error") { - var uniqueFacCountries = {}; - facCountries.forEach(function(c) { uniqueFacCountries[c] = true; }); - var facCountryCount = Object.keys(uniqueFacCountries).length; - validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries); - var geoSet = {}; - (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; }); - var geoCountryCount = Object.keys(geoSet).length; - var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; }); - validations.geolocation.country_mismatches = mismatches; - - // Detect global/anycast networks: 5+ facility countries OR Content/NSP type - var netInfoType = (net.info_type || "").toLowerCase(); - var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp"; - if (isGlobalNetwork) { - // Global/anycast/CDN network: geo mismatches are expected, not anomalies - validations.geolocation.status = "pass"; - if (geoCountryCount === 0) { - validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available"; - } else { - validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")"; - } - validations.geolocation.country_mismatches = []; - } else if (facCountryCount <= 2 && geoCountryCount >= 10) { - // Actual anomaly: small network appearing in many countries - validations.geolocation.status = "warning"; - validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration"; - } - } - - validations.bogon = bogonResult; - - // Calculate overall health score (0-100) - var checks = [ - { key: "bogon", weight: 15 }, - { key: "irr", weight: 10 }, - { key: "rpki_completeness", weight: 15 }, - { key: "abuse_contact", weight: 5 }, - { key: "blocklist", weight: 15 }, - { key: "manrs", weight: 5 }, - { key: "rdns", weight: 5 }, - { key: "visibility", weight: 10 }, - { key: "rpsl", weight: 5 }, - { key: "ix_route_server", weight: 5 }, - { key: "resource_cert", weight: 10 }, - ]; - - var totalWeight = 0; - var earnedScore = 0; - var checkResults = []; - - checks.forEach(function(c) { - var v = validations[c.key]; - var points = 0; - if (v && v.status === "info") { - // "info" = unable to verify (e.g. API auth required) — exclude from scoring - checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" }); - return; - } - if (v && v.status === "pass") points = c.weight; - else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); - totalWeight += c.weight; - earnedScore += points; - checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" }); - }); - - var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; - var duration = Date.now() - start; - - // Build relationships from neighbour data - var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts - ? neighbourData.data.neighbour_counts : {}; - var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours - ? neighbourData.data.neighbours : []; - var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 20) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 20) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) - .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) - .slice(0, 30) - .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); - - const validateResult = { - meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length }, - asn: targetAsn, - name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown", - health_score: healthScore, - score_breakdown: checkResults, - validations: validations, - relationships: { - counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, - upstreams: relUpstreams, - downstreams: relDownstreams, - top_peers: relPeers, - source: "RIPE Stat asn-neighbours", - note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", - }, - }; - // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures - // Full results with prefixes are cached for the standard 15 minutes - const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL; - resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL); - return res.end(JSON.stringify(validateResult, null, 2)); - } catch (err) { - res.writeHead(500); - return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); - } + return validateRoute.handleValidate(req, res, url); } + // ============================================================ // Main lookup endpoint: /api/lookup?asn=X // ============================================================ diff --git a/server/routes/validate/bogon.js b/server/routes/validate/bogon.js new file mode 100644 index 0000000..0c25cb9 --- /dev/null +++ b/server/routes/validate/bogon.js @@ -0,0 +1,62 @@ +// 11. Bogon Detection (local check, no external API calls) +function checkBogonPrefix(prefix) { + var bogonV4 = [ + { net: "0.0.0.0", mask: 8 }, { net: "10.0.0.0", mask: 8 }, + { net: "100.64.0.0", mask: 10 }, { net: "127.0.0.0", mask: 8 }, + { net: "169.254.0.0", mask: 16 }, { net: "172.16.0.0", mask: 12 }, + { net: "192.0.2.0", mask: 24 }, { net: "192.168.0.0", mask: 16 }, + { net: "198.51.100.0", mask: 24 }, { net: "203.0.113.0", mask: 24 }, + { net: "240.0.0.0", mask: 4 }, + ]; + if (prefix.includes(":")) return { prefix: prefix, is_bogon: false, reason: "IPv6 bogon check skipped" }; + var split = prefix.split("/"); + var addr = split[0]; + var mask = parseInt(split[1] || "0"); + var parts = addr.split(".").map(Number); + var ip = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + for (var bi = 0; bi < bogonV4.length; bi++) { + var b = bogonV4[bi]; + var bParts = b.net.split(".").map(Number); + var bIp = ((bParts[0] << 24) | (bParts[1] << 16) | (bParts[2] << 8) | bParts[3]) >>> 0; + var bMask = (~((1 << (32 - b.mask)) - 1)) >>> 0; + if ((ip & bMask) === (bIp & bMask) && mask >= b.mask) { + return { prefix: prefix, is_bogon: true, reason: "Matches bogon " + b.net + "/" + b.mask }; + } + } + return { prefix: prefix, is_bogon: false }; +} + +function checkBogonAsn(asnNum) { + if (asnNum === 0 || asnNum === 23456 || asnNum === 65535) return true; + if (asnNum >= 64496 && asnNum <= 64511) return true; + if (asnNum >= 64512 && asnNum <= 65534) return true; + return false; +} + +// "pass" must mean prefixes/neighbours were actually fetched and checked clean -- +// not that the upstream source failed and left nothing to check. A source is +// unavailable if it's falsy (fetchRipeStatCached network failure) OR if it's a +// truthy object with status:"error" (local-db-client.js's DB-error signal -- +// see the 2026-07-16 audit; that object is NOT null, so a plain `!x` check +// alone misses this case and would show "pass" on 0 checked prefixes). +function isSourceUnavailable(x) { return !x || x.status === "error"; } + +function computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData) { + var bogonPrefixResults = allPrefixes.map(checkBogonPrefix); + var bogonPrefixes = bogonPrefixResults.filter(function(r) { return r.is_bogon; }); + var asnInPaths = neighbours.map(function(n) { return n.asn; }); + var bogonAsns = asnInPaths.filter(checkBogonAsn); + var prefixesUnavailable = isSourceUnavailable(prefixData); + var neighboursUnavailable = isSourceUnavailable(neighbourData); + var bogonDataUnavailable = prefixesUnavailable && neighboursUnavailable; + return { + status: bogonDataUnavailable ? "info" : (bogonPrefixes.length === 0 && bogonAsns.length === 0 ? "pass" : "fail"), + bogon_prefixes: bogonPrefixes, + bogon_asns_in_paths: bogonAsns, + total_prefixes_checked: allPrefixes.length, + prefixes_source_unavailable: prefixesUnavailable, + neighbours_source_unavailable: neighboursUnavailable, + }; +} + +module.exports = { computeBogonResult }; diff --git a/server/routes/validate/checks.js b/server/routes/validate/checks.js new file mode 100644 index 0000000..7f9aaf3 --- /dev/null +++ b/server/routes/validate/checks.js @@ -0,0 +1,337 @@ +const { fetchJSON, fetchBgproutesVisibility } = require("../../services/http-helpers"); +const { fetchRipeStatCached } = require("../../services/ripe-stat"); +const { fetchPeeringDB } = require("../../services/peeringdb"); +const { validateRPKIWithCache } = require("../../services/rpki"); +const { ensureManrsCache, checkManrsMembership } = require("../../services/manrs"); + +// 12. IRR Validation +// fetchJSON resolves(null) on timeout/network-error/parse-failure rather than +// rejecting, so the .catch() below never sees the common failure mode -- a +// dead upstream would otherwise silently read as "0 entries, no mismatches, pass". +function checkIRR(rawAsn) { + return fetchJSON("https://irrexplorer.nlnog.net/api/prefixes/asn/" + rawAsn).then(function(irrData) { + if (irrData === null) { + return { status: "info", message: "IRR Explorer unavailable (fetch failed)", total_entries: 0, mismatches: [], mismatch_count: 0 }; + } + var entries = Array.isArray(irrData) ? irrData : []; + var mismatches = entries.filter(function(e) { + if (!e.bgpOrigins && !e.bgp_origins) return false; + if (!e.irrRoutes && !e.irr_origins) return false; + var bgpArr = e.bgpOrigins || e.bgp_origins || []; + var irrArr = e.irrRoutes || e.irr_origins || []; + var bgpSet = {}; + bgpArr.forEach(function(a) { bgpSet[String(typeof a === "object" ? a.asn : a)] = true; }); + var match = false; + irrArr.forEach(function(a) { if (bgpSet[String(typeof a === "object" ? a.asn : a)]) match = true; }); + return Object.keys(bgpSet).length > 0 && irrArr.length > 0 && !match; + }); + return { + status: mismatches.length === 0 ? "pass" : "warning", + total_entries: entries.length, + mismatches: mismatches.slice(0, 10).map(function(e) { return { prefix: e.prefix, bgp_origins: e.bgpOrigins || e.bgp_origins, irr_origins: e.irrRoutes || e.irr_origins }; }), + mismatch_count: mismatches.length, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 13. RPKI ROA Completeness (local validation against Cloudflare RPKI feed - all RIRs) +// Caller must await ensureAspaCache() before calling this. +function checkRpkiCompleteness(rawAsn, allPrefixes) { + return Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(rpkiResults) { + var checkedResults = rpkiResults.filter(function(r) { return r.status !== "unavailable"; }); + var unavailableCount = rpkiResults.length - checkedResults.length; + var withRoa = checkedResults.filter(function(r) { return r.status === "valid"; }); + var coverage = checkedResults.length > 0 ? Math.round((withRoa.length / checkedResults.length) * 100) : 0; + var overSpecific = checkedResults.filter(function(r) { + var mask = parseInt((r.prefix || "").split("/")[1] || "0"); + return !r.prefix.includes(":") && mask >= 25 && r.status !== "valid"; + }); + // If the local RPKI DB was unreachable for every prefix, we haven't actually + // checked anything -- say so instead of reporting a misleading pass/fail. + var allUnavailable = rpkiResults.length > 0 && checkedResults.length === 0; + return { + status: allUnavailable ? "info" : coverage >= 90 ? "pass" : coverage >= 50 ? "warning" : "fail", + coverage_pct: coverage, + total_checked: checkedResults.length, + db_unavailable_count: unavailableCount, + with_roa: withRoa.length, + over_specific: overSpecific.map(function(r) { return r.prefix; }), + details: rpkiResults, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 14. Abuse Contact Validation +function checkAbuseContact(rawAsn) { + return fetchRipeStatCached("https://stat.ripe.net/data/abuse-contact-finder/data.json?resource=AS" + rawAsn).then(function(data) { + if (data === null) return { status: "info", message: "abuse-contact-finder unavailable (fetch failed)", contacts: [], has_valid_email: false }; + var contacts = data && data.data && data.data.abuse_contacts ? data.data.abuse_contacts : []; + var hasEmail = contacts.length > 0 && contacts.some(function(c) { return c && c.includes("@"); }); + return { status: hasEmail ? "pass" : "fail", contacts: contacts, has_valid_email: hasEmail }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 15. Spamhaus DROP/Blocklist +function checkBlocklist(samplePrefixes) { + return Promise.all( + samplePrefixes.slice(0, 5).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/blocklist/data.json?resource=" + encodeURIComponent(pfx)).then(function(data) { + // fetchRipeStatCached resolves(null) on timeout/network-error rather than + // throwing, so the .catch() below never sees that case -- distinguish it + // explicitly instead of letting a failed lookup read as "not listed". + if (data === null) return { prefix: pfx, listed: false, checked: false }; + var sources = data && data.data && data.data.sources ? data.data.sources : []; + var listed = sources.filter(function(s) { return s.prefix_count > 0 || (s.entries && s.entries.length > 0); }); + return { prefix: pfx, listed: listed.length > 0, checked: true, sources: listed.map(function(s) { return s.source || s.name || "unknown"; }) }; + }).catch(function() { return { prefix: pfx, listed: false, checked: false }; }); + }) + ).then(function(results) { + var checkedResults = results.filter(function(r) { return r.checked; }); + var listedPrefixes = checkedResults.filter(function(r) { return r.listed; }); + var allUnchecked = results.length > 0 && checkedResults.length === 0; + return { + status: allUnchecked ? "info" : (listedPrefixes.length === 0 ? "pass" : "fail"), + checked: checkedResults.length, + unavailable: results.length - checkedResults.length, + listed_prefixes: listedPrefixes, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 16. MANRS Compliance — scraped from public participants list (24h cache) +function checkManrs(rawAsn) { + return ensureManrsCache().then(function() { + return checkManrsMembership(rawAsn); + }).catch(function(e) { return { status: "info", participant: "unknown", message: "MANRS check unavailable: " + e.message, note: "https://www.manrs.org/netops/participants/" }; }); +} + +// 17. Reverse DNS Coverage (3 prefix sample — more causes semaphore starvation on large ASNs) +function checkRdns(samplePrefixes) { + var rdnsSampleSize = Math.min(3, samplePrefixes.length); + return Promise.all( + samplePrefixes.slice(0, rdnsSampleSize).map(function(pfx) { + return fetchRipeStatCached("https://stat.ripe.net/data/reverse-dns-consistency/data.json?resource=" + encodeURIComponent(pfx), { timeout: 4000 }).then(function(data) { + if (data === null) return { prefix: pfx, has_rdns: false, details: [], error: true }; + var pfxData = data && data.data && data.data.prefixes ? data.data.prefixes : {}; + var hasDelegation = false; + var details = []; + // API returns { ipv4: { "prefix": { complete, domains } }, ipv6: { ... } } + ["ipv4", "ipv6"].forEach(function(af) { + var afData = pfxData[af] || {}; + Object.keys(afData).forEach(function(p) { + var entry = afData[p]; + if (entry && entry.complete) hasDelegation = true; + if (entry && entry.domains) { + entry.domains.forEach(function(d) { + if (d.found) hasDelegation = true; + details.push({ domain: d.domain, found: !!d.found }); + }); + } + }); + }); + // Fallback: old array format + if (Array.isArray(pfxData)) { + pfxData.forEach(function(p) { + if (p.ipv4 || p.ipv6 || (p.delegations && p.delegations.length > 0)) hasDelegation = true; + }); + } + return { prefix: pfx, has_rdns: hasDelegation, details: details }; + }).catch(function() { return { prefix: pfx, has_rdns: false, error: true }; }); + }) + ).then(function(results) { + var checkedResults = results.filter(function(r) { return !r.error; }); + var withRdns = checkedResults.filter(function(r) { return r.has_rdns; }); + var coverage = checkedResults.length > 0 ? Math.round((withRdns.length / checkedResults.length) * 100) : 0; + var failedPrefixes = checkedResults.filter(function(r) { return !r.has_rdns; }).map(function(r) { return r.prefix; }); + var allUnavailable = results.length > 0 && checkedResults.length === 0; + return { + status: allUnavailable ? "info" : (coverage >= 80 ? "pass" : coverage >= 50 ? "warning" : "fail"), + coverage_pct: coverage, + checked: checkedResults.length, + unavailable: results.length - checkedResults.length, + results: results, + failed_prefixes: failedPrefixes, + }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 18. BGP Visibility (uses routing-status API which is more reliable than visibility API) +function checkVisibility(rawAsn, samplePrefixes) { + return fetchRipeStatCached("https://stat.ripe.net/data/routing-status/data.json?resource=AS" + rawAsn, { timeout: 8000 }).then(function(rsData) { + var vis = rsData && rsData.data && rsData.data.visibility ? rsData.data.visibility : {}; + var v4 = vis.v4 || {}; + var v6 = vis.v6 || {}; + var totalPeers = (v4.total_ris_peers || 0) + (v6.total_ris_peers || 0); + var seeingPeers = (v4.ris_peers_seeing || 0) + (v6.ris_peers_seeing || 0); + var score = totalPeers > 0 ? Math.round((seeingPeers / totalPeers) * 100) : 0; + var observedNeighbours = rsData && rsData.data ? (rsData.data.observed_neighbours || 0) : 0; + // If routing-status returned no data, try bgproutes.io + if (totalPeers === 0 && samplePrefixes[0]) { + return fetchBgproutesVisibility(samplePrefixes[0]).then(function(bgprFb) { + if (bgprFb && bgprFb.vps_seeing > 0) { + seeingPeers = bgprFb.vps_seeing; + totalPeers = Math.max(bgprFb.vps_seeing, 300); + score = Math.round((seeingPeers / totalPeers) * 100); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "bgproutes.io_fallback" }; + }).catch(function() { + // Neither RIPE routing-status nor the bgproutes.io fallback returned data -- + // this network hasn't actually been measured, not confirmed at 0% visibility. + return { status: "info", message: "visibility could not be measured (both data sources unavailable)", visibility_score: 0, total_ris_peers: 0, seen_by: 0, source: "unavailable" }; + }); + } + return { status: score >= 80 ? "pass" : score >= 50 ? "warning" : "fail", visibility_score: score, total_ris_peers: totalPeers, seen_by: seeingPeers, v4_seeing: v4.ris_peers_seeing || 0, v4_total: v4.total_ris_peers || 0, v6_seeing: v6.ris_peers_seeing || 0, v6_total: v6.total_ris_peers || 0, observed_neighbours: observedNeighbours, source: "ripe_routing_status" }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 19. BGP Communities Analysis +function checkCommunities(samplePrefixes) { + return (samplePrefixes.length > 0 + ? (function() { + var now = new Date(); + var end = now.toISOString().replace(/\.\d+Z/, ""); + var startTime = new Date(now.getTime() - 3600000).toISOString().replace(/\.\d+Z/, ""); + return fetchRipeStatCached("https://stat.ripe.net/data/bgp-updates/data.json?resource=" + encodeURIComponent(samplePrefixes[0]) + "&starttime=" + startTime + "&endtime=" + end); + })() + : Promise.resolve(null) + ).then(function(data) { + var updates = data && data.data && data.data.updates ? data.data.updates : []; + var communityMap = {}; + var wellKnown = { "65535:0": "GRACEFUL_SHUTDOWN", "65535:65281": "NO_EXPORT", "65535:65282": "NO_ADVERTISE", "65535:666": "BLACKHOLE" }; + updates.forEach(function(u) { + var attrs = u.attrs || {}; + var communities = attrs.community || []; + communities.forEach(function(c) { + var key = Array.isArray(c) ? c.join(":") : String(c); + if (!communityMap[key]) communityMap[key] = { community: key, count: 0, well_known: wellKnown[key] || null }; + communityMap[key].count++; + }); + }); + var sorted = Object.values(communityMap).sort(function(a, b) { return b.count - a.count; }); + var hasBlackhole = sorted.some(function(c) { return c.well_known === "BLACKHOLE"; }); + return { status: hasBlackhole ? "warning" : "pass", total_updates: updates.length, unique_communities: sorted.length, top_communities: sorted.slice(0, 20), well_known_detected: sorted.filter(function(c) { return c.well_known; }) }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 20. Geolocation Verification +function checkGeolocation(samplePrefixes) { + return (samplePrefixes.length > 0 + ? fetchRipeStatCached("https://stat.ripe.net/data/maxmind-geo-lite-pfx/data.json?resource=" + encodeURIComponent(samplePrefixes[0])) + : Promise.resolve(null) + ).then(function(data) { + var locatedPfxs = data && data.data && data.data.located_resources ? data.data.located_resources : []; + var countries = {}; + locatedPfxs.forEach(function(l) { var locs = l.locations || []; locs.forEach(function(loc) { if (loc.country) countries[loc.country] = true; }); }); + return { status: Object.keys(countries).length > 0 ? "pass" : "warning", geo_countries: Object.keys(countries), sample_prefix: samplePrefixes[0] || null, located_resources: locatedPfxs.length }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 21. RPSL/IRR Object Validation (query all 5 RIRs in parallel) +function checkRpsl(rawAsn) { + // Try RIPE first (has richest policy data), then RDAP for other RIRs + var ripePromise = fetchJSON("https://rest.db.ripe.net/lookup/ripe/aut-num/AS" + rawAsn + ".json", { timeout: 5000 }).then(function(data) { + var objects = data && data.objects && data.objects.object ? data.objects.object : []; + if (objects.length === 0) return null; + var attrs = objects[0] && objects[0].attributes && objects[0].attributes.attribute ? objects[0].attributes.attribute : []; + var hasImport = attrs.some(function(a) { return a.name === "import" || a.name === "mp-import"; }); + var hasExport = attrs.some(function(a) { return a.name === "export" || a.name === "mp-export"; }); + var hasRemarks = attrs.some(function(a) { return a.name === "remarks"; }); + return { status: (hasImport || hasExport) ? "pass" : "warning", exists: true, has_import: hasImport, has_export: hasExport, has_remarks: hasRemarks, has_policy: hasImport || hasExport, source: "RIPE" }; + }).catch(function() { return null; }); + + var rdapEndpoints = [ + { name: "APNIC", url: "https://rdap.apnic.net/autnum/" + rawAsn }, + { name: "ARIN", url: "https://rdap.arin.net/registry/autnum/" + rawAsn }, + { name: "LACNIC", url: "https://rdap.lacnic.net/rdap/autnum/" + rawAsn }, + { name: "AFRINIC", url: "https://rdap.afrinic.net/rdap/autnum/" + rawAsn }, + ]; + var rdapPromises = rdapEndpoints.map(function(ep) { + return fetchJSON(ep.url, { timeout: 5000 }).then(function(data) { + if (!data || data.errorCode || !data.handle) return null; + var hasRemarks = !!(data.remarks && data.remarks.length > 0); + var name = data.name || ""; + return { status: hasRemarks ? "pass" : "warning", exists: true, has_import: false, has_export: false, has_remarks: hasRemarks, has_policy: false, source: ep.name, rdap_name: name, rdap_handle: data.handle || "" }; + }).catch(function() { return null; }); + }); + + return Promise.all([ripePromise].concat(rdapPromises)).then(function(results) { + // Take first successful result + for (var ri = 0; ri < results.length; ri++) { + if (results[ri] !== null) return results[ri]; + } + return { status: "warning", exists: false, has_policy: false }; + }); +} + +// 22. IXP Route Server Participation (Bug 5 fix: fair scoring for bilateral peering) +// Always use asn= for netixlan (more reliable than net_id when PDB rate-limits) +function checkIxRouteServer(rawAsn) { + var ixRsQueryUrl = "/netixlan?asn=" + rawAsn; + return fetchPeeringDB(ixRsQueryUrl).then(function(ixData) { + var connections = ixData && ixData.data ? ixData.data : []; + var rsParticipants = connections.filter(function(c) { return c.is_rs_peer === true; }); + var totalIx = connections.length; + var rsCount = rsParticipants.length; + var rsPct = totalIx > 0 ? Math.round((rsCount / totalIx) * 100) : 0; + var status, note; + + if (totalIx > 0 && rsCount > 0) { + // Using route servers - good + status = "pass"; + note = rsCount + " of " + totalIx + " IX connections use route servers (" + rsPct + "%)"; + } else if (totalIx >= 10 && rsCount === 0) { + // Network with 10+ IX connections but no RS = deliberate bilateral peering policy + status = "pass"; + note = "Bilateral peering policy — " + totalIx + " IX connections, all bilateral (no route server usage)"; + } else if (totalIx < 3 && rsCount === 0) { + // Very small IX presence and no RS + status = "warning"; + note = "Only " + totalIx + " IX connection(s) and no route server usage"; + } else { + // Small-medium network (3-9 IX) without RS - informational + status = "info"; + note = totalIx + " IX connections without route server usage — consider enabling RS for broader reachability"; + } + + return { status: status, total_ix_connections: totalIx, rs_peer_count: rsCount, rs_peer_pct: rsPct, note: note }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// 23. Resource Certification (local RPKI validation - all prefixes, all RIRs) +function checkResourceCert(rawAsn, allPrefixes) { + return Promise.all( + allPrefixes.map(function(pfx) { return validateRPKIWithCache(rawAsn, pfx); }) + ).then(function(results) { + var checked = results.filter(function(r) { return r.status !== "unavailable"; }); + var hasRoa = checked.some(function(r) { return r.status === "valid" || r.validating_roas > 0; }); + var allUnavailable = results.length > 0 && checked.length === 0; + return { status: allUnavailable ? "info" : hasRoa ? "pass" : "fail", has_roas: hasRoa, checked: checked.length, roa_count: checked.filter(function(r) { return r.status === "valid"; }).length }; + }).catch(function(e) { return { status: "error", error: String(e) }; }); +} + +// Geolocation cross-ref with PeeringDB facilities +function fetchFacCountries(netId) { + return netId + ? fetchPeeringDB("/netfac?net_id=" + netId).then(function(facData) { + return (facData && facData.data ? facData.data : []).map(function(f) { return f.country; }).filter(Boolean); + }).catch(function() { return []; }) + : Promise.resolve([]); +} + +module.exports = { + checkIRR, + checkRpkiCompleteness, + checkAbuseContact, + checkBlocklist, + checkManrs, + checkRdns, + checkVisibility, + checkCommunities, + checkGeolocation, + checkRpsl, + checkIxRouteServer, + checkResourceCert, + fetchFacCountries, +}; diff --git a/server/routes/validate/index.js b/server/routes/validate/index.js new file mode 100644 index 0000000..c56039d --- /dev/null +++ b/server/routes/validate/index.js @@ -0,0 +1,208 @@ +const { fetchRipeStatCached } = require("../../services/ripe-stat"); +const { fetchPeeringDB } = require("../../services/peeringdb"); +const { ensureAspaCache } = require("../../services/rpki"); +const { validateResultCache, resultCacheGet, resultCacheSet, RESULT_CACHE_TTL } = require("../../caches/response-caches"); +const { computeBogonResult } = require("./bogon"); +const checks = require("./checks"); + +// Calculate overall health score (0-100) +const SCORE_WEIGHTS = [ + { key: "bogon", weight: 15 }, + { key: "irr", weight: 10 }, + { key: "rpki_completeness", weight: 15 }, + { key: "abuse_contact", weight: 5 }, + { key: "blocklist", weight: 15 }, + { key: "manrs", weight: 5 }, + { key: "rdns", weight: 5 }, + { key: "visibility", weight: 10 }, + { key: "rpsl", weight: 5 }, + { key: "ix_route_server", weight: 5 }, + { key: "resource_cert", weight: 10 }, +]; + +function scoreChecks(validations) { + var totalWeight = 0; + var earnedScore = 0; + var checkResults = []; + + SCORE_WEIGHTS.forEach(function(c) { + var v = validations[c.key]; + var points = 0; + if (v && v.status === "info") { + // "info" = unable to verify (e.g. API auth required) — exclude from scoring + checkResults.push({ check: c.key, weight: c.weight, earned: 0, status: "info" }); + return; + } + if (v && v.status === "pass") points = c.weight; + else if (v && v.status === "warning") points = Math.round(c.weight * 0.5); + totalWeight += c.weight; + earnedScore += points; + checkResults.push({ check: c.key, weight: c.weight, earned: points, status: v ? v.status : "error" }); + }); + + var healthScore = totalWeight > 0 ? Math.round((earnedScore / totalWeight) * 100) : 0; + return { healthScore: healthScore, checkResults: checkResults }; +} + +// Enrich geolocation (Bug 4 fix: handle anycast/CDN/global networks) +function enrichGeolocation(validations, facCountries, net) { + if (!validations.geolocation || validations.geolocation.status === "error") return; + var uniqueFacCountries = {}; + facCountries.forEach(function(c) { uniqueFacCountries[c] = true; }); + var facCountryCount = Object.keys(uniqueFacCountries).length; + validations.geolocation.pdb_facility_countries = Object.keys(uniqueFacCountries); + var geoSet = {}; + (validations.geolocation.geo_countries || []).forEach(function(c) { geoSet[c] = true; }); + var geoCountryCount = Object.keys(geoSet).length; + var mismatches = Object.keys(geoSet).filter(function(c) { return !uniqueFacCountries[c] && facCountryCount > 0; }); + validations.geolocation.country_mismatches = mismatches; + + // Detect global/anycast networks: 5+ facility countries OR Content/NSP type + var netInfoType = (net.info_type || "").toLowerCase(); + var isGlobalNetwork = facCountryCount >= 5 || netInfoType === "content" || netInfoType === "nsp"; + if (isGlobalNetwork) { + // Global/anycast/CDN network: geo mismatches are expected, not anomalies + validations.geolocation.status = "pass"; + if (geoCountryCount === 0) { + validations.geolocation.note = "Global network (" + facCountryCount + " countries, type: " + (net.info_type || "N/A") + ") - no MaxMind geolocation data available"; + } else { + validations.geolocation.note = "Global/anycast network - multi-country presence expected (" + facCountryCount + " facility countries, type: " + (net.info_type || "N/A") + ")"; + } + validations.geolocation.country_mismatches = []; + } else if (facCountryCount <= 2 && geoCountryCount >= 10) { + // Actual anomaly: small network appearing in many countries + validations.geolocation.status = "warning"; + validations.geolocation.note = "Prefixes geolocated in " + geoCountryCount + " countries but only " + facCountryCount + " facility countries - possible hijack or misconfiguration"; + } +} + +// Build relationships from neighbour data +function buildRelationships(neighbourData) { + var relNeighbours = neighbourData && neighbourData.data && neighbourData.data.neighbour_counts + ? neighbourData.data.neighbour_counts : {}; + var relList = neighbourData && neighbourData.data && neighbourData.data.neighbours + ? neighbourData.data.neighbours : []; + var relUpstreams = relList.filter(function(n) { return n.type === "left"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relDownstreams = relList.filter(function(n) { return n.type === "right"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 20) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + var relPeers = relList.filter(function(n) { return n.type === "uncertain"; }) + .sort(function(a, b) { return (b.power || 0) - (a.power || 0); }) + .slice(0, 30) + .map(function(n) { return { asn: n.asn, power: n.power || 0, v4_peers: n.v4_peers || 0, v6_peers: n.v6_peers || 0 }; }); + + return { + counts: { upstreams: relNeighbours.left || relUpstreams.length, downstreams: relNeighbours.right || relDownstreams.length, peers: relNeighbours.unique || relPeers.length, uncertain: relNeighbours.uncertain || 0 }, + upstreams: relUpstreams, + downstreams: relDownstreams, + top_peers: relPeers, + source: "RIPE Stat asn-neighbours", + note: "left=upstream providers, right=downstream customers, uncertain=peers. Sorted by power score.", + }; +} + +// Unified Validation endpoint: /api/validate?asn=X +// Runs ALL validations in parallel, returns comprehensive report +async function handleValidate(req, res, url) { + const rawAsn = (url.searchParams.get("asn") || "").replace(/[^0-9]/g, ""); + if (!rawAsn) { + res.writeHead(400); + return res.end(JSON.stringify({ error: "Missing or invalid ASN parameter" })); + } + const cachedValidate = resultCacheGet(validateResultCache, rawAsn); + if (cachedValidate !== undefined) { + res.writeHead(200, { "Content-Type": "application/json", "X-Cache": "HIT" }); + return res.end(JSON.stringify(cachedValidate)); + } + const start = Date.now(); + const targetAsn = parseInt(rawAsn); + + try { + // Phase 1: Fetch core data — 5s cap prevents large ASNs from blocking Phase 2 + const [prefixData, pdbNet, neighbourData, overviewData] = await Promise.all([ + fetchRipeStatCached("https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchPeeringDB("/net?asn=" + rawAsn), + fetchRipeStatCached("https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + fetchRipeStatCached("https://stat.ripe.net/data/as-overview/data.json?resource=AS" + rawAsn, { timeout: 5000 }), + ]); + + const allPrefixes = (prefixData && prefixData.data && prefixData.data.prefixes ? prefixData.data.prefixes : []).map(function(p) { return p.prefix; }); + // Use all prefixes for RPKI validation (local lookup is fast, no API calls) + const samplePrefixes = allPrefixes; + const net = pdbNet && pdbNet.data && pdbNet.data[0] ? pdbNet.data[0] : {}; + const netId = net.id; + const neighbours = neighbourData && neighbourData.data && neighbourData.data.neighbours ? neighbourData.data.neighbours : []; + + const bogonResult = computeBogonResult(allPrefixes, neighbours, prefixData, neighbourData); + + // Phase 2: All API-dependent validations in parallel + await ensureAspaCache(); // Ensure ROA data is loaded before RPKI-dependent checks + var validationPromises = { + irr: checks.checkIRR(rawAsn), + rpki_completeness: checks.checkRpkiCompleteness(rawAsn, allPrefixes), + abuse_contact: checks.checkAbuseContact(rawAsn), + blocklist: checks.checkBlocklist(samplePrefixes), + manrs: checks.checkManrs(rawAsn), + rdns: checks.checkRdns(samplePrefixes), + visibility: checks.checkVisibility(rawAsn, samplePrefixes), + communities: checks.checkCommunities(samplePrefixes), + geolocation: checks.checkGeolocation(samplePrefixes), + rpsl: checks.checkRpsl(rawAsn), + ix_route_server: checks.checkIxRouteServer(rawAsn), + resource_cert: checks.checkResourceCert(rawAsn, allPrefixes), + }; + + const facCountriesPromise = checks.fetchFacCountries(netId); + + // Run all validations in parallel — 5s cap per check, total validate bounded to ~10s + var keys = Object.keys(validationPromises); + var promises = keys.map(function(k) { + return Promise.race([ + validationPromises[k], + new Promise(function(resolve) { setTimeout(function() { resolve({ status: "info", message: "timed out" }); }, 5000); }), + ]); + }); + var settled = await Promise.allSettled(promises); + var facCountries = await facCountriesPromise; + + var validations = {}; + keys.forEach(function(key, i) { + if (settled[i].status === "fulfilled") { + validations[key] = settled[i].value; + } else { + validations[key] = { status: "error", error: settled[i].reason ? String(settled[i].reason) : "Unknown error" }; + } + }); + + enrichGeolocation(validations, facCountries, net); + validations.bogon = bogonResult; + + const { healthScore, checkResults } = scoreChecks(validations); + const duration = Date.now() - start; + const relationships = buildRelationships(neighbourData); + + const validateResult = { + meta: { query: "AS" + rawAsn, duration_ms: duration, timestamp: new Date().toISOString(), total_prefixes: allPrefixes.length, prefixes_sampled: samplePrefixes.length }, + asn: targetAsn, + name: net.name || (overviewData && overviewData.data ? overviewData.data.holder : "") || "Unknown", + health_score: healthScore, + score_breakdown: checkResults, + validations: validations, + relationships: relationships, + }; + // Cache 0-prefix results only briefly (90s) — they may be due to temporary API failures + // Full results with prefixes are cached for the standard 15 minutes + const validateCacheTTL = allPrefixes.length === 0 ? 90 * 1000 : RESULT_CACHE_TTL; + resultCacheSet(validateResultCache, rawAsn, validateResult, validateCacheTTL); + return res.end(JSON.stringify(validateResult, null, 2)); + } catch (err) { + res.writeHead(500); + return res.end(JSON.stringify({ error: "Validation failed", message: err.message })); + } +} + +module.exports = { handleValidate };