diff --git a/server.js b/server.js index ce8c709..6eaa42d 100644 --- a/server.js +++ b/server.js @@ -1466,119 +1466,28 @@ async function renderHtmlToPdf(html) { } } -// ============================================================ -// 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 { TIER1_ASNS } = require("./server/data/tier1-asns"); - -const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes -const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts -const CACHE_TTL_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 - -// ============================================================ -// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN -// ============================================================ -const rdapCache = new Map(); // key: asn string, value: { data, ts } -const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours - -function rdapCacheGet(asn) { - const e = rdapCache.get(String(asn)); - if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data; - return undefined; // undefined = not cached, null = cached miss -} -function rdapCacheSet(asn, data) { - if (rdapCache.size > 5000) { - rdapCache.delete(rdapCache.keys().next().value); - } - rdapCache.set(String(asn), { data, ts: Date.now() }); -} - -// ============================================================ -// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering -// ============================================================ -const whoisCache = new Map(); // key: asn string, value: { data, ts, ttl } -const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; -// A "not found" result after all 5 RIR sources failed is far more often a shared -// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real -// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't -// tell timeout/network-error apart from a genuine 404, so cache that specific -// conclusion much more briefly than a confirmed, parsed WHOIS record. -const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000; -function whoisCacheGet(asn) { - const e = whoisCache.get(String(asn)); - if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data; - return undefined; -} -function whoisCacheSet(asn, data, ttl) { - if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); - whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL }); -} - -// ============================================================ -// Quick-IX Cache — 1h TTL, for Peering Recommendations -// ============================================================ -const quickIxCache = new Map(); // key: asn string, value: { data, ts } -const QUICK_IX_CACHE_TTL = 60 * 60 * 1000; -function quickIxCacheGet(asn) { - const e = quickIxCache.get(String(asn)); - if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data; - return undefined; -} -function quickIxCacheSet(asn, data) { - if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value); - quickIxCache.set(String(asn), { data, ts: Date.now() }); -} - -// ============================================================ -// bgproutes.io Vantage Points Cache — 1h TTL, prevents 429 -// ============================================================ -let bgproutesVpCache = null; -let bgproutesVpCacheTs = 0; -const BGPROUTES_VP_TTL = 60 * 60 * 1000; // 1 hour - -// ============================================================ -// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs -// ============================================================ -const bgproutesResultCache = new Map(); -const aspaResultCache = new Map(); -const validateResultCache = new Map(); -const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes -function resultCacheGet(map, key) { - const e = map.get(String(key)); - const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL; - if (e && (Date.now() - e.ts) < ttl) return e.data; - return undefined; -} -function resultCacheSet(map, key, data, ttlMs) { - if (map.size > 2000) map.delete(map.keys().next().value); - map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL }); -} +const { + responseCache, + cacheGet, + cacheSet, + CACHE_TTL_LOOKUP, + CACHE_TTL_LOOKUP_DEGRADED, + CACHE_TTL_DEFAULT, + rdapCacheGet, + rdapCacheSet, + whoisCacheGet, + whoisCacheSet, + WHOIS_NOT_FOUND_CACHE_TTL, + quickIxCacheGet, + quickIxCacheSet, + bgproutesResultCache, + aspaResultCache, + validateResultCache, + resultCacheGet, + resultCacheSet, +} = require("./server/caches/response-caches"); +// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16: +// confirmed dead code, never read anywhere in the file. // ============================================================ // MANRS Participants Cache (scraped from public HTML page, 24h TTL) @@ -1654,313 +1563,9 @@ const rpkiAspaMap = new Map(); // customer_asid -> Set let rpkiAspaLastFetch = 0; let rpkiAspaFetching = false; -// ============================================================ -// Local ROA Store — validates prefixes without RIPE Stat API calls -// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA -// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix) -// ============================================================ -const roaStore = { - v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start - v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex - ready: false, - count: 0, - lastBuild: 0, +const { roaStore } = require("./server/caches/roa-store"); - // Parse IPv4 prefix string to 32-bit unsigned integer - _ipv4ToUint32(ip) { - const parts = ip.split("."); - return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; - }, - - // Build ROA store from Cloudflare feed roas array - build(roas) { - const v4 = []; - const v6 = []; - for (let i = 0; i < roas.length; i++) { - const r = roas[i]; - const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn); - const prefix = r.prefix; - const maxLen = r.maxLength || r.maxPrefixLength || 0; - if (!prefix || !asn) continue; - - const slashIdx = prefix.indexOf("/"); - if (slashIdx < 0) continue; - const prefixLen = parseInt(prefix.substring(slashIdx + 1)); - const addr = prefix.substring(0, slashIdx); - - if (prefix.indexOf(":") >= 0) { - // IPv6 — store as zero-padded hex string for sorting - const expanded = this._expandIPv6(addr); - if (expanded) { - v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen }); - } - } else { - // IPv4 — store as numeric range - const start = this._ipv4ToUint32(addr); - const hostBits = 32 - prefixLen; - const end = (start | ((1 << hostBits) - 1)) >>> 0; - v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen }); - } - } - - // Sort for binary search - v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen); - v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen); - - this.v4Entries = v4; - this.v6Entries = v6; - this.count = v4.length + v6.length; - this.ready = true; - this.lastBuild = Date.now(); - }, - - // Expand IPv6 address to 32-char hex for reliable sorting - _expandIPv6(addr) { - try { - let groups = addr.split("::"); - let left = groups[0] ? groups[0].split(":") : []; - let right = groups.length > 1 && groups[1] ? groups[1].split(":") : []; - const missing = 8 - left.length - right.length; - const mid = []; - for (let i = 0; i < missing; i++) mid.push("0000"); - const all = [...left, ...mid, ...right]; - return all.map(g => g.padStart(4, "0")).join(""); - } catch (_e) { - return null; - } - }, - - // Validate a prefix against the local ROA store - // Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N} - validate(asn, prefix) { - if (!this.ready) return null; // Signal caller to use fallback - - const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn); - const slashIdx = prefix.indexOf("/"); - if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 }; - const prefixLen = parseInt(prefix.substring(slashIdx + 1)); - const addr = prefix.substring(0, slashIdx); - - if (prefix.indexOf(":") >= 0) { - return this._validateV6(asnNum, addr, prefixLen, prefix); - } - return this._validateV4(asnNum, addr, prefixLen, prefix); - }, - - _validateV4(asn, addr, prefixLen, prefix) { - const queryStart = this._ipv4ToUint32(addr); - const entries = this.v4Entries; - - // Binary search: find rightmost entry where start <= queryStart - let lo = 0, hi = entries.length - 1; - let insertionPoint = -1; - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - if (entries[mid].start <= queryStart) { - insertionPoint = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - - // Scan backwards from insertion point to find covering ROAs - // ROAs are sorted by start, so we scan back while start could still cover our prefix - const matched = []; - const unmatchedAs = []; - for (let i = insertionPoint; i >= 0; i--) { - const e = entries[i]; - // If this ROA's network start is too far left, no more matches possible - if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away - // Check if query prefix is contained within this ROA - if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) { - if (prefixLen <= e.maxLen) { - if (e.asn === asn) { - matched.push(e); - } else { - unmatchedAs.push(e); - } - } - // prefixLen > maxLen → too specific, invalid if ASN matches - else if (e.asn === asn) { - unmatchedAs.push(e); // ASN matches but length exceeds maxLen - } - } - } - - if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length }; - if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length }; - return { prefix, status: "not_found", validating_roas: 0 }; - }, - - _validateV6(asn, addr, prefixLen, prefix) { - const queryHex = this._expandIPv6(addr); - if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 }; - const entries = this.v6Entries; - - // Binary search for approximate position - let lo = 0, hi = entries.length - 1; - let insertionPoint = -1; - while (lo <= hi) { - const mid = (lo + hi) >>> 1; - if (entries[mid].prefixHex <= queryHex) { - insertionPoint = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - - const matched = []; - const unmatchedAs = []; - // Scan backwards from insertion point - for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) { - const e = entries[i]; - // Check if query prefix is covered by this ROA entry - // A covering ROA has a shorter or equal prefix length and its network prefix matches - if (e.prefixLen <= prefixLen) { - // Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up) - const hexChars = Math.ceil(e.prefixLen / 4); - if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) { - if (prefixLen <= e.maxLen) { - if (e.asn === asn) matched.push(e); - else unmatchedAs.push(e); - } else if (e.asn === asn) { - unmatchedAs.push(e); - } - } - } - // Stop if we're too far away - if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break; - } - - if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length }; - if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length }; - return { prefix, status: "not_found", validating_roas: 0 }; - }, - - // Persist to disk for fast restart - saveToDisk(filePath) { - try { - const data = JSON.stringify({ - ts: this.lastBuild, - v4Count: this.v4Entries.length, - v6Count: this.v6Entries.length, - v4: this.v4Entries, - v6: this.v6Entries, - }); - fs.writeFileSync(filePath, data); - console.log("[ROA] Saved " + this.count + " ROAs to disk"); - } catch (e) { - console.warn("[ROA] Disk save failed:", e.message); - } - }, - - // Load from disk cache (returns true if loaded) - loadFromDisk(filePath) { - try { - if (!fs.existsSync(filePath)) return false; - const raw = fs.readFileSync(filePath, "utf8"); - const data = JSON.parse(raw); - // Only use if less than 6 hours old - if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false; - this.v4Entries = data.v4; - this.v6Entries = data.v6; - this.count = data.v4Count + data.v6Count; - this.lastBuild = data.ts; - this.ready = true; - console.log("[ROA] Loaded " + this.count + " ROAs from disk cache"); - return true; - } catch (e) { - console.warn("[ROA] Disk load failed:", e.message); - return false; - } - }, -}; - -// ============================================================ -// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN -// Eliminates redundant PDB API calls under load -// ============================================================ -const pdbSourceCache = { - net: new Map(), // key: asn string → {data, ts} - netixlan: new Map(), // key: net_id string → {data, ts} - netfac: new Map(), // key: net_id string → {data, ts} - facCoords: new Map(), // key: fac_id string → {lat, lon, ts} - TTL_NET: 6 * 60 * 60 * 1000, // 6 hours - TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours - TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days - MAX_NET: 5000, - MAX_IXFAC: 5000, - MAX_COORDS: 10000, - hits: 0, - misses: 0, - - get(type, key) { - const map = this[type]; - const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC); - const entry = map.get(String(key)); - if (!entry) { this.misses++; return null; } - if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; } - this.hits++; - return entry.data; - }, - - set(type, key, data) { - const map = this[type]; - const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC); - if (map.size >= max) { - // Evict oldest entry (Map preserves insertion order) - map.delete(map.keys().next().value); - } - map.set(String(key), { data, ts: Date.now() }); - }, - - // Disk persistence - saveToDisk(filePath) { - try { - const serialize = (map) => { - const obj = {}; - for (const [k, v] of map) obj[k] = v; - return obj; - }; - const data = JSON.stringify({ - ts: Date.now(), - net: serialize(this.net), - netixlan: serialize(this.netixlan), - netfac: serialize(this.netfac), - facCoords: serialize(this.facCoords), - }); - fs.writeFileSync(filePath, data); - console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")"); - } catch (e) { - console.warn("[PDB-CACHE] Disk save failed:", e.message); - } - }, - - loadFromDisk(filePath) { - try { - if (!fs.existsSync(filePath)) return false; - const raw = fs.readFileSync(filePath, "utf8"); - const data = JSON.parse(raw); - const now = Date.now(); - const load = (map, obj, ttl) => { - for (const [k, v] of Object.entries(obj || {})) { - if (now - v.ts < ttl) map.set(k, v); - } - }; - load(this.net, data.net, this.TTL_NET); - load(this.netixlan, data.netixlan, this.TTL_IXFAC); - load(this.netfac, data.netfac, this.TTL_IXFAC); - load(this.facCoords, data.facCoords, this.TTL_COORDS); - console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")"); - return true; - } catch (e) { - console.warn("[PDB-CACHE] Disk load failed:", e.message); - return false; - } - }, -}; +const { pdbSourceCache } = require("./server/caches/pdb-source-cache"); // ============================================================ // RIPE Stat Source Cache + Semaphore (L2) diff --git a/server/caches/pdb-source-cache.js b/server/caches/pdb-source-cache.js new file mode 100644 index 0000000..23881d4 --- /dev/null +++ b/server/caches/pdb-source-cache.js @@ -0,0 +1,85 @@ +const fs = require("fs"); + +// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN +// Eliminates redundant PDB API calls under load +const pdbSourceCache = { + net: new Map(), // key: asn string → {data, ts} + netixlan: new Map(), // key: net_id string → {data, ts} + netfac: new Map(), // key: net_id string → {data, ts} + facCoords: new Map(), // key: fac_id string → {lat, lon, ts} + TTL_NET: 6 * 60 * 60 * 1000, // 6 hours + TTL_IXFAC: 6 * 60 * 60 * 1000, // 6 hours + TTL_COORDS: 7 * 24 * 60 * 60 * 1000, // 7 days + MAX_NET: 5000, + MAX_IXFAC: 5000, + MAX_COORDS: 10000, + hits: 0, + misses: 0, + + get(type, key) { + const map = this[type]; + const ttl = type === "facCoords" ? this.TTL_COORDS : (type === "net" ? this.TTL_NET : this.TTL_IXFAC); + const entry = map.get(String(key)); + if (!entry) { this.misses++; return null; } + if (Date.now() - entry.ts > ttl) { map.delete(String(key)); this.misses++; return null; } + this.hits++; + return entry.data; + }, + + set(type, key, data) { + const map = this[type]; + const max = type === "facCoords" ? this.MAX_COORDS : (type === "net" ? this.MAX_NET : this.MAX_IXFAC); + if (map.size >= max) { + // Evict oldest entry (Map preserves insertion order) + map.delete(map.keys().next().value); + } + map.set(String(key), { data, ts: Date.now() }); + }, + + // Disk persistence + saveToDisk(filePath) { + try { + const serialize = (map) => { + const obj = {}; + for (const [k, v] of map) obj[k] = v; + return obj; + }; + const data = JSON.stringify({ + ts: Date.now(), + net: serialize(this.net), + netixlan: serialize(this.netixlan), + netfac: serialize(this.netfac), + facCoords: serialize(this.facCoords), + }); + fs.writeFileSync(filePath, data); + console.log("[PDB-CACHE] Saved to disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")"); + } catch (e) { + console.warn("[PDB-CACHE] Disk save failed:", e.message); + } + }, + + loadFromDisk(filePath) { + try { + if (!fs.existsSync(filePath)) return false; + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + const now = Date.now(); + const load = (map, obj, ttl) => { + for (const [k, v] of Object.entries(obj || {})) { + if (now - v.ts < ttl) map.set(k, v); + } + }; + load(this.net, data.net, this.TTL_NET); + load(this.netixlan, data.netixlan, this.TTL_IXFAC); + load(this.netfac, data.netfac, this.TTL_IXFAC); + load(this.facCoords, data.facCoords, this.TTL_COORDS); + console.log("[PDB-CACHE] Loaded from disk (net=" + this.net.size + " ix=" + this.netixlan.size + " fac=" + this.netfac.size + ")"); + return true; + } catch (e) { + console.warn("[PDB-CACHE] Disk load failed:", e.message); + return false; + } + }, +}; + +module.exports = { pdbSourceCache }; diff --git a/server/caches/response-caches.js b/server/caches/response-caches.js new file mode 100644 index 0000000..35038f3 --- /dev/null +++ b/server/caches/response-caches.js @@ -0,0 +1,112 @@ +// Task 6: In-memory cache with TTL + Rate Limiting +const responseCache = new Map(); + +function cacheGet(key) { + const entry = responseCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expires) { + responseCache.delete(key); + return null; + } + return entry.data; +} + +function cacheSet(key, data, ttlMs) { + responseCache.set(key, { data, expires: Date.now() + ttlMs }); + // Evict old entries periodically (keep cache under 500 entries) + if (responseCache.size > 500) { + const now = Date.now(); + for (const [k, v] of responseCache) { + if (now > v.expires) responseCache.delete(k); + } + } +} + +const CACHE_TTL_LOOKUP = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_LOOKUP_DEGRADED = 90 * 1000; // matches the existing 90s guard for suspicious /api/validate zero-counts +const CACHE_TTL_DEFAULT = 5 * 60 * 1000; // 5 minutes + +// RDAP Cache — prevents 429 flooding on LACNIC/AFRINIC/APNIC/ARIN +const rdapCache = new Map(); // key: asn string, value: { data, ts } +const RDAP_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +function rdapCacheGet(asn) { + const e = rdapCache.get(String(asn)); + if (e && (Date.now() - e.ts) < RDAP_CACHE_TTL) return e.data; + return undefined; // undefined = not cached, null = cached miss +} +function rdapCacheSet(asn, data) { + if (rdapCache.size > 5000) { + rdapCache.delete(rdapCache.keys().next().value); + } + rdapCache.set(String(asn), { data, ts: Date.now() }); +} + +// WHOIS Cache — 24h TTL, prevents repeated RDAP hammering +const whoisCache = new Map(); // key: asn string, value: { data, ts, ttl } +const WHOIS_CACHE_TTL = 24 * 60 * 60 * 1000; +// A "not found" result after all 5 RIR sources failed is far more often a shared +// network blip (DNS, outbound connectivity, simultaneous rate-limit) than a real +// ASN existing nowhere across RIPE+APNIC+ARIN+LACNIC+AFRINIC -- fetchJSON can't +// tell timeout/network-error apart from a genuine 404, so cache that specific +// conclusion much more briefly than a confirmed, parsed WHOIS record. +const WHOIS_NOT_FOUND_CACHE_TTL = 10 * 60 * 1000; +function whoisCacheGet(asn) { + const e = whoisCache.get(String(asn)); + if (e && (Date.now() - e.ts) < (e.ttl || WHOIS_CACHE_TTL)) return e.data; + return undefined; +} +function whoisCacheSet(asn, data, ttl) { + if (whoisCache.size > 5000) whoisCache.delete(whoisCache.keys().next().value); + whoisCache.set(String(asn), { data, ts: Date.now(), ttl: ttl || WHOIS_CACHE_TTL }); +} + +// Quick-IX Cache — 1h TTL, for Peering Recommendations +const quickIxCache = new Map(); // key: asn string, value: { data, ts } +const QUICK_IX_CACHE_TTL = 60 * 60 * 1000; +function quickIxCacheGet(asn) { + const e = quickIxCache.get(String(asn)); + if (e && (Date.now() - e.ts) < QUICK_IX_CACHE_TTL) return e.data; + return undefined; +} +function quickIxCacheSet(asn, data) { + if (quickIxCache.size > 2000) quickIxCache.delete(quickIxCache.keys().next().value); + quickIxCache.set(String(asn), { data, ts: Date.now() }); +} + +// bgproutes + ASPA result caches — 15min TTL, prevent re-hitting slow APIs +const bgproutesResultCache = new Map(); +const aspaResultCache = new Map(); +const validateResultCache = new Map(); +const RESULT_CACHE_TTL = 15 * 60 * 1000; // 15 minutes +function resultCacheGet(map, key) { + const e = map.get(String(key)); + const ttl = e && e.ttl ? e.ttl : RESULT_CACHE_TTL; + if (e && (Date.now() - e.ts) < ttl) return e.data; + return undefined; +} +function resultCacheSet(map, key, data, ttlMs) { + if (map.size > 2000) map.delete(map.keys().next().value); + map.set(String(key), { data, ts: Date.now(), ttl: ttlMs || RESULT_CACHE_TTL }); +} + +module.exports = { + responseCache, + cacheGet, + cacheSet, + CACHE_TTL_LOOKUP, + CACHE_TTL_LOOKUP_DEGRADED, + CACHE_TTL_DEFAULT, + rdapCacheGet, + rdapCacheSet, + whoisCacheGet, + whoisCacheSet, + WHOIS_NOT_FOUND_CACHE_TTL, + quickIxCacheGet, + quickIxCacheSet, + bgproutesResultCache, + aspaResultCache, + validateResultCache, + resultCacheGet, + resultCacheSet, +}; diff --git a/server/caches/roa-store.js b/server/caches/roa-store.js new file mode 100644 index 0000000..c2a03a8 --- /dev/null +++ b/server/caches/roa-store.js @@ -0,0 +1,225 @@ +const fs = require("fs"); + +// Local ROA Store — validates prefixes without RIPE Stat API calls. +// Parses ~400k ROAs from the same Cloudflare RPKI feed used for ASPA. +// Uses sorted arrays + binary search for O(log n) lookups (~0.1ms per prefix) +const roaStore = { + v4Entries: [], // [{start, end, asn, prefixLen, maxLen}] sorted by start + v6Entries: [], // [{prefixHex, prefixLen, asn, maxLen}] sorted by prefixHex + ready: false, + count: 0, + lastBuild: 0, + + // Parse IPv4 prefix string to 32-bit unsigned integer + _ipv4ToUint32(ip) { + const parts = ip.split("."); + return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0; + }, + + // Build ROA store from Cloudflare feed roas array + build(roas) { + const v4 = []; + const v6 = []; + for (let i = 0; i < roas.length; i++) { + const r = roas[i]; + const asn = typeof r.asn === "string" ? parseInt(r.asn.replace("AS", "")) : Number(r.asn); + const prefix = r.prefix; + const maxLen = r.maxLength || r.maxPrefixLength || 0; + if (!prefix || !asn) continue; + + const slashIdx = prefix.indexOf("/"); + if (slashIdx < 0) continue; + const prefixLen = parseInt(prefix.substring(slashIdx + 1)); + const addr = prefix.substring(0, slashIdx); + + if (prefix.indexOf(":") >= 0) { + // IPv6 — store as zero-padded hex string for sorting + const expanded = this._expandIPv6(addr); + if (expanded) { + v6.push({ prefixHex: expanded, prefixLen, asn, maxLen: maxLen || prefixLen }); + } + } else { + // IPv4 — store as numeric range + const start = this._ipv4ToUint32(addr); + const hostBits = 32 - prefixLen; + const end = (start | ((1 << hostBits) - 1)) >>> 0; + v4.push({ start, end, asn, prefixLen, maxLen: maxLen || prefixLen }); + } + } + + // Sort for binary search + v4.sort((a, b) => a.start - b.start || a.prefixLen - b.prefixLen); + v6.sort((a, b) => a.prefixHex < b.prefixHex ? -1 : a.prefixHex > b.prefixHex ? 1 : a.prefixLen - b.prefixLen); + + this.v4Entries = v4; + this.v6Entries = v6; + this.count = v4.length + v6.length; + this.ready = true; + this.lastBuild = Date.now(); + }, + + // Expand IPv6 address to 32-char hex for reliable sorting + _expandIPv6(addr) { + try { + let groups = addr.split("::"); + let left = groups[0] ? groups[0].split(":") : []; + let right = groups.length > 1 && groups[1] ? groups[1].split(":") : []; + const missing = 8 - left.length - right.length; + const mid = []; + for (let i = 0; i < missing; i++) mid.push("0000"); + const all = [...left, ...mid, ...right]; + return all.map(g => g.padStart(4, "0")).join(""); + } catch (_e) { + return null; + } + }, + + // Validate a prefix against the local ROA store + // Returns: {prefix, status: "valid"|"invalid"|"not_found", validating_roas: N} + validate(asn, prefix) { + if (!this.ready) return null; // Signal caller to use fallback + + const asnNum = typeof asn === "string" ? parseInt(asn.replace("AS", "")) : Number(asn); + const slashIdx = prefix.indexOf("/"); + if (slashIdx < 0) return { prefix, status: "not_found", validating_roas: 0 }; + const prefixLen = parseInt(prefix.substring(slashIdx + 1)); + const addr = prefix.substring(0, slashIdx); + + if (prefix.indexOf(":") >= 0) { + return this._validateV6(asnNum, addr, prefixLen, prefix); + } + return this._validateV4(asnNum, addr, prefixLen, prefix); + }, + + _validateV4(asn, addr, prefixLen, prefix) { + const queryStart = this._ipv4ToUint32(addr); + const entries = this.v4Entries; + + // Binary search: find rightmost entry where start <= queryStart + let lo = 0, hi = entries.length - 1; + let insertionPoint = -1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (entries[mid].start <= queryStart) { + insertionPoint = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + // Scan backwards from insertion point to find covering ROAs + // ROAs are sorted by start, so we scan back while start could still cover our prefix + const matched = []; + const unmatchedAs = []; + for (let i = insertionPoint; i >= 0; i--) { + const e = entries[i]; + // If this ROA's network start is too far left, no more matches possible + if (queryStart - e.start > 0x01000000) break; // heuristic: skip if > /8 away + // Check if query prefix is contained within this ROA + if (e.start <= queryStart && queryStart <= e.end && prefixLen >= e.prefixLen) { + if (prefixLen <= e.maxLen) { + if (e.asn === asn) { + matched.push(e); + } else { + unmatchedAs.push(e); + } + } + // prefixLen > maxLen → too specific, invalid if ASN matches + else if (e.asn === asn) { + unmatchedAs.push(e); // ASN matches but length exceeds maxLen + } + } + } + + if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length }; + if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length }; + return { prefix, status: "not_found", validating_roas: 0 }; + }, + + _validateV6(asn, addr, prefixLen, prefix) { + const queryHex = this._expandIPv6(addr); + if (!queryHex) return { prefix, status: "not_found", validating_roas: 0 }; + const entries = this.v6Entries; + + // Binary search for approximate position + let lo = 0, hi = entries.length - 1; + let insertionPoint = -1; + while (lo <= hi) { + const mid = (lo + hi) >>> 1; + if (entries[mid].prefixHex <= queryHex) { + insertionPoint = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + const matched = []; + const unmatchedAs = []; + // Scan backwards from insertion point + for (let i = insertionPoint; i >= 0 && i > insertionPoint - 500; i--) { + const e = entries[i]; + // Check if query prefix is covered by this ROA entry + // A covering ROA has a shorter or equal prefix length and its network prefix matches + if (e.prefixLen <= prefixLen) { + // Compare the first prefixLen bits (in hex chars: prefixLen/4 chars, rounded up) + const hexChars = Math.ceil(e.prefixLen / 4); + if (queryHex.substring(0, hexChars) === e.prefixHex.substring(0, hexChars)) { + if (prefixLen <= e.maxLen) { + if (e.asn === asn) matched.push(e); + else unmatchedAs.push(e); + } else if (e.asn === asn) { + unmatchedAs.push(e); + } + } + } + // Stop if we're too far away + if (queryHex.substring(0, 4) !== e.prefixHex.substring(0, 4)) break; + } + + if (matched.length > 0) return { prefix, status: "valid", validating_roas: matched.length }; + if (unmatchedAs.length > 0) return { prefix, status: "invalid", validating_roas: unmatchedAs.length }; + return { prefix, status: "not_found", validating_roas: 0 }; + }, + + // Persist to disk for fast restart + saveToDisk(filePath) { + try { + const data = JSON.stringify({ + ts: this.lastBuild, + v4Count: this.v4Entries.length, + v6Count: this.v6Entries.length, + v4: this.v4Entries, + v6: this.v6Entries, + }); + fs.writeFileSync(filePath, data); + console.log("[ROA] Saved " + this.count + " ROAs to disk"); + } catch (e) { + console.warn("[ROA] Disk save failed:", e.message); + } + }, + + // Load from disk cache (returns true if loaded) + loadFromDisk(filePath) { + try { + if (!fs.existsSync(filePath)) return false; + const raw = fs.readFileSync(filePath, "utf8"); + const data = JSON.parse(raw); + // Only use if less than 6 hours old + if (Date.now() - data.ts > 6 * 60 * 60 * 1000) return false; + this.v4Entries = data.v4; + this.v6Entries = data.v6; + this.count = data.v4Count + data.v6Count; + this.lastBuild = data.ts; + this.ready = true; + console.log("[ROA] Loaded " + this.count + " ROAs from disk cache"); + return true; + } catch (e) { + console.warn("[ROA] Disk load failed:", e.message); + return false; + } + }, +}; + +module.exports = { roaStore };