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

185 lines
7.2 KiB
JavaScript

const fs = require("fs");
const { fetchJSON } = require("./http-helpers");
const localDb = require("../../local-db-client");
// RIPE Stat Source Cache + Semaphore (L2)
// Prevents 429 rate-limiting by throttling + caching responses
const ripeStatCache = new Map(); // key: "endpoint:resource" → {data, ts}
const RIPE_STAT_CACHE_MAX = 2000;
const RIPE_STAT_TTL = {
"announced-prefixes": 15 * 60 * 1000,
"asn-neighbours": 15 * 60 * 1000,
"as-overview": 60 * 60 * 1000,
"rir-stats-country": 24 * 60 * 60 * 1000,
"visibility": 15 * 60 * 1000,
"prefix-size-distribution": 60 * 60 * 1000,
"abuse-contact-finder": 24 * 60 * 60 * 1000,
"blocklist": 60 * 60 * 1000,
"reverse-dns-consistency": 60 * 60 * 1000,
"routing-status": 15 * 60 * 1000,
"bgp-updates": 15 * 60 * 1000,
"maxmind-geo-lite-pfx": 24 * 60 * 60 * 1000,
"looking-glass": 15 * 60 * 1000,
"whois": 24 * 60 * 60 * 1000,
"rpki-validation": 6 * 60 * 60 * 1000,
};
// Counting semaphore — limits concurrent RIPE Stat requests
class Semaphore {
constructor(max) { this.max = max; this.current = 0; this.queue = []; }
acquire() {
if (this.current < this.max) { this.current++; return Promise.resolve(); }
return new Promise((resolve) => this.queue.push(resolve));
}
release() {
this.current--;
if (this.queue.length > 0) { this.current++; this.queue.shift()(); }
}
}
const ripeStatSemaphore = new Semaphore(15);
// Cached + throttled RIPE Stat fetch
// Renamed from fetchRipeStatCached 2026-07-16: a second function with that exact
// name was added ~150 lines down (the local-DB-routing wrapper) during the Postgres
// refactor. In JS, two `function` declarations sharing a name silently collapse to
// the LAST one in source order -- the real RIPE Stat fetch+cache+throttle
// implementation below was completely shadowed, and every call to
// fetchRipeStatCached() for any endpoint NOT in {announced-prefixes, asn-neighbours,
// as-overview, visibility, prefix-size-distribution} was unconditionally returning
// null instead of ever reaching this code. That silently broke blocklist,
// abuse-contact-finder, bgp-updates, routing-status, looking-glass, whois,
// reverse-dns-consistency, maxmind-geo-lite-pfx, and ixs lookups -- see the router's
// fallback call to this function for the fix.
async function fetchRipeStatCachedFromApi(url, options) {
// Extract endpoint name from URL for TTL lookup
const match = url.match(/\/data\/([^/]+)\//);
const endpoint = match ? match[1] : "default";
const resourceMatch = url.match(/resource=([^&]+)/);
const resource = resourceMatch ? resourceMatch[1] : url;
const cacheKey = endpoint + ":" + resource;
// Check cache
const cached = ripeStatCache.get(cacheKey);
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
if (cached && (Date.now() - cached.ts) < ttl) {
return cached.data;
}
// Throttle via semaphore
await ripeStatSemaphore.acquire();
try {
// Double-check cache after acquiring semaphore (another request may have filled it)
const cached2 = ripeStatCache.get(cacheKey);
if (cached2 && (Date.now() - cached2.ts) < ttl) {
return cached2.data;
}
const result = await fetchJSON(url, options);
// Only cache successful results — never cache null (failed/rate-limited responses)
// Caching null causes cascading failures: retry hits cache, returns null again
if (result !== null) {
if (ripeStatCache.size >= RIPE_STAT_CACHE_MAX) {
ripeStatCache.delete(ripeStatCache.keys().next().value);
}
ripeStatCache.set(cacheKey, { data: result, ts: Date.now() });
}
return result;
} finally {
ripeStatSemaphore.release();
}
}
// Cached + throttled RIPE Stat with one retry on failure
async function fetchRipeStatCachedWithRetry(url, options) {
const result = await fetchRipeStatCached(url, options);
if (result !== null) return result;
await new Promise(r => setTimeout(r, 1500));
return fetchRipeStatCached(url, options);
}
// RIPE Stat cache disk persistence (skip null entries)
function saveRipeStatCacheToDisk(filePath) {
try {
const obj = {};
for (const [k, v] of ripeStatCache) {
if (v.data !== null) obj[k] = v;
}
fs.writeFileSync(filePath, JSON.stringify({ ts: Date.now(), entries: obj }));
console.log("[RIPE-CACHE] Saved " + Object.keys(obj).length + " entries to disk");
} catch (e) {
console.warn("[RIPE-CACHE] Disk save failed:", e.message);
}
}
function loadRipeStatCacheFromDisk(filePath) {
try {
if (!fs.existsSync(filePath)) return false;
const raw = fs.readFileSync(filePath, "utf8");
const data = JSON.parse(raw);
const now = Date.now();
for (const [k, v] of Object.entries(data.entries || {})) {
const match = k.match(/^([^:]+):/);
const endpoint = match ? match[1] : "default";
const ttl = RIPE_STAT_TTL[endpoint] || 15 * 60 * 1000;
if (now - v.ts < ttl) {
ripeStatCache.set(k, v);
}
}
console.log("[RIPE-CACHE] Loaded " + ripeStatCache.size + " entries from disk");
return true;
} catch (e) {
console.warn("[RIPE-CACHE] Disk load failed:", e.message);
return false;
}
}
// MASTER RIPE Stat API wrapper (Local-first, zero external API calls).
// Analyzes RIPE Stat URL and dispatches to appropriate localDb function.
async function fetchRipeStatCached(url, options = {}) {
try {
// Detect which RIPE Stat endpoint this is and call local DB
if (url.includes('/announced-prefixes/')) {
const asnMatch = url.match(/resource=AS(\d+)/);
if (asnMatch) return await localDb.getRipeStatAnnouncedPrefixes(parseInt(asnMatch[1]));
}
if (url.includes('/asn-neighbours/')) {
const asnMatch = url.match(/resource=AS(\d+)/);
if (asnMatch) return await localDb.getRipeStatAsnNeighbours(parseInt(asnMatch[1]));
}
if (url.includes('/as-overview/')) {
const asnMatch = url.match(/resource=AS(\d+)/);
if (asnMatch) return await localDb.getRipeStatAsOverview(parseInt(asnMatch[1]));
}
if (url.includes('/visibility/')) {
const asnMatch = url.match(/resource=AS(\d+)/);
if (asnMatch) return await localDb.getRipeStatVisibility(parseInt(asnMatch[1]));
}
if (url.includes('/prefix-size-distribution/')) {
const asnMatch = url.match(/resource=AS(\d+)/);
if (asnMatch) return await localDb.getRipeStatPrefixSizeDistribution(parseInt(asnMatch[1]));
}
// For every other RIPE Stat endpoint (not mirrored into the local Postgres DB) --
// e.g. blocklist, abuse-contact-finder, bgp-updates, routing-status, looking-glass,
// whois, reverse-dns-consistency, maxmind-geo-lite-pfx, ixs -- fall through to the
// real RIPE Stat API with its own cache/throttle/never-cache-null protections,
// instead of returning null unconditionally (see fetchRipeStatCachedFromApi's
// header comment for how this silently broke ~9 checks until 2026-07-16).
return fetchRipeStatCachedFromApi(url, options);
} catch (e) {
console.error("[fetchRipeStatCached] Error:", e.message);
return Promise.resolve(null);
}
}
module.exports = {
ripeStatCache,
fetchRipeStatCached,
fetchRipeStatCachedFromApi,
fetchRipeStatCachedWithRetry,
saveRipeStatCacheToDisk,
loadRipeStatCacheFromDisk,
Semaphore,
};