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.
226 lines
8.0 KiB
JavaScript
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 };
|