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:
parent
dff1bd7dd4
commit
e5c1c2a6ae
443
server.js
443
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<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)
|
||||
|
||||
85
server/caches/pdb-source-cache.js
Normal file
85
server/caches/pdb-source-cache.js
Normal 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 };
|
||||
112
server/caches/response-caches.js
Normal file
112
server/caches/response-caches.js
Normal 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
225
server/caches/roa-store.js
Normal 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 };
|
||||
Loading…
x
Reference in New Issue
Block a user