Files
MyIP/common/maxmind-service.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

158 lines
5.2 KiB
JavaScript

import path from 'path';
import { fileURLToPath } from 'url';
import maxmind from 'maxmind';
import logger from './logger.js';
import { matchLocale } from './locale-registry.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export const MAXMIND_DB_DIR = path.resolve(__dirname, './maxmind-db');
export const MAXMIND_CITY_DB = 'GeoLite2-City.mmdb';
export const MAXMIND_ASN_DB = 'GeoLite2-ASN.mmdb';
const cityDbPath = path.join(MAXMIND_DB_DIR, MAXMIND_CITY_DB);
const asnDbPath = path.join(MAXMIND_DB_DIR, MAXMIND_ASN_DB);
// The languages the City database carries `names` for — a property of the data
// itself, not of the UI: registering a new front-end locale changes nothing
// here, and this list only moves if MaxMind's own coverage does.
export const SUPPORTED_LANGS = ['de', 'en', 'es', 'fr', 'ja', 'pt-BR', 'ru', 'zh-CN'];
// Resolve any BCP-47 tag onto that set: exact match (canonical spelling wins),
// then the base language, then a sibling of the same family — `zh-TW` reads
// `zh-CN` names rather than falling all the way to English. Anything else, junk
// included, lands on `en`. Family resolution is defined once, in the locale
// registry; only the resolver is borrowed, never its list of UI locales.
export const normalizeLang = (tag) => {
if (typeof tag !== 'string') return 'en';
return matchLocale(tag.trim(), SUPPORTED_LANGS) ?? 'en';
};
let cityLookup = null;
let asnLookup = null;
let reloadPromise = null;
/**
* Return the canonical MaxMind database paths.
*/
export function getMaxMindDbPaths() {
return {
dbDir: MAXMIND_DB_DIR,
cityDbPath,
asnDbPath,
};
}
/**
* Report whether both City and ASN readers are ready to serve lookups.
*/
export function isMaxMindReady() {
return Boolean(cityLookup && asnLookup);
}
/**
* Open fresh MaxMind readers from the provided database paths.
*/
export async function openMaxMindReaders(dbPaths = getMaxMindDbPaths()) {
const [city, asn] = await Promise.all([
maxmind.open(dbPaths.cityDbPath),
maxmind.open(dbPaths.asnDbPath),
]);
return { city, asn };
}
/**
* Replace the active MaxMind readers after both databases are opened successfully.
*/
export async function reloadMaxMindDatabases(reason = 'manual') {
if (reloadPromise) {
return reloadPromise;
}
reloadPromise = openMaxMindReaders()
.then(({ city, asn }) => {
cityLookup = city;
asnLookup = asn;
logger.info(`📦 MaxMind databases loaded (${reason})`);
return true;
})
.catch(error => {
logger.error({ err: error, reason }, 'Failed to load MaxMind databases');
throw error;
})
.finally(() => {
reloadPromise = null;
});
return reloadPromise;
}
/**
* 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
* at a time — the bgp-prefixes Radar view does, thousands of times per
* request. No formatting, no 503 throw: null when there is no answer — the
* City database isn't loaded, or the lookup failed — so the caller can tell
* it from an address MaxMind places in no country ({ country: null }).
*/
export const lookupCountryRange = (ip) => {
if (!cityLookup) return null;
try {
const [record, prefixLength] = cityLookup.getWithPrefixLength(ip);
return { country: record?.country?.iso_code || null, prefixLength };
} catch {
return null;
}
};
/**
* Look up an IP address and return the API response shape expected by the frontend.
* `lang` is any raw tag — normalization happens here, so no caller can skip it.
*/
export function lookupMaxMind(ip, lang) {
if (!isMaxMindReady()) {
const error = new Error('MaxMind database is not ready');
error.statusCode = 503;
throw error;
}
const city = cityLookup.get(ip);
const asn = asnLookup.get(ip);
return formatMaxMindResult(ip, normalizeLang(lang), city, asn);
}
/**
* Pick the requested localized name with English and N/A fallbacks.
*/
function getLocalizedName(names, lang) {
if (!names) {
return 'N/A';
}
return names[lang] || names.en || 'N/A';
}
/**
* Normalize MaxMind City and ASN records into this API's stable JSON format.
*/
function formatMaxMindResult(ip, lang, city, asn) {
city = city || {};
asn = asn || {};
return {
ip,
city: city.city ? getLocalizedName(city.city.names, lang) : 'N/A',
region: city.subdivisions?.[0] ? getLocalizedName(city.subdivisions[0].names, lang) : 'N/A',
country: city.country ? city.country.iso_code : 'N/A',
country_name: city.country ? getLocalizedName(city.country.names, lang) : 'N/A',
country_code: city.country ? city.country.iso_code : 'N/A',
latitude: city.location ? city.location.latitude : 'N/A',
longitude: city.location ? city.location.longitude : 'N/A',
asn: asn.autonomous_system_number ? `AS${asn.autonomous_system_number}` : 'N/A',
org: asn.autonomous_system_organization ? asn.autonomous_system_organization : 'N/A',
};
}