Feat(datasets): one updater engine for every offline dataset

MaxMind and CAIDA each had their own updater with its own lock, state file,
publish step, boot download and timer. One engine (common/dataset-updater.js)
now owns all of that, and each dataset is a row in common/datasets.js that
holds only its own logic: find the newest remote version, fetch or distill
it, validate, reload.

- Locks: proper-lockfile, a heartbeat lock shared by every process, so a
  crashed holder's lock goes stale on its own; a booting server waits for a
  live holder and loads what it published.
- State: one file per dataset, written with write-file-atomic; the old
  .maxmind-update-state.json / .caida-update-state.json are read once, so an
  upgrade doesn't re-download.
- Schedule: croner at a fixed time (DATASET_UPDATE_CRON, default 04:30 local)
  instead of 24 h from process start, plus a catch-up run at boot for a check
  a downtime skipped. DATASET_AUTO_UPDATE (default on) replaces
  MAXMIND_AUTO_UPDATE / CAIDA_AUTO_UPDATE, which still apply when it is unset.
- Reload: the engine watches every dataset's files, so a publish by another
  process or a hand-placed .mmdb is picked up; MaxMind's own watcher goes.

MaxMind's City and ASN editions now publish together under one version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 5.5 committed 2026-10-05 02:21:58 +08:00
1 parent 152906b762
commit 287c50fe1a
17 files changed
+1122 -1093

No files matched your search

+6 -4
View File
@@ -17,12 +17,14 @@ RIPESTAT_SOURCE_APP=""
# dashboard, not its name.
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_KV_NAMESPACE_ID=""
# MaxMind
# MaxMind — GeoLite2 is downloaded only when both are set
MAXMIND_ACCOUNT_ID=""
MAXMIND_LICENSE_KEY=""
MAXMIND_AUTO_UPDATE="false"
# CAIDA
CAIDA_AUTO_UPDATE="false"
# Offline datasets (MaxMind, CAIDA, PeeringDB): missing ones download at boot;
# all are re-checked daily at DATASET_UPDATE_CRON (server-local time) unless
# DATASET_AUTO_UPDATE is "false"
DATASET_AUTO_UPDATE="true"
DATASET_UPDATE_CRON="30 4 * * *"
# SITE
VITE_SITE_URL="https://ipcheck.ing"
# DOCS SITE — origin of the GitBook docs site powering the in-app
+4
View File
@@ -55,6 +55,10 @@ common/peeringdb-db/.caida-update-state.json
common/peeringdb-db/.caida-update.lock
common/peeringdb-db/*.bak
common/peeringdb-db/*.next
# Dataset updater (common/dataset-updater.js): state (+ its atomic-write
# temp files) and the lock directory, in every dataset directory.
common/*-db/.dataset-state.json*
common/*-db/.dataset.lock
.learnings/
docs/
.plan/
+14 -4
View File
@@ -42,10 +42,20 @@ behind `dns-resolver` (gated by `tests/dns-resolvers-data.test.js`).
no prefix or relationship counts — the ASN Profile has both.
- **`/api/whois` `?q=`:** IPs (RDAP, whoiser fallback), domains (whoiser, RDAP
fallback), `AS<n>` (RDAP autnum); all carry a `__raw` block.
- **Offline datasets** (`common/caida-updater.js` rows): fetched when missing at
boot, refreshed daily with `CAIDA_AUTO_UPDATE=true`; PeeringDB only with a
Cloudflare key. Its 116 MB dump is stream-distilled (never parsed whole) to a
~6 MB per-ASN index without contact data (`common/peeringdb-distill.js`).
- **Offline datasets** (MaxMind, CAIDA, PeeringDB) are rows in
`common/datasets.js`, run by one engine,
`common/dataset-updater.js` (lock, atomic state, publish, boot download,
daily schedule). A row holds only its own logic — find the newest remote
version, fetch / distill it, validate, reload; a new dataset is a new row,
never a new updater. Fetched when missing at boot; refreshed daily at
`DATASET_UPDATE_CRON` (default 04:30 local) unless `DATASET_AUTO_UPDATE=false`
(a row's pre-engine flag, e.g. `CAIDA_AUTO_UPDATE`, still applies when that
is unset), with a catch-up run at boot for a check a downtime skipped. Files
changed on disk by anyone else (another process, a hand-placed `.mmdb`) are
reloaded by the engine's watcher. MaxMind only with its credentials (City + ASN publish together);
PeeringDB only with a Cloudflare key; its 116 MB dump is
stream-distilled (never parsed whole) to a ~6 MB per-ASN index without
contact data (`common/peeringdb-distill.js`).
## Conventions
+18 -16
View File
@@ -46,9 +46,9 @@ import { isCompleteProfile } from './common/asn-profile.js';
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
import updateUserAchievement from './api/update-user-achievement.js';
import { reloadMaxMindDatabases, startMaxMindFileWatcher } from './common/maxmind-service.js';
import { startMaxMindAutoUpdate, bootstrapMaxMindIfMissing } from './common/maxmind-updater.js';
import { startCaidaAutoUpdate, bootstrapCaidaIfMissing } from './common/caida-updater.js';
import { reloadMaxMindDatabases, isMaxMindReady } from './common/maxmind-service.js';
import { bootstrapDatasets, startDatasetScheduler, watchDatasets } from './common/dataset-updater.js';
import { datasets } from './common/datasets.js';
import { bootstrapServiceStatus, startServiceStatusPolling } from './common/service-status-store.js';
import { initUpstreamUserAgent } from './common/upstream-ua.js';
@@ -338,22 +338,24 @@ if (process.env.SENTRY_DSN_BACKEND) {
}
// Bootstrap every offline dataset (MaxMind, CAIDA incl. the PeeringDB
// mirror) before accepting traffic so we never serve mid-download. Each step
// is non-fatal: a failure leaves the dependent API in a degraded state
// (MaxMind → 503; CAIDA → empty graph, RIPEstat fallback or no peering
// section) but doesn't block the listener.
// Bootstrap every offline dataset (common/datasets.js) before accepting
// traffic so we never serve mid-download. Each step is non-fatal: a failure
// leaves the dependent API in a degraded state (MaxMind → 503; CAIDA → empty
// graph, RIPEstat fallback or no peering section) but doesn't block the
// listener.
async function bootBackend() {
await bootstrapMaxMindIfMissing({ reload: reloadMaxMindDatabases });
await reloadMaxMindDatabases('startup').catch(() => {
logger.error('❌ MaxMind API will return 503 until databases are loaded successfully');
});
await bootstrapCaidaIfMissing();
await bootstrapDatasets(datasets);
// A database already on disk was not loaded by the bootstrap.
if (!isMaxMindReady()) {
await reloadMaxMindDatabases('startup').catch(() => {
logger.error('❌ MaxMind API will return 503 until databases are available: set MAXMIND_ACCOUNT_ID + '
+ 'MAXMIND_LICENSE_KEY, or drop GeoLite2-City.mmdb + GeoLite2-ASN.mmdb into common/maxmind-db/');
});
}
await bootstrapServiceStatus();
startMaxMindFileWatcher();
startMaxMindAutoUpdate({ reload: reloadMaxMindDatabases });
startCaidaAutoUpdate();
watchDatasets(datasets);
startDatasetScheduler(datasets);
startServiceStatusPolling();
app.listen(backEndPort, () => {
+3 -3
View File
@@ -1,5 +1,5 @@
// Local CAIDA as2org lookup. Snapshot file is auto-downloaded by
// common/caida-updater.js; this module just parses and serves.
// Local CAIDA as2org lookup. Snapshot file is auto-downloaded by the
// dataset updater (common/datasets.js); this module just parses and serves.
//
// We use CAIDA's pipe-delimited TXT (~12MB) rather than the equivalent
// JSONL (~28MB): identical content but split('|') beats JSON.parse per
@@ -81,7 +81,7 @@ function loadDatabase() {
loadDatabase();
/** Reload the snapshot after caida-updater publishes a fresh file. */
/** Reload the snapshot after the dataset updater publishes a fresh file. */
export function reloadAsOrgDatabase(reason = 'reload') {
logger.info(`🔄 Reloading CAIDA as2org snapshot (${reason})`);
loadDatabase();
+2 -2
View File
@@ -1,5 +1,5 @@
// Local CAIDA AS Relationships lookup. Snapshot is downloaded
// by common/caida-updater.js; this module parses and serves.
// by the dataset updater (common/datasets.js); this module parses and serves.
//
// CAIDA as-rel2 row format: `<a>|<b>|<rel>|<source>`
// rel = -1 → p2c (a is provider of b) ← providers + customers indexes
@@ -123,7 +123,7 @@ function loadDatabase() {
loadDatabase();
/** Reload the snapshot after caida-updater publishes a fresh file. */
/** Reload the snapshot after the dataset updater publishes a fresh file. */
export function reloadAsRelDatabase(reason = 'reload') {
logger.info(`🔄 Reloading CAIDA as-rel snapshot (${reason})`);
loadDatabase();
-453
View File
@@ -1,453 +0,0 @@
// Vendor-scoped CAIDA auto-updater. Each dataset is a row in `datasets`
// below; the orchestration (lock / state / atomic publish / validation /
// reload) is shared. Mirrors common/maxmind-updater.js's editions-array
// pattern.
//
// Registered datasets:
// - as2org (AS → org-name mapping) common/as-org-db
// - as-rel (AS relationships, p2c) common/as-rel-db
// - peeringdb (PeeringDB dump → per-ASN index) common/peeringdb-db
//
// All share CAIDA_AUTO_UPDATE=true to gate the periodic scheduler (off by
// default); bootstrap always runs so a fresh checkout works. A row may add
// `enabled()` (skipped by both when false — peeringdb needs the Cloudflare
// key, as the ASN Profile is hidden without it), leave out `decompress`
// (the download is used as is) and add `distill(rawPath, tempDir, { signal })`
// (turns the download into the published file; the raw file is deleted).
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import logger from './logger.js';
import { fetchUpstream } from './fetch-with-timeout.js';
import { withCronMonitor } from './sentry-cron.js';
import { createDecompressor } from './decompress.js';
import { AS_ORG_DB_DIR, AS_ORG_FILE, reloadAsOrgDatabase } from './as-org-db.js';
import { AS_REL_DB_DIR, AS_REL_FILE, reloadAsRelDatabase } from './as-rel-db.js';
import { PEERINGDB_DB_DIR, PEERINGDB_FILE, readPeeringdbIndex, reloadPeeringdbDatabase } from './peeringdb-db.js';
import { distillPeeringdbDump } from './peeringdb-distill.js';
import { hasRadarApiKey } from './cf-radar.js';
const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
const INITIAL_UPDATE_DELAY_MS = 60 * 1000;
const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
const BOOTSTRAP_TIMEOUT_MS = 2 * 60 * 1000;
const STATE_FILE = '.caida-update-state.json';
const LOCK_FILE = '.caida-update.lock';
// ---------- PeeringDB row helpers ----------
// Arrow consts, so declared ahead of the `datasets` registry that holds them.
// Healthy index has ~33k networks; <20k indicates truncation or schema change.
const validatePeeringdb = async (stagedPath) => {
const MIN_VALID = 20000;
const { nets } = readPeeringdbIndex(stagedPath);
if (nets.size < MIN_VALID) {
throw new Error(`Staged PeeringDB index has only ${nets.size} networks; refusing to publish`);
}
};
// CAIDA mirrors the full PeeringDB dump daily (a day late) under YYYY/MM/.
// List the current UTC month, then the previous one for the days before the
// month's first dump lands; the newest filename wins. The listing is small,
// so it takes fetchUpstream's timeout and User-Agent (the caller's signal
// still aborts it); the dump download itself does not. Exported for tests.
export const findPeeringdbDump = async ({ signal, now = new Date() } = {}) => {
const base = 'https://publicdata.caida.org/datasets/peeringdb/';
for (const back of [0, 1]) {
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - back, 1));
const dir = `${base}${month.getUTCFullYear()}/${String(month.getUTCMonth() + 1).padStart(2, '0')}/`;
const res = await fetchUpstream(dir, { signal });
if (res.status === 404) continue;
if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`);
const html = await res.text();
const names = [...html.matchAll(/\bpeeringdb_2_dump_\d{4}_\d{2}_\d{2}\.json\b/g)].map(m => m[0]);
if (names.length === 0) continue;
names.sort((a, b) => b.localeCompare(a));
return { url: dir + names[0], identifier: names[0] };
}
throw new Error('No PeeringDB dump in the current or previous month listing');
};
// A row without `enabled` is always on. Exported for tests.
export const isDatasetEnabled = (dataset) => !dataset.enabled || Boolean(dataset.enabled());
// Exported for tests (rows are re-pointed at a temp dir there).
export const datasets = [
{
id: 'as2org',
dbDir: AS_ORG_DB_DIR,
canonicalFile: AS_ORG_FILE,
archiveExt: '.txt.gz',
decompress: 'gzip',
// Stable 'latest' symlink server-side → HEAD + Last-Modified is enough.
findRemote: async ({ signal } = {}) => {
const url = 'https://publicdata.caida.org/datasets/as-organizations/latest.as-org2info.txt.gz';
const res = await fetch(url, { method: 'HEAD', redirect: 'follow', signal });
if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`);
return { url, identifier: res.headers.get('last-modified') };
},
validate: validateAsOrg,
reload: reloadAsOrgDatabase,
},
{
id: 'as-rel',
dbDir: AS_REL_DB_DIR,
canonicalFile: AS_REL_FILE,
archiveExt: '.txt.bz2',
decompress: 'bzip2',
// No 'latest' symlink — scrape directory listing, lex-sort YYYYMMDD
// filenames (= chrono), take the newest.
findRemote: async ({ signal } = {}) => {
const dir = 'https://publicdata.caida.org/datasets/as-relationships/serial-2/';
const res = await fetch(dir, { signal });
if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`);
const html = await res.text();
const matches = [...html.matchAll(/\b(\d{8})\.as-rel2\.txt\.bz2\b/g)];
if (matches.length === 0) throw new Error('No as-rel2.txt.bz2 found in listing');
matches.sort((a, b) => b[1].localeCompare(a[1]));
const filename = matches[0][0];
return { url: dir + filename, identifier: filename };
},
validate: validateAsRel,
reload: reloadAsRelDatabase,
},
{
id: 'peeringdb',
dbDir: PEERINGDB_DB_DIR,
canonicalFile: PEERINGDB_FILE,
archiveExt: '.json',
enabled: hasRadarApiKey,
findRemote: findPeeringdbDump,
// ~116 MB raw dump → ~6 MB index; only the index is published.
distill: async (rawPath, tempDir, { signal } = {}) => {
const outPath = path.join(tempDir, PEERINGDB_FILE);
const counts = await distillPeeringdbDump(rawPath, outPath, { signal });
logger.info({ dataset: 'peeringdb', ...counts }, 'PeeringDB dump distilled');
return outPath;
},
validate: validatePeeringdb,
reload: reloadPeeringdbDatabase,
},
];
let schedulerStarted = false;
const updateInProgress = new Set();
// ---------- Public API ----------
/**
* Download any missing CAIDA snapshots at boot. Always runs regardless of
* CAIDA_AUTO_UPDATE — a missing snapshot at boot means the operator wants
* one. Each dataset is independent; one failing doesn't abort others.
* Never throws.
*/
export async function bootstrapCaidaIfMissing() {
for (const dataset of datasets.filter(isDatasetEnabled)) {
try {
await bootstrapDataset(dataset);
} catch (error) {
logger.warn({ err: error, dataset: dataset.id }, '⚠️ CAIDA bootstrap failed');
}
}
}
/** Start the periodic updater. Opt-in via CAIDA_AUTO_UPDATE=true. */
export function startCaidaAutoUpdate() {
if (schedulerStarted) return;
schedulerStarted = true;
if (!isAutoUpdateEnabled()) return;
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN). Note runAllUpdates degrades per-dataset
// internally, so the monitor mainly catches a dead/hung scheduler.
const run = () => {
withCronMonitor('caida-auto-update', runAllUpdates, {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60,
maxRuntime: 30,
}).catch(error => {
logger.error({ err: error }, 'CAIDA auto update tick failed');
});
};
setTimeout(run, INITIAL_UPDATE_DELAY_MS).unref?.();
setInterval(run, UPDATE_INTERVAL_MS).unref?.();
const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS);
logger.info(`🗓️ CAIDA auto update plan: next check at ${nextRunAt.toLocaleString('en-US', { hour12: false })}, then every 24 hours (${datasets.filter(isDatasetEnabled).length} datasets)`);
}
// ---------- Orchestration ----------
async function runAllUpdates() {
for (const dataset of datasets.filter(isDatasetEnabled)) {
try {
await updateDataset(dataset);
} catch (error) {
logger.error({ err: error, dataset: dataset.id }, 'CAIDA update failed');
}
}
}
// Exported for tests (with a temp-dir dataset row); not part of the API.
export async function bootstrapDataset(dataset) {
await fsp.mkdir(dataset.dbDir, { recursive: true });
await clearOrphanedLock(dataset);
if (snapshotExists(dataset)) return { status: 'present' };
const timeoutMinutes = BOOTSTRAP_TIMEOUT_MS / 60000;
logger.warn(`📥 CAIDA ${dataset.id} snapshot missing; attempting initial download (timeout ${timeoutMinutes} min)...`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), BOOTSTRAP_TIMEOUT_MS);
timer.unref?.();
try {
const result = await updateDataset(dataset, {
signal: controller.signal,
reloadReason: 'bootstrap',
});
if (result.updated) {
logger.warn(`✅ CAIDA ${dataset.id} snapshot downloaded and ready`);
return { status: 'downloaded' };
}
logger.warn(`⚠️ CAIDA ${dataset.id} bootstrap did not publish (${result.reason}).`);
return { status: 'no-op', reason: result.reason };
} catch (error) {
const reason = controller.signal.aborted
? `download did not complete within ${timeoutMinutes} min`
: error.message;
logger.warn(`⚠️ CAIDA ${dataset.id} initial download failed: ${reason}`);
return { status: 'failed', error };
} finally {
clearTimeout(timer);
}
}
// Exported for tests, like bootstrapDataset.
export async function updateDataset(dataset, { signal, reloadReason = 'auto update' } = {}) {
if (updateInProgress.has(dataset.id)) {
return { updated: false, reason: 'already-running' };
}
updateInProgress.add(dataset.id);
await fsp.mkdir(dataset.dbDir, { recursive: true });
const lock = await acquireUpdateLock(dataset);
if (!lock) {
updateInProgress.delete(dataset.id);
return { updated: false, reason: 'locked' };
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), `myip-caida-${dataset.id}-`));
try {
const result = await downloadAndPublish(dataset, tempDir, { signal });
if (result.updated && dataset.reload) {
dataset.reload(reloadReason);
}
return result;
} finally {
updateInProgress.delete(dataset.id);
await fsp.rm(tempDir, { recursive: true, force: true });
await lock.release();
}
}
async function downloadAndPublish(dataset, tempDir, { signal } = {}) {
const state = await readUpdateState(dataset);
const remote = await dataset.findRemote({ signal });
const targetPath = path.join(dataset.dbDir, dataset.canonicalFile);
if (fs.existsSync(targetPath) && state.identifier && state.identifier === remote.identifier) {
return { updated: false, reason: 'not-modified' };
}
let stagedPath = await downloadAndDecompress(dataset, remote.url, tempDir, { signal });
if (dataset.distill) {
const rawPath = stagedPath;
try {
stagedPath = await dataset.distill(rawPath, tempDir, { signal });
} catch (error) {
throw new Error(`${dataset.id} distill phase failed: ${error.message}`);
} finally {
await fsp.rm(rawPath, { force: true });
}
}
await dataset.validate(stagedPath);
await publishFile(stagedPath, targetPath);
await writeUpdateState(dataset, {
identifier: remote.identifier,
updatedAt: new Date().toISOString(),
});
logger.info({ dataset: dataset.id, identifier: remote.identifier }, 'CAIDA snapshot updated');
return { updated: true, identifier: remote.identifier };
}
// Stream archive to disk, then decompress to staged .txt (a row without
// `decompress` stages the download itself). Split so errors clearly
// identify which phase failed (network vs decompression).
async function downloadAndDecompress(dataset, url, tempDir, { signal } = {}) {
const archivePath = path.join(tempDir, `archive${dataset.archiveExt}`);
const stagedPath = path.join(tempDir, dataset.canonicalFile);
const response = await fetch(url, { redirect: 'follow', signal });
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${dataset.id}: HTTP ${response.status}`);
}
try {
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal });
} catch (error) {
throw new Error(`${dataset.id} download phase failed: ${error.message}`);
}
if (!dataset.decompress) return archivePath;
try {
await pipeline(
fs.createReadStream(archivePath),
createDecompressor(dataset.decompress),
fs.createWriteStream(stagedPath),
);
} catch (error) {
throw new Error(`${dataset.id} ${dataset.decompress} decompression phase failed: ${error.message}`);
}
return stagedPath;
}
// ---------- Validators (per-dataset, refuse partial / corrupt downloads) ----------
// Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change.
async function validateAsOrg(stagedPath) {
const MIN_VALID = 50000;
const text = await fsp.readFile(stagedPath, 'utf8');
const orgs = new Map();
const pending = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length === 5 && parts[0] && parts[2]) {
orgs.set(parts[0], parts[2]);
} else if (parts.length === 6 && Number(parts[0]) && parts[3]) {
pending.push(parts[3]);
}
}
let resolved = 0;
for (const orgId of pending) if (orgs.has(orgId)) resolved++;
if (resolved < MIN_VALID) {
throw new Error(`Staged as2org has only ${resolved} resolvable ASNs; refusing to publish`);
}
}
// Healthy as-rel2 has ~400k p2c rows; <100k indicates truncation.
async function validateAsRel(stagedPath) {
const MIN_VALID = 100000;
const text = await fsp.readFile(stagedPath, 'utf8');
let p2cRows = 0;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length >= 3 && parts[2] === '-1') p2cRows++;
}
if (p2cRows < MIN_VALID) {
throw new Error(`Staged as-rel has only ${p2cRows} p2c rows; refusing to publish`);
}
}
// ---------- Generic helpers ----------
async function publishFile(stagedPath, targetPath) {
const nextPath = `${targetPath}.next`;
const backupPath = `${targetPath}.bak`;
await fsp.copyFile(stagedPath, nextPath);
await fsp.copyFile(targetPath, backupPath).catch(error => {
if (error.code !== 'ENOENT') throw error;
});
try {
await fsp.rename(nextPath, targetPath);
} catch (error) {
await fsp.copyFile(backupPath, targetPath).catch(() => {});
throw error;
} finally {
await Promise.all([
fsp.rm(nextPath, { force: true }),
fsp.rm(backupPath, { force: true }),
]);
}
}
function isAutoUpdateEnabled() {
return process.env.CAIDA_AUTO_UPDATE === 'true';
}
// Any non-dot file with the canonical file's extension (.txt for the
// CAIDA rows, so manual downloads under another name still count).
function snapshotExists(dataset) {
if (!fs.existsSync(dataset.dbDir)) return false;
const ext = path.extname(dataset.canonicalFile);
return fs.readdirSync(dataset.dbDir).some(f => f.endsWith(ext) && !f.startsWith('.'));
}
// At boot, any pre-existing lock is necessarily from a previous crashed run
// (we just started, no in-process updater can hold it). Clear it so a dead
// Ctrl+C doesn't block restart for LOCK_STALE_MS.
async function clearOrphanedLock(dataset) {
const lockPath = path.join(dataset.dbDir, LOCK_FILE);
if (fs.existsSync(lockPath)) {
await fsp.rm(lockPath, { force: true });
logger.warn(`🧹 Cleared orphaned CAIDA ${dataset.id} lock from previous boot`);
}
}
async function readUpdateState(dataset) {
try {
const content = await fsp.readFile(path.join(dataset.dbDir, STATE_FILE), 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') return {};
throw error;
}
}
async function writeUpdateState(dataset, state) {
await fsp.writeFile(
path.join(dataset.dbDir, STATE_FILE),
`${JSON.stringify(state, null, 2)}\n`,
'utf8',
);
}
async function acquireUpdateLock(dataset) {
const lockPath = path.join(dataset.dbDir, LOCK_FILE);
try {
const handle = await fsp.open(lockPath, 'wx');
await handle.writeFile(JSON.stringify({
pid: process.pid,
dataset: dataset.id,
startedAt: new Date().toISOString(),
}));
return {
release: async () => {
await handle.close();
await fsp.rm(lockPath, { force: true });
},
};
} catch (error) {
if (error.code !== 'EEXIST') throw error;
const stat = await fsp.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fsp.rm(lockPath, { force: true });
return acquireUpdateLock(dataset);
}
logger.info(`CAIDA ${dataset.id} update skipped: another process is updating`);
return null;
}
}
+360
View File
@@ -0,0 +1,360 @@
// One engine for every offline snapshot the backend serves — the rows in
// common/datasets.js. A row supplies the domain part: where the newest
// remote version is, how to fetch and distill it into the files it
// publishes, how to validate them, how to reload them. The engine owns the
// rest:
// - a cross-process lock per dataset directory (proper-lockfile: the lock
// carries a heartbeat, so a crashed holder's lock goes stale on its own);
// - a state file per dataset, written atomically (write-file-atomic);
// - publishing a row's files together, rolled back if any rename fails;
// - the boot download of a missing snapshot, under a timeout;
// - the daily schedule (croner), at a fixed wall-clock time, plus a
// catch-up run at boot for datasets a downtime kept from their check;
// - reloading files published by anyone else — another process, or an
// operator dropping files in by hand (watchDatasets).
//
// Row shape:
// id short name, used in logs
// dir directory holding the published files, state and lock
// files published file names, relative to `dir`
// enabled() optional; false skips the row everywhere (a missing key
// or credential), as if it weren't registered
// findRemote({ signal }) → { identifier, … } — identifier names the remote
// version; the whole object is handed on to fetch
// fetch({ remote, tempDir, signal }) → { [file]: stagedPath } for every
// name in `files`
// validate(staged) throws to refuse a truncated or corrupt download
// reload(reason) swaps the in-memory copy for the published files
// legacyState optional { file, toState(json) → { identifier, updatedAt } }:
// the state file an earlier updater wrote, read once so
// an upgrade doesn't re-download
// legacyAutoUpdateEnv optional env var name that used to gate this row's
// schedule (see isAutoUpdateEnabled)
//
// State file (`.dataset-state.json`): { identifier, updatedAt, checkedAt } —
// the published version, when it was published, when the remote was last
// asked about it.
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { setTimeout as sleep } from 'timers/promises';
import lockfile from 'proper-lockfile';
import writeFileAtomic from 'write-file-atomic';
import { Cron } from 'croner';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import { createDecompressor } from './decompress.js';
export const STATE_FILE = '.dataset-state.json';
export const LOCK_FILE = '.dataset.lock';
// A holder refreshes its lock every minute; one untouched for ten is dead.
const LOCK_OPTIONS = { stale: 10 * 60 * 1000, update: 60 * 1000 };
const LOCK_RETRY_MS = 2000;
const BOOTSTRAP_TIMEOUT_MS = 5 * 60 * 1000;
export const DEFAULT_UPDATE_CRON = '30 4 * * *';
// A dataset not checked for this long missed a daily run (the server was
// down at the scheduled time): check it shortly after boot.
const CATCH_UP_AFTER_MS = 25 * 60 * 60 * 1000;
const CATCH_UP_DELAY_MS = 60 * 1000;
// ---------- download helpers for rows ----------
/** Stream `url` into `destPath`. Long downloads: no fetchUpstream timeout, the caller's signal bounds it. */
export const downloadToFile = async (url, destPath, { signal, headers } = {}) => {
const response = await fetch(url, { redirect: 'follow', signal, headers });
if (!response.ok || !response.body) throw new Error(`download failed: HTTP ${response.status}`);
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destPath), { signal });
};
/** Decompress `srcPath` ('gzip' | 'bzip2') into `destPath`. */
export const decompressFile = async (srcPath, destPath, format) => {
await pipeline(fs.createReadStream(srcPath), createDecompressor(format), fs.createWriteStream(destPath));
};
// ---------- row state ----------
export const isRowEnabled = (row) => !row.enabled || Boolean(row.enabled());
/** Whether every file the row publishes is on disk. */
export const isPresent = (row) => row.files.every((file) => fs.existsSync(path.join(row.dir, file)));
/** The row's state, falling back to an earlier updater's state file; {} when neither exists. */
export const readState = async (row) => {
const read = async (file) => JSON.parse(await fsp.readFile(path.join(row.dir, file), 'utf8'));
try {
return await read(STATE_FILE);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
if (!row.legacyState) return {};
try {
return row.legacyState.toState(await read(row.legacyState.file)) || {};
} catch {
return {};
}
};
const writeState = (row, state) =>
writeFileAtomic(path.join(row.dir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
// The published files as this process last loaded them (mtime + size per
// file), so the watcher can tell someone else's publish from its own.
const loadedFingerprints = new Map();
const fingerprint = (row) => row.files.map((file) => {
try {
const stat = fs.statSync(path.join(row.dir, file));
return `${stat.mtimeMs}:${stat.size}`;
} catch {
return '-';
}
}).join('|');
const reloadRow = async (row, reason) => {
loadedFingerprints.set(row.id, fingerprint(row));
await row.reload(reason);
};
// ---------- lock ----------
// Resolves the release function, or null when another process holds the
// lock and `wait` is off. With `wait`, polls until the lock frees or `signal`
// aborts (which rejects).
const acquireLock = async (row, { wait = false, signal } = {}) => {
for (;;) {
try {
return await lockfile.lock(row.dir, {
...LOCK_OPTIONS,
lockfilePath: path.join(row.dir, LOCK_FILE),
// Losing the heartbeat mid-update must not kill the server;
// the update finishes and the next one re-locks.
onCompromised: (error) => logger.warn({ err: error, dataset: row.id }, 'dataset lock compromised'),
});
} catch (error) {
if (error.code !== 'ELOCKED') throw error;
if (!wait) return null;
await sleep(LOCK_RETRY_MS, undefined, { signal });
}
}
};
// ---------- publish ----------
// Copy every staged file next to its target, then rename them all into
// place; a failed rename restores the previous files.
const publish = async (row, staged) => {
const plan = row.files.map((file) => {
const target = path.join(row.dir, file);
return { source: staged[file], target, next: `${target}.next`, backup: `${target}.bak` };
});
try {
for (const step of plan) {
await fsp.copyFile(step.source, step.next);
await fsp.copyFile(step.target, step.backup).catch((error) => {
if (error.code !== 'ENOENT') throw error;
});
}
try {
for (const step of plan) await fsp.rename(step.next, step.target);
} catch (error) {
for (const step of plan) await fsp.copyFile(step.backup, step.target).catch(() => {});
throw error;
}
} finally {
for (const step of plan) {
await fsp.rm(step.next, { force: true });
await fsp.rm(step.backup, { force: true });
}
}
};
// ---------- update ----------
/**
* One locked update of `row`: ask the remote, and when it has a version the
* state doesn't (or a file is missing) fetch, validate, publish, record and
* reload. Resolves { updated: true, identifier } or { updated: false, reason:
* 'not-modified' | 'locked' }; throws when a step fails (nothing published).
* `wait` waits out another holder of the lock instead of answering 'locked'.
*/
export const updateDataset = async (row, { signal, reason = 'auto update', wait = false } = {}) => {
await fsp.mkdir(row.dir, { recursive: true });
const release = await acquireLock(row, { wait, signal });
if (!release) return { updated: false, reason: 'locked' };
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), `myip-dataset-${row.id}-`));
try {
const state = await readState(row);
const remote = await row.findRemote({ signal });
const now = new Date().toISOString();
if (isPresent(row) && state.identifier === remote.identifier) {
await writeState(row, { ...state, checkedAt: now });
return { updated: false, reason: 'not-modified' };
}
const staged = await row.fetch({ remote, tempDir, signal });
const missing = row.files.filter((file) => !staged[file]);
if (missing.length) throw new Error(`${row.id} fetch produced no ${missing.join(', ')}`);
await row.validate(staged);
await publish(row, staged);
await writeState(row, { identifier: remote.identifier, updatedAt: now, checkedAt: now });
logger.info({ dataset: row.id, identifier: remote.identifier }, 'dataset updated');
await reloadRow(row, reason);
return { updated: true, identifier: remote.identifier };
} finally {
await fsp.rm(tempDir, { recursive: true, force: true });
await release().catch(() => {});
}
};
/**
* Boot: download `row` if a file it publishes is missing, under a timeout.
* Another process (another backend, a manual run) holding the lock is waited
* out, and what it published is loaded. Never throws; resolves { status:
* 'disabled' | 'present' | 'downloaded' | 'loaded' | 'no-op' | 'failed' }.
*/
export const bootstrapDataset = async (row, { timeoutMs = BOOTSTRAP_TIMEOUT_MS } = {}) => {
if (!isRowEnabled(row)) return { status: 'disabled' };
if (isPresent(row)) return { status: 'present' };
const minutes = Math.round(timeoutMs / 60000);
logger.warn(`📥 Dataset ${row.id} missing; downloading (timeout ${minutes} min)...`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), timeoutMs);
timer.unref?.();
try {
const result = await updateDataset(row, { signal: controller.signal, reason: 'bootstrap', wait: true });
if (result.updated) {
logger.warn(`✅ Dataset ${row.id} downloaded and ready`);
return { status: 'downloaded' };
}
// The remote matched what another process published while we waited.
if (isPresent(row)) {
await reloadRow(row, 'bootstrap');
logger.warn(`✅ Dataset ${row.id} published by another process; loaded`);
return { status: 'loaded' };
}
logger.warn(`⚠️ Dataset ${row.id} bootstrap did not publish (${result.reason})`);
return { status: 'no-op' };
} catch (error) {
const why = controller.signal.aborted ? `did not complete within ${minutes} min` : error.message;
logger.warn({ err: error }, `⚠️ Dataset ${row.id} initial download failed: ${why}`);
return { status: 'failed', error };
} finally {
clearTimeout(timer);
}
};
/** Bootstrap every row in parallel; never throws. */
export const bootstrapDatasets = (rows) => Promise.all(rows.map((row) => bootstrapDataset(row)));
// ---------- schedule ----------
/**
* Whether the schedule updates `row`. DATASET_AUTO_UPDATE decides when set
* ('false' = off); otherwise the row's pre-engine flag when set, so an
* existing .env keeps its choice; otherwise on.
*/
export const isAutoUpdateEnabled = (row, env = process.env) => {
const flag = env.DATASET_AUTO_UPDATE;
if (flag) return flag !== 'false';
const legacy = row.legacyAutoUpdateEnv && env[row.legacyAutoUpdateEnv];
if (legacy) return legacy === 'true';
return true;
};
/** Update each row in turn; one failing doesn't stop the rest. */
export const updateDatasets = async (rows) => {
for (const row of rows) {
try {
await updateDataset(row);
} catch (error) {
logger.error({ err: error, dataset: row.id }, 'dataset update failed');
}
}
};
/**
* The rows of `rows` a downtime kept from their daily check: last checked
* (or published, for a state from before checks were recorded) longer ago
* than a day and a bit, or never. Exported for tests.
*/
export const rowsMissingACheck = async (rows, now = Date.now()) => {
const stale = [];
for (const row of rows) {
const { checkedAt, updatedAt } = await readState(row).catch(() => ({}));
const last = Date.parse(checkedAt || updatedAt);
if (!(now - last < CATCH_UP_AFTER_MS)) stale.push(row);
}
return stale;
};
let scheduler = null;
/**
* Run the auto-updating rows daily at DATASET_UPDATE_CRON (server-local time,
* default 04:30). `protect` skips a tick while the previous run is still
* going. Sentry Crons sees the run as one 'dataset-update' check-in.
*/
export const startDatasetScheduler = (rows) => {
if (scheduler) return scheduler;
const scheduled = rows.filter((row) => isRowEnabled(row) && isAutoUpdateEnabled(row));
if (!scheduled.length) {
logger.info('🗓️ Dataset auto update: off');
return null;
}
const pattern = process.env.DATASET_UPDATE_CRON || DEFAULT_UPDATE_CRON;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
scheduler = new Cron(pattern, { protect: true, unref: true }, () => withCronMonitor(
'dataset-update',
() => updateDatasets(scheduled),
{ schedule: { type: 'crontab', value: pattern }, timezone, checkinMargin: 60, maxRuntime: 60 },
).catch((error) => logger.error({ err: error }, 'dataset update run failed')));
const next = scheduler.nextRun()?.toLocaleString('en-US', { hour12: false });
logger.info(`🗓️ Dataset auto update: ${scheduled.map((row) => row.id).join(', ')} at cron ${pattern} (next ${next})`);
rowsMissingACheck(scheduled).then((stale) => {
if (!stale.length) return;
logger.info(`🗓️ Dataset catch-up in ${CATCH_UP_DELAY_MS / 1000}s: ${stale.map((row) => row.id).join(', ')}`);
setTimeout(() => updateDatasets(stale), CATCH_UP_DELAY_MS).unref?.();
});
return scheduler;
};
// ---------- watch ----------
/**
* Reload a row when its published files change under it — another process
* published, or someone dropped files in by hand — but not after this
* process's own publish (already reloaded). Polls every `intervalMs`; events
* within `settleMs` coalesce (a publish renames several files). Watches every
* row, enabled or not: hand-placed files need no credentials. Returns a
* stop function. Options are injectable for tests.
*/
export const watchDatasets = (rows, { intervalMs = 5000, settleMs = 1000 } = {}) => {
const stops = [];
for (const row of rows) {
if (!loadedFingerprints.has(row.id)) loadedFingerprints.set(row.id, fingerprint(row));
let timer = null;
const check = () => {
clearTimeout(timer);
timer = setTimeout(() => {
const current = fingerprint(row);
if (current === loadedFingerprints.get(row.id) || !isPresent(row)) return;
logger.info({ dataset: row.id }, 'dataset files changed on disk; reloading');
reloadRow(row, 'file change').catch((error) => {
logger.warn({ err: error, dataset: row.id }, 'dataset reload after a file change failed');
});
}, settleMs);
timer.unref?.();
};
for (const file of row.files) {
const target = path.join(row.dir, file);
fs.watchFile(target, { interval: intervalMs, persistent: false }, check);
stops.push(() => fs.unwatchFile(target, check));
}
stops.push(() => clearTimeout(timer));
}
return () => stops.forEach((stop) => stop());
};
+263
View File
@@ -0,0 +1,263 @@
// The offline datasets the backend serves, as rows for the engine in
// common/dataset-updater.js (row shape documented there). Each row holds
// only the dataset's own logic: where its newest remote version is, how to
// turn the download into the published files, a validator that refuses a
// truncated or corrupt download, and the reload of its in-memory copy.
//
// maxmind GeoLite2 City + ASN, published common/maxmind-service.js
// together; only with MaxMind credentials
// as2org CAIDA AS → org name common/as-org-db.js
// as-rel CAIDA AS relationships common/as-rel-db.js
// peeringdb PeeringDB dump (CAIDA mirror), common/peeringdb-db.js
// distilled to a per-ASN index; only with the Cloudflare key,
// as the ASN Profile is hidden without it
//
// Sources: https://dev.maxmind.com/geoip/geolite2-free-geolocation-data ·
// https://publicdata.caida.org/datasets/ (CAIDA AUA) · https://www.peeringdb.com
import fsp from 'fs/promises';
import path from 'path';
import * as tar from 'tar';
import maxmind from 'maxmind';
import logger from './logger.js';
import { fetchUpstream } from './fetch-with-timeout.js';
import { downloadToFile, decompressFile } from './dataset-updater.js';
import { AS_ORG_DB_DIR, AS_ORG_FILE, reloadAsOrgDatabase } from './as-org-db.js';
import { AS_REL_DB_DIR, AS_REL_FILE, reloadAsRelDatabase } from './as-rel-db.js';
import { PEERINGDB_DB_DIR, PEERINGDB_FILE, readPeeringdbIndex, reloadPeeringdbDatabase } from './peeringdb-db.js';
import { distillPeeringdbDump } from './peeringdb-distill.js';
import { hasRadarApiKey } from './cf-radar.js';
import { MAXMIND_DB_DIR, MAXMIND_CITY_DB, MAXMIND_ASN_DB, reloadMaxMindDatabases } from './maxmind-service.js';
// The pre-engine CAIDA updater's state: same { identifier, updatedAt } shape.
const CAIDA_LEGACY_STATE = { file: '.caida-update-state.json', toState: (json) => json };
// Download an archive and decompress it to the one file a row publishes.
const fetchCompressed = (file, format) => async ({ remote, tempDir, signal }) => {
const archive = path.join(tempDir, 'archive');
const staged = path.join(tempDir, file);
await downloadToFile(remote.url, archive, { signal });
await decompressFile(archive, staged, format);
return { [file]: staged };
};
// ---------- validators ----------
// Healthy as2org resolves ~110k ASNs; <50k indicates truncation or schema change.
const validateAsOrg = async ({ [AS_ORG_FILE]: stagedPath }) => {
const MIN_VALID = 50000;
const text = await fsp.readFile(stagedPath, 'utf8');
const orgs = new Map();
const pending = [];
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length === 5 && parts[0] && parts[2]) {
orgs.set(parts[0], parts[2]);
} else if (parts.length === 6 && Number(parts[0]) && parts[3]) {
pending.push(parts[3]);
}
}
let resolved = 0;
for (const orgId of pending) if (orgs.has(orgId)) resolved++;
if (resolved < MIN_VALID) {
throw new Error(`Staged as2org has only ${resolved} resolvable ASNs; refusing to publish`);
}
};
// Healthy as-rel2 has ~400k p2c rows; <100k indicates truncation.
const validateAsRel = async ({ [AS_REL_FILE]: stagedPath }) => {
const MIN_VALID = 100000;
const text = await fsp.readFile(stagedPath, 'utf8');
let p2cRows = 0;
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length >= 3 && parts[2] === '-1') p2cRows++;
}
if (p2cRows < MIN_VALID) {
throw new Error(`Staged as-rel has only ${p2cRows} p2c rows; refusing to publish`);
}
};
// Healthy index has ~33k networks; <20k indicates truncation or schema change.
const validatePeeringdb = async ({ [PEERINGDB_FILE]: stagedPath }) => {
const MIN_VALID = 20000;
const { nets } = readPeeringdbIndex(stagedPath);
if (nets.size < MIN_VALID) {
throw new Error(`Staged PeeringDB index has only ${nets.size} networks; refusing to publish`);
}
};
// ---------- remote lookups ----------
// CAIDA mirrors the full PeeringDB dump daily (a day late) under YYYY/MM/.
// List the current UTC month, then the previous one for the days before the
// month's first dump lands; the newest filename wins. Exported for tests.
export const findPeeringdbDump = async ({ signal, now = new Date() } = {}) => {
const base = 'https://publicdata.caida.org/datasets/peeringdb/';
for (const back of [0, 1]) {
const month = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - back, 1));
const dir = `${base}${month.getUTCFullYear()}/${String(month.getUTCMonth() + 1).padStart(2, '0')}/`;
const res = await fetchUpstream(dir, { signal });
if (res.status === 404) continue;
if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`);
const html = await res.text();
const names = [...html.matchAll(/\bpeeringdb_2_dump_\d{4}_\d{2}_\d{2}\.json\b/g)].map((m) => m[0]);
if (names.length === 0) continue;
names.sort((a, b) => b.localeCompare(a));
return { url: dir + names[0], identifier: names[0] };
}
throw new Error('No PeeringDB dump in the current or previous month listing');
};
// ---------- MaxMind ----------
// The two editions, published together: one version identifier covers both.
const MAXMIND_EDITIONS = [
{ editionId: 'GeoLite2-City', file: MAXMIND_CITY_DB },
{ editionId: 'GeoLite2-ASN', file: MAXMIND_ASN_DB },
];
export const hasMaxMindCredentials = () =>
Boolean(process.env.MAXMIND_ACCOUNT_ID && process.env.MAXMIND_LICENSE_KEY);
const maxmindUrl = (editionId) => `https://download.maxmind.com/geoip/databases/${editionId}/download?suffix=tar.gz`;
const maxmindAuth = () => ({
Authorization: `Basic ${Buffer.from(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`).toString('base64')}`,
});
// Last-Modified of each edition, joined: either edition changing is a new
// version. Exported for tests (and the legacy-state mapping below).
export const maxmindIdentifier = (lastModifieds) => lastModifieds.map((value) => value || '?').join(' | ');
// The pre-engine MaxMind updater kept { [editionId]: { lastModified, updatedAt } }.
const MAXMIND_LEGACY_STATE = {
file: '.maxmind-update-state.json',
toState: (json) => ({
identifier: maxmindIdentifier(MAXMIND_EDITIONS.map(({ editionId }) => json[editionId]?.lastModified)),
updatedAt: json['GeoLite2-City']?.updatedAt,
}),
};
// The archive nests the .mmdb in a dated directory; find it by name.
const findFile = async (dir, name) => {
for (const entry of await fsp.readdir(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === name) return entryPath;
if (entry.isDirectory()) {
const found = await findFile(entryPath, name);
if (found) return found;
}
}
return null;
};
// ---------- rows ----------
// Exported for tests (rows are re-pointed at a temp dir there).
export const datasets = [
{
id: 'maxmind',
dir: MAXMIND_DB_DIR,
files: MAXMIND_EDITIONS.map(({ file }) => file),
enabled: hasMaxMindCredentials,
legacyState: MAXMIND_LEGACY_STATE,
legacyAutoUpdateEnv: 'MAXMIND_AUTO_UPDATE',
findRemote: async ({ signal } = {}) => {
const lastModifieds = [];
for (const { editionId } of MAXMIND_EDITIONS) {
const res = await fetchUpstream(maxmindUrl(editionId), { method: 'HEAD', headers: maxmindAuth(), signal });
if (!res.ok) throw new Error(`Failed to check ${editionId}: HTTP ${res.status}`);
lastModifieds.push(res.headers.get('last-modified'));
}
return { identifier: maxmindIdentifier(lastModifieds) };
},
fetch: async ({ tempDir, signal }) => {
const staged = {};
for (const { editionId, file } of MAXMIND_EDITIONS) {
const archive = path.join(tempDir, `${editionId}.tar.gz`);
const extractDir = path.join(tempDir, editionId);
await fsp.mkdir(extractDir);
await downloadToFile(maxmindUrl(editionId), archive, { signal, headers: maxmindAuth() });
await tar.x({ file: archive, cwd: extractDir });
staged[file] = await findFile(extractDir, file);
if (!staged[file]) throw new Error(`${editionId} archive did not contain ${file}`);
}
return staged;
},
// Both must open as MaxMind databases.
validate: async (staged) => {
await Promise.all(MAXMIND_EDITIONS.map(({ file }) => maxmind.open(staged[file])));
},
reload: reloadMaxMindDatabases,
},
{
id: 'as2org',
dir: AS_ORG_DB_DIR,
files: [AS_ORG_FILE],
legacyState: CAIDA_LEGACY_STATE,
legacyAutoUpdateEnv: 'CAIDA_AUTO_UPDATE',
// Stable 'latest' symlink server-side → HEAD + Last-Modified is enough.
findRemote: async ({ signal } = {}) => {
const url = 'https://publicdata.caida.org/datasets/as-organizations/latest.as-org2info.txt.gz';
const res = await fetchUpstream(url, { method: 'HEAD', signal });
if (!res.ok) throw new Error(`HEAD failed: HTTP ${res.status}`);
return { url, identifier: res.headers.get('last-modified') };
},
fetch: fetchCompressed(AS_ORG_FILE, 'gzip'),
validate: validateAsOrg,
reload: reloadAsOrgDatabase,
},
{
id: 'as-rel',
dir: AS_REL_DB_DIR,
files: [AS_REL_FILE],
legacyState: CAIDA_LEGACY_STATE,
legacyAutoUpdateEnv: 'CAIDA_AUTO_UPDATE',
// No 'latest' symlink — scrape the directory listing; YYYYMMDD
// filenames sort chronologically, the newest wins.
findRemote: async ({ signal } = {}) => {
const dir = 'https://publicdata.caida.org/datasets/as-relationships/serial-2/';
const res = await fetchUpstream(dir, { signal });
if (!res.ok) throw new Error(`Directory listing failed: HTTP ${res.status}`);
const html = await res.text();
const matches = [...html.matchAll(/\b(\d{8})\.as-rel2\.txt\.bz2\b/g)];
if (matches.length === 0) throw new Error('No as-rel2.txt.bz2 found in listing');
matches.sort((a, b) => b[1].localeCompare(a[1]));
const filename = matches[0][0];
return { url: dir + filename, identifier: filename };
},
fetch: fetchCompressed(AS_REL_FILE, 'bzip2'),
validate: validateAsRel,
reload: reloadAsRelDatabase,
},
{
id: 'peeringdb',
dir: PEERINGDB_DB_DIR,
files: [PEERINGDB_FILE],
enabled: hasRadarApiKey,
legacyState: CAIDA_LEGACY_STATE,
legacyAutoUpdateEnv: 'CAIDA_AUTO_UPDATE',
findRemote: findPeeringdbDump,
// ~116 MB raw dump → ~6 MB index; only the index is published.
fetch: async ({ remote, tempDir, signal }) => {
const raw = path.join(tempDir, 'dump.json');
const staged = path.join(tempDir, PEERINGDB_FILE);
await downloadToFile(remote.url, raw, { signal });
try {
const counts = await distillPeeringdbDump(raw, staged, { signal });
logger.info({ dataset: 'peeringdb', ...counts }, 'PeeringDB dump distilled');
} catch (error) {
throw new Error(`peeringdb distill failed: ${error.message}`);
} finally {
await fsp.rm(raw, { force: true });
}
return { [PEERINGDB_FILE]: staged };
},
validate: validatePeeringdb,
reload: reloadPeeringdbDatabase,
},
];
+1 -34
View File
@@ -1,4 +1,3 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import maxmind from 'maxmind';
@@ -33,11 +32,9 @@ export const normalizeLang = (tag) => {
let cityLookup = null;
let asnLookup = null;
let reloadPromise = null;
let watchersStarted = false;
let reloadDebounceTimer = null;
/**
* Return the canonical MaxMind database paths used by the backend and updater.
* Return the canonical MaxMind database paths.
*/
export function getMaxMindDbPaths() {
return {
@@ -92,36 +89,6 @@ export async function reloadMaxMindDatabases(reason = 'manual') {
return reloadPromise;
}
/**
* Watch database files and reload readers when another process publishes new files.
*/
export function startMaxMindFileWatcher() {
if (watchersStarted) {
return;
}
watchersStarted = true;
// Debounce file events so City and ASN replacements are handled as one reload.
const scheduleReload = () => {
clearTimeout(reloadDebounceTimer);
reloadDebounceTimer = setTimeout(() => {
reloadMaxMindDatabases('file change').catch(() => {
// Keep the existing readers when a newly written database is invalid.
});
}, 1000);
reloadDebounceTimer.unref?.();
};
for (const filePath of [cityDbPath, asnDbPath]) {
fs.watchFile(filePath, { interval: 5000, persistent: false }, (current, previous) => {
if (current.mtimeMs !== previous.mtimeMs || current.size !== previous.size) {
scheduleReload();
}
});
}
}
/**
* Bare ISO country code of an IP (null when unknown) plus the prefix length
* of the MaxMind network holding it, so a caller can walk a range one network
-532
View File
@@ -1,532 +0,0 @@
import fs from 'fs';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import * as tar from 'tar';
import maxmind from 'maxmind';
import logger from './logger.js';
import { withCronMonitor } from './sentry-cron.js';
import {
getMaxMindDbPaths,
MAXMIND_ASN_DB,
MAXMIND_CITY_DB,
} from './maxmind-service.js';
const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const INITIAL_UPDATE_DELAY_MS = 60 * 1000;
const LOCK_STALE_MS = 2 * 60 * 60 * 1000;
// Cap the blocking "download databases at boot if missing" path. Long enough
// for a reasonable initial download over slow residential links, short enough
// that a misconfigured credential or a blocked outbound connection doesn't
// hold up the whole server indefinitely. We only abort the download — the
// server still starts after this timeout, just without MaxMind ready.
const BOOTSTRAP_TIMEOUT_MS = 5 * 60 * 1000;
const STATE_FILE = '.maxmind-update-state.json';
const LOCK_FILE = '.maxmind-update.lock';
const MAXMIND_UPDATE_ENV_KEYS = [
'MAXMIND_ACCOUNT_ID',
'MAXMIND_LICENSE_KEY',
'MAXMIND_AUTO_UPDATE',
];
const editions = [
{
editionId: 'GeoLite2-City',
fileName: MAXMIND_CITY_DB,
},
{
editionId: 'GeoLite2-ASN',
fileName: MAXMIND_ASN_DB,
},
];
let schedulerStarted = false;
let updateInProgress = false;
/**
* Start the periodic MaxMind updater when credentials and opt-in env vars are present.
*/
export function startMaxMindAutoUpdate({ reload } = {}) {
if (schedulerStarted) {
return;
}
schedulerStarted = true;
if (!hasAnyUpdateEnvironment()) {
return;
}
if (!isAutoUpdateEnabled()) {
logger.info('MaxMind auto update plan: disabled');
return;
}
if (!hasDownloadCredentials()) {
logger.info('MaxMind auto update skipped: MAXMIND_ACCOUNT_ID or MAXMIND_LICENSE_KEY is missing');
return;
}
// Run one update cycle and keep the scheduler alive if that cycle fails.
// withCronMonitor reports ok/error check-ins to Sentry Crons (no-op
// without a backend DSN) so a silently dead scheduler gets noticed.
const run = () => {
withCronMonitor('maxmind-auto-update', () => updateMaxMindDatabases({ reload }), {
schedule: { type: 'interval', value: UPDATE_INTERVAL_MS / (60 * 60 * 1000), unit: 'hour' },
checkinMargin: 60, // minutes of lateness tolerated before "missed"
maxRuntime: 30, // minutes before an in-progress run counts as failed
}).catch(error => {
logger.error({ err: error }, 'MaxMind auto update failed');
});
};
const initialTimer = setTimeout(run, INITIAL_UPDATE_DELAY_MS);
initialTimer.unref?.();
const intervalTimer = setInterval(run, UPDATE_INTERVAL_MS);
intervalTimer.unref?.();
logUpdatePlan();
}
/**
* One-shot "download the MaxMind databases at boot if they are missing" path.
*
* The decision tree matches what we want the operator to experience on a
* cold checkout:
* - Both mmdb files already on disk → do nothing, let reload handle it.
* - Files missing AND no MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY configured
* → print a clear "how to fix this" warning and return; server will start
* anyway but the MaxMind API will 503 until DBs are provided.
* - Files missing AND credentials present → run a single download cycle,
* capped at BOOTSTRAP_TIMEOUT_MS. On failure (timeout, auth error, network
* block), log a warning and return; the server still starts.
*
* MAXMIND_AUTO_UPDATE is intentionally NOT consulted here — that flag only
* gates the periodic scheduler. If the operator put valid credentials in .env,
* the intent is obviously "I want MaxMind working", and making them set a
* second flag just to get the first download would be confusing.
*
* Never throws: the caller ({@link ./../backend-server.js}) treats this as
* advisory and always proceeds to `app.listen`.
*/
export async function bootstrapMaxMindIfMissing({ reload } = {}) {
const { cityDbPath, asnDbPath } = getMaxMindDbPaths();
if (fs.existsSync(cityDbPath) && fs.existsSync(asnDbPath)) {
return { status: 'present' };
}
if (!hasDownloadCredentials()) {
logger.warn(
'⚠️ MaxMind databases are missing and MAXMIND_ACCOUNT_ID / MAXMIND_LICENSE_KEY are not configured.\n' +
' Set the credentials in .env and restart, or drop GeoLite2-City.mmdb + GeoLite2-ASN.mmdb\n' +
' into common/maxmind-db/. Starting server anyway; MaxMind API will return 503 until\n' +
' databases are available.'
);
return { status: 'missing-credentials' };
}
const timeoutMinutes = BOOTSTRAP_TIMEOUT_MS / 60000;
logger.info(`📥 MaxMind databases missing; attempting initial download (timeout ${timeoutMinutes} min)...`);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('bootstrap timed out')), BOOTSTRAP_TIMEOUT_MS);
timer.unref?.();
try {
await updateMaxMindDatabases({
reload,
signal: controller.signal,
reloadReason: 'bootstrap',
});
return { status: 'downloaded' };
} catch (error) {
// Disambiguate "we hit the 5-minute cap" from "the download itself
// failed" so operators know whether to look at network / firewall vs
// credentials / MaxMind account status.
const reason = controller.signal.aborted
? `download did not complete within ${timeoutMinutes} min (check connectivity to download.maxmind.com)`
: error.message;
logger.warn(
`⚠️ MaxMind initial download failed: ${reason}\n` +
' Starting server anyway; MaxMind API will return 503 until databases are available.'
);
return { status: 'failed', error };
} finally {
clearTimeout(timer);
}
}
/**
* Run one locked update cycle and reload readers only after a successful publish.
*
* `signal` is an optional AbortSignal that is threaded into every HTTP fetch
* and stream pipeline, so callers (notably the bootstrap path at startup) can
* cap the whole cycle with a single timeout and have in-flight network I/O
* actually stop instead of quietly dangling in the background.
*
* `reloadReason` lets the caller tag the reload log line ("auto update" for
* the scheduler, "bootstrap" for the startup download, etc.).
*/
export async function updateMaxMindDatabases({ reload, signal, reloadReason = 'auto update' } = {}) {
if (updateInProgress) {
return { updated: false, reason: 'already-running' };
}
updateInProgress = true;
const { dbDir } = getMaxMindDbPaths();
await fsp.mkdir(dbDir, { recursive: true });
const lock = await acquireUpdateLock(dbDir);
if (!lock) {
updateInProgress = false;
return { updated: false, reason: 'locked' };
}
const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'myip-maxmind-'));
try {
const result = await downloadAndReplaceDatabases(dbDir, tempDir, { signal });
if (result.updated && reload) {
await reload(reloadReason);
}
return result;
} finally {
updateInProgress = false;
await fsp.rm(tempDir, { recursive: true, force: true });
await lock.release();
}
}
/**
* Check the explicit opt-in flag for automatic MaxMind updates.
*/
function isAutoUpdateEnabled() {
return process.env.MAXMIND_AUTO_UPDATE === 'true';
}
/**
* Check whether both MaxMind download credential variables are configured.
*/
function hasDownloadCredentials() {
return Boolean(process.env.MAXMIND_ACCOUNT_ID && process.env.MAXMIND_LICENSE_KEY);
}
/**
* Check whether the deployment has any MaxMind update-related environment config.
*/
function hasAnyUpdateEnvironment() {
return MAXMIND_UPDATE_ENV_KEYS.some(key => Object.hasOwn(process.env, key));
}
/**
* Print the next MaxMind update check and repeat interval.
*/
function logUpdatePlan() {
const nextRunAt = new Date(Date.now() + INITIAL_UPDATE_DELAY_MS);
logger.info(`🗓️ MaxMind auto update plan: next check at ${formatScheduleTime(nextRunAt)}, then every ${formatDuration(UPDATE_INTERVAL_MS)}`);
}
/**
* Format scheduler timestamps for startup logs.
*/
function formatScheduleTime(date) {
return date.toLocaleString('en-US', { hour12: false });
}
/**
* Format millisecond durations into compact hour or minute text.
*/
function formatDuration(milliseconds) {
const hours = milliseconds / (60 * 60 * 1000);
if (Number.isInteger(hours)) {
return `${hours} hour${hours === 1 ? '' : 's'}`;
}
const minutes = milliseconds / (60 * 1000);
if (Number.isInteger(minutes)) {
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
return `${milliseconds} ms`;
}
/**
* Download all required newer editions, validate them, then publish them together.
*/
async function downloadAndReplaceDatabases(dbDir, tempDir, { signal } = {}) {
const state = await readUpdateState(dbDir);
const plannedUpdates = [];
for (const edition of editions) {
const remoteInfo = await getRemoteInfo(edition, { signal });
const targetPath = path.join(dbDir, edition.fileName);
if (!shouldDownloadDatabase(targetPath, state, edition.editionId, remoteInfo.lastModified)) {
continue;
}
const downloadedPath = await downloadAndExtractDatabase(edition, tempDir, { signal });
plannedUpdates.push({
...edition,
lastModified: remoteInfo.lastModified,
sourcePath: downloadedPath,
targetPath,
});
}
if (plannedUpdates.length === 0) {
return { updated: false, reason: 'not-modified' };
}
await validatePlannedDatabases(dbDir, plannedUpdates);
await publishDatabases(plannedUpdates);
for (const update of plannedUpdates) {
state[update.editionId] = {
lastModified: update.lastModified,
updatedAt: new Date().toISOString(),
};
}
await writeUpdateState(dbDir, state);
logger.info({ editions: plannedUpdates.map(update => update.editionId) }, 'MaxMind databases updated');
return {
updated: true,
editions: plannedUpdates.map(update => update.editionId),
};
}
/**
* Fetch remote metadata for a MaxMind edition without downloading the archive.
*/
async function getRemoteInfo(edition, { signal } = {}) {
const response = await fetch(getDownloadUrl(edition.editionId), {
method: 'HEAD',
headers: getAuthHeaders(),
redirect: 'follow',
signal,
});
if (!response.ok) {
throw new Error(`Failed to check ${edition.editionId}: HTTP ${response.status}`);
}
return {
lastModified: response.headers.get('last-modified'),
};
}
/**
* Decide whether the local database should be refreshed from MaxMind.
*/
function shouldDownloadDatabase(targetPath, state, editionId, lastModified) {
if (!fs.existsSync(targetPath)) {
return true;
}
if (!lastModified) {
return true;
}
const lastRemoteUpdate = Date.parse(lastModified);
if (Number.isNaN(lastRemoteUpdate)) {
return true;
}
if (!state[editionId]?.lastModified) {
return true;
}
const recordedUpdate = Date.parse(state[editionId].lastModified);
return Number.isNaN(recordedUpdate) || lastRemoteUpdate > recordedUpdate;
}
/**
* Download a MaxMind archive, extract it, and return the matching .mmdb file path.
*/
async function downloadAndExtractDatabase(edition, tempDir, { signal } = {}) {
const editionDir = path.join(tempDir, edition.editionId);
const archivePath = path.join(tempDir, `${edition.editionId}.tar.gz`);
await fsp.mkdir(editionDir, { recursive: true });
const response = await fetch(getDownloadUrl(edition.editionId), {
headers: getAuthHeaders(),
redirect: 'follow',
signal,
});
if (!response.ok || !response.body) {
throw new Error(`Failed to download ${edition.editionId}: HTTP ${response.status}`);
}
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(archivePath), { signal });
await tar.x({ file: archivePath, cwd: editionDir });
const mmdbPath = await findExtractedDatabase(editionDir, edition.fileName);
if (!mmdbPath) {
throw new Error(`Downloaded ${edition.editionId} archive did not contain ${edition.fileName}`);
}
return mmdbPath;
}
/**
* Open the staged database set to ensure both City and ASN files are valid.
*/
async function validatePlannedDatabases(dbDir, plannedUpdates) {
const stagedPaths = new Map(plannedUpdates.map(update => [update.fileName, update.sourcePath]));
const cityPath = stagedPaths.get(MAXMIND_CITY_DB) || path.join(dbDir, MAXMIND_CITY_DB);
const asnPath = stagedPaths.get(MAXMIND_ASN_DB) || path.join(dbDir, MAXMIND_ASN_DB);
await Promise.all([
maxmind.open(cityPath),
maxmind.open(asnPath),
]);
}
/**
* Atomically publish prepared databases while keeping backups for rollback.
*/
async function publishDatabases(plannedUpdates) {
const preparedUpdates = plannedUpdates.map(update => ({
...update,
nextPath: `${update.targetPath}.next`,
backupPath: `${update.targetPath}.bak`,
}));
for (const update of preparedUpdates) {
await fsp.copyFile(update.sourcePath, update.nextPath);
await fsp.copyFile(update.targetPath, update.backupPath).catch(error => {
if (error.code !== 'ENOENT') {
throw error;
}
});
}
try {
for (const update of preparedUpdates) {
await fsp.rename(update.nextPath, update.targetPath);
}
} catch (error) {
for (const update of preparedUpdates) {
await fsp.copyFile(update.backupPath, update.targetPath).catch(() => {});
}
throw error;
} finally {
for (const update of preparedUpdates) {
await Promise.all([
fsp.rm(update.nextPath, { force: true }),
fsp.rm(update.backupPath, { force: true }),
]);
}
}
}
/**
* Recursively find the expected .mmdb file inside an extracted MaxMind archive.
*/
async function findExtractedDatabase(dir, fileName) {
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === fileName) {
return entryPath;
}
if (entry.isDirectory()) {
const found = await findExtractedDatabase(entryPath, fileName);
if (found) {
return found;
}
}
}
return null;
}
/**
* Build the MaxMind permalink download URL for an edition archive.
*/
function getDownloadUrl(editionId) {
return `https://download.maxmind.com/geoip/databases/${editionId}/download?suffix=tar.gz`;
}
/**
* Build the Basic Auth header required by MaxMind downloads.
*/
function getAuthHeaders() {
const auth = Buffer.from(`${process.env.MAXMIND_ACCOUNT_ID}:${process.env.MAXMIND_LICENSE_KEY}`).toString('base64');
return {
Authorization: `Basic ${auth}`,
};
}
/**
* Read the updater state file that records previously downloaded remote versions.
*/
async function readUpdateState(dbDir) {
try {
const content = await fsp.readFile(path.join(dbDir, STATE_FILE), 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
return {};
}
throw error;
}
}
/**
* Persist the updater state after a successful database publish.
*/
async function writeUpdateState(dbDir, state) {
await fsp.writeFile(path.join(dbDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
/**
* Acquire a filesystem lock so multiple pm2 processes do not update concurrently.
*/
async function acquireUpdateLock(dbDir) {
const lockPath = path.join(dbDir, LOCK_FILE);
try {
const handle = await fsp.open(lockPath, 'wx');
await handle.writeFile(JSON.stringify({
pid: process.pid,
startedAt: new Date().toISOString(),
}));
return {
// Release the inter-process lock after this update cycle finishes.
release: async () => {
await handle.close();
await fsp.rm(lockPath, { force: true });
},
};
} catch (error) {
if (error.code !== 'EEXIST') {
throw error;
}
const stat = await fsp.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
await fsp.rm(lockPath, { force: true });
return acquireUpdateLock(dbDir);
}
logger.info('MaxMind update skipped: another process is updating databases');
return null;
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
// Local PeeringDB lookup: a network's self-reported peering profile (types,
// website, scope, traffic, ratio, policy, IRR as-set), the exchanges it is
// on and the facilities it is in. common/caida-updater.js downloads CAIDA's
// on and the facilities it is in. common/datasets.js downloads CAIDA's
// daily mirror of the full PeeringDB dump and distills it
// (common/peeringdb-distill.js → buildPeeringdbIndex below); only the
// compact per-ASN index ever lands in this directory. Contact data (poc,
@@ -234,7 +234,7 @@ const loadDatabase = () => {
loadDatabase();
/** Reload the index after caida-updater publishes a fresh one. */
/** Reload the index after the dataset updater publishes a fresh one. */
export const reloadPeeringdbDatabase = (reason = 'reload') => {
logger.info(`🔄 Reloading PeeringDB index (${reason})`);
loadDatabase();
+2 -2
View File
@@ -1,5 +1,5 @@
// Sentry Cron check-in wrapper for the backend's periodic jobs (MaxMind /
// CAIDA / service-status). Same gating as sentry-instrument.js: without
// Sentry Cron check-in wrapper for the backend's periodic jobs (the dataset
// updater, service-status). Same gating as sentry-instrument.js: without
// SENTRY_DSN_BACKEND this is a pure passthrough and @sentry/node is never
// loaded, so job modules can call it unconditionally.
//
+4 -1
View File
@@ -42,6 +42,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^10.0.5",
"croner": "^10.0.1",
"dagre": "^0.8.5",
"dotenv": "^17.4.2",
"express": "^5.2.1",
@@ -55,6 +56,7 @@
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"proper-lockfile": "^4.1.2",
"reka-ui": "^2.10.4",
"tailwind-merge": "^3.7.0",
"tar": "^7.5.22",
@@ -69,7 +71,8 @@
"vue-router": "^5.3.1",
"vue-sonner": "^2.0.9",
"whoiser": "^1.18.0",
"world-atlas": "^2.0.2"
"world-atlas": "^2.0.2",
"write-file-atomic": "^8.0.0"
},
"devDependencies": {
"@sentry/vite-plugin": "^5.4.0",
+49
View File
@@ -217,6 +217,9 @@ importers:
concurrently:
specifier: ^10.0.5
version: 10.0.5
croner:
specifier: ^10.0.1
version: 10.0.1
dagre:
specifier: ^0.8.5
version: 0.8.5
@@ -256,6 +259,9 @@ importers:
pino-pretty:
specifier: ^13.1.3
version: 13.1.3
proper-lockfile:
specifier: ^4.1.2
version: 4.1.2
reka-ui:
specifier: ^2.10.4
version: 2.10.4(vue@3.5.42)
@@ -301,6 +307,9 @@ importers:
world-atlas:
specifier: ^2.0.2
version: 2.0.2
write-file-atomic:
specifier: ^8.0.0
version: 8.0.0
devDependencies:
'@sentry/vite-plugin':
specifier: ^5.4.0
@@ -1695,6 +1704,10 @@ packages:
core-js@3.49.0:
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
croner@10.0.1:
resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==}
engines: {node: '>=18.0'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2485,6 +2498,9 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
protobufjs@7.6.5:
resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==}
engines: {node: '>=12.0.0'}
@@ -2557,6 +2573,10 @@ packages:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
rolldown@1.2.5:
resolution: {integrity: sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2630,6 +2650,13 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-update-notifier@2.0.0:
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
engines: {node: '>=10'}
@@ -2982,6 +3009,10 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
write-file-atomic@8.0.0:
resolution: {integrity: sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -4512,6 +4543,8 @@ snapshots:
core-js@3.49.0: {}
croner@10.0.1: {}
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -5265,6 +5298,12 @@ snapshots:
progress@2.0.3: {}
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
retry: 0.12.0
signal-exit: 3.0.7
protobufjs@7.6.5:
dependencies:
'@protobufjs/aspromise': 1.1.2
@@ -5349,6 +5388,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
retry@0.12.0: {}
rolldown@1.2.5:
dependencies:
'@oxc-project/types': 0.146.0
@@ -5477,6 +5518,10 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
simple-update-notifier@2.0.0:
dependencies:
semver: 7.6.3
@@ -5767,6 +5812,10 @@ snapshots:
wrappy@1.0.2: {}
write-file-atomic@8.0.0:
dependencies:
signal-exit: 4.1.0
ws@8.21.0: {}
y18n@5.0.8: {}
+275
View File
@@ -0,0 +1,275 @@
// Tests for common/dataset-updater.js — the engine, over synthetic rows in a
// temp dir: publish + state + reload, not-modified, refusals, the earlier
// updaters' state, the cross-process lock, the boot download, the schedule's
// env resolution and catch-up, and the watcher. No network: rows fetch from
// local strings.
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { after, afterEach, describe, it } from 'node:test';
import lockfile from 'proper-lockfile';
import {
STATE_FILE, LOCK_FILE, updateDataset, bootstrapDataset, readState, isPresent,
isAutoUpdateEnabled, startDatasetScheduler, DEFAULT_UPDATE_CRON, rowsMissingACheck, watchDatasets,
} from '../common/dataset-updater.js';
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'myip-dataset-test-'));
after(() => fs.rmSync(root, { recursive: true, force: true }));
const savedEnv = { auto: process.env.DATASET_AUTO_UPDATE, cron: process.env.DATASET_UPDATE_CRON };
afterEach(() => {
for (const [name, value] of [['DATASET_AUTO_UPDATE', savedEnv.auto], ['DATASET_UPDATE_CRON', savedEnv.cron]]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
// A two-file row whose remote is `version`; records fetches and reloads.
const makeRow = (overrides = {}) => {
const log = { fetches: 0, reloads: [] };
const row = {
id: 'test',
dir: fs.mkdtempSync(path.join(root, 'row-')),
files: ['a.txt', 'b.txt'],
version: 'v1',
findRemote: async () => ({ identifier: row.version }),
fetch: async ({ remote, tempDir }) => {
log.fetches++;
const staged = {};
for (const file of row.files) {
staged[file] = path.join(tempDir, file);
fs.writeFileSync(staged[file], `${file} ${remote.identifier}\n`);
}
return staged;
},
validate: async () => {},
reload: async (reason) => { log.reloads.push(reason); },
...overrides,
};
return { row, log };
};
const read = (row, file) => fs.readFileSync(path.join(row.dir, file), 'utf8');
const state = (row) => JSON.parse(fs.readFileSync(path.join(row.dir, STATE_FILE), 'utf8'));
describe('updateDataset', () => {
it('publishes every file, records the version and reloads', async () => {
const { row, log } = makeRow();
assert.deepEqual(await updateDataset(row), { updated: true, identifier: 'v1' });
assert.equal(read(row, 'a.txt'), 'a.txt v1\n');
assert.equal(read(row, 'b.txt'), 'b.txt v1\n');
assert.equal(state(row).identifier, 'v1');
assert.deepEqual(log.reloads, ['auto update']);
// Only the published files, the state and no leftovers (lock released).
assert.deepEqual(fs.readdirSync(row.dir).sort(), [STATE_FILE, 'a.txt', 'b.txt']);
});
it('an unchanged remote only records the check', async () => {
const { row, log } = makeRow();
await updateDataset(row);
const first = state(row);
assert.deepEqual(await updateDataset(row), { updated: false, reason: 'not-modified' });
assert.equal(log.fetches, 1);
assert.equal(state(row).updatedAt, first.updatedAt);
assert.ok(state(row).checkedAt >= first.checkedAt);
});
it('a new remote version, or a missing file, is fetched again', async () => {
const { row, log } = makeRow();
await updateDataset(row);
row.version = 'v2';
assert.equal((await updateDataset(row)).updated, true);
assert.equal(read(row, 'b.txt'), 'b.txt v2\n');
fs.rmSync(path.join(row.dir, 'a.txt'));
assert.equal((await updateDataset(row)).updated, true);
assert.equal(log.fetches, 3);
});
it('publishes nothing when validation refuses or a file is not produced', async () => {
const refused = makeRow({ validate: async () => { throw new Error('too small'); } });
await assert.rejects(updateDataset(refused.row), /too small/);
assert.deepEqual(fs.readdirSync(refused.row.dir), []);
assert.deepEqual(refused.log.reloads, []);
const { row } = makeRow();
row.fetch = async ({ tempDir }) => {
const a = path.join(tempDir, 'a.txt');
fs.writeFileSync(a, 'a');
return { 'a.txt': a };
};
await assert.rejects(updateDataset(row), /fetch produced no b\.txt/);
assert.deepEqual(fs.readdirSync(row.dir), []);
});
it('reads an earlier updater\'s state, so an upgrade does not re-download', async () => {
const { row, log } = makeRow({
legacyState: { file: '.old-state.json', toState: (json) => ({ identifier: json.version }) },
});
for (const file of row.files) fs.writeFileSync(path.join(row.dir, file), 'old');
fs.writeFileSync(path.join(row.dir, '.old-state.json'), JSON.stringify({ version: 'v1' }));
assert.equal((await readState(row)).identifier, 'v1');
assert.deepEqual(await updateDataset(row), { updated: false, reason: 'not-modified' });
assert.equal(log.fetches, 0);
assert.equal(state(row).identifier, 'v1', 'carried into the new state file');
});
it('answers locked while another holder has the lock', async () => {
const { row, log } = makeRow();
const release = await lockfile.lock(row.dir, { lockfilePath: path.join(row.dir, LOCK_FILE) });
try {
assert.deepEqual(await updateDataset(row), { updated: false, reason: 'locked' });
assert.equal(log.fetches, 0);
} finally {
await release();
}
assert.equal((await updateDataset(row)).updated, true);
});
});
describe('bootstrapDataset', () => {
it('skips a disabled row and a present one', async () => {
const disabled = makeRow({ enabled: () => false });
assert.equal((await bootstrapDataset(disabled.row)).status, 'disabled');
const { row, log } = makeRow();
await updateDataset(row);
assert.equal((await bootstrapDataset(row)).status, 'present');
assert.equal(log.fetches, 1);
});
it('downloads a missing dataset and reloads it as a bootstrap', async () => {
const { row, log } = makeRow();
assert.equal((await bootstrapDataset(row)).status, 'downloaded');
assert.ok(isPresent(row));
assert.deepEqual(log.reloads, ['bootstrap']);
});
it('waits out another holder of the lock, then loads what it published', async () => {
const { row, log } = makeRow();
const release = await lockfile.lock(row.dir, { lockfilePath: path.join(row.dir, LOCK_FILE) });
// The other process publishes v1 and its state, then lets go.
setTimeout(async () => {
for (const file of row.files) fs.writeFileSync(path.join(row.dir, file), `${file} v1\n`);
fs.writeFileSync(path.join(row.dir, STATE_FILE), JSON.stringify({ identifier: 'v1' }));
await release();
}, 100);
assert.equal((await bootstrapDataset(row)).status, 'loaded');
assert.equal(log.fetches, 0, 'no second download');
assert.deepEqual(log.reloads, ['bootstrap']);
});
it('never throws: a failing or slow download is reported as failed', async () => {
const broken = makeRow({ findRemote: async () => { throw new Error('listing 503'); } });
const failed = await bootstrapDataset(broken.row);
assert.equal(failed.status, 'failed');
assert.match(failed.error.message, /listing 503/);
const slow = makeRow({
findRemote: ({ signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
}),
});
assert.equal((await bootstrapDataset(slow.row, { timeoutMs: 50 })).status, 'failed');
});
});
describe('isAutoUpdateEnabled', () => {
const row = { legacyAutoUpdateEnv: 'OLD_FLAG' };
it('is on by default', () => {
assert.equal(isAutoUpdateEnabled(row, {}), true);
assert.equal(isAutoUpdateEnabled({}, {}), true);
});
it('DATASET_AUTO_UPDATE decides when set, over the legacy flag', () => {
assert.equal(isAutoUpdateEnabled(row, { DATASET_AUTO_UPDATE: 'false', OLD_FLAG: 'true' }), false);
assert.equal(isAutoUpdateEnabled(row, { DATASET_AUTO_UPDATE: 'true', OLD_FLAG: 'false' }), true);
});
it('otherwise an existing .env\'s legacy flag keeps its choice', () => {
assert.equal(isAutoUpdateEnabled(row, { OLD_FLAG: 'false' }), false);
assert.equal(isAutoUpdateEnabled(row, { OLD_FLAG: 'true' }), true);
});
});
describe('startDatasetScheduler', () => {
it('schedules nothing when no row auto-updates', () => {
process.env.DATASET_AUTO_UPDATE = 'false';
assert.equal(startDatasetScheduler([makeRow().row]), null);
});
it('runs at DATASET_UPDATE_CRON, defaulting to 04:30', () => {
assert.equal(DEFAULT_UPDATE_CRON, '30 4 * * *');
process.env.DATASET_AUTO_UPDATE = 'true';
process.env.DATASET_UPDATE_CRON = '15 5 * * *';
const job = startDatasetScheduler([makeRow().row]);
try {
assert.equal(job.getPattern(), '15 5 * * *');
const next = job.nextRun();
assert.equal(next.getHours(), 5);
assert.equal(next.getMinutes(), 15);
} finally {
job.stop();
}
});
});
describe('rowsMissingACheck', () => {
const HOUR = 60 * 60 * 1000;
const now = Date.parse('2026-10-05T12:00:00Z');
const withState = (stateBody) => {
const { row } = makeRow();
if (stateBody) fs.writeFileSync(path.join(row.dir, STATE_FILE), JSON.stringify(stateBody));
return row;
};
it('picks rows not checked for over a day, or never', async () => {
const fresh = withState({ checkedAt: new Date(now - 3 * HOUR).toISOString() });
const missed = withState({ checkedAt: new Date(now - 30 * HOUR).toISOString() });
const never = withState(null);
// A pre-engine state has no checkedAt: its publish time stands in.
const legacy = withState({ updatedAt: new Date(now - 2 * HOUR).toISOString() });
const stale = await rowsMissingACheck([fresh, missed, never, legacy], now);
assert.deepEqual(stale, [missed, never]);
});
});
describe('watchDatasets', () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const fast = { intervalMs: 20, settleMs: 30 };
it('reloads when someone else changes the files, not after its own publish', async () => {
const { row, log } = makeRow();
await updateDataset(row);
const stop = watchDatasets([row], fast);
try {
row.version = 'v2';
await updateDataset(row); // this process: reloads once, by itself
await sleep(150);
assert.deepEqual(log.reloads, ['auto update', 'auto update']);
// Another process (or a hand) replaces the files.
for (const file of row.files) fs.writeFileSync(path.join(row.dir, file), `${file} by hand, longer\n`);
await sleep(150);
assert.deepEqual(log.reloads, ['auto update', 'auto update', 'file change']);
} finally {
stop();
}
});
it('picks up files dropped into an empty dataset, once all are there', async () => {
const { row, log } = makeRow({ enabled: () => false }); // no credentials: hand-placed only
const stop = watchDatasets([row], fast);
try {
fs.writeFileSync(path.join(row.dir, 'a.txt'), 'a');
await sleep(150);
assert.deepEqual(log.reloads, [], 'not while a file is still missing');
fs.writeFileSync(path.join(row.dir, 'b.txt'), 'b');
await sleep(150);
assert.deepEqual(log.reloads, ['file change']);
} finally {
stop();
}
});
});
@@ -1,8 +1,10 @@
// Tests for common/caida-updater.js — the PeeringDB row and the two
// capabilities it adds (no decompression; a distill step), the Cloudflare-key
// gate, and that a decompressing row still publishes as before. Every row is
// re-pointed at a temp dir and `fetch` is stubbed: no network, nothing
// written into the repo's dataset directories.
// Tests for common/datasets.js — the dataset rows run through the engine
// (common/dataset-updater.js): the MaxMind row (two editions published
// together; the credential gate), the PeeringDB row (no decompression; a
// distill step; the Cloudflare-key gate), a decompressing CAIDA row, and the
// rows reading the pre-engine updaters' state. Every row is re-pointed at a temp dir
// and `fetch` is stubbed: no network, nothing written into the repo's
// dataset directories.
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -11,23 +13,25 @@ import path from 'node:path';
import zlib from 'node:zlib';
import { after, afterEach, describe, it } from 'node:test';
import {
datasets, updateDataset, bootstrapDataset, findPeeringdbDump, isDatasetEnabled,
} from '../common/caida-updater.js';
import * as tar from 'tar';
import { datasets, findPeeringdbDump, maxmindIdentifier, hasMaxMindCredentials } from '../common/datasets.js';
import { updateDataset, isRowEnabled } from '../common/dataset-updater.js';
import { PEERINGDB_FILE, readPeeringdbIndex, expandNet } from '../common/peeringdb-db.js';
import { setUpstreamUserAgent } from '../common/fetch-with-timeout.js';
const realFetch = globalThis.fetch;
const savedEnv = { key: process.env.CLOUDFLARE_API_KEY, legacy: process.env.CLOUDFLARE_API };
const ENV_KEYS = ['CLOUDFLARE_API_KEY', 'CLOUDFLARE_API', 'MAXMIND_ACCOUNT_ID', 'MAXMIND_LICENSE_KEY'];
const savedEnv = Object.fromEntries(ENV_KEYS.map((name) => [name, process.env[name]]));
afterEach(() => {
globalThis.fetch = realFetch;
for (const [name, value] of [['CLOUDFLARE_API_KEY', savedEnv.key], ['CLOUDFLARE_API', savedEnv.legacy]]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
for (const name of ENV_KEYS) {
if (savedEnv[name] === undefined) delete process.env[name];
else process.env[name] = savedEnv[name];
}
});
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'myip-caida-test-'));
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'myip-datasets-test-'));
after(() => fs.rmSync(root, { recursive: true, force: true }));
const row = (id) => datasets.find((dataset) => dataset.id === id);
@@ -37,7 +41,7 @@ const tempRow = (id, overrides = {}) => {
const reloads = [];
const dataset = {
...row(id),
dbDir: fs.mkdtempSync(path.join(root, `${id}-`)),
dir: fs.mkdtempSync(path.join(root, `${id}-`)),
reload: (reason) => reloads.push(reason),
...overrides,
};
@@ -129,11 +133,11 @@ describe('peeringdb row', () => {
it('is gated on the Cloudflare key; the CAIDA rows are not', () => {
delete process.env.CLOUDFLARE_API_KEY;
delete process.env.CLOUDFLARE_API;
assert.equal(isDatasetEnabled(row('peeringdb')), false);
assert.equal(isDatasetEnabled(row('as2org')), true);
assert.equal(isDatasetEnabled(row('as-rel')), true);
assert.equal(isRowEnabled(row('peeringdb')), false);
assert.equal(isRowEnabled(row('as2org')), true);
assert.equal(isRowEnabled(row('as-rel')), true);
process.env.CLOUDFLARE_API_KEY = 'k';
assert.equal(isDatasetEnabled(row('peeringdb')), true);
assert.equal(isRowEnabled(row('peeringdb')), true);
});
it('downloads without decompressing, distills, publishes only the index', async () => {
@@ -146,11 +150,11 @@ describe('peeringdb row', () => {
assert.deepEqual(result, { updated: true, identifier: 'peeringdb_2_dump_2026_10_02.json' });
assert.deepEqual(reloads, ['auto update']);
const files = fs.readdirSync(dataset.dbDir).sort();
assert.deepEqual(files, ['.caida-update-state.json', PEERINGDB_FILE]);
const published = fs.readFileSync(path.join(dataset.dbDir, PEERINGDB_FILE), 'utf8');
const files = fs.readdirSync(dataset.dir).sort();
assert.deepEqual(files, ['.dataset-state.json', PEERINGDB_FILE]);
const published = fs.readFileSync(path.join(dataset.dir, PEERINGDB_FILE), 'utf8');
assert.equal(published.includes('@'), false, 'no contact data');
const record = expandNet(readPeeringdbIndex(path.join(dataset.dbDir, PEERINGDB_FILE)), 64500);
const record = expandNet(readPeeringdbIndex(path.join(dataset.dir, PEERINGDB_FILE)), 64500);
assert.deepEqual(record.ixs, [{ name: 'AMS-IX', city: 'Amsterdam', country: 'NL', speed: 100000, rsPeer: true }]);
// Same dump again → nothing to do.
@@ -163,7 +167,7 @@ describe('peeringdb row', () => {
findRemote: (opts) => findPeeringdbDump({ ...opts, now: new Date('2026-10-20T00:00:00Z') }),
});
await assert.rejects(updateDataset(dataset), /only 1 networks/);
assert.deepEqual(fs.readdirSync(dataset.dbDir), []);
assert.deepEqual(fs.readdirSync(dataset.dir), []);
assert.deepEqual(reloads, []);
});
@@ -172,25 +176,12 @@ describe('peeringdb row', () => {
const { dataset } = tempRow('peeringdb', {
findRemote: (opts) => findPeeringdbDump({ ...opts, now: new Date('2026-10-20T00:00:00Z') }),
});
await assert.rejects(updateDataset(dataset), /peeringdb distill phase failed: truncated/);
});
it('bootstrap skips a present index but not a directory holding only state files', async () => {
const requested = stubFetch({});
const { dataset } = tempRow('peeringdb');
fs.writeFileSync(path.join(dataset.dbDir, '.caida-update-state.json'), '{}');
assert.equal((await bootstrapDataset(dataset)).status, 'failed');
assert.ok(requested.length > 0, 'a state file alone is not a snapshot');
requested.length = 0;
fs.writeFileSync(path.join(dataset.dbDir, PEERINGDB_FILE), '{}');
assert.equal((await bootstrapDataset(dataset)).status, 'present');
assert.deepEqual(requested, []);
await assert.rejects(updateDataset(dataset), /peeringdb distill failed: truncated/);
});
});
describe('decompressing rows', () => {
it('still download, decompress and publish the text snapshot', async () => {
describe('CAIDA rows', () => {
it('a decompressing row downloads, decompresses and publishes the text snapshot', async () => {
const text = '# as2org fixture\n1|20260101|Example|US|ARIN\n';
stubFetch({ 'https://example.invalid/latest.txt.gz': [200, zlib.gzipSync(text)] });
const { dataset, reloads } = tempRow('as2org', {
@@ -198,7 +189,95 @@ describe('decompressing rows', () => {
validate: async () => {},
});
assert.deepEqual(await updateDataset(dataset), { updated: true, identifier: 'v1' });
assert.equal(fs.readFileSync(path.join(dataset.dbDir, dataset.canonicalFile), 'utf8'), text);
assert.equal(fs.readFileSync(path.join(dataset.dir, dataset.files[0]), 'utf8'), text);
assert.deepEqual(reloads, ['auto update']);
});
it('read the pre-engine CAIDA state, so an upgrade does not re-download', async () => {
const requested = stubFetch({});
const { dataset } = tempRow('as-rel', {
findRemote: async () => ({ url: 'https://example.invalid/x.bz2', identifier: '20260901.as-rel2.txt.bz2' }),
});
fs.writeFileSync(path.join(dataset.dir, dataset.files[0]), 'snapshot');
fs.writeFileSync(path.join(dataset.dir, '.caida-update-state.json'),
JSON.stringify({ identifier: '20260901.as-rel2.txt.bz2', updatedAt: '2026-09-06T00:32:40.420Z' }));
assert.deepEqual(await updateDataset(dataset), { updated: false, reason: 'not-modified' });
assert.deepEqual(requested, []);
});
it('keep the pre-engine CAIDA_AUTO_UPDATE as their legacy schedule flag', () => {
for (const id of ['as2org', 'as-rel', 'peeringdb']) {
assert.equal(row(id).legacyAutoUpdateEnv, 'CAIDA_AUTO_UPDATE', id);
}
});
});
describe('maxmind row', () => {
const MM = 'https://download.maxmind.com/geoip/databases/';
// A tar.gz holding `<dated dir>/<file>`, as MaxMind ships it.
const archiveOf = async (file, body) => {
const dir = fs.mkdtempSync(path.join(root, 'mm-src-'));
fs.mkdirSync(path.join(dir, 'GeoLite2_20261003'));
fs.writeFileSync(path.join(dir, 'GeoLite2_20261003', file), body);
const out = path.join(dir, 'archive.tar.gz');
await tar.c({ gzip: true, cwd: dir, file: out }, ['GeoLite2_20261003']);
return fs.readFileSync(out);
};
// Answers HEAD with each edition's Last-Modified, GET with its archive;
// records the requests.
const stubMaxMind = (archives, lastModified = { City: 'Fri, 02 Oct 2026 16:46:10 GMT', ASN: 'Sun, 04 Oct 2026 08:30:32 GMT' }) => {
const requested = [];
globalThis.fetch = async (url, init = {}) => {
const edition = String(url).includes('GeoLite2-City') ? 'City' : 'ASN';
requested.push(`${init.method || 'GET'} ${edition} ${init.headers?.Authorization ? 'auth' : 'anon'}`);
if (init.method === 'HEAD') return new Response(null, { headers: { 'last-modified': lastModified[edition] } });
return new Response(archives[edition]);
};
return requested;
};
it('is gated on both credentials', () => {
delete process.env.MAXMIND_ACCOUNT_ID;
process.env.MAXMIND_LICENSE_KEY = 'key';
assert.equal(hasMaxMindCredentials(), false);
assert.equal(isRowEnabled(row('maxmind')), false);
process.env.MAXMIND_ACCOUNT_ID = 'id';
assert.equal(isRowEnabled(row('maxmind')), true);
});
it('downloads both editions with auth, extracts and publishes them together', async () => {
process.env.MAXMIND_ACCOUNT_ID = 'id';
process.env.MAXMIND_LICENSE_KEY = 'key';
const requested = stubMaxMind({
City: await archiveOf('GeoLite2-City.mmdb', 'city-db'),
ASN: await archiveOf('GeoLite2-ASN.mmdb', 'asn-db'),
});
const { dataset, reloads } = tempRow('maxmind', { validate: async () => {} }); // real mmdb check needs real files
const result = await updateDataset(dataset);
assert.equal(result.identifier, maxmindIdentifier(['Fri, 02 Oct 2026 16:46:10 GMT', 'Sun, 04 Oct 2026 08:30:32 GMT']));
assert.equal(fs.readFileSync(path.join(dataset.dir, 'GeoLite2-City.mmdb'), 'utf8'), 'city-db');
assert.equal(fs.readFileSync(path.join(dataset.dir, 'GeoLite2-ASN.mmdb'), 'utf8'), 'asn-db');
assert.deepEqual(reloads, ['auto update']);
assert.ok(requested.every((entry) => entry.endsWith('auth')), 'every request authenticated');
});
it('reads the pre-engine MaxMind state, so an upgrade does not re-download', async () => {
process.env.MAXMIND_ACCOUNT_ID = 'id';
process.env.MAXMIND_LICENSE_KEY = 'key';
const requested = stubMaxMind({});
const { dataset } = tempRow('maxmind');
for (const file of dataset.files) fs.writeFileSync(path.join(dataset.dir, file), 'db');
fs.writeFileSync(path.join(dataset.dir, '.maxmind-update-state.json'), JSON.stringify({
'GeoLite2-City': { lastModified: 'Fri, 02 Oct 2026 16:46:10 GMT', updatedAt: '2026-10-03T03:55:12.165Z' },
'GeoLite2-ASN': { lastModified: 'Sun, 04 Oct 2026 08:30:32 GMT', updatedAt: '2026-10-04T16:27:03.986Z' },
}));
assert.deepEqual(await updateDataset(dataset), { updated: false, reason: 'not-modified' });
assert.deepEqual(requested, ['HEAD City auth', 'HEAD ASN auth'], 'only the version checks');
});
it('keeps MAXMIND_AUTO_UPDATE as its legacy schedule flag', () => {
assert.equal(row('maxmind').legacyAutoUpdateEnv, 'MAXMIND_AUTO_UPDATE');
});
});