mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
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>
248 lines
9.8 KiB
JavaScript
248 lines
9.8 KiB
JavaScript
// 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/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,
|
|
// org addresses) is never read.
|
|
//
|
|
// Index file (JSON):
|
|
// { v: 1,
|
|
// ix: { <ix id>: [name, city, country] },
|
|
// fac: { <fac id>: [name, city, country] },
|
|
// nets: { <asn>: { website, types[], scope, traffic, ratio, policy, irr,
|
|
// ix: [[ix id, Mbps summed over ports, rs peer 1|0]],
|
|
// fac: [fac id] } } }
|
|
// Empty / "Not Disclosed" fields are omitted; a network with nothing left
|
|
// is dropped. IX and facility rows are stored once and referenced by id.
|
|
//
|
|
// Sources: https://publicdata.caida.org/datasets/peeringdb/ (CAIDA AUA) ·
|
|
// https://www.peeringdb.com
|
|
|
|
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 PEERINGDB_DB_DIR = path.join(__dirname, 'peeringdb-db');
|
|
export const PEERINGDB_FILE = 'peeringdb-index.json';
|
|
export const PEERINGDB_INDEX_VERSION = 1;
|
|
|
|
// The four dump tables the index is built from.
|
|
export const PEERINGDB_TABLES = ['net', 'ix', 'netixlan', 'netfac'];
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Distill: dump rows → index */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const UNDISCLOSED = 'not disclosed';
|
|
|
|
// Trimmed text, or undefined for empty / "Not Disclosed".
|
|
export const cleanText = (value) => {
|
|
if (typeof value !== 'string') return undefined;
|
|
const text = value.trim();
|
|
return text && text.toLowerCase() !== UNDISCLOSED ? text : undefined;
|
|
};
|
|
|
|
// http(s) URLs only, and none with userinfo — "name@host" is how an
|
|
// e-mail address typed into the field would otherwise pass as a link.
|
|
const cleanWebsite = (value) => {
|
|
const text = cleanText(value);
|
|
if (!text) return undefined;
|
|
try {
|
|
const url = new URL(text);
|
|
const web = url.protocol === 'http:' || url.protocol === 'https:';
|
|
return web && !url.username && !url.password ? text : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const isLive = (row) => !row.status || row.status === 'ok';
|
|
|
|
// Per-table projection to the fields the index needs, applied to each row
|
|
// as it is scanned so nothing else is kept.
|
|
export const projectRow = {
|
|
net: (r) => ({
|
|
id: r.id,
|
|
asn: Number(r.asn),
|
|
website: cleanWebsite(r.website),
|
|
types: Array.isArray(r.info_types) ? r.info_types.map(cleanText).filter(Boolean) : [],
|
|
scope: cleanText(r.info_scope),
|
|
traffic: cleanText(r.info_traffic),
|
|
ratio: cleanText(r.info_ratio),
|
|
policy: cleanText(r.policy_general),
|
|
irr: cleanText(r.irr_as_set),
|
|
live: isLive(r),
|
|
}),
|
|
ix: (r) => ({ id: r.id, name: cleanText(r.name), city: cleanText(r.city), country: cleanText(r.country), live: isLive(r) }),
|
|
netixlan: (r) => ({
|
|
asn: Number(r.asn),
|
|
ixId: r.ix_id,
|
|
speed: Number(r.speed) || 0,
|
|
rs: r.is_rs_peer === true,
|
|
live: isLive(r) && r.operational !== false,
|
|
}),
|
|
netfac: (r) => ({
|
|
netId: r.net_id,
|
|
facId: r.fac_id,
|
|
name: cleanText(r.name),
|
|
city: cleanText(r.city),
|
|
country: cleanText(r.country),
|
|
live: isLive(r),
|
|
}),
|
|
};
|
|
|
|
const place = (row) => [row.name, row.city || '', row.country || ''];
|
|
|
|
/**
|
|
* Projected rows of the four tables → index object (shape in the header).
|
|
* Pure; the streaming distiller and the tests share it.
|
|
*/
|
|
export const buildPeeringdbIndex = ({ net = [], ix = [], netixlan = [], netfac = [] }) => {
|
|
const ixById = new Map(ix.filter((r) => r.live && r.name).map((r) => [r.id, r]));
|
|
const asnByNetId = new Map(net.map((r) => [r.id, r.asn]));
|
|
const ixTable = {};
|
|
const facTable = {};
|
|
|
|
// asn → Map<ix id, [ix id, speed, rs]>; several ports at one IX sum.
|
|
const portsByAsn = new Map();
|
|
for (const row of netixlan) {
|
|
if (!row.live || !row.asn) continue;
|
|
const exchange = ixById.get(row.ixId);
|
|
if (!exchange) continue;
|
|
let atIx = portsByAsn.get(row.asn);
|
|
if (!atIx) portsByAsn.set(row.asn, (atIx = new Map()));
|
|
const entry = atIx.get(exchange.id);
|
|
if (entry) {
|
|
entry[1] += row.speed;
|
|
entry[2] = entry[2] || (row.rs ? 1 : 0);
|
|
} else {
|
|
atIx.set(exchange.id, [exchange.id, row.speed, row.rs ? 1 : 0]);
|
|
ixTable[exchange.id] ??= place(exchange);
|
|
}
|
|
}
|
|
|
|
// netfac carries the facility's own name / city / country.
|
|
const facsByAsn = new Map();
|
|
for (const row of netfac) {
|
|
if (!row.live || !row.name) continue;
|
|
const asn = asnByNetId.get(row.netId);
|
|
if (!asn) continue;
|
|
let ids = facsByAsn.get(asn);
|
|
if (!ids) facsByAsn.set(asn, (ids = new Set()));
|
|
ids.add(row.facId);
|
|
facTable[row.facId] ??= place(row);
|
|
}
|
|
|
|
const byPlace = (a, b) => a[2].localeCompare(b[2]) || a[1].localeCompare(b[1]) || a[0].localeCompare(b[0]);
|
|
const nets = {};
|
|
for (const row of net) {
|
|
if (!row.live || !row.asn || nets[row.asn]) continue;
|
|
const entry = {};
|
|
for (const key of ['website', 'scope', 'traffic', 'ratio', 'policy', 'irr']) {
|
|
if (row[key]) entry[key] = row[key];
|
|
}
|
|
if (row.types.length) entry.types = row.types;
|
|
const ports = [...(portsByAsn.get(row.asn)?.values() || [])]
|
|
.sort((a, b) => b[1] - a[1] || ixTable[a[0]][0].localeCompare(ixTable[b[0]][0]));
|
|
if (ports.length) entry.ix = ports;
|
|
const facs = [...(facsByAsn.get(row.asn) || [])].sort((a, b) => byPlace(facTable[a], facTable[b]));
|
|
if (facs.length) entry.fac = facs;
|
|
if (Object.keys(entry).length) nets[row.asn] = entry;
|
|
}
|
|
|
|
// Keep only the IX / facility rows some network still references.
|
|
const used = (table, key) => {
|
|
const ids = new Set();
|
|
for (const entry of Object.values(nets)) {
|
|
for (const item of entry[key] || []) ids.add(key === 'ix' ? item[0] : item);
|
|
}
|
|
return Object.fromEntries(Object.entries(table).filter(([id]) => ids.has(Number(id))));
|
|
};
|
|
return { v: PEERINGDB_INDEX_VERSION, ix: used(ixTable, 'ix'), fac: used(facTable, 'fac'), nets };
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Load + lookup */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const toPlaceMap = (table) => new Map(Object.entries(table || {}).map(([id, [name, city, country]]) => [
|
|
Number(id), { name, city: city || null, country: country || null },
|
|
]));
|
|
|
|
/**
|
|
* Read an index file into lookup tables { nets, ix, fac } (Maps keyed by
|
|
* number). The file text and the parsed object stay local, so only the
|
|
* Maps outlive the call. Throws on a malformed or wrong-version file.
|
|
*/
|
|
export const readPeeringdbIndex = (filePath) => {
|
|
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
if (parsed?.v !== PEERINGDB_INDEX_VERSION || !parsed.nets) {
|
|
throw new Error(`unsupported PeeringDB index (v=${parsed?.v})`);
|
|
}
|
|
const nets = new Map();
|
|
for (const asn of Object.keys(parsed.nets)) nets.set(Number(asn), parsed.nets[asn]);
|
|
return { nets, ix: toPlaceMap(parsed.ix), fac: toPlaceMap(parsed.fac) };
|
|
};
|
|
|
|
/** One network's record from loaded tables, expanded for the API; null when absent. */
|
|
export const expandNet = (tables, asn) => {
|
|
const entry = tables?.nets.get(Number(asn));
|
|
if (!entry) return null;
|
|
return {
|
|
website: entry.website || null,
|
|
types: entry.types || [],
|
|
scope: entry.scope || null,
|
|
traffic: entry.traffic || null,
|
|
ratio: entry.ratio || null,
|
|
policy: entry.policy || null,
|
|
irrAsSet: entry.irr || null,
|
|
ixs: (entry.ix || []).flatMap(([id, speed, rs]) => {
|
|
const exchange = tables.ix.get(id);
|
|
return exchange ? [{ ...exchange, speed, rsPeer: rs === 1 }] : [];
|
|
}),
|
|
facilities: (entry.fac || []).flatMap((id) => {
|
|
const facility = tables.fac.get(id);
|
|
return facility ? [{ ...facility }] : [];
|
|
}),
|
|
};
|
|
};
|
|
|
|
let tables = null;
|
|
let loadedFrom = null;
|
|
|
|
const loadDatabase = () => {
|
|
const filePath = path.join(PEERINGDB_DB_DIR, PEERINGDB_FILE);
|
|
if (!fs.existsSync(filePath)) {
|
|
// Info, not warn: deployments without a Cloudflare key never fetch one.
|
|
logger.info({ dir: PEERINGDB_DB_DIR }, '⚠️ PeeringDB index not present; ASN Profile shows no peering data');
|
|
tables = null;
|
|
loadedFrom = null;
|
|
return;
|
|
}
|
|
const start = Date.now();
|
|
try {
|
|
tables = readPeeringdbIndex(filePath);
|
|
loadedFrom = PEERINGDB_FILE;
|
|
logger.info(`📦 PeeringDB index loaded — ${tables.nets.size} networks, ${tables.ix.size} exchanges, ${tables.fac.size} facilities in ${Date.now() - start}ms`);
|
|
} catch (error) {
|
|
logger.warn({ err: error, path: filePath }, '⚠️ Failed to parse PeeringDB index');
|
|
}
|
|
};
|
|
|
|
loadDatabase();
|
|
|
|
/** Reload the index after the dataset updater publishes a fresh one. */
|
|
export const reloadPeeringdbDatabase = (reason = 'reload') => {
|
|
logger.info(`🔄 Reloading PeeringDB index (${reason})`);
|
|
loadDatabase();
|
|
};
|
|
|
|
/** Whether an index is loaded (false until the updater has built one). */
|
|
export const isPeeringdbLoaded = () => loadedFrom !== null;
|
|
|
|
/** The network's PeeringDB record (see expandNet), or null. */
|
|
export const lookupPeeringdb = (asn) => expandNet(tables, asn);
|