refactor: extract cache modules from server.js (roaStore, pdbSourceCache, response caches)

Also drops confirmed-dead code found during extraction: unused TIER1_ASNS
require (its only consumer, computeRouteLeakDetection, already moved to
server/route-leak.js with its own require) and bgproutesVpCache/
bgproutesVpCacheTs/BGPROUTES_VP_TTL (declared, never read anywhere).

Verified via smoke-test harness (28/28 match). server.js: 6302 -> 5907 lines.
This commit is contained in:
Rene Fichtmueller 2026-07-16 23:28:00 +02:00
parent dff1bd7dd4
commit e5c1c2a6ae
4 changed files with 446 additions and 419 deletions

443
server.js
View File

@ -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<provider_asn>
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)

View File

@ -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 };

View File

@ -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,
};

225
server/caches/roa-store.js Normal file
View File

@ -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 };