Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 81 additions & 5 deletions apps/geo-ip-service/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -16,24 +16,78 @@ 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

| Variable | Default | Meaning |
| -------- | ------- | ------- |
| `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 <allocation>
```

## 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 <allocation> bun apps/geo-ip-service/src/update.ts
nomad alloc signal -s SIGHUP <allocation>
```

Every run downloads each edition, so run it only when needed.

## API

All bodies are JSON. Errors are `{ "error": "<message>" }`.
Expand Down Expand Up @@ -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`

Expand All @@ -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.
28 changes: 26 additions & 2 deletions apps/geo-ip-service/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -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<Edition, string> = {
Expand Down Expand Up @@ -40,12 +42,15 @@ const TELSTRA: GeoLocation = { asNumber: 1221, asName: 'Telstra Pty Ltd' };

let server: ReturnType<typeof startServer> | 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;
}

Expand Down Expand Up @@ -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] });

Expand Down Expand Up @@ -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();
Expand Down
16 changes: 10 additions & 6 deletions apps/geo-ip-service/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');
Expand All @@ -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) });
Expand Down
17 changes: 17 additions & 0 deletions apps/geo-ip-service/src/config.ts
Original file line number Diff line number Diff line change
@@ -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 }),
};
37 changes: 28 additions & 9 deletions apps/geo-ip-service/src/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,21 @@ export interface DatabaseState {

interface LoadedDatabase {
reader: Reader<Response>;
buildDate: Date;
loadedAt: Date;
}

export function databasePath(directory: string, edition: Edition): string {
return join(directory, `${edition}.mmdb`);
}

export function openDatabase(edition: Edition, bytes: Buffer): Reader<Response> {
const reader = new Reader<Response>(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<Edition, LoadedDatabase>();
readonly #errors = new Map<Edition, string>();
Expand All @@ -44,24 +51,36 @@ export class GeoDatabases {

async load(edition: Edition, path: string): Promise<DatabaseState> {
try {
const reader = new Reader<Response>(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<DatabaseState[]> {
const states: DatabaseState[] = [];
for (const edition of EDITIONS) {
states.push(await this.load(edition, databasePath(directory, edition)));
}
return states;
}

replace(edition: Edition, reader: Reader<Response>): 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,
};
Expand Down
Loading
Loading