feat(geo): default IP geolocation to the online ipwho.is resolver

Make the keyless ipwho.is HTTPS lookup the primary resolver for every
self-host. The bundled MaxMind GeoLite2 offline tier becomes an optional
fallback, consulted only when the online lookup misses or when egress is
disabled. A missing offline tier is now the expected baseline rather than
a gap, so the one-shot admin alert fires only when neither resolver can
produce a location.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:56:20 +02:00
1 parent 44ef6eb678
commit aae65b8bb1
2 files changed
+122 -71

No files matched your search

+91 -47
View File
@@ -16,7 +16,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
* - Reader throws are swallowed (the helper is called from a
* fire-and-forget audit-log path; a thrown error would surface as
* an unhandled rejection).
* - The offline-first `lookupIpLocation` path picks the German city
* - The online-first `lookupIpLocation` path (v1.18.10 W7) resolves via
* `ipwho.is` by default and only consults the offline MMDB as a
* fallback (online miss or egress disabled), picking the German city
* name first, English second, with the country ISO code from
* `country` or `registered_country`.
*
@@ -171,57 +173,29 @@ describe("lookupIpAsn — offline ASN resolver (v1.4.27 B3)", () => {
});
});
describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () => {
it("prefers the German exonym from the MMDB Names record", async () => {
// 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)", () => {
// Helper: a non-ok online response forces the offline fallback to run.
function onlineMiss(): Response {
return new Response("", {
status: 503,
headers: { "content-type": "application/json" },
});
}
it("prefers the online provider even when the offline DB is present", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
country: { iso_code: "DE" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
// Offline hit — no online fallback was needed.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("falls back to the English city name when the German exonym is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Birmingham" } },
country: { iso_code: "GB" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB");
});
it("accepts the registered_country ISO when country is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Geneva" } },
registered_country: { iso_code: "CH" },
});
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH");
});
it("falls back to the online provider when the offline DB has no city/country", async () => {
readerState.cityExists = true;
readerState.city = () => ({ city: { names: {} } });
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
JSON.stringify({
success: true,
city: "Hamburg",
country_code: "DE",
}),
JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }),
).buffer as ArrayBuffer,
{
status: 200,
@@ -232,11 +206,64 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Hamburg, DE");
// 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);
});
it("falls back to the online provider when the offline DB is missing entirely", async () => {
it("falls back to the offline German exonym when the online lookup misses", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München", en: "Munich" } },
country: { iso_code: "DE" },
});
const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
vi.stubGlobal("fetch", fetchSpy);
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);
});
it("offline fallback uses the English city name when the German exonym is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Birmingham" } },
country: { iso_code: "GB" },
});
const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("212.58.224.1")).toBe("Birmingham, GB");
});
it("offline fallback accepts the registered_country ISO when country is absent", async () => {
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { en: "Geneva" } },
registered_country: { iso_code: "CH" },
});
const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("194.158.0.1")).toBe("Geneva, CH");
});
it("returns null when both online and the offline DB miss", async () => {
readerState.cityExists = true;
readerState.city = () => ({ city: { names: {} } });
const fetchSpy = vi.fn().mockResolvedValue(onlineMiss());
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("resolves online when the offline DB is missing entirely", async () => {
readerState.cityExists = false;
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
@@ -256,7 +283,24 @@ describe("lookupIpLocation — offline-first city resolver (v1.4.27 B3)", () =>
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("skips the online fallback when IP_GEO_LOOKUP_DISABLED=1 and offline misses", async () => {
it("uses the offline tier when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is present", async () => {
process.env.IP_GEO_LOOKUP_DISABLED = "1";
readerState.cityExists = true;
readerState.city = () => ({
city: { names: { de: "München" } },
country: { iso_code: "DE" },
});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
// Egress disabled → online short-circuits to null without a request, then
// the offline tier resolves.
expect(await lookupIpLocation("85.214.0.1")).toBe("München, DE");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns null when IP_GEO_LOOKUP_DISABLED=1 and the offline DB is missing", async () => {
process.env.IP_GEO_LOOKUP_DISABLED = "1";
readerState.cityExists = false;
const fetchSpy = vi.fn();
+31 -24
View File
@@ -1,19 +1,18 @@
/**
* IP geolocation lookup for audit-log enrichment.
*
* v1.15.12 E2: online-first resolver. The baseline that always works is
* the `ipwho.is` HTTPS lookup (free, no key) — every self-host resolves
* a location out of the box with no MaxMind licence configured. The
* 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 upgrade: when the offline databases are present
* (`offlineGeoReady()`), the resolver prefers the microsecond-scale
* local read for both speed and zero egress; when they are absent it
* goes straight online.
* 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`.
*
* 1. Private / loopback / opt-out → null (no lookup).
* 2. Per-IP cache hit → return immediately.
* 3. Offline MMDB present → local read; on a miss fall through online.
* 4. Otherwise → online `ipwho.is` lookup.
* 3. Online `ipwho.is` lookup → return on success.
* 4. Offline MMDB present → local read fallback on an online miss.
*
* The legacy contract still holds: `lookupIpLocation` returns a
* `"City, CC"` string or `null`, never throws. `lookupIpAsn` is the
@@ -369,31 +368,39 @@ export async function lookupIpLocation(
const cached = getCachedLocation(ip);
if (cached) return cached.value;
// v1.15.12 E2 — online-first precedence. The optional MaxMind offline
// tier is preferred ONLY when its databases are actually present
// (`offlineGeoReady()`): a local hit is faster and egress-free. When
// the offline DBs are absent — the default for a self-host with no
// `MAXMIND_LICENSE_KEY` — we skip straight to the online provider,
// which is the baseline that always resolves a location.
// v1.18.10 (W7) — online-first by default. The `ipwho.is` HTTPS lookup
// is now the primary resolver for every self-host: it needs no MaxMind
// licence and resolves a location out of the box. The bundled GeoLite2
// offline tier is an OPTIONAL fallback kept only for the egress-disabled
// case (`IP_GEO_LOOKUP_DISABLED=1`) or for an online miss when the DBs
// happen to be present — it is no longer the default path, and a missing
// offline tier is the expected baseline rather than a gap to alert on.
const online = await lookupIpLocationOnline(ip);
if (online) {
setCachedLocation(ip, online);
return online;
}
// Online missed (provider down, rate-limited, or egress disabled). Fall
// back to the offline MMDB if the operator configured one.
if (offlineGeoReady()) {
const offline = lookupIpLocationOffline(ip);
if (offline) {
setCachedLocation(ip, offline);
return offline;
}
// Offline DBs present but this IP missed (freshly-allocated range
// lagging the monthly GeoLite2 roll) → fall through to online.
} else {
// No offline tier configured — fire the one-shot admin alert so the
// maintainer can wire `MAXMIND_LICENSE_KEY` for the offline upgrade
// when convenient. Online still resolves; the alert is informational.
// Fire-and-forget so the audit-log path stays fast.
// Neither resolver produced a location: the online lookup missed (or
// egress is disabled) AND no offline tier is configured. That is the
// only genuine "no resolver at all" gap — fire the one-shot admin alert
// so the maintainer can wire `MAXMIND_LICENSE_KEY` or re-enable egress.
// The happy path (ipwho.is resolving by default) never reaches here, so
// a self-host without the offline DBs is not nagged on every lookup.
void notifyOfflineGeoUnavailable();
}
const online = await lookupIpLocationOnline(ip);
setCachedLocation(ip, online);
return online;
setCachedLocation(ip, null);
return null;
}
/**