PeerCortex/server/caches/roa-store.js
Rene Fichtmueller e5c1c2a6ae 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.
2026-07-16 23:28:00 +02:00

226 lines
8.0 KiB
JavaScript

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