const https = require("https"); const localDb = require("../../local-db-client"); const { UA } = require("../data/constants"); const { fetchJSON } = require("./http-helpers"); const { roaStore } = require("../caches/roa-store"); const { recordAspaAdoptionSnapshot } = require("../aspa-adoption/tracker"); // RPKI ASPA + ROA Cache from Cloudflare RPKI JSON feed const rpkiAspaMap = new Map(); // customer_asid -> Set let rpkiAspaLastFetch = 0; let rpkiAspaFetching = false; function fetchRpkiAspaFeed() { if (rpkiAspaFetching) return Promise.resolve(); rpkiAspaFetching = true; console.log("[RPKI] Fetching Cloudflare RPKI feed (ASPA + ROA)..."); return new Promise((resolve) => { const options = { headers: { "User-Agent": UA }, timeout: 120000, }; https.get("https://rpki.cloudflare.com/rpki.json", options, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { try { const parsed = JSON.parse(data); // Load ASPA objects const aspas = parsed.aspas || []; rpkiAspaMap.clear(); aspas.forEach((a) => { const customerAsid = Number(a.customer_asid); const providers = (a.providers || []).map(Number); rpkiAspaMap.set(customerAsid, new Set(providers)); }); // Load ROA objects into local store (eliminates RIPE Stat per-prefix calls) const roas = parsed.roas || []; roaStore.build(roas); roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json"); // Track ASPA adoption — persist to disk + update in-memory trend recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count, rpkiAspaMap); rpkiAspaLastFetch = Date.now(); console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed"); } catch (e) { console.error("[RPKI] Failed to parse RPKI feed:", e.message); } rpkiAspaFetching = false; resolve(); }); }).on("error", (e) => { console.error("[RPKI] Fetch failed:", e.message); rpkiAspaFetching = false; resolve(); }); }); } // Ensure ASPA + ROA cache is fresh async function ensureAspaCache() { if (Date.now() - rpkiAspaLastFetch > 4 * 60 * 60 * 1000) { await fetchRpkiAspaFeed(); } } // Lookup ASPA object for a given ASN from the RPKI feed cache. feedLoaded // distinguishes "the feed loaded fine and this ASN genuinely has no ASPA object" // from "the Cloudflare feed fetch has never succeeded, so we don't actually know" -- // rpkiAspaLastFetch is only set on a successful parse (see fetchRpkiAspaFeed), so // 0 means every attempt so far has failed and exists:false here is not trustworthy. function lookupAspaFromRpki(asn) { const asnNum = Number(asn); const feedLoaded = rpkiAspaLastFetch > 0; if (rpkiAspaMap.has(asnNum)) { const providers = rpkiAspaMap.get(asnNum); return { exists: true, providers: [...providers].sort((a, b) => a - b), feedLoaded }; } return { exists: false, providers: [], feedLoaded }; } // Validate RPKI for a prefix — uses local PostgreSQL database (sub-10ms, zero external API calls) // Returns: { prefix, status: "valid"|"invalid"|"not_found"|"unavailable", validating_roas: N } // "not_found" means the DB was queried and genuinely has no covering ROA -- a real result. // "unavailable" means the DB query itself failed (connection/timeout/error) -- we don't know. // Conflating these used to make DB hiccups masquerade as "no ROA published" (a real, worse- // sounding finding). Callers computing coverage percentages should exclude "unavailable". async function validateRPKIWithCache(asn, prefix) { try { const result = await localDb.validateRpki(prefix, asn); if (result.status === 'valid') { return { prefix, status: "valid", validating_roas: 1 }; } else if (result.status === 'invalid') { return { prefix, status: "invalid", validating_roas: 1 }; } else if (result.status === 'unknown') { return { prefix, status: "unavailable", validating_roas: 0 }; } else { // 'not-found': genuinely no covering ROA return { prefix, status: "not_found", validating_roas: 0 }; } } catch (_e) { console.error("[RPKI] Error validating " + prefix + ":", _e.message); return { prefix, status: "unavailable", validating_roas: 0 }; } } // Feature 30: RIPE NCC RPKI Validator cross-check (max 5 prefixes) async function fetchRipeRpkiValidator(asn, prefix) { try { const encoded = encodeURIComponent(prefix); const url = "https://rpki-validator.ripe.net/api/v1/validity/AS" + asn + "/" + encoded; const result = await fetchJSON(url, { timeout: 5000 }); if (result && result.validated_route) { return { prefix: prefix, validity: result.validated_route.validity || {}, state: (result.validated_route.validity && result.validated_route.validity.state) || "unknown", }; } return { prefix: prefix, state: "unknown", error: "no_data" }; } catch (_e) { return { prefix: prefix, state: "error", error: "timeout_or_unavailable" }; } } // Cross-check a sample of prefixes against RIPE RPKI Validator (max 5, in parallel). // agreement_pct only counts pairs where BOTH sources actually returned a real // verdict -- a zero-sample or all-RIPE-lookups-failed result reports agreement_pct: // null (not a fabricated 100), since nothing was actually cross-checked. async function crossCheckRpki(asn, prefixes, localResults) { const sample = prefixes.slice(0, 5); if (sample.length === 0) return { cloudflare_valid: 0, ripe_valid: 0, agreement_pct: null, disagreements: [], sample_size: 0, comparable_size: 0 }; const ripeResults = await Promise.all( sample.map((pfx) => fetchRipeRpkiValidator(asn, pfx)) ); const localMap = new Map(); for (const lr of localResults) { localMap.set(lr.prefix, lr.status); } let cloudflareValid = 0; let ripeValid = 0; let agreements = 0; let comparable = 0; const disagreements = []; for (let i = 0; i < sample.length; i++) { const pfx = sample[i]; const cfStatus = localMap.get(pfx) || "not_found"; const ripeState = ripeResults[i].state; const cfIsValid = cfStatus === "valid"; const ripeIsValid = ripeState === "valid" || ripeState === "VALID"; if (cfIsValid) cloudflareValid++; if (ripeIsValid) ripeValid++; // A failed/unknown RIPE lookup isn't a comparison at all -- exclude it from // the agreement percentage instead of counting it as a silent "agreement". if (ripeState === "error" || ripeState === "unknown") { continue; } comparable++; if (cfIsValid === ripeIsValid) { agreements++; } else { disagreements.push({ prefix: pfx, cloudflare: cfStatus, ripe: ripeState, }); } } const agreementPct = comparable > 0 ? Math.round((agreements / comparable) * 100) : null; return { cloudflare_valid: cloudflareValid, ripe_valid: ripeValid, agreement_pct: agreementPct, disagreements: disagreements, sample_size: sample.length, comparable_size: comparable }; } // rpkiAspaLastFetch is a reassigned `let`, not a mutated object -- a bare // export would only capture its value (0) at require time. Expose it via a // getter so callers (e.g. /api/health) always see the current value. function getRpkiAspaLastFetch() { return rpkiAspaLastFetch; } module.exports = { rpkiAspaMap, getRpkiAspaLastFetch, fetchRpkiAspaFeed, ensureAspaCache, lookupAspaFromRpki, validateRPKIWithCache, fetchRipeRpkiValidator, crossCheckRpki, };