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) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
const {
|
||||||
// Task 6: In-memory cache with TTL + Rate Limiting
|
responseCache,
|
||||||
// ============================================================
|
cacheGet,
|
||||||
const responseCache = new Map();
|
cacheSet,
|
||||||
|
CACHE_TTL_LOOKUP,
|
||||||
function cacheGet(key) {
|
CACHE_TTL_LOOKUP_DEGRADED,
|
||||||
const entry = responseCache.get(key);
|
CACHE_TTL_DEFAULT,
|
||||||
if (!entry) return null;
|
rdapCacheGet,
|
||||||
if (Date.now() > entry.expires) {
|
rdapCacheSet,
|
||||||
responseCache.delete(key);
|
whoisCacheGet,
|
||||||
return null;
|
whoisCacheSet,
|
||||||
}
|
WHOIS_NOT_FOUND_CACHE_TTL,
|
||||||
return entry.data;
|
quickIxCacheGet,
|
||||||
}
|
quickIxCacheSet,
|
||||||
|
bgproutesResultCache,
|
||||||
function cacheSet(key, data, ttlMs) {
|
aspaResultCache,
|
||||||
responseCache.set(key, { data, expires: Date.now() + ttlMs });
|
validateResultCache,
|
||||||
// Evict old entries periodically (keep cache under 500 entries)
|
resultCacheGet,
|
||||||
if (responseCache.size > 500) {
|
resultCacheSet,
|
||||||
const now = Date.now();
|
} = require("./server/caches/response-caches");
|
||||||
for (const [k, v] of responseCache) {
|
// bgproutesVpCache/bgproutesVpCacheTs/BGPROUTES_VP_TTL removed 2026-07-16:
|
||||||
if (now > v.expires) responseCache.delete(k);
|
// confirmed dead code, never read anywhere in the file.
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// MANRS Participants Cache (scraped from public HTML page, 24h TTL)
|
// 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 rpkiAspaLastFetch = 0;
|
||||||
let rpkiAspaFetching = false;
|
let rpkiAspaFetching = false;
|
||||||
|
|
||||||
// ============================================================
|
const { roaStore } = require("./server/caches/roa-store");
|
||||||
// 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
|
const { pdbSourceCache } = require("./server/caches/pdb-source-cache");
|
||||||
_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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// RIPE Stat Source Cache + Semaphore (L2)
|
// 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