From 0eac84aa953b6ba0ba32e205fa9faefb66d4114c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 01:05:44 +0200 Subject: [PATCH] fix(geo): read the offline databases first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver queried the online provider first and read the mounted GeoLite2 databases only when that missed. Every document describing the feature said the opposite, including the self-hosting runbook and the admin notification that tells an operator their login IPs are leaving the server. So an operator who mounted the databases in order to stop that egress kept all of it, and nothing said so: the location resolved, the admin overview filled in, and the only difference was where the address had been. Now the local databases answer and the provider only ever sees an address they could not place. A host with the City database mounted and a hit makes no request at all — including when the ASN database is absent, because reaching out for a carrier string would send the address off the host anyway, which is the thing the mount was for. IP_GEO_LOOKUP_DISABLED=1 remains the full off-switch: it disables the online step only, so an offline hit still resolves and an offline miss returns nothing rather than going online. The wide-event warning on the fallback path named ipwho.is even when the operator had pointed IP_GEO_LOOKUP_URL somewhere else; it now names the host the runtime actually queries. The admin notification copy needed no change — it fires only when the databases are absent, and then every lookup does go online. The two cases in geo-asn.test.ts that pinned the old order are the watched red for this change. --- docs/self-hosting/geolite2.md | 26 ++- src/lib/__tests__/geo-asn.test.ts | 25 ++- src/lib/__tests__/geo-offline-first.test.ts | 234 ++++++++++++++++++++ src/lib/geo.ts | 104 +++++---- 4 files changed, 321 insertions(+), 68 deletions(-) create mode 100644 src/lib/__tests__/geo-offline-first.test.ts diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md index b9dacd0a0..4e69c1c3e 100644 --- a/docs/self-hosting/geolite2.md +++ b/docs/self-hosting/geolite2.md @@ -3,15 +3,15 @@ The admin login overview shows a coarse `City, CC` location and a carrier next to every sign-in. Two tiers can produce that: -- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, - keyless, and on by default — which also means every login IP address of - every account on the instance leaves the host. - **Offline.** MaxMind's GeoLite2-City and GeoLite2-ASN databases read from a local directory. The ASN database is the authoritative source - for the carrier and the AS number. Note that the resolver is - online-first: having the databases in place is not by itself enough to - stop the outbound lookup, see "Turning the online lookup off entirely" - below. + for the carrier and the AS number. The resolver reads these first: an + address the local databases can place never leaves the host. +- **Online.** A third-party lookup, `https://ipwho.is` by default. Free, + keyless, and the only tier on a host without the databases — which + means every login IP address of every account on that instance leaves + the host. With the databases mounted it sees only the addresses they + could not place, and you can switch it off entirely (below). The offline tier is optional and absent by default. The published image only carries the databases when the image build had a MaxMind licence key @@ -73,8 +73,8 @@ is the subject of this page. ## Turning the online lookup off entirely -Once the offline tier resolves, the third-party lookup is redundant. To -stop all IP egress: +With the databases mounted, the third-party lookup already only sees +addresses they could not place. To remove that remainder too: ```env IP_GEO_LOOKUP_DISABLED="1" @@ -127,6 +127,8 @@ the value is in `.env` but not on the `environment:` whitelist — compare against `docker-compose.yml`. **The carrier column is empty.** The carrier also comes from the online -provider's ISP field, so it is normally filled even without the ASN -database. If both tiers are off (`IP_GEO_LOOKUP_DISABLED="1"` and no -`GeoLite2-ASN.mmdb`), there is no source for it and it stays empty. +provider's ISP field, but the online lookup only runs for an address the +City database could not place. On a host with `GeoLite2-City.mmdb` and no +`GeoLite2-ASN.mmdb`, a location that resolves locally has no carrier to +go with it — mount the ASN database too. With neither database, the +online provider fills both, unless `IP_GEO_LOOKUP_DISABLED="1"` is set. diff --git a/src/lib/__tests__/geo-asn.test.ts b/src/lib/__tests__/geo-asn.test.ts index f10903253..437afe6cc 100644 --- a/src/lib/__tests__/geo-asn.test.ts +++ b/src/lib/__tests__/geo-asn.test.ts @@ -188,11 +188,12 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => { }); }); -// v1.18.10 (W7) — online-first resolver. The `ipwho.is` HTTPS lookup is the -// DEFAULT path; the bundled GeoLite2 offline tier is an OPTIONAL fallback -// consulted only when the online lookup misses (provider down / non-ok) or -// when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. -describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => { +// Offline-first resolver: the GeoLite2 City MMDB answers when it is mounted +// and holds the address, and the `ipwho.is` HTTPS lookup only sees what it +// could not place. Between v1.18.10 and v1.37 it ran the other way round — +// these two cases pinned that order, and they are the reason a host with the +// databases mounted still sent every login IP to a third party. +describe("lookupIpLocation — offline-first city resolver", () => { // Helper: a non-ok online response forces the offline fallback to run. function onlineMiss(): Response { return new Response("", { @@ -201,7 +202,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => }); } - it("prefers the online provider even when the offline DB is present", async () => { + it("prefers the offline DB over the online provider, and sends nothing", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, @@ -221,12 +222,13 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => vi.stubGlobal("fetch", fetchSpy); const { lookupIpLocation } = await import("../geo"); - // Online wins by default; the offline München record is not consulted. - expect(await lookupIpLocation("85.214.0.1")).toBe("Berlin, DE"); - expect(fetchSpy).toHaveBeenCalledTimes(1); + // The mounted database answers, so the provider is never asked and the + // address never leaves the host. + expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it("falls back to the offline German exonym when the online lookup misses", async () => { + it("uses the German exonym the offline DB carries rather than the provider's fold", async () => { readerState.cityExists = true; readerState.city = () => ({ city: { names: { de: "München", en: "Munich" } }, @@ -237,8 +239,7 @@ describe("lookupIpLocation — online-first city resolver (v1.18.10 W7)", () => const { lookupIpLocation } = await import("../geo"); expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE"); - // Online was tried first (and missed), then the offline tier resolved. - expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("offline fallback uses the English city name when the German exonym is absent", async () => { diff --git a/src/lib/__tests__/geo-offline-first.test.ts b/src/lib/__tests__/geo-offline-first.test.ts new file mode 100644 index 000000000..097511712 --- /dev/null +++ b/src/lib/__tests__/geo-offline-first.test.ts @@ -0,0 +1,234 @@ +/** + * The offline tier is consulted FIRST, and a local hit makes no request. + * + * From v1.18.10 until v1.37 the resolver queried the online provider first + * and read the MMDBs only when that missed. Every document describing the + * feature said the opposite — the contributor notes, the self-hosting runbook, + * the admin notification that tells an operator their login IPs are leaving + * the server. So an operator who mounted the databases in order to stop that + * egress kept every bit of it, and nothing said so: the location resolved, the + * admin overview filled in, and the only difference was where the address had + * been. + * + * The assertion that matters is therefore about the fetch that does NOT + * happen. Watched red: restore the old order (online lookup first, offline as + * the fallback) and the first two cases fail on a call count of 1. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// The MMDB readers are stubbed: `loadMmdbReader` only needs the file to +// exist, and what the reader answers is this table. +const cityRows = new Map(); +const asnRows = new Map(); + +vi.mock("mmdb-lib", () => ({ + Reader: class { + private readonly rows: Map; + constructor(buf: Buffer) { + // The two readers are told apart by the stub file's own content, which + // is the only thing distinguishing them at construction time. + this.rows = buf.toString().startsWith("asn") ? asnRows : cityRows; + } + get(ip: string): unknown { + return this.rows.get(ip) ?? null; + } + }, +})); + +// safeFetch's requirePublicHost path runs through undici's own `fetch`. +// Delegate it to the global stub so an outbound call is visible either way. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn(async () => undefined), +})); +vi.mock("@/lib/db", () => ({ + prisma: { user: { findMany: vi.fn(async () => []) } }, +})); + +const ORIGINAL_ENV = { ...process.env }; +let tmpRoot: string; + +function jsonOk(value: unknown): Response { + return new Response( + new TextEncoder().encode(JSON.stringify(value)).buffer as ArrayBuffer, + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +function writeCityDb(): void { + writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("city-stub")); +} + +function writeAsnDb(): void { + writeFileSync(join(tmpRoot, "GeoLite2-ASN.mmdb"), Buffer.from("asn-stub")); +} + +beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.IP_GEO_LOOKUP_URL; + delete process.env.IP_GEO_LOOKUP_DISABLED; + delete process.env.IP_GEO_ALLOW_INSECURE; + tmpRoot = mkdtempSync(join(tmpdir(), "healthlog-geo-offline-first-")); + process.env.GEOLITE2_DIR = tmpRoot; + cityRows.clear(); + asnRows.clear(); + vi.resetModules(); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); + process.env = ORIGINAL_ENV; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("geo resolver is offline-first", () => { + it("makes no outbound request when the MMDBs place the address", async () => { + writeCityDb(); + writeAsnDb(); + cityRows.set("8.8.8.8", { + city: { names: { de: "Nürnberg", en: "Nuremberg" } }, + country: { iso_code: "DE" }, + }); + asnRows.set("8.8.8.8", { + autonomous_system_number: 3320, + autonomous_system_organization: "Deutsche Telekom AG", + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("8.8.8.8")).toEqual({ + location: "Nürnberg, DE", + asn: 3320, + carrier: "Deutsche Telekom AG", + }); + expect( + fetchSpy, + "an address the local databases placed still left the host", + ).not.toHaveBeenCalled(); + }); + + it("makes no outbound request when only the City MMDB is mounted", async () => { + // The ASN database is a separate download and an operator may have only + // the City one. Reaching out for a carrier string would send the address + // off the host anyway, which is the thing the mount was for. + writeCityDb(); + cityRows.set("9.9.9.9", { + city: { names: { en: "Zurich" } }, + country: { iso_code: "CH" }, + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("9.9.9.9")).toEqual({ + location: "Zurich, CH", + asn: null, + carrier: null, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("falls through to the online provider when the MMDB holds no row", async () => { + writeCityDb(); + writeAsnDb(); + // Databases present, this address simply not in them. + const fetchSpy = vi.fn().mockResolvedValue( + jsonOk({ + success: true, + city: "Berlin", + country_code: "DE", + connection: { asn: 3209, isp: "Vodafone" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("1.1.1.1")).toEqual({ + location: "Berlin, DE", + asn: 3209, + carrier: "Vodafone", + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("keeps the offline ASN authoritative when the location came from online", async () => { + writeCityDb(); + writeAsnDb(); + asnRows.set("1.1.1.1", { + autonomous_system_number: 13335, + autonomous_system_organization: "Cloudflare, Inc.", + }); + const fetchSpy = vi.fn().mockResolvedValue( + jsonOk({ + success: true, + city: "Berlin", + country_code: "DE", + connection: { asn: 3209, isp: "Vodafone" }, + }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("1.1.1.1")).toEqual({ + location: "Berlin, DE", + asn: 13335, + carrier: "Cloudflare, Inc.", + }); + }); + + it("leaves the online path unchanged when no MMDB is mounted", async () => { + const fetchSpy = vi + .fn() + .mockResolvedValue( + jsonOk({ success: true, city: "Berlin", country_code: "DE" }), + ); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect(await lookupIpGeo("8.8.8.8")).toEqual({ + location: "Berlin, DE", + asn: null, + carrier: null, + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ipwho.is/8.8.8.8"); + }); + + it("resolves locally with IP_GEO_LOOKUP_DISABLED set, and never reaches the network without the databases", async () => { + // The kill switch is unchanged by the reordering: it disables step 3 + // only, so an offline hit still resolves and an offline miss returns + // null rather than going online. + process.env.IP_GEO_LOOKUP_DISABLED = "1"; + writeCityDb(); + cityRows.set("8.8.8.8", { + city: { names: { en: "Vienna" } }, + country: { iso_code: "AT" }, + }); + + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const { lookupIpGeo } = await import("../geo"); + + expect((await lookupIpGeo("8.8.8.8")).location).toBe("Vienna, AT"); + expect((await lookupIpGeo("1.1.1.1")).location).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 81a4591d4..7813c32c9 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -1,18 +1,24 @@ /** * IP geolocation lookup for audit-log enrichment. * - * v1.18.10 (W7): online-first resolver. The `ipwho.is` HTTPS lookup - * (free, no key) is the DEFAULT path — every self-host resolves a - * location out of the box with no MaxMind licence configured. The - * bundled MaxMind GeoLite2-City MMDB at `/opt/geolite2/GeoLite2-City.mmdb` - * is an OPTIONAL fallback (perspective: removed later): it is consulted - * only when the online lookup misses (provider down / rate-limited) or - * when egress is disabled via `IP_GEO_LOOKUP_DISABLED=1`. + * OFFLINE-FIRST resolver. The MaxMind GeoLite2 MMDBs at `/opt/geolite2/` + * (or wherever `GEOLITE2_DIR` points) answer whenever they can, and the + * online provider only ever sees an address they could not place. That + * ordering is the whole reason an operator mounts the databases: a lookup + * that resolves locally sends nothing anywhere. The databases stay OPTIONAL + * — a self-host without them resolves every location through the online + * provider, which needs no licence and works out of the box. * - * 1. Private / loopback / opt-out → null (no lookup). + * 1. Private / loopback → null (no lookup). * 2. Per-IP cache hit → return immediately. - * 3. Online `ipwho.is` lookup → return on success. - * 4. Offline MMDB present → local read fallback on an online miss. + * 3. Offline MMDB present → local read; on a hit the request ends here. + * 4. Online lookup → only on an offline miss, and only when + * `IP_GEO_LOOKUP_DISABLED` is not set. + * + * It was the other way round from v1.18.10 until v1.37: online first, the + * local databases as the fallback. The documentation described this order the + * whole time, so a host with the MMDBs mounted was sending every login IP to a + * third party while its own runbook said it was not. * * The legacy contract still holds: `lookupIpLocation` returns a * `"City, CC"` string or `null`, never throws. `lookupIpGeo` is the @@ -121,8 +127,9 @@ const PRIVATE_IP = // the ip-api.com shape (`status` + `countryCode`), so operators whose egress // ipwho.is rejects can point `IP_GEO_LOOKUP_URL` at `https://ip-api.com/json` // (or any keyed provider matching one of those shapes) with no code change. -// The offline GeoLite2 MMDB tier stays OPTIONAL — consulted only when the -// online lookup misses, and skipped entirely when the databases are absent. +// The offline GeoLite2 MMDB tier stays OPTIONAL, but it goes FIRST when it is +// there: this provider is consulted only for an address the local databases +// could not place, and not at all on a host that has them and hits. const DEFAULT_GEO_URL = "https://ipwho.is"; // The host portion of DEFAULT_GEO_URL — the provider name the admin status @@ -261,10 +268,10 @@ export function __resetGeoLite2CacheForTests(): void { // // The CI workflow drops an `.empty` marker into the geo asset directory // when the maintainer has not configured `MAXMIND_LICENSE_KEY`, in -// which case the City + ASN MMDBs are absent and every lookup hits the -// online fallback. `offlineGeoReady()` is the canonical truth used by -// `/api/version` and the admin status surface so the two paths cannot -// disagree. +// which case the City + ASN MMDBs are absent and every lookup goes to the +// online provider. `offlineGeoReady()` is the canonical truth used by +// `/api/version`, the admin status surface, and the resolver's own +// offline-first branch, so the three cannot disagree. // // `notifiedThisProcess` is a module-level latch so the admin only // receives the "offline geo is disabled" notification once per worker @@ -313,7 +320,7 @@ async function notifyOfflineGeoUnavailable(): Promise { return; } getEvent()?.addWarning( - "geo: offline databases unavailable, falling back to ipwho.is — notifying admins", + `geo: offline databases unavailable, every lookup goes online to ${resolveGeoProviderHost()} — notifying admins`, ); for (const admin of admins) { await dispatchLocalisedNotification({ @@ -507,15 +514,24 @@ async function lookupIpOnline(ip: string): Promise { /** * Resolve location + carrier + AS number for an IP in a single pass. * - * v1.25.8 — the online provider lookup now also yields the carrier, so a - * self-host without the optional offline GeoLite2-ASN MMDB still surfaces a - * network operator in the admin sign-in overview. The merge order: + * OFFLINE FIRST. The order below is the one the documentation has always + * described and the code did not do: for two releases the online provider was + * queried first and the local databases were the fallback, so an operator who + * had mounted the MMDBs precisely so that no address would leave the host was + * sending every login IP to a third party anyway. The MMDBs answer; the online + * provider only sees an address the local databases could not place. * - * 1. Online lookup (one request) → location + carrier + ASN. - * 2. Offline City MMDB → location fallback on an online miss (if present). - * 3. Offline ASN MMDB → AUTHORITATIVE carrier/ASN when present (it carries - * the canonical org name the short-label folder expects); the online - * carrier is the fallback so a host without the DBs still resolves one. + * 1. Offline ASN MMDB → carrier + AS number when configured (authoritative: + * it carries the canonical org name the short-label folder expects). + * 2. Offline City MMDB → location. Present and resolving means the request + * ends here, with no egress at all. + * 3. Online lookup → only on an offline miss (databases absent, unreadable, + * or holding no row for this address). Fills the location, and the + * carrier/ASN when the offline tier had none, so a host without the + * databases still surfaces an operator in the admin sign-in overview. + * + * `IP_GEO_LOOKUP_DISABLED=1` remains the full off-switch for step 3 — with it + * set, an offline miss resolves to null rather than reaching the network. * * Never throws; the auth-audit caller is fire-and-forget. The resolved * record (including all-null misses) is cached per IP for a bounded TTL. @@ -527,28 +543,28 @@ export async function lookupIpGeo(ip: string | null): Promise { const cached = getCachedGeo(ip); if (cached) return cached; - // v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup - // is the primary resolver for every self-host: it needs no MaxMind licence - // and resolves out of the box. The bundled GeoLite2 offline tier is an - // OPTIONAL fallback, so a missing offline tier is the expected baseline. - const online = await lookupIpOnline(ip); - let location = online?.location ?? null; - let asn = online?.asn ?? null; - let carrier = online?.carrier ?? null; - - // Offline City MMDB fills the location when the online tier missed it. - if (!location && offlineGeoReady()) { - location = lookupIpLocationOffline(ip); - } - - // Offline ASN MMDB is authoritative for the carrier/ASN when configured; - // prefer it over the online ISP string but keep the online value as the - // fallback. `lookupIpAsn` also fires the one-shot "no resolver" admin alert - // when neither an offline ASN reader nor the offline tier is present. + // Offline ASN MMDB first, and unconditionally: it is a local file read, and + // it is also what fires the one-shot "no offline resolver" admin alert when + // neither an ASN reader nor the offline tier is present. + let asn: number | null = null; + let carrier: string | null = null; const offlineAsn = lookupIpAsn(ip); if (offlineAsn) { asn = offlineAsn.asn; - if (offlineAsn.carrier) carrier = offlineAsn.carrier; + carrier = offlineAsn.carrier; + } + + let location = offlineGeoReady() ? lookupIpLocationOffline(ip) : null; + + // The address the local databases could not place is the only one that + // travels. A host with the MMDBs mounted and a hit makes no request at all. + if (!location) { + const online = await lookupIpOnline(ip); + if (online) { + location = online.location; + if (asn === null) asn = online.asn; + if (!carrier) carrier = online.carrier; + } } const resolved: GeoResolved = { location, asn, carrier };