mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
44ef6eb678
commit
aae65b8bb1
2 files changed
+122
-71
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user