Files
MyIP/common/as-rel-db.js
T
jason5ng32andClaude Opus 5.5 287c50fe1a 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>
2026-10-05 02:21:58 +08:00

162 lines
5.8 KiB
JavaScript

// Local CAIDA AS Relationships lookup. Snapshot is downloaded
// 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
// rel = 0 → p2p (peering) ← peers index
// rel = 1 → s2s (sibling) ← skipped
//
// Source: https://publicdata.caida.org/datasets/as-relationships/serial-2/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from './logger.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const AS_REL_DB_DIR = path.join(__dirname, 'as-rel-db');
export const AS_REL_FILE = 'as-rel2.txt';
// customer ASN → Set<provider ASN>. Hot path of asn-connectivity.
const providersIndex = new Map();
// ASN → Set<peer ASN>, symmetric p2p. Feeds asn-connectivity's peering edges.
const peersIndex = new Map();
// provider ASN → Set<customer ASN>. Feeds asn-connectivity's neighbour lists.
const customersIndex = new Map();
// ASN → number of distinct customers it provides transit for. Ranking
// signal when an intermediate has more providers than MAX_INTERMEDIATE_BRANCH
// can show; roughly correlates with customer-cone size.
const customerCount = new Map();
// Tier 1 set, derived from the snapshot rather than hardcoded:
// (a) the AS has no providers in CAIDA's p2c topology (nobody sells
// transit to it ≈ CAIDA's clique notion), AND
// (b) it provides transit to at least TIER1_MIN_CUSTOMERS ASes — filters
// out defunct ASNs that look settlement-free only because they're
// inactive (e.g. AS1239 Sprint after the T-Mobile merger).
// 100 is the empirical sweet spot: captures Telxius/Orange/Liberty
// (customer cones 110-340) without admitting clearly defunct ASNs.
const TIER1_MIN_CUSTOMERS = 100;
const tier1Set = new Set();
let loadedFrom = null;
function findSnapshot() {
if (!fs.existsSync(AS_REL_DB_DIR)) return null;
const txts = fs.readdirSync(AS_REL_DB_DIR).filter(f => f.endsWith('.txt'));
if (txts.length === 0) return null;
const withMtime = txts.map(f => {
const full = path.join(AS_REL_DB_DIR, f);
return { full, mtimeMs: fs.statSync(full).mtimeMs };
});
withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
return withMtime[0].full;
}
function addToIndex(index, key, value) {
let set = index.get(key);
if (!set) {
set = new Set();
index.set(key, set);
}
set.add(value);
}
function parsePipeText(text) {
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const parts = line.split('|');
if (parts.length < 3) continue;
const a = Number(parts[0]);
const b = Number(parts[1]);
if (!a || !b) continue;
if (parts[2] === '-1') {
addToIndex(providersIndex, b, a);
addToIndex(customersIndex, a, b);
customerCount.set(a, (customerCount.get(a) || 0) + 1);
} else if (parts[2] === '0') {
addToIndex(peersIndex, a, b);
addToIndex(peersIndex, b, a);
}
}
}
function rebuildTier1Set() {
tier1Set.clear();
for (const [asn, count] of customerCount) {
if (providersIndex.has(asn)) continue; // has upstream → not Tier 1
if (count >= TIER1_MIN_CUSTOMERS) tier1Set.add(asn);
}
}
function loadDatabase() {
const filePath = findSnapshot();
if (!filePath) {
logger.warn({ dir: AS_REL_DB_DIR }, '⚠️ CAIDA as-rel snapshot not found; asn-connectivity will return empty graphs until the updater downloads one');
providersIndex.clear();
peersIndex.clear();
customersIndex.clear();
customerCount.clear();
tier1Set.clear();
loadedFrom = null;
return;
}
const start = Date.now();
try {
const text = fs.readFileSync(filePath, 'utf8');
providersIndex.clear();
peersIndex.clear();
customersIndex.clear();
customerCount.clear();
parsePipeText(text);
rebuildTier1Set();
loadedFrom = path.basename(filePath);
logger.info(`📦 CAIDA as-rel loaded (${loadedFrom}) — ${providersIndex.size} customers, ${customerCount.size} providers, ${peersIndex.size} peering ASes, ${tier1Set.size} Tier 1s in ${Date.now() - start}ms`);
} catch (error) {
logger.warn({ err: error, path: filePath }, '⚠️ Failed to parse CAIDA as-rel snapshot');
}
}
loadDatabase();
/** 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();
}
/** Providers (transit upstreams) of an ASN. Empty array when not in dataset. */
export function providersOf(asn) {
const set = providersIndex.get(Number(asn));
return set ? [...set] : [];
}
/** Settlement-free peers of an ASN. Empty array when none observed. */
export function peersOf(asn) {
const set = peersIndex.get(Number(asn));
return set ? [...set] : [];
}
/** Customers (transit downstreams) of an ASN. Empty array when it has none. */
export const customersOf = (asn) => {
const set = customersIndex.get(Number(asn));
return set ? [...set] : [];
};
/** How many distinct ASes this one provides transit for. 0 when never a provider. */
export function customerCountOf(asn) {
return customerCount.get(Number(asn)) || 0;
}
/** Whether a snapshot is loaded (false until the updater has fetched one). */
export const isAsRelLoaded = () => loadedFrom !== null;
/** Whether this ASN is in the CAIDA-derived Tier 1 set. */
export function isTier1(asn) {
return tier1Set.has(Number(asn));
}