Real dependency graph was denser than the initial plan assumed: - recordAspaAdoptionSnapshot needed atlasState (atlas-probes.js) and rpkiAspaMap (rpki.js) -- threaded rpkiAspaMap through as an explicit parameter instead of a rpki.js<->tracker.js circular require. - atlasProbeCache-style reassignment problem recurred for rpkiAspaLastFetch (a `let`, not a mutated object) -- exposed via a getRpkiAspaLastFetch() getter, same fix pattern as atlas-probes.js's atlasState wrapper. - ripe-stat.js's master fetchRipeStatCached dispatcher lives ~600 lines away from its own cache/semaphore/fallback internals in the original file; kept together in one module per the extraction plan (splitting these apart previously caused the duplicate-declaration bug fixed earlier this session). Also drops confirmed-dead subCableCache/globalFacCache (declared, never read). Verified via smoke-test harness (28/28 match). server.js: 5622 -> 5018 lines.
201 lines
7.5 KiB
JavaScript
201 lines
7.5 KiB
JavaScript
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<provider_asn>
|
|
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,
|
|
};
|