fix(geo): make GEOLITE2_DIR reachable and tell self-hosters what they can do

The runtime has always read GEOLITE2_DIR, but the variable was absent
from the compose `environment:` whitelist, from both env examples, and
from the docs. A variable that is not on that whitelist never reaches
the container even when the operator's `.env` sets it, so the override
existed in code and was unreachable from the documented path.

Put it on the whitelist, document it in both examples and the manifest,
and add `docs/self-hosting/geolite2.md`: fetch the databases with a free
MaxMind account, mount the directory read-only, point the variable at
it. The compose file carries the bind-mount line commented out beside
the app service.

The admin notification told the reader to set MAXMIND_LICENSE_KEY in
this repository's Actions secrets — something nobody running the
published image can do, about a fallback that sends their sign-in
addresses to a third party. It now names the mount, names the online
provider it is actually querying, and links the runbook. Six locales.
It also no longer claims the carrier column stays empty; the online
tier has filled the carrier from the ISP field since v1.25.8.

A blank GEOLITE2_DIR now resolves to the default instead of the process
working directory: compose substitutes an unset variable to the empty
string, which is not nullish.

Refs #659
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 14:47:55 +02:00
1 parent 832a269ab3
commit 189024cc00
16 files changed
+341 -27

No files matched your search

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -97,6 +97,25 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
expect(offlineGeoReady()).toBe(false);
});
it("treats a blank GEOLITE2_DIR as unset rather than as the working directory", async () => {
// A compose `${GEOLITE2_DIR:-}` substitutes to the EMPTY STRING, which
// is not nullish. Without the trim, `path.join("", "GeoLite2-City.mmdb")`
// is a RELATIVE path and the readiness check answers from whatever the
// process working directory happens to hold. Proven by putting a stub
// MMDB in the working directory and asserting the blank value still
// resolves to the documented default (which has none).
const originalCwd = process.cwd();
writeFileSync(join(tmpRoot, "GeoLite2-City.mmdb"), Buffer.from("stub"));
process.chdir(tmpRoot);
try {
process.env.GEOLITE2_DIR = "";
const { offlineGeoReady } = await import("../geo");
expect(offlineGeoReady()).toBe(false);
} finally {
process.chdir(originalCwd);
}
});
it("offlineGeoReady() is true when the City MMDB is present and no marker", async () => {
writeFileSync(
join(tmpRoot, "GeoLite2-City.mmdb"),
@@ -123,15 +142,60 @@ describe("offline-geo runtime detection (v1.4.27 R5)", () => {
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
params: expect.objectContaining({
secretsUrl: expect.stringContaining(
"github.com/MBombeck/HealthLog/settings/secrets/actions",
),
host: "ipwho.is",
docsUrl: expect.stringContaining("docs/self-hosting/geolite2.md"),
}),
metadata: expect.objectContaining({ source: "geo-offline-detection" }),
}),
);
});
it("names the real online provider in the notification params", async () => {
writeFileSync(join(tmpRoot, ".empty"), "");
process.env.IP_GEO_LOOKUP_DISABLED = "1";
process.env.IP_GEO_LOOKUP_URL = "https://ip-api.example/json";
const { lookupIpLocation } = await import("../geo");
await lookupIpLocation("8.8.8.8");
await flushMicrotasks();
expect(dispatchSpy).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ host: "ip-api.example" }),
}),
);
});
it("points the admin at a reachable action, never at a repository secret", async () => {
// issue #659 — the message used to tell a self-hoster to set
// MAXMIND_LICENSE_KEY in this repository's Actions secrets, which is
// not something they can do. Every locale must name the mount instead.
const locales = ["de", "en", "es", "fr", "it", "pl"] as const;
for (const locale of locales) {
const bundle = JSON.parse(
readFileSync(
join(__dirname, `../../../messages/${locale}.json`),
"utf8",
),
) as {
notifications: { admin: { offlineGeoUnavailableBody: string } };
};
const body = bundle.notifications.admin.offlineGeoUnavailableBody;
expect(body, `${locale} still names a build-time secret`).not.toMatch(
/MAXMIND_LICENSE_KEY|GitHub|Actions|secret/i,
);
expect(body, `${locale} does not name the reachable override`).toContain(
"GEOLITE2_DIR",
);
expect(body, `${locale} drops the {host} placeholder`).toContain(
"{host}",
);
expect(body, `${locale} drops the {docsUrl} placeholder`).toContain(
"{docsUrl}",
);
}
});
it("does NOT fire the notification when the City MMDB is present", async () => {
writeFileSync(
join(tmpRoot, "GeoLite2-City.mmdb"),
+37 -7
View File
@@ -31,7 +31,11 @@
* The base path is overridable via `GEOLITE2_DIR` (default
* `/opt/geolite2`). When the DB files are missing the helpers silently
* skip the offline tier — local dev without the MMDBs still works and
* falls straight back to the online provider.
* falls straight back to the online provider. A self-hoster running the
* published image mounts their own `.mmdb` directory and points the
* variable at it; `docs/self-hosting/geolite2.md` is the runbook and
* `docker-compose.yml` carries the variable on its `environment:`
* whitelist so a value in `.env` actually reaches the container.
*
* Default provider for the online lookup is ipwho.is (free, no key, HTTPS).
* Both the ipwho.is shape (`success`/`country_code`) and the ip-api.com shape
@@ -147,8 +151,20 @@ export function resolveGeoProviderHost(): string {
}
}
/**
* Directory the offline MMDBs are read from.
*
* `GEOLITE2_DIR` is on the compose `environment:` whitelist, so an
* operator who mounts their own databases can point the runtime at them
* without rebuilding the image (`docs/self-hosting/geolite2.md`). Compose
* substitutes an unset variable to the EMPTY STRING rather than leaving
* it absent, and an empty string is not nullish — so trim and treat blank
* as unset, otherwise a stack that merely lists the variable would read
* the process working directory and silently lose the offline tier.
*/
function geoLiteDir(): string {
return process.env.GEOLITE2_DIR ?? "/opt/geolite2";
const configured = process.env.GEOLITE2_DIR?.trim();
return configured ? configured : "/opt/geolite2";
}
// ── Per-IP location cache ────────────────────────────────────────────
@@ -257,6 +273,14 @@ export function __resetGeoLite2CacheForTests(): void {
let notifiedThisProcess = false;
/**
* Where the admin notification sends someone who wants the offline tier.
* The in-repo path is the durable one — the runbook ships with the source
* that emits this link, so the two cannot drift apart.
*/
const GEOLITE2_DOCS_URL =
"https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/geolite2.md";
export function offlineGeoReady(): boolean {
try {
const dir = geoLiteDir();
@@ -296,9 +320,14 @@ async function notifyOfflineGeoUnavailable(): Promise<void> {
userId: admin.id,
titleKey: "notifications.admin.offlineGeoUnavailableTitle",
messageKey: "notifications.admin.offlineGeoUnavailableBody",
// The message names what the person reading it can actually do.
// It used to point at a licence-key secret in this repository's
// Actions settings, which nobody running the published image can
// set (issue #659) — the reachable path is mounting your own
// databases and pointing GEOLITE2_DIR at them.
params: {
secretsUrl:
"https://github.com/MBombeck/HealthLog/settings/secrets/actions",
host: resolveGeoProviderHost(),
docsUrl: GEOLITE2_DOCS_URL,
},
metadata: { source: "geo-offline-detection" },
});
@@ -554,9 +583,10 @@ export function lookupIpAsn(
if (!ip || PRIVATE_IP.test(ip)) return null;
const reader = getAsnReader();
if (!reader) {
// No offline ASN data — alert once per process. There is no online
// fallback for ASN, but the maintainer still wants to know the
// carrier chip is going to stay empty until the secret is set.
// No offline ASN data — alert once per process. The online tier can
// still fill the carrier from its ISP field, but the ASN number and
// the authoritative carrier come from the MMDB, so the admin is told
// once that the offline tier is not being read.
if (!offlineGeoReady()) {
void notifyOfflineGeoUnavailable();
}