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>
158 lines
5.2 KiB
JavaScript
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',
|
|
};
|
|
}
|