Rene Fichtmueller 3ce478953a refactor: extract aspa-adoption/tracker.js, services/ripe-stat.js, services/rpki.js
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.
2026-07-16 23:51:30 +02:00

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