From eba1393cbf917a5c77db08f42477eb65177f37f1 Mon Sep 17 00:00:00 2001 From: Jim Toth Date: Thu, 15 Oct 2026 18:16:02 -0400 Subject: [PATCH] Adds geo-ip-service MaxMind database updates with verified atomic swap, persisted request interval, SIGHUP reload, one-shot update command and download stub --- apps/geo-ip-service/README.md | 86 ++++++- apps/geo-ip-service/src/app.test.ts | 28 +- apps/geo-ip-service/src/app.ts | 16 +- apps/geo-ip-service/src/config.ts | 17 ++ apps/geo-ip-service/src/databases.ts | 37 ++- apps/geo-ip-service/src/main.ts | 58 ++++- apps/geo-ip-service/src/maxmind-downloads.ts | 80 ++++++ apps/geo-ip-service/src/tar.ts | 28 ++ .../src/testing/maxmind-stub.ts | 117 +++++++++ apps/geo-ip-service/src/update.ts | 64 +++++ apps/geo-ip-service/src/updater.test.ts | 243 ++++++++++++++++++ apps/geo-ip-service/src/updater.ts | 194 ++++++++++++++ packages/service-kit/README.md | 1 + packages/service-kit/src/env.test.ts | 16 ++ packages/service-kit/src/env.ts | 9 + 15 files changed, 962 insertions(+), 32 deletions(-) create mode 100644 apps/geo-ip-service/src/config.ts create mode 100644 apps/geo-ip-service/src/maxmind-downloads.ts create mode 100644 apps/geo-ip-service/src/tar.ts create mode 100644 apps/geo-ip-service/src/testing/maxmind-stub.ts create mode 100644 apps/geo-ip-service/src/update.ts create mode 100644 apps/geo-ip-service/src/updater.test.ts create mode 100644 apps/geo-ip-service/src/updater.ts diff --git a/apps/geo-ip-service/README.md b/apps/geo-ip-service/README.md index e9d66be..076be9a 100644 --- a/apps/geo-ip-service/README.md +++ b/apps/geo-ip-service/README.md @@ -1,6 +1,6 @@ # geo-ip-service -Resolves IP addresses to location and network data from the MaxMind GeoLite2 City and ASN databases. It is an internal service with no authentication, meant to be reached only by other services, never from the public edge. Services in this repository call it through [geo-ip-client](../../packages/geo-ip-client/). +Resolves IP addresses to location and network data from the MaxMind GeoLite2 City and ASN databases, which it downloads and keeps up to date itself. It is an internal service with no authentication, meant to be reached only by other services, never from the public edge. Services in this repository call it through [geo-ip-client](../../packages/geo-ip-client/). ## Running locally @@ -16,7 +16,7 @@ curl localhost:3000/lookup/89.160.20.112 curl localhost:3000/lookup -H 'content-type: application/json' -d '{"addresses":["89.160.20.112","2001:480::1"],"h3Resolution":4}' ``` -The test databases hold only a few made-up networks; [test-data](test-data/) says where to find the list. +The test databases hold only a few made-up networks; [test-data](test-data/) says where to find the list. To run the download and update cycle as well, see [Testing updates locally](#testing-updates-locally). ## Configuration @@ -24,16 +24,70 @@ The test databases hold only a few made-up networks; [test-data](test-data/) say | -------- | ------- | ------- | | `GEOIP_DATA_DIR` | required | directory holding `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb` | | `MAX_BATCH_SIZE` | `10000` | most addresses accepted in one batch lookup, the same as the client's default batch size | +| `MAXMIND_ACCOUNT_ID` | none | MaxMind account ID; together with the license key it turns downloads on | +| `MAXMIND_LICENSE_KEY` | none | MaxMind license key, handled as a [secret](../../packages/service-kit/#secrets) | +| `MAXMIND_DOWNLOAD_URL` | `https://download.maxmind.com/geoip/databases` | where editions are downloaded from | +| `GEOIP_CHECK_INTERVAL` | `1h` | how often the service decides whether an edition is due for an update; deciding makes no request | +| `GEOIP_MAX_AGE` | `72h` | build age after which an edition is due for an update | +| `MAXMIND_MIN_REQUEST_INTERVAL` | `12h` | least time between two requests to MaxMind for the same edition, kept across restarts | | `PORT`, `LOG_LEVEL` | | see [service-kit](../../packages/service-kit/#environment) | +Durations are written as a number and a unit: `ms`, `s`, `m`, `h` or `d`. Setting only one of the two MaxMind credentials is a configuration error. + ## Databases -Both editions are loaded at startup. A file must hold the edition its name says, which is checked against the database's own metadata, so a City file in the ASN slot is refused. +Both editions are loaded from `GEOIP_DATA_DIR` at startup, which in a deployment is a host volume, so a restart or redeploy uses the files already there. A file must hold the edition its name says, which is checked against the database's own metadata, so a City file in the ASN slot is refused. - Without the City edition the service is `down`: `/healthz` answers `503` and lookups answer `503`. - Without the ASN edition the service is `degraded`: lookups answer, and results have no `asNumber` or `asName`. - When loading an edition fails and an earlier copy of it is loaded, the earlier copy keeps serving and the failure is reported in `/healthz` and `/metrics`. +`SIGHUP` reloads both editions from `GEOIP_DATA_DIR` without contacting MaxMind, for example after copying files onto the volume or running the [one-shot update](#one-shot-update): + +```sh +nomad alloc signal -s SIGHUP +``` + +## Updates + +With both MaxMind credentials set, the service keeps the databases current on its own. Every `GEOIP_CHECK_INTERVAL` it decides, per edition, whether the edition is due, and it contacts MaxMind only for an edition that is due: + +- the edition is not loaded, or its build is older than `GEOIP_MAX_AGE`, and +- the last request to MaxMind for that edition was at least `MAXMIND_MIN_REQUEST_INTERVAL` ago. + +A fresh volume therefore costs one download per edition on the first start, and a restart with current files costs nothing. MaxMind publishes GeoLite2 twice a week, so with the defaults each edition is downloaded about twice a week, with a few checksum requests in between. + +An update of a due edition: + +1. Fetches the published SHA-256 checksum of the edition's archive. When it matches the archive installed last time, the edition is `unchanged` and nothing else is downloaded. +2. Downloads the archive. The credentials go only to MaxMind in the `Authorization` header; MaxMind redirects the download to a file host, which gets no credentials. +3. Checks the archive against the checksum, takes the `.mmdb` out of it, opens it, and checks that it is the right edition and not an older build than the one loaded. +4. Writes it next to the current file, renames it over the current file, and swaps the new database in. Lookups in progress finish on the old one; no lookup fails during the swap. + +A failure at any step leaves the current file and database in place. Each attempt is recorded per edition in `update-state.json` in `GEOIP_DATA_DIR`, with its time, outcome, error and archive checksum; this record is what keeps the request interval across restarts, so leave it on the volume. `/healthz` shows it per edition under `update`, along with `nextCheckAfter`, the earliest time the service will contact MaxMind for the edition again. + +While an update runs, the service holds the downloaded archive, the unpacked archive and the new database in memory next to the database being replaced. Size the job's memory for that, with the City edition as the large one. + +Without credentials the service never contacts MaxMind and only loads the files it finds, which suits local development and a volume filled by hand. + +## One-shot update + +`src/update.ts` runs one update with the same code as the service, ignoring the schedule and the request interval, and always downloads. It installs into `GEOIP_DATA_DIR` or `--dir`, logs each edition's outcome, and exits `0` when every edition was installed and `1` otherwise. Use it to check the credentials and the download path right away instead of waiting for the schedule, or to fill a volume. + +```sh +bun apps/geo-ip-service/src/update.ts --dir /tmp/geoip-check +bun apps/geo-ip-service/src/update.ts --edition GeoLite2-ASN +``` + +In a deployment, run it inside the service's allocation, where the credentials and the volume are already in place, then signal the service to load the new files: + +```sh +nomad alloc exec bun apps/geo-ip-service/src/update.ts +nomad alloc signal -s SIGHUP +``` + +Every run downloads each edition, so run it only when needed. + ## API All bodies are JSON. Errors are `{ "error": "" }`. @@ -107,7 +161,7 @@ Looks up one address, with `h3Resolution` as an optional query parameter: `GET / ### `GET /healthz` -The [service-kit health report](../../packages/service-kit/#health) with one check per edition, holding its `buildDate`, its `loadedAt` time and the `lastError` of its last load, if that failed. +The [service-kit health report](../../packages/service-kit/#health) with one check per edition, holding its `buildDate`, its `loadedAt` time, the `lastError` of its last load, if that failed, and its [update](#updates) record under `update`. ### `GET /metrics` @@ -122,7 +176,29 @@ Metrics in the Prometheus text format: | `geoip_lookups_total` | counter | `status` | addresses looked up, by result | | `geoip_requests_total` | counter | `route`, `code` | lookup requests by route (`batch` or `single`) and HTTP status | | `geoip_batch_duration_seconds` | summary | | time to answer a batch, from receiving it to the encoded answer | +| `geoip_updates_enabled` | gauge | | `1` when MaxMind credentials are set and the service downloads updates | +| `geoip_update_last_attempt_timestamp_seconds` | gauge | `edition` | time of the last request to MaxMind for the edition | +| `geoip_update_requests_total` | counter | `edition`, `outcome` | update attempts by outcome: `downloaded`, `unchanged` or `failed` | ## Tests -The tests run against MaxMind's test databases in [test-data](test-data/), so they need no license key or network access. +The tests run against MaxMind's test databases in [test-data](test-data/), so they need no license key or network access. Update tests run against a stub of the MaxMind download service in [src/testing/maxmind-stub.ts](src/testing/maxmind-stub.ts), which serves the test databases as archives; each test sets the stub up for the scenario it needs. + +## Testing updates locally + +The stub also runs on its own, which lets the real service go through download and update cycles in seconds without contacting MaxMind: + +```sh +bun apps/geo-ip-service/src/testing/maxmind-stub.ts +``` + +In another terminal, run the service against it with a short schedule and an empty directory: + +```sh +GEOIP_DATA_DIR=/tmp/geoip-stub \ +MAXMIND_DOWNLOAD_URL=http://localhost:4000/ MAXMIND_ACCOUNT_ID=000000 MAXMIND_LICENSE_KEY=stub-license-key \ +GEOIP_CHECK_INTERVAL=1s GEOIP_MAX_AGE=1s MAXMIND_MIN_REQUEST_INTERVAL=5s \ +bun run --filter geo-ip-service dev +``` + +It downloads both editions at once, then checks the checksum every five seconds and reports `unchanged`. `curl localhost:3000/healthz` shows each edition's update record and next check. The one-shot update runs against the stub with the same variables. diff --git a/apps/geo-ip-service/src/app.test.ts b/apps/geo-ip-service/src/app.test.ts index 6a2b006..929d243 100644 --- a/apps/geo-ip-service/src/app.test.ts +++ b/apps/geo-ip-service/src/app.test.ts @@ -1,8 +1,10 @@ import { GeoIpClient, type GeoLocation } from '@anyone-protocol/geo-ip-client'; +import { createLogger } from '@anyone-protocol/service-kit'; import { afterEach, describe, expect, it } from 'bun:test'; import { join } from 'node:path'; import { startServer } from './app'; import { ASN, CITY, type Edition, GeoDatabases } from './databases'; +import { DatabaseUpdater } from './updater'; const TEST_DATA = join(import.meta.dir, '..', 'test-data'); const TEST_FILES: Record = { @@ -40,12 +42,15 @@ const TELSTRA: GeoLocation = { asNumber: 1221, asName: 'Telstra Pty Ltd' }; let server: ReturnType | undefined; -async function serve({ editions = [CITY, ASN], maxBatchSize = 10 }: { editions?: Edition[]; maxBatchSize?: number } = {}) { +async function serve({ editions = [CITY, ASN], maxBatchSize = 10, withUpdater = false }: { editions?: Edition[]; maxBatchSize?: number; withUpdater?: boolean } = {}) { const databases = new GeoDatabases(); for (const edition of editions) { await databases.load(edition, TEST_FILES[edition]); } - server = startServer({ port: 0, databases, maxBatchSize }); + const updater = withUpdater + ? new DatabaseUpdater({ directory: TEST_DATA, databases, downloads: undefined, maxAgeMs: 0, minRequestIntervalMs: 0, logger: createLogger({ service: 'geo-ip-service', write: () => {} }) }) + : undefined; + server = startServer({ port: 0, databases, maxBatchSize, updater }); return server; } @@ -171,6 +176,14 @@ describe('GET /healthz', () => { }); }); + it('reports the update status of each database', async () => { + await serve({ withUpdater: true }); + + expect(await (await get('/healthz')).json()).toMatchObject({ + checks: { [CITY]: { status: 'ok', update: { enabled: false, lastOutcome: null, nextCheckAfter: null } } }, + }); + }); + it('is degraded without the ASN database and still answers lookups', async () => { await serve({ editions: [CITY] }); @@ -213,6 +226,17 @@ describe('GET /metrics', () => { }); }); +describe('GET /metrics with database updates', () => { + it('adds the update metrics', async () => { + await serve({ withUpdater: true }); + + const text = await (await get('/metrics')).text(); + + expect(text).toContain('geoip_updates_enabled 0'); + expect(text).toContain('# TYPE geoip_update_requests_total counter'); + }); +}); + describe('an unknown path', () => { it('is not found', async () => { await serve(); diff --git a/apps/geo-ip-service/src/app.ts b/apps/geo-ip-service/src/app.ts index 81fdecc..3cb5f30 100644 --- a/apps/geo-ip-service/src/app.ts +++ b/apps/geo-ip-service/src/app.ts @@ -3,11 +3,13 @@ import { createHealthReporter, healthResponse, type HealthCheck, type HealthStat import { ASN, CITY, EDITIONS, type Edition, type GeoDatabases } from './databases'; import { isH3Resolution, MAX_H3_RESOLUTION } from './location'; import { Counter, METRICS_CONTENT_TYPE, metricFamily, Summary } from './metrics'; +import type { DatabaseUpdater } from './updater'; export interface ServerOptions { port: number; databases: GeoDatabases; maxBatchSize: number; + updater?: DatabaseUpdater; } interface BatchRequest { @@ -46,14 +48,15 @@ function parseH3Query(url: string): number | undefined | Response { return isH3Resolution(resolution) ? resolution : errorResponse(400, H3_RESOLUTION_ERROR); } -function databaseCheck(databases: GeoDatabases, edition: Edition): HealthCheck { +function databaseCheck(databases: GeoDatabases, edition: Edition, updater: DatabaseUpdater | undefined): HealthCheck { return () => { const { loaded, buildDate, loadedAt, lastError } = databases.state(edition); - return { status: loaded ? 'ok' : STATUS_WITHOUT_EDITION[edition], buildDate, loadedAt, lastError }; + const update = updater?.status(edition); + return { status: loaded ? 'ok' : STATUS_WITHOUT_EDITION[edition], buildDate, loadedAt, lastError, ...(update && { update }) }; }; } -function createMetrics(databases: GeoDatabases) { +function createMetrics(databases: GeoDatabases, updater: DatabaseUpdater | undefined) { const requests = new Counter('geoip_requests_total', 'Lookup requests by route and HTTP status code'); const lookups = new Counter('geoip_lookups_total', 'Addresses looked up, by result'); const batchDuration = new Summary('geoip_batch_duration_seconds', 'Time to answer a batch lookup'); @@ -71,17 +74,18 @@ function createMetrics(databases: GeoDatabases) { requests.render(), lookups.render(), batchDuration.render(), + ...(updater ? [updater.renderMetrics()] : []), ].join('\n') + '\n'; }; return { requests, lookups, batchDuration, render }; } -export function startServer({ port, databases, maxBatchSize }: ServerOptions) { +export function startServer({ port, databases, maxBatchSize, updater }: ServerOptions) { const health = createHealthReporter('geo-ip-service', { - checks: Object.fromEntries(EDITIONS.map((edition) => [edition, databaseCheck(databases, edition)])), + checks: Object.fromEntries(EDITIONS.map((edition) => [edition, databaseCheck(databases, edition, updater)])), }); - const metrics = createMetrics(databases); + const metrics = createMetrics(databases, updater); const finish = (route: string, response: Response) => { metrics.requests.inc({ route, code: String(response.status) }); diff --git a/apps/geo-ip-service/src/config.ts b/apps/geo-ip-service/src/config.ts new file mode 100644 index 0000000..590a1fd --- /dev/null +++ b/apps/geo-ip-service/src/config.ts @@ -0,0 +1,17 @@ +import { env } from '@anyone-protocol/service-kit'; +import { MAXMIND_DOWNLOAD_URL } from './maxmind-downloads'; + +const HOUR_MS = 60 * 60 * 1000; + +export const maxMindEnv = { + GEOIP_DATA_DIR: env.string(), + MAXMIND_ACCOUNT_ID: env.string({ optional: true }), + MAXMIND_LICENSE_KEY: env.secret(env.string({ optional: true })), + MAXMIND_DOWNLOAD_URL: env.url({ default: MAXMIND_DOWNLOAD_URL }), +}; + +export const updateScheduleEnv = { + GEOIP_CHECK_INTERVAL: env.durationMs({ default: HOUR_MS }), + GEOIP_MAX_AGE: env.durationMs({ default: 72 * HOUR_MS }), + MAXMIND_MIN_REQUEST_INTERVAL: env.durationMs({ default: 12 * HOUR_MS }), +}; diff --git a/apps/geo-ip-service/src/databases.ts b/apps/geo-ip-service/src/databases.ts index 526d3d6..aa3b0ab 100644 --- a/apps/geo-ip-service/src/databases.ts +++ b/apps/geo-ip-service/src/databases.ts @@ -21,7 +21,6 @@ export interface DatabaseState { interface LoadedDatabase { reader: Reader; - buildDate: Date; loadedAt: Date; } @@ -29,6 +28,14 @@ export function databasePath(directory: string, edition: Edition): string { return join(directory, `${edition}.mmdb`); } +export function openDatabase(edition: Edition, bytes: Buffer): Reader { + const reader = new Reader(bytes); + if (reader.metadata.databaseType !== edition) { + throw new Error(`database holds ${reader.metadata.databaseType}, expected ${edition}`); + } + return reader; +} + export class GeoDatabases { readonly #loaded = new Map(); readonly #errors = new Map(); @@ -44,24 +51,36 @@ export class GeoDatabases { async load(edition: Edition, path: string): Promise { try { - const reader = new Reader(await readFile(path)); - if (reader.metadata.databaseType !== edition) { - throw new Error(`${path} holds ${reader.metadata.databaseType}, expected ${edition}`); - } - this.#loaded.set(edition, { reader, buildDate: reader.metadata.buildEpoch, loadedAt: new Date(this.#now()) }); - this.#errors.delete(edition); + this.replace(edition, openDatabase(edition, await readFile(path))); } catch (error) { - this.#errors.set(edition, error instanceof Error ? error.message : String(error)); + this.#errors.set(edition, `${path}: ${error instanceof Error ? error.message : String(error)}`); } return this.state(edition); } + async loadAll(directory: string): Promise { + const states: DatabaseState[] = []; + for (const edition of EDITIONS) { + states.push(await this.load(edition, databasePath(directory, edition))); + } + return states; + } + + replace(edition: Edition, reader: Reader): void { + this.#loaded.set(edition, { reader, loadedAt: new Date(this.#now()) }); + this.#errors.delete(edition); + } + + buildDate(edition: Edition): Date | undefined { + return this.#loaded.get(edition)?.reader.metadata.buildEpoch; + } + state(edition: Edition): DatabaseState { const database = this.#loaded.get(edition); return { edition, loaded: database !== undefined, - buildDate: database?.buildDate.toISOString() ?? null, + buildDate: database?.reader.metadata.buildEpoch.toISOString() ?? null, loadedAt: database?.loadedAt.toISOString() ?? null, lastError: this.#errors.get(edition) ?? null, }; diff --git a/apps/geo-ip-service/src/main.ts b/apps/geo-ip-service/src/main.ts index 59047c1..a003133 100644 --- a/apps/geo-ip-service/src/main.ts +++ b/apps/geo-ip-service/src/main.ts @@ -1,28 +1,66 @@ import { GEO_IP_CLIENT_DEFAULTS } from '@anyone-protocol/geo-ip-client'; import { commonEnv, createLogger, env, handleShutdownSignals, handleUncaughtErrors, parseEnv } from '@anyone-protocol/service-kit'; import { startServer } from './app'; -import { databasePath, EDITIONS, GeoDatabases } from './databases'; +import { maxMindEnv, updateScheduleEnv } from './config'; +import { type DatabaseState, GeoDatabases } from './databases'; +import { MaxMindDownloads, maxMindCredentials } from './maxmind-downloads'; +import { DatabaseUpdater } from './updater'; const config = parseEnv({ ...commonEnv, - GEOIP_DATA_DIR: env.string(), + ...maxMindEnv, + ...updateScheduleEnv, MAX_BATCH_SIZE: env.integer({ default: GEO_IP_CLIENT_DEFAULTS.maxBatchSize }), }); const logger = createLogger({ service: 'geo-ip-service', level: config.LOG_LEVEL }); handleUncaughtErrors(logger); +const credentials = maxMindCredentials(config.MAXMIND_ACCOUNT_ID, config.MAXMIND_LICENSE_KEY); const databases = new GeoDatabases(); -for (const edition of EDITIONS) { - const state = await databases.load(edition, databasePath(config.GEOIP_DATA_DIR, edition)); - if (state.loaded) { - logger.info('Database loaded', { ...state }); - } else { - logger.error('Database not loaded', { ...state }); +const updater = new DatabaseUpdater({ + directory: config.GEOIP_DATA_DIR, + databases, + downloads: credentials && new MaxMindDownloads({ baseUrl: config.MAXMIND_DOWNLOAD_URL, credentials }), + maxAgeMs: config.GEOIP_MAX_AGE, + minRequestIntervalMs: config.MAXMIND_MIN_REQUEST_INTERVAL, + logger, +}); + +function logLoaded(states: DatabaseState[]) { + for (const state of states) { + if (state.loaded) { + logger.info('Database loaded', { ...state }); + } else { + logger.warn('Database not loaded', { ...state }); + } } } -const server = startServer({ port: config.PORT, databases, maxBatchSize: config.MAX_BATCH_SIZE }); +logLoaded(await databases.loadAll(config.GEOIP_DATA_DIR)); +const server = startServer({ port: config.PORT, databases, maxBatchSize: config.MAX_BATCH_SIZE, updater }); logger.info('Listening', { port: server.port, maxBatchSize: config.MAX_BATCH_SIZE }); -handleShutdownSignals([['http server', () => server.stop()]], { logger }); +if (updater.enabled) { + logger.info('Database updates enabled', { + checkIntervalMs: config.GEOIP_CHECK_INTERVAL, + maxAgeMs: config.GEOIP_MAX_AGE, + minRequestIntervalMs: config.MAXMIND_MIN_REQUEST_INTERVAL, + }); +} else { + logger.info('Database updates disabled without MaxMind credentials'); +} +updater.start(config.GEOIP_CHECK_INTERVAL); + +process.on('SIGHUP', async () => { + logger.info('Reloading databases from disk', { signal: 'SIGHUP' }); + logLoaded(await databases.loadAll(config.GEOIP_DATA_DIR)); +}); + +handleShutdownSignals( + [ + ['database updates', () => updater.stop()], + ['http server', () => server.stop()], + ], + { logger }, +); diff --git a/apps/geo-ip-service/src/maxmind-downloads.ts b/apps/geo-ip-service/src/maxmind-downloads.ts new file mode 100644 index 0000000..5c5ffbe --- /dev/null +++ b/apps/geo-ip-service/src/maxmind-downloads.ts @@ -0,0 +1,80 @@ +import { Secret } from '@anyone-protocol/service-kit'; +import type { Edition } from './databases'; + +export const MAXMIND_DOWNLOAD_URL = 'https://download.maxmind.com/geoip/databases'; + +export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; + +export interface MaxMindCredentials { + accountId: string; + licenseKey: Secret; +} + +export interface MaxMindDownloadsOptions { + baseUrl?: string; + credentials: MaxMindCredentials; + timeoutMs?: number; +} + +const SHA256 = /^[0-9a-f]{64}$/; + +class DownloadFailed extends Error {} + +export function maxMindCredentials(accountId: string | undefined, licenseKey: Secret | undefined): MaxMindCredentials | undefined { + if (accountId && licenseKey) return { accountId, licenseKey }; + if (accountId || licenseKey) { + throw new Error('Invalid environment: MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY must be set together'); + } + return undefined; +} + +export class MaxMindDownloads { + readonly #baseUrl: string; + readonly #authorization: Secret; + readonly #timeoutMs: number; + + constructor({ baseUrl = MAXMIND_DOWNLOAD_URL, credentials, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS }: MaxMindDownloadsOptions) { + this.#baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + this.#authorization = new Secret(`Basic ${btoa(`${credentials.accountId}:${credentials.licenseKey.reveal()}`)}`); + this.#timeoutMs = timeoutMs; + } + + async checksum(edition: Edition): Promise { + const text = await (await this.#download(edition, 'tar.gz.sha256')).text(); + const checksum = text.trim().split(/\s+/)[0]?.toLowerCase() ?? ''; + if (!SHA256.test(checksum)) { + throw new Error(`MaxMind sent a checksum file for ${edition} without a SHA-256 checksum`); + } + return checksum; + } + + async archive(edition: Edition): Promise> { + return new Uint8Array(await (await this.#download(edition, 'tar.gz')).arrayBuffer()); + } + + async #download(edition: Edition, suffix: string): Promise { + try { + return await this.#fetch(edition, suffix); + } catch (error) { + if (error instanceof DownloadFailed) throw error; + throw new Error(`MaxMind request for ${edition} ${suffix} failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async #fetch(edition: Edition, suffix: string): Promise { + const url = new URL(`${edition}/download`, this.#baseUrl); + url.searchParams.set('suffix', suffix); + const signal = AbortSignal.timeout(this.#timeoutMs); + + let response = await fetch(url, { headers: { authorization: this.#authorization.reveal() }, redirect: 'manual', signal }); + const location = response.headers.get('location'); + if (response.status >= 300 && response.status < 400 && location) { + response = await fetch(new URL(location, url), { redirect: 'manual', signal }); + } + if (!response.ok) { + const detail = (await response.text().catch(() => '')).trim().slice(0, 200); + throw new DownloadFailed(`MaxMind answered ${response.status} for ${edition} ${suffix}${detail ? `: ${detail}` : ''}`); + } + return response; + } +} diff --git a/apps/geo-ip-service/src/tar.ts b/apps/geo-ip-service/src/tar.ts new file mode 100644 index 0000000..662d670 --- /dev/null +++ b/apps/geo-ip-service/src/tar.ts @@ -0,0 +1,28 @@ +const BLOCK_SIZE = 512; + +function field(header: Uint8Array, start: number, length: number): string { + const bytes = header.subarray(start, start + length); + const end = bytes.indexOf(0); + return new TextDecoder().decode(end === -1 ? bytes : bytes.subarray(0, end)); +} + +export function findTarEntry(archive: Uint8Array, matches: (path: string) => boolean): Uint8Array | undefined { + let offset = 0; + while (offset + BLOCK_SIZE <= archive.length) { + const header = archive.subarray(offset, offset + BLOCK_SIZE); + const name = field(header, 0, 100); + if (name === '') return undefined; + + const prefix = field(header, 345, 155); + const path = prefix ? `${prefix}/${name}` : name; + const size = Number.parseInt(field(header, 124, 12).trim() || '0', 8); + const type = field(header, 156, 1); + const dataStart = offset + BLOCK_SIZE; + + if ((type === '0' || type === '') && matches(path)) { + return archive.subarray(dataStart, dataStart + size); + } + offset = dataStart + Math.ceil(size / BLOCK_SIZE) * BLOCK_SIZE; + } + return undefined; +} diff --git a/apps/geo-ip-service/src/testing/maxmind-stub.ts b/apps/geo-ip-service/src/testing/maxmind-stub.ts new file mode 100644 index 0000000..0ae4fb0 --- /dev/null +++ b/apps/geo-ip-service/src/testing/maxmind-stub.ts @@ -0,0 +1,117 @@ +import { join } from 'node:path'; +import { ASN, CITY, type Edition } from '../databases'; + +export const STUB_ACCOUNT_ID = '000000'; +export const STUB_LICENSE_KEY = 'stub-license-key'; + +export const TEST_DATABASE_FILES: Record = { + [CITY]: join(import.meta.dir, '..', '..', 'test-data', 'GeoLite2-City-Test.mmdb'), + [ASN]: join(import.meta.dir, '..', '..', 'test-data', 'GeoLite2-ASN-Test.mmdb'), +}; + +const BLOCK_SIZE = 512; +const encoder = new TextEncoder(); + +function tarHeader(path: string, size: number): Uint8Array { + const header = new Uint8Array(BLOCK_SIZE); + const put = (text: string, offset: number) => header.set(encoder.encode(text), offset); + put(path, 0); + put('0000644\0', 100); + put('0000000\0', 108); + put('0000000\0', 116); + put(`${size.toString(8).padStart(11, '0')}\0`, 124); + put('00000000000\0', 136); + put(' ', 148); + put('0', 156); + put('ustar\0', 257); + put('00', 263); + put(`${header.reduce((sum, byte) => sum + byte, 0).toString(8).padStart(6, '0')}\0 `, 148); + return header; +} + +export function makeArchive(files: Record): Uint8Array { + const blocks: Uint8Array[] = []; + for (const [path, data] of Object.entries(files)) { + const padded = new Uint8Array(Math.ceil(data.length / BLOCK_SIZE) * BLOCK_SIZE); + padded.set(data); + blocks.push(tarHeader(path, data.length), padded); + } + blocks.push(new Uint8Array(2 * BLOCK_SIZE)); + return Bun.gzipSync(Buffer.concat(blocks)); +} + +export function editionArchive(edition: Edition, database: Uint8Array, release = '20261014'): Uint8Array { + return makeArchive({ + [`${edition}_${release}/COPYRIGHT.txt`]: encoder.encode('Database and contents Copyright (c) MaxMind, Inc.\n'), + [`${edition}_${release}/${edition}.mmdb`]: database, + }); +} + +export interface StubRequest { + path: string; + suffix: string | null; + authorization: string | null; +} + +export type StubOverride = (request: Request, url: URL) => Response | undefined; + +export async function startMaxMindStub({ port = 0 }: { port?: number } = {}) { + const archives = new Map(); + for (const edition of [CITY, ASN] as const) { + archives.set(edition, editionArchive(edition, await Bun.file(TEST_DATABASE_FILES[edition]).bytes())); + } + const expectedAuthorization = `Basic ${btoa(`${STUB_ACCOUNT_ID}:${STUB_LICENSE_KEY}`)}`; + const requests: StubRequest[] = []; + const control: { override: StubOverride | undefined } = { override: undefined }; + + const answer = (request: Request, url: URL): Response => { + const download = /^\/(GeoLite2-City|GeoLite2-ASN)\/download$/.exec(url.pathname); + const file = /^\/files\/(GeoLite2-City|GeoLite2-ASN)\.tar\.gz$/.exec(url.pathname); + const edition = (download?.[1] ?? file?.[1]) as Edition | undefined; + const archive = edition ? archives.get(edition) : undefined; + if (!archive) return new Response('Not Found', { status: 404 }); + + if (file) return new Response(archive); + if (request.headers.get('authorization') !== expectedAuthorization) { + return new Response('Invalid account ID or license key', { status: 401 }); + } + const suffix = url.searchParams.get('suffix'); + if (suffix === 'tar.gz.sha256') { + const checksum = new Bun.CryptoHasher('sha256').update(archive).digest('hex'); + return new Response(`${checksum} ${edition}_20261014.tar.gz\n`); + } + if (suffix === 'tar.gz') { + return new Response(null, { status: 302, headers: { location: `/files/${edition}.tar.gz` } }); + } + return new Response('Invalid suffix', { status: 400 }); + }; + + const server = Bun.serve({ + port, + fetch(request) { + const url = new URL(request.url); + requests.push({ path: url.pathname, suffix: url.searchParams.get('suffix'), authorization: request.headers.get('authorization') }); + return control.override?.(request, url) ?? answer(request, url); + }, + }); + + return { + server, + requests, + url: server.url.href, + publish(edition: Edition, archive: Uint8Array) { + archives.set(edition, archive); + }, + override(handler: StubOverride | undefined) { + control.override = handler; + }, + }; +} + +export type MaxMindStub = Awaited>; + +if (import.meta.main) { + const stub = await startMaxMindStub({ port: Number(process.env.PORT ?? 4000) }); + console.log(`MaxMind stub serving the test databases at ${stub.url}`); + console.log(`MAXMIND_DOWNLOAD_URL=${stub.url} MAXMIND_ACCOUNT_ID=${STUB_ACCOUNT_ID} MAXMIND_LICENSE_KEY=${STUB_LICENSE_KEY}`); +} diff --git a/apps/geo-ip-service/src/update.ts b/apps/geo-ip-service/src/update.ts new file mode 100644 index 0000000..4c63e03 --- /dev/null +++ b/apps/geo-ip-service/src/update.ts @@ -0,0 +1,64 @@ +import { createLogger, handleUncaughtErrors, LOG_LEVELS, env, parseEnv } from '@anyone-protocol/service-kit'; +import { parseArgs } from 'node:util'; +import { maxMindEnv } from './config'; +import { EDITIONS, type Edition, GeoDatabases } from './databases'; +import { MaxMindDownloads, maxMindCredentials } from './maxmind-downloads'; +import { DatabaseUpdater } from './updater'; + +const USAGE = `Downloads the GeoLite2 databases from MaxMind once, checks them and installs them. + +Usage: bun apps/geo-ip-service/src/update.ts [--dir ] [--edition ]... + + --dir directory to install into, default GEOIP_DATA_DIR + --edition edition to download, ${EDITIONS.join(' or ')}; repeat for both, default both + +Reads MAXMIND_ACCOUNT_ID, MAXMIND_LICENSE_KEY and MAXMIND_DOWNLOAD_URL like the service. Ignores the +update schedule and always downloads. A running service picks the new files up on SIGHUP.`; + +const { values } = parseArgs({ + options: { + dir: { type: 'string' }, + edition: { type: 'string', multiple: true }, + help: { type: 'boolean' }, + }, +}); +if (values.help) { + console.log(USAGE); + process.exit(0); +} + +const config = parseEnv({ + ...maxMindEnv, + GEOIP_DATA_DIR: env.string({ optional: true }), + LOG_LEVEL: env.oneOf(LOG_LEVELS, { default: 'info' }), +}); +const logger = createLogger({ service: 'geo-ip-service-update', level: config.LOG_LEVEL }); +handleUncaughtErrors(logger); + +const directory = values.dir ?? config.GEOIP_DATA_DIR; +const editions = (values.edition ?? EDITIONS) as Edition[]; +const unknown = editions.filter((edition) => !EDITIONS.includes(edition)); +const credentials = maxMindCredentials(config.MAXMIND_ACCOUNT_ID, config.MAXMIND_LICENSE_KEY); +if (!directory || unknown.length > 0 || !credentials) { + console.error(!directory ? 'Set --dir or GEOIP_DATA_DIR.' : unknown.length > 0 ? `Unknown edition: ${unknown.join(', ')}.` : 'Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY.'); + console.error(`\n${USAGE}`); + process.exit(2); +} + +const databases = new GeoDatabases(); +await databases.loadAll(directory); +const updater = new DatabaseUpdater({ + directory, + databases, + downloads: new MaxMindDownloads({ baseUrl: config.MAXMIND_DOWNLOAD_URL, credentials }), + maxAgeMs: 0, + minRequestIntervalMs: 0, + logger, +}); + +let failed = false; +for (const edition of editions) { + const record = await updater.update(edition, { force: true }); + failed ||= record.lastOutcome === 'failed'; +} +process.exit(failed ? 1 : 0); diff --git a/apps/geo-ip-service/src/updater.test.ts b/apps/geo-ip-service/src/updater.test.ts new file mode 100644 index 0000000..1878d38 --- /dev/null +++ b/apps/geo-ip-service/src/updater.test.ts @@ -0,0 +1,243 @@ +import { createLogger, Secret } from '@anyone-protocol/service-kit'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ASN, CITY, databasePath, type Edition, GeoDatabases } from './databases'; +import { MaxMindDownloads } from './maxmind-downloads'; +import { editionArchive, makeArchive, type MaxMindStub, STUB_ACCOUNT_ID, STUB_LICENSE_KEY, startMaxMindStub, TEST_DATABASE_FILES } from './testing/maxmind-stub'; +import { DatabaseUpdater, UPDATE_RECORDS_FILE } from './updater'; + +const HOUR = 60 * 60 * 1000; +const BUILD = Date.parse('2026-10-14T19:30:47Z'); + +let stub: MaxMindStub; +let directory: string; +let clock: number; +let logLines: Record[]; + +function updater({ databases = new GeoDatabases(() => clock), credentials = true } = {}) { + const logger = createLogger({ service: 'geo-ip-service', write: (line) => logLines.push(JSON.parse(line)) }); + const downloads = new MaxMindDownloads({ + baseUrl: stub.url, + credentials: { accountId: STUB_ACCOUNT_ID, licenseKey: new Secret(STUB_LICENSE_KEY) }, + }); + return { + databases, + updater: new DatabaseUpdater({ + directory, + databases, + downloads: credentials ? downloads : undefined, + maxAgeMs: 72 * HOUR, + minRequestIntervalMs: 12 * HOUR, + logger, + now: () => clock, + }), + }; +} + +async function restart() { + const databases = new GeoDatabases(() => clock); + await databases.loadAll(directory); + return updater({ databases }); +} + +function requestsSince(count: number) { + return stub.requests.slice(count).map(({ path, suffix }) => (suffix ? `${path}?suffix=${suffix}` : path)); +} + +async function testDatabase(edition: Edition) { + return Bun.file(TEST_DATABASE_FILES[edition]).bytes(); +} + +beforeEach(async () => { + stub = await startMaxMindStub(); + directory = await mkdtemp(join(tmpdir(), 'geo-ip-updates-')); + clock = BUILD + 24 * HOUR; + logLines = []; +}); + +afterEach(async () => { + await stub.server.stop(true); + await rm(directory, { recursive: true, force: true }); +}); + +describe('on an empty volume', () => { + it('downloads every edition, installs it and serves it', async () => { + const { updater: geo, databases } = updater(); + + await geo.checkAll(); + + expect(requestsSince(0)).toEqual([ + '/GeoLite2-City/download?suffix=tar.gz.sha256', + '/GeoLite2-City/download?suffix=tar.gz', + '/files/GeoLite2-City.tar.gz', + '/GeoLite2-ASN/download?suffix=tar.gz.sha256', + '/GeoLite2-ASN/download?suffix=tar.gz', + '/files/GeoLite2-ASN.tar.gz', + ]); + expect(await readFile(databasePath(directory, CITY))).toEqual(Buffer.from(await testDatabase(CITY))); + expect(databases.lookup('89.160.20.112')).toMatchObject({ status: 'found', location: { cityName: 'Linköping', asName: 'Bredband2 AB' } }); + expect(geo.status(CITY)).toMatchObject({ enabled: true, lastOutcome: 'downloaded', lastError: null }); + }); + + it('sends the credentials to MaxMind and not to the host it redirects to', async () => { + await updater().updater.checkAll(); + + const authorized = stub.requests.filter((request) => request.authorization !== null).map((request) => request.path); + expect(authorized).toEqual(['/GeoLite2-City/download', '/GeoLite2-City/download', '/GeoLite2-ASN/download', '/GeoLite2-ASN/download']); + }); + + it('never contacts MaxMind without credentials', async () => { + const { updater: geo } = updater({ credentials: false }); + + await geo.checkAll(); + + expect(stub.requests).toEqual([]); + expect(geo.status(CITY)).toMatchObject({ enabled: false, nextCheckAfter: null }); + }); +}); + +describe('with databases on the volume', () => { + beforeEach(async () => { + await updater().updater.checkAll(); + }); + + it('does not contact MaxMind after a restart while the databases are younger than the maximum age', async () => { + const before = stub.requests.length; + const { updater: geo } = await restart(); + + await geo.checkAll(); + clock = BUILD + 72 * HOUR - 1; + await geo.checkAll(); + + expect(requestsSince(before)).toEqual([]); + }); + + it('asks only for the checksum once a database is older than the maximum age and MaxMind has nothing newer', async () => { + const before = stub.requests.length; + const { updater: geo } = await restart(); + clock = BUILD + 72 * HOUR; + + await geo.checkAll(); + + expect(requestsSince(before)).toEqual(['/GeoLite2-City/download?suffix=tar.gz.sha256', '/GeoLite2-ASN/download?suffix=tar.gz.sha256']); + expect(geo.status(CITY)).toMatchObject({ lastOutcome: 'unchanged', nextCheckAfter: new Date(clock + 12 * HOUR).toISOString() }); + }); + + it('waits the minimum request interval before asking MaxMind again, across restarts', async () => { + clock = BUILD + 72 * HOUR; + await (await restart()).updater.checkAll(); + const before = stub.requests.length; + + clock += 12 * HOUR - 1; + await (await restart()).updater.checkAll(); + expect(requestsSince(before)).toEqual([]); + + clock += 1; + await (await restart()).updater.checkAll(); + expect(requestsSince(before)).toHaveLength(2); + }); + + it('downloads a new release and swaps it in without failing a lookup', async () => { + const { updater: geo, databases } = await restart(); + stub.publish(CITY, editionArchive(CITY, await testDatabase(CITY), '20261017')); + clock = BUILD + 72 * HOUR; + + let stopped = false; + const results: string[] = []; + const lookups = (async () => { + while (!stopped) { + results.push(databases.lookup('89.160.20.112').status); + await Bun.sleep(0); + } + })(); + await geo.checkAll(); + stopped = true; + await lookups; + + expect(geo.status(CITY).lastOutcome).toBe('downloaded'); + expect(geo.status(ASN).lastOutcome).toBe('unchanged'); + expect(results.length).toBeGreaterThan(1); + expect(results.every((status) => status === 'found')).toBe(true); + }); + + it('downloads every edition when forced, whatever their age', async () => { + const before = stub.requests.length; + const { updater: geo } = await restart(); + + await geo.update(CITY, { force: true }); + + expect(requestsSince(before)).toEqual(['/GeoLite2-City/download?suffix=tar.gz.sha256', '/GeoLite2-City/download?suffix=tar.gz', '/files/GeoLite2-City.tar.gz']); + }); + + describe('when an update fails', () => { + const failures: [string, (stub: MaxMindStub) => Promise | void, string][] = [ + ['MaxMind refuses the credentials', (s) => s.override(() => new Response('Invalid account ID or license key', { status: 401 })), 'MaxMind answered 401'], + ['MaxMind limits the rate', (s) => s.override(() => new Response('Too many requests', { status: 429 })), 'MaxMind answered 429'], + ['MaxMind fails', (s) => s.override(() => new Response('', { status: 500 })), 'MaxMind answered 500'], + ['MaxMind is unreachable', (s) => s.server.stop(true), 'MaxMind request for GeoLite2-City tar.gz.sha256 failed'], + [ + 'the archive does not match its checksum', + async (s) => { + s.publish(CITY, editionArchive(CITY, await testDatabase(CITY), '20261017')); + s.override((_, url) => (url.pathname.startsWith('/files/') ? new Response(makeArchive({})) : undefined)); + }, + 'does not match the published', + ], + ['the archive holds no database', (s) => s.publish(CITY, makeArchive({ 'GeoLite2-City_20261017/README.txt': new Uint8Array(1) })), 'archive holds no GeoLite2-City.mmdb'], + ['the archive holds another edition', async (s) => s.publish(CITY, editionArchive(CITY, await testDatabase(ASN))), 'database holds GeoLite2-ASN, expected GeoLite2-City'], + ['the database is corrupt', (s) => s.publish(CITY, editionArchive(CITY, new Uint8Array(4096).fill(7))), ''], + ]; + + it.each(failures)('keeps serving the installed database when %s', async (_, breakStub, error) => { + const installed = await readFile(databasePath(directory, CITY)); + const { updater: geo, databases } = await restart(); + await breakStub(stub); + clock = BUILD + 72 * HOUR; + + await geo.update(CITY); + + expect(geo.status(CITY)).toMatchObject({ lastOutcome: 'failed', lastError: expect.stringContaining(error) }); + expect(databases.lookup('89.160.20.112')).toMatchObject({ status: 'found' }); + expect(await readFile(databasePath(directory, CITY))).toEqual(installed); + expect(geo.renderMetrics()).toContain('geoip_update_requests_total{edition="GeoLite2-City",outcome="failed"} 1'); + expect(logLines).toContainEqual(expect.objectContaining({ level: 'warn', message: 'Database update failed', edition: CITY })); + }); + + it('refuses a release older than the loaded database', async () => { + class LaterDatabases extends GeoDatabases { + override buildDate() { + return new Date('2030-01-01T00:00:00Z'); + } + } + const { updater: geo } = updater({ databases: new LaterDatabases() }); + + await geo.update(CITY, { force: true }); + + expect(geo.status(CITY).lastError).toContain('is older than the loaded 2030-01-01T00:00:00.000Z'); + }); + + it('keeps the license key out of the logs even when MaxMind repeats it', async () => { + stub.override(() => new Response(`Invalid license key ${STUB_LICENSE_KEY}`, { status: 401 })); + const { updater: geo } = await restart(); + + await geo.update(CITY); + + expect(JSON.stringify(logLines)).not.toContain(STUB_LICENSE_KEY); + expect(JSON.stringify(logLines)).toContain('Invalid license key [redacted]'); + }); + }); +}); + +describe('update records', () => { + it('starts from no records when the file is unreadable', async () => { + await Bun.write(join(directory, UPDATE_RECORDS_FILE), 'not json'); + const { updater: geo } = updater(); + + await geo.checkAll(); + + expect(geo.status(CITY).lastOutcome).toBe('downloaded'); + expect(logLines).toContainEqual(expect.objectContaining({ level: 'warn', message: 'Update records unreadable, starting from none' })); + }); +}); diff --git a/apps/geo-ip-service/src/updater.ts b/apps/geo-ip-service/src/updater.ts new file mode 100644 index 0000000..c747eac --- /dev/null +++ b/apps/geo-ip-service/src/updater.ts @@ -0,0 +1,194 @@ +import type { Logger } from '@anyone-protocol/service-kit'; +import { mkdir, rename } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { databasePath, EDITIONS, type Edition, type GeoDatabases, openDatabase } from './databases'; +import type { MaxMindDownloads } from './maxmind-downloads'; +import { Counter, metricFamily } from './metrics'; +import { findTarEntry } from './tar'; + +export const UPDATE_RECORDS_FILE = 'update-state.json'; + +export type UpdateOutcome = 'downloaded' | 'unchanged' | 'failed'; + +export interface UpdateRecord { + lastAttemptAt: string | null; + lastOutcome: UpdateOutcome | null; + lastError: string | null; + archiveSha256: string | null; +} + +export interface UpdateStatus extends UpdateRecord { + enabled: boolean; + nextCheckAfter: string | null; +} + +export type UpdateRecords = Partial>; + +export interface DatabaseUpdaterOptions { + directory: string; + databases: GeoDatabases; + downloads: MaxMindDownloads | undefined; + maxAgeMs: number; + minRequestIntervalMs: number; + logger: Logger; + now?: () => number; +} + +const NO_ATTEMPT: UpdateRecord = { lastAttemptAt: null, lastOutcome: null, lastError: null, archiveSha256: null }; + +function sha256(bytes: Uint8Array): string { + return new Bun.CryptoHasher('sha256').update(bytes).digest('hex'); +} + +export class DatabaseUpdater { + readonly requests = new Counter('geoip_update_requests_total', 'Update attempts against MaxMind, by edition and outcome'); + readonly #options: DatabaseUpdaterOptions; + readonly #now: () => number; + #records: UpdateRecords = {}; + #running: Promise | undefined; + #timer: ReturnType | undefined; + + constructor(options: DatabaseUpdaterOptions) { + this.#options = options; + this.#now = options.now ?? Date.now; + } + + get enabled(): boolean { + return this.#options.downloads !== undefined; + } + + start(intervalMs: number): void { + const check = () => this.checkAll().catch((error) => this.#options.logger.error('Database update check failed', { error })); + void check(); + this.#timer = setInterval(check, intervalMs); + } + + stop(): void { + clearInterval(this.#timer); + } + + checkAll(): Promise { + this.#running ??= this.#checkAll().finally(() => { + this.#running = undefined; + }); + return this.#running; + } + + async update(edition: Edition, { force = false } = {}): Promise { + const { downloads, databases, logger } = this.#options; + if (!downloads) { + throw new Error('Database updates are disabled without MaxMind credentials'); + } + + const previous = (await this.#readRecords())[edition] ?? NO_ATTEMPT; + const lastAttemptAt = new Date(this.#now()).toISOString(); + let record: UpdateRecord; + try { + const checksum = await downloads.checksum(edition); + if (!force && databases.state(edition).loaded && checksum === previous.archiveSha256) { + record = { lastAttemptAt, lastOutcome: 'unchanged', lastError: null, archiveSha256: checksum }; + } else { + await this.#install(edition, await downloads.archive(edition), checksum); + record = { lastAttemptAt, lastOutcome: 'downloaded', lastError: null, archiveSha256: checksum }; + } + } catch (error) { + const lastError = error instanceof Error ? error.message : String(error); + record = { lastAttemptAt, lastOutcome: 'failed', lastError, archiveSha256: previous.archiveSha256 }; + } + + await this.#writeRecords({ ...(await this.#readRecords()), [edition]: record }); + this.requests.inc({ edition, outcome: record.lastOutcome ?? 'failed' }); + if (record.lastOutcome === 'failed') { + logger.warn('Database update failed', { edition, error: record.lastError }); + } else { + logger.info(record.lastOutcome === 'downloaded' ? 'Database downloaded' : 'Database unchanged', { edition, buildDate: databases.state(edition).buildDate }); + } + return record; + } + + status(edition: Edition): UpdateStatus { + const record = this.#records[edition] ?? NO_ATTEMPT; + const nextCheckAfter = this.#nextCheckAfter(edition, record); + return { + enabled: this.enabled, + ...record, + nextCheckAfter: this.enabled ? new Date(Number.isFinite(nextCheckAfter) ? nextCheckAfter : this.#now()).toISOString() : null, + }; + } + + renderMetrics(): string { + const attempts = EDITIONS.flatMap((edition) => { + const lastAttemptAt = this.#records[edition]?.lastAttemptAt; + return lastAttemptAt ? [{ labels: { edition }, value: Date.parse(lastAttemptAt) / 1000 }] : []; + }); + return [ + metricFamily('geoip_updates_enabled', 'gauge', 'Whether the service downloads database updates from MaxMind', [{ value: Number(this.enabled) }]), + metricFamily('geoip_update_last_attempt_timestamp_seconds', 'gauge', 'Time of the last update attempt against MaxMind', attempts), + this.requests.render(), + ].join('\n'); + } + + async #checkAll(): Promise { + const records = await this.#readRecords(); + if (!this.enabled) return; + for (const edition of EDITIONS) { + if (this.#now() >= this.#nextCheckAfter(edition, records[edition] ?? NO_ATTEMPT)) { + await this.update(edition); + } + } + } + + #nextCheckAfter(edition: Edition, record: UpdateRecord): number { + const buildDate = this.#options.databases.buildDate(edition); + const staleAt = buildDate ? buildDate.getTime() + this.#options.maxAgeMs : -Infinity; + const allowedAt = record.lastAttemptAt ? Date.parse(record.lastAttemptAt) + this.#options.minRequestIntervalMs : -Infinity; + return Math.max(staleAt, allowedAt); + } + + async #install(edition: Edition, archive: Uint8Array, checksum: string): Promise { + const actual = sha256(archive); + if (actual !== checksum) { + throw new Error(`archive checksum ${actual} does not match the published ${checksum}`); + } + const file = findTarEntry(Bun.gunzipSync(archive), (path) => basename(path) === `${edition}.mmdb`); + if (!file) { + throw new Error(`archive holds no ${edition}.mmdb`); + } + + const bytes = Buffer.from(file); + const reader = openDatabase(edition, bytes); + const loadedBuild = this.#options.databases.buildDate(edition); + if (loadedBuild && reader.metadata.buildEpoch < loadedBuild) { + throw new Error(`downloaded build ${reader.metadata.buildEpoch.toISOString()} is older than the loaded ${loadedBuild.toISOString()}`); + } + + const path = databasePath(this.#options.directory, edition); + await mkdir(this.#options.directory, { recursive: true }); + await Bun.write(`${path}.download`, bytes); + await rename(`${path}.download`, path); + this.#options.databases.replace(edition, reader); + } + + async #readRecords(): Promise { + const file = Bun.file(join(this.#options.directory, UPDATE_RECORDS_FILE)); + try { + this.#records = (await file.exists()) ? ((await file.json()) as UpdateRecords) : {}; + } catch (error) { + this.#options.logger.warn('Update records unreadable, starting from none', { error }); + this.#records = {}; + } + return this.#records; + } + + async #writeRecords(records: UpdateRecords): Promise { + this.#records = records; + const path = join(this.#options.directory, UPDATE_RECORDS_FILE); + try { + await mkdir(this.#options.directory, { recursive: true }); + await Bun.write(`${path}.tmp`, `${JSON.stringify(records, null, 2)}\n`); + await rename(`${path}.tmp`, path); + } catch (error) { + this.#options.logger.error('Update records could not be written', { error }); + } + } +} diff --git a/packages/service-kit/README.md b/packages/service-kit/README.md index f0ad236..4edaed8 100644 --- a/packages/service-kit/README.md +++ b/packages/service-kit/README.md @@ -33,6 +33,7 @@ handleShutdownSignals([['http server', () => server.stop()]], { logger }); | ------ | ------- | ------- | | `env.string()` | any value | `string` | | `env.integer()` | an optional minus sign and digits | `number` | +| `env.durationMs()` | digits and a unit: `ms`, `s`, `m`, `h` or `d`, such as `30s` or `12h` | milliseconds as a `number` | | `env.url()` | an absolute URL | `string` | | `env.oneOf(values)` | one of `values`, exactly | the matching value | | `env.secret(parser)` | whatever `parser` accepts | the parsed value wrapped in a `Secret` | diff --git a/packages/service-kit/src/env.test.ts b/packages/service-kit/src/env.test.ts index 5c98da7..4c0ee1f 100644 --- a/packages/service-kit/src/env.test.ts +++ b/packages/service-kit/src/env.test.ts @@ -40,6 +40,22 @@ describe('parseEnv', () => { }); }); +describe('env.durationMs', () => { + it('reads a number with a unit as milliseconds', () => { + const config = parseEnv( + { A: env.durationMs(), B: env.durationMs(), C: env.durationMs(), D: env.durationMs(), E: env.durationMs() }, + { A: '500ms', B: '30s', C: '15m', D: '12h', E: '3d' }, + ); + + expect(config).toEqual({ A: 500, B: 30_000, C: 900_000, D: 43_200_000, E: 259_200_000 }); + }); + + it('rejects a duration without a known unit', () => { + expect(() => parseEnv({ INTERVAL: env.durationMs() }, { INTERVAL: '12' })).toThrow('INTERVAL must be a duration'); + expect(() => parseEnv({ INTERVAL: env.durationMs() }, { INTERVAL: '1.5h' })).toThrow('INTERVAL must be a duration'); + }); +}); + describe('env.secret', () => { it('wraps the parsed value so it prints as redacted', () => { const config = parseEnv({ DB_PASS: env.secret(env.string()) }, { DB_PASS: 'env-secret-value' }); diff --git a/packages/service-kit/src/env.ts b/packages/service-kit/src/env.ts index f408159..f887a38 100644 --- a/packages/service-kit/src/env.ts +++ b/packages/service-kit/src/env.ts @@ -45,6 +45,14 @@ function parseInteger(raw: string): number { return Number(raw); } +const DURATION_UNITS_MS: Record = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + +function parseDuration(raw: string): number { + const match = /^(\d+)(ms|s|m|h|d)$/.exec(raw); + if (!match) throw new InvalidVariable('must be a duration such as 500ms, 30s, 15m, 12h or 3d'); + return Number(match[1]) * (DURATION_UNITS_MS[match[2] ?? ''] ?? 0); +} + function parseUrl(raw: string): string { if (!URL.canParse(raw)) throw new InvalidVariable('must be an absolute URL'); return raw; @@ -67,6 +75,7 @@ function secret(parse: EnvParser): EnvParser> | Extr export const env = { string: variable((raw) => raw), integer: variable(parseInteger), + durationMs: variable(parseDuration), url: variable(parseUrl), oneOf: oneOf as DefineOneOf, secret,