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