diff --git a/.env.example b/.env.example index f3e0196b2..20f090f7b 100644 --- a/.env.example +++ b/.env.example @@ -247,3 +247,6 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # ----------------------------------------------------------------------------- # Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress. # IP_GEO_ALLOW_INSECURE="true" +# +# Directory the offline GeoLite2 databases are read from; default /opt/geolite2 (the path the image bakes its own copy into). Point it at a directory you mount yourself to bring your own MMDBs — see docs/self-hosting/geolite2.md. +# GEOLITE2_DIR="/var/lib/geolite2" diff --git a/.env.production.example b/.env.production.example index 5a8b78bea..76c50df27 100644 --- a/.env.production.example +++ b/.env.production.example @@ -293,6 +293,24 @@ API_TOKEN_HMAC_KEY="" # off the resolver uses only the offline GeoLite2 MMDB tier, if present. # IP_GEO_LOOKUP_DISABLED="1" +# ----------------------------------------------------------------------------- +# Offline GeoLite2 databases -- optional +# ----------------------------------------------------------------------------- +# Where the runtime looks for GeoLite2-City.mmdb and GeoLite2-ASN.mmdb. +# Unset it reads /opt/geolite2, the path the published image bakes its own +# copy into -- but only if the image build had a MaxMind licence key, so on +# a stock `:latest` the directory is usually empty and every login IP goes +# to the online provider instead. +# +# To resolve locations on your own host: create a free MaxMind account, +# pull both databases with `geoipupdate`, mount the directory into the +# container read-only (the commented `volumes:` block in docker-compose.yml +# shows the line), and point this at the container path. Pair it with +# IP_GEO_LOOKUP_DISABLED="1" above for zero IP egress. Refreshing the files +# on the host is enough -- the reader picks them up on the next container +# restart. See docs/self-hosting/geolite2.md. +# GEOLITE2_DIR="/var/lib/geolite2" + # ----------------------------------------------------------------------------- # Environmental-context module (Open-Meteo) -- optional # ----------------------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index f56bfb753..6fdc9bb20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,7 +154,7 @@ These keep tripping operators; they're worth knowing before you push something t - **`SESSION_COOKIE_SECURE`** controls the `Secure` flag on every cookie the app issues. Unset → flag tracks `NODE_ENV === "production"`. Set to `false` for LAN / Tailscale / VPN-only HTTP self-hosts where the operator deliberately serves plain HTTP. Set to `true` to force the flag even in dev. The compose `environment:` block must list it explicitly — vars not on the whitelist don't reach the container even when `.env` has them. v1.5.2 closed the missing-whitelist regression. - **`pull_policy: always`** in `docker-compose.yml` is load-bearing. Without it Docker keeps a stale `:latest` digest cached and silently skips the registry round-trip on `compose up`. - **`ENCRYPTION_KEYS` map vs legacy `ENCRYPTION_KEY`.** The map (`{"v1":"","v2":""}` + `ENCRYPTION_ACTIVE_KEY_ID=v2`) is the modern path. The legacy single-key env var stays accepted as `v1`. Rotation playbook lives at `docs/ops/encryption-key-rotation.md`; do NOT drop a key from the map before the rotation script reports zero remaining rows on that key. -- **GeoLite2 databases.** Operators run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build`; the image bakes the MMDBs into `/opt/geolite2/`. Without them the resolver falls back to online `ipwho.is` and the admin login overview surfaces "unknown" for offline lookups. +- **GeoLite2 databases.** Two ways in. Building your own image: run `scripts/fetch-geolite2.sh` with a MaxMind licence key before `docker build` and the MMDBs bake into `/opt/geolite2/`. Running the published image: mount a directory of `.mmdb` files and point `GEOLITE2_DIR` at it — it is on the compose `environment:` whitelist and documented in `docs/self-hosting/geolite2.md`. Without either, the resolver falls back to the online provider and every login IP leaves the host. - **APNs `.p8` key.** v1.4.47.x established `APNS_KEY_B64` as the env-var contract — pasting the PEM body verbatim through Coolify's `env_file` mangled the literal newlines. The key must be scoped "Sandbox & Production" in the Apple Developer Portal; a Sandbox-only key fails with `BadEnvironmentKeyInToken` on production builds. ## iOS handoff diff --git a/docker-compose.yml b/docker-compose.yml index 1987d0311..f07a0b262 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,8 +15,10 @@ # Leave it unset (or true) only when a TLS reverse proxy serves HTTPS. # # Persistent state lives in exactly one named volume (`pgdata`, Postgres' -# data dir). The app container is stateless — GeoLite2 MMDBs are baked into -# the image — so a backup of the database is a backup of everything. +# data dir). The app container is stateless, so a backup of the database is +# a backup of everything. The optional GeoLite2 databases are either baked +# into the image at build time or mounted read-only from the host and +# pointed at with GEOLITE2_DIR — either way they are replaceable, not state. services: # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing # self-host deployments stay working with `docker compose up`. @@ -96,6 +98,15 @@ services: IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" + # Directory the offline GeoLite2 databases are read from. Default + # `/opt/geolite2` — the path the published image bakes its own copy + # into, if the build had a MaxMind licence key. Point this at a + # directory you mount yourself (see the `volumes:` block below) to + # bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed + # with `geoipupdate` on your own schedule. Listed here so an operator + # value in .env reaches the container (the `environment:` block is a + # whitelist). See docs/self-hosting/geolite2.md. + GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}" # all = bundle web + worker in this container (default, single-host) # web = serve HTTP only; pair with a separate app-worker service HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}" @@ -219,6 +230,16 @@ services: # Set "true" only if your own iOS build carries Apple's Critical Alerts # entitlement; otherwise urgent alerts use the time-sensitive level. APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" + # Bring your own GeoLite2 databases. The published image only carries + # MMDBs if the build had a MaxMind licence key, so on `:latest` the + # offline tier is usually absent and every login IP goes to the online + # provider instead. Create a free MaxMind account, pull + # GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the + # directory read-only, and set GEOLITE2_DIR to the container path. + # Steps: docs/self-hosting/geolite2.md. + # + # volumes: + # - ./geolite2:/var/lib/geolite2:ro depends_on: db: condition: service_healthy diff --git a/docs/README.md b/docs/README.md index 296b7b3a5..7d83a7001 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,10 @@ notes, and machine-readable specs that ship alongside the source: off-by-default MCP connector and pointing Claude.ai / ChatGPT / Claude Desktop at it, and [`self-hosting/sso.md`](./self-hosting/sso.md) — OIDC SSO login: IdP setup, the identity-pinning security model, - `OIDC_ONLY` consequences, and the break-glass runbook. + `OIDC_ONLY` consequences, and the break-glass runbook. Also + [`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your + own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending + login IPs to a third-party lookup. - [`migration/`](./migration/) — release-by-release migration notes. Read the entry for the version you're upgrading from. - [`audit/`](./audit/) — per-release audit summaries archived for diff --git a/docs/self-hosting/geolite2.md b/docs/self-hosting/geolite2.md new file mode 100644 index 000000000..de985ecdc --- /dev/null +++ b/docs/self-hosting/geolite2.md @@ -0,0 +1,122 @@ +# Offline geolocation (GeoLite2) + +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. Nothing leaves the host, and the ASN database + is the authoritative source for the carrier. + +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 +(`scripts/fetch-geolite2.sh`, run from the release workflow), which is a +build-time decision you do not control when you pull `:latest`. Bringing +your own copy is the way to get the offline tier on a self-host, and it +is the subject of this page. + +## What you need + +- A free MaxMind account. Sign up at + , then create a licence key + under **My Account → Manage License Keys**. GeoLite2 is free; the + account and key exist because MaxMind requires them for the download. +- `geoipupdate` (MaxMind's own downloader) or any other way to fetch the + two `.mmdb` files onto the host. Most distributions package + `geoipupdate`; there is also an official container image. + +## Setup + +1. Pick a directory on the host and fetch both databases into it. With + `geoipupdate`, `/etc/GeoIP.conf` looks like this: + + ```conf + AccountID + LicenseKey + EditionIDs GeoLite2-City GeoLite2-ASN + DatabaseDirectory /srv/healthlog/geolite2 + ``` + + Then run `geoipupdate`. The directory should end up holding + `GeoLite2-City.mmdb` and `GeoLite2-ASN.mmdb`. The City database is the + one the readiness check looks for; the ASN database is what fills the + carrier and AS number. + +2. Mount the directory into the app container read-only. In + `docker-compose.yml` the `app` service carries a commented `volumes:` + block for exactly this: + + ```yaml + volumes: + - /srv/healthlog/geolite2:/var/lib/geolite2:ro + ``` + +3. Point the runtime at the container path: + + ```env + GEOLITE2_DIR="/var/lib/geolite2" + ``` + + `GEOLITE2_DIR` is on the compose `environment:` whitelist, so a value + in your `.env` reaches the container. Variables that are not on that + whitelist never do, even when compose substitutes them elsewhere in + the file. + +4. `docker compose up -d` and sign in once. **Admin → System status** + shows `Geo data: Offline (GeoLite2)` when the databases are being + read, and names the online provider when they are not. + +## Turning the online lookup off entirely + +Once the offline tier resolves, the third-party lookup is redundant. To +stop all IP egress: + +```env +IP_GEO_LOOKUP_DISABLED="1" +``` + +The resolver then uses only the local databases and returns nothing when +they miss, rather than asking anyone. An instance handling other people's +health data is a reasonable place to make that trade. + +## Keeping the databases current + +MaxMind reissues GeoLite2 on its own schedule, and a stale city database +is quietly wrong rather than loudly broken — it keeps answering, with +last month's allocations. Run `geoipupdate` on a schedule (a weekly cron +or systemd timer is enough) against the same directory. + +The reader loads each database once per worker process and holds it, so a +refreshed file on the host is picked up at the next container restart, +not immediately. A weekly `geoipupdate` followed by +`docker compose restart app` keeps the two in step. + +## Licence and attribution + +The databases are downloaded under your own MaxMind account, so their +terms are between you and MaxMind — read what your account agrees to at +. HealthLog's own attribution +("this product includes GeoLite2 data created by MaxMind") is already on +the `/about` page and covers an instance that uses them. + +## Troubleshooting + +**System status still says the online provider.** The readiness check +requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty` +marker file beside it. Check the path from inside the container: +`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means +the bind mount did not land; a `.empty` file means the image was built +without a licence key and you are still reading the baked-in directory +rather than your own. + +**The variable has no effect.** Confirm it actually reached the process: +`docker compose exec app printenv GEOLITE2_DIR`. If it prints nothing, +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. diff --git a/messages/de.json b/messages/de.json index 5c3cc6864..77c4745b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — MAXMIND_LICENSE_KEY in CI setzen", + "offlineGeoFallback": "Online ({host}) — GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", "umamiTitle": "Umami", "umamiDescription": "Web-Analyse mit Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Einnahme überfällig: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) im Zeitfenster {window} ist seit {minutes} Minuten überfällig.", "offlineGeoUnavailableTitle": "Offline-Geodatenbanken nicht geladen", - "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte aktuell über den Online-Fallback ipwho.is auf. Um die Offline-Datenbanken GeoLite2-City und GeoLite2-ASN zu aktivieren, setze MAXMIND_LICENSE_KEY in den GitHub-Actions-Secrets unter {secretsUrl} und deploye neu. Bis dahin bleibt die Carrier-Spalte in der Login-Übersicht leer.", + "offlineGeoUnavailableBody": "HealthLog löst Login-Standorte über die Online-Abfrage bei {host} auf, damit verlässt jede Login-IP diesen Server. Um sie stattdessen lokal aufzulösen, lade die Datenbanken GeoLite2-City und GeoLite2-ASN mit einem kostenlosen MaxMind-Konto herunter, binde das Verzeichnis in den Container ein und setze GEOLITE2_DIR darauf. Anleitung: {docsUrl}", "tlsPinChangedTitle": "TLS-Leaf-Zertifikat geändert: {host}", "tlsPinChangedBody": "Das von {host} ausgelieferte TLS-Leaf hat jetzt den SPKI-Pin {servedPin}, der nicht im bekannten Pin-Satz enthalten ist. Gepinnte Clients (die native App) können sich nicht mehr verbinden, sobald der alte Pin weg ist. Extrahiere den neuen Leaf-Pin erneut, ergänze ihn in TLS_LEAF_SPKI_PINS sowie im Pin-Satz des nativen Clients und veröffentliche einen Build, bevor dieses Zertifikat am {validTo} abläuft. Siehe docs/ops/tls-cert-pin.md." }, diff --git a/messages/en.json b/messages/en.json index f93b91fdf..e281fa113 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — set MAXMIND_LICENSE_KEY in CI", + "offlineGeoFallback": "Online ({host}) — mount GeoLite2 databases and set GEOLITE2_DIR", "apiGlobal": "Allow API endpoints and API tokens.", "umamiTitle": "Umami", "umamiDescription": "Web analytics with Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dose overdue: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) in the {window} window is {minutes} minutes overdue.", "offlineGeoUnavailableTitle": "Offline geolocation databases not loaded", - "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online ipwho.is fallback. To enable the offline GeoLite2-City + GeoLite2-ASN databases, set MAXMIND_LICENSE_KEY in GitHub Actions secrets at {secretsUrl} and redeploy. Until then the carrier column on the login overview stays empty.", + "offlineGeoUnavailableBody": "HealthLog is resolving login locations through the online lookup at {host}, so every login IP address leaves this server. To resolve them locally instead, download the GeoLite2-City and GeoLite2-ASN databases with a free MaxMind account, mount the directory into the container, and point GEOLITE2_DIR at it. Steps: {docsUrl}", "tlsPinChangedTitle": "TLS leaf certificate changed: {host}", "tlsPinChangedBody": "The TLS leaf served by {host} now has SPKI pin {servedPin}, which is not in the known pin set. Pinned clients (the native app) will fail to connect once the old pin is gone. Re-extract the new leaf pin, add it to TLS_LEAF_SPKI_PINS and the native client's pin set, and ship a build before this certificate expires on {validTo}. See docs/ops/tls-cert-pin.md." }, diff --git a/messages/es.json b/messages/es.json index a1248c1fe..8f10194b8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", - "offlineGeoFallback": "En línea ({host}) — configura MAXMIND_LICENSE_KEY en CI", + "offlineGeoFallback": "En línea ({host}) — monta las bases GeoLite2 y define GEOLITE2_DIR", "apiGlobal": "Permitir endpoints de API y tokens de API.", "umamiTitle": "Umami", "umamiDescription": "Analítica web con Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dosis atrasada: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) en la ventana {window} lleva {minutes} minutos de retraso.", "offlineGeoUnavailableTitle": "Bases de datos de geolocalización sin conexión no cargadas", - "offlineGeoUnavailableBody": "HealthLog está resolviendo las ubicaciones de inicio de sesión mediante el respaldo en línea ipwho.is. Para habilitar las bases sin conexión GeoLite2-City y GeoLite2-ASN, configura MAXMIND_LICENSE_KEY en los secrets de GitHub Actions en {secretsUrl} y vuelve a desplegar. Hasta entonces, la columna de operador en el resumen de inicios de sesión permanece vacía.", + "offlineGeoUnavailableBody": "HealthLog resuelve las ubicaciones de inicio de sesión mediante la consulta en línea a {host}, por lo que cada IP de inicio de sesión sale de este servidor. Para resolverlas localmente, descarga las bases GeoLite2-City y GeoLite2-ASN con una cuenta gratuita de MaxMind, monta el directorio en el contenedor y apunta GEOLITE2_DIR a él. Pasos: {docsUrl}", "tlsPinChangedTitle": "Certificado de hoja TLS cambiado: {host}", "tlsPinChangedBody": "La hoja TLS servida por {host} ahora tiene el pin SPKI {servedPin}, que no está en el conjunto de pines conocidos. Los clientes con pin fijo (la app nativa) no podrán conectarse una vez que desaparezca el pin antiguo. Vuelve a extraer el nuevo pin de la hoja, añádelo a TLS_LEAF_SPKI_PINS y al conjunto de pines del cliente nativo, y publica una versión antes de que este certificado caduque el {validTo}. Consulta docs/ops/tls-cert-pin.md." }, diff --git a/messages/fr.json b/messages/fr.json index 79d87c87a..88f747fb6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", - "offlineGeoFallback": "En ligne ({host}) — définissez MAXMIND_LICENSE_KEY dans la CI", + "offlineGeoFallback": "En ligne ({host}) — montez les bases GeoLite2 et définissez GEOLITE2_DIR", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", "umamiTitle": "Umami", "umamiDescription": "Analyse web avec Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Prise en retard : {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) dans la fenêtre {window} est en retard de {minutes} minutes.", "offlineGeoUnavailableTitle": "Bases de géolocalisation hors ligne non chargées", - "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via le fallback en ligne ipwho.is. Pour activer les bases hors ligne GeoLite2-City et GeoLite2-ASN, définissez MAXMIND_LICENSE_KEY dans les secrets GitHub Actions à {secretsUrl} puis redéployez. En attendant, la colonne opérateur dans l'aperçu des connexions reste vide.", + "offlineGeoUnavailableBody": "HealthLog résout les localisations de connexion via la requête en ligne vers {host}, donc chaque adresse IP de connexion quitte ce serveur. Pour les résoudre localement, téléchargez les bases GeoLite2-City et GeoLite2-ASN avec un compte MaxMind gratuit, montez le répertoire dans le conteneur et faites-y pointer GEOLITE2_DIR. Étapes : {docsUrl}", "tlsPinChangedTitle": "Certificat feuille TLS modifié : {host}", "tlsPinChangedBody": "La feuille TLS servie par {host} a maintenant le pin SPKI {servedPin}, qui ne figure pas dans l'ensemble de pins connus. Les clients épinglés (l'app native) ne pourront plus se connecter une fois l'ancien pin disparu. Réextrayez le nouveau pin de la feuille, ajoutez-le à TLS_LEAF_SPKI_PINS et à l'ensemble de pins du client natif, et publiez une version avant l'expiration de ce certificat le {validTo}. Voir docs/ops/tls-cert-pin.md." }, diff --git a/messages/it.json b/messages/it.json index a192e831f..45318df0e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — imposta MAXMIND_LICENSE_KEY in CI", + "offlineGeoFallback": "Online ({host}) — monta i database GeoLite2 e imposta GEOLITE2_DIR", "apiGlobal": "Consentire endpoint API e token API.", "umamiTitle": "Umami", "umamiDescription": "Analisi web con Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dose in ritardo: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) nella finestra {window} è in ritardo di {minutes} minuti.", "offlineGeoUnavailableTitle": "Database di geolocalizzazione offline non caricati", - "offlineGeoUnavailableBody": "HealthLog sta risolvendo le posizioni di accesso tramite il fallback online ipwho.is. Per abilitare i database offline GeoLite2-City e GeoLite2-ASN, imposta MAXMIND_LICENSE_KEY nei secret di GitHub Actions su {secretsUrl} e ridistribuisci. Fino ad allora la colonna operatore nella panoramica degli accessi rimane vuota.", + "offlineGeoUnavailableBody": "HealthLog risolve le posizioni di accesso tramite la ricerca online su {host}, quindi ogni IP di accesso lascia questo server. Per risolverle localmente, scarica i database GeoLite2-City e GeoLite2-ASN con un account MaxMind gratuito, monta la directory nel container e fai puntare GEOLITE2_DIR ad essa. Passaggi: {docsUrl}", "tlsPinChangedTitle": "Certificato foglia TLS modificato: {host}", "tlsPinChangedBody": "La foglia TLS servita da {host} ora ha il pin SPKI {servedPin}, che non è nell'insieme dei pin noti. I client con pin fisso (l'app nativa) non potranno più connettersi una volta scomparso il vecchio pin. Riestrai il nuovo pin della foglia, aggiungilo a TLS_LEAF_SPKI_PINS e all'insieme dei pin del client nativo, e pubblica una build prima che questo certificato scada il {validTo}. Vedi docs/ops/tls-cert-pin.md." }, diff --git a/messages/pl.json b/messages/pl.json index c7281731c..bed55cf20 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5968,7 +5968,7 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — ustaw MAXMIND_LICENSE_KEY w CI", + "offlineGeoFallback": "Online ({host}) — podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", "umamiTitle": "Umami", "umamiDescription": "Analityka internetowa z Umami", @@ -6769,7 +6769,7 @@ "reminderCheckOverdueTitle": "Dawka opóźniona: {medication}", "reminderCheckOverdueBody": "{medication} ({dose}) w przedziale {window} jest opóźnione o {minutes} minut.", "offlineGeoUnavailableTitle": "Bazy geolokalizacji offline nie zostały załadowane", - "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez awaryjne źródło online ipwho.is. Aby włączyć bazy offline GeoLite2-City i GeoLite2-ASN, ustaw MAXMIND_LICENSE_KEY w sekretach GitHub Actions pod adresem {secretsUrl} i ponownie wdroż. Do tego czasu kolumna operatora w przeglądzie logowań pozostaje pusta.", + "offlineGeoUnavailableBody": "HealthLog rozwiązuje lokalizacje logowania przez zapytanie online do {host}, więc każdy adres IP logowania opuszcza ten serwer. Aby rozwiązywać je lokalnie, pobierz bazy GeoLite2-City i GeoLite2-ASN na bezpłatnym koncie MaxMind, podłącz katalog do kontenera i ustaw na niego GEOLITE2_DIR. Kroki: {docsUrl}", "tlsPinChangedTitle": "Zmieniono certyfikat liścia TLS: {host}", "tlsPinChangedBody": "Liść TLS serwowany przez {host} ma teraz pin SPKI {servedPin}, którego nie ma w znanym zestawie pinów. Klienci z przypiętym pinem (aplikacja natywna) nie połączą się po usunięciu starego pinu. Ponownie wyodrębnij nowy pin liścia, dodaj go do TLS_LEAF_SPKI_PINS oraz do zestawu pinów klienta natywnego i opublikuj kompilację, zanim ten certyfikat wygaśnie {validTo}. Zobacz docs/ops/tls-cert-pin.md." }, diff --git a/scripts/__tests__/check-env.test.ts b/scripts/__tests__/check-env.test.ts index b97436fb1..5fe355f7c 100644 --- a/scripts/__tests__/check-env.test.ts +++ b/scripts/__tests__/check-env.test.ts @@ -289,4 +289,46 @@ describe("environment inventory", () => { expect(manifestNames).toContain(variableName); } }); + + it("forwards GEOLITE2_DIR through the compose whitelist and documents it", () => { + // issue #659 — `src/lib/geo.ts` has always read this variable, but it + // was absent from the compose `environment:` block, and a variable + // that is not on that whitelist never reaches the container even when + // the operator's `.env` sets it. The override existed in code and was + // unreachable from the documented path. + const repositoryRoot = join(__dirname, "../.."); + const compose = readFileSync( + join(repositoryRoot, "docker-compose.yml"), + "utf8", + ); + const manifest = JSON.parse( + readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"), + ) as { groups: Array<{ variables: Array<{ name: string }> }> }; + + // The compose line must SUBSTITUTE the operator's own value — a + // hardcoded literal would list the key and still ignore `.env`. + expect(compose).toMatch( + /^\s*GEOLITE2_DIR: "\$\{GEOLITE2_DIR(:-[^}]*)?\}"$/m, + ); + // ...and the container needs a place to read the files from. + expect(compose).toMatch(/geolite2:ro$/m); + + for (const exampleFile of [".env.example", ".env.production.example"]) { + const example = readFileSync(join(repositoryRoot, exampleFile), "utf8"); + expect(example, `${exampleFile} does not document GEOLITE2_DIR`).toMatch( + /^# GEOLITE2_DIR=/m, + ); + } + + expect( + manifest.groups.flatMap((group) => + group.variables.map((variable) => variable.name), + ), + ).toContain("GEOLITE2_DIR"); + + // The runbook the admin notification and both examples point at. + expect(() => + readFileSync(join(repositoryRoot, "docs/self-hosting/geolite2.md")), + ).not.toThrow(); + }); }); diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 2ee26a1a1..447d71f49 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -132,6 +132,17 @@ } ] }, + { + "name": "Offline geolocation", + "description": "Optional offline GeoLite2 tier for the admin login overview. Unset, the runtime reads /opt/geolite2 (the path the image bakes its own copy into) and falls back to the online provider when the databases are absent.", + "required": false, + "variables": [ + { + "name": "GEOLITE2_DIR", + "purpose": "Directory holding GeoLite2-City.mmdb + GeoLite2-ASN.mmdb. Point it at a read-only mount to bring your own databases. Default /opt/geolite2." + } + ] + }, { "name": "APNs (iOS push)", "description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", diff --git a/src/lib/__tests__/geo-offline-detection.test.ts b/src/lib/__tests__/geo-offline-detection.test.ts index f56898822..c12090b59 100644 --- a/src/lib/__tests__/geo-offline-detection.test.ts +++ b/src/lib/__tests__/geo-offline-detection.test.ts @@ -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"), diff --git a/src/lib/geo.ts b/src/lib/geo.ts index 2896b8d62..81a4591d4 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -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 { 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(); }