mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/compose-env-and-claims' into release/v1.39.3
This commit is contained in:
commit
026e8fc1ed
64 files changed
+1059
-158
No files matched your search
@@ -833,6 +833,10 @@ paths:
|
||||
"unknown"` and a machine-readable `reason`, so the UI can offer a retry instead of a red banner nobody can act
|
||||
on. The one genuine error is a feed that answers successfully with no version tag in it. Nothing is cached
|
||||
server-side; the client is expected to hold the result.
|
||||
|
||||
|
||||
An operator who sets `UPDATE_CHECK_DISABLED` turns the outbound request off: the route then answers `status:
|
||||
"unknown"` with `reason: "disabled"` and contacts nobody.
|
||||
responses:
|
||||
"200":
|
||||
description: Up to date, an update is available, or the check could not be completed.
|
||||
|
||||
@@ -70,10 +70,11 @@ worker`. Default `all` keeps the single-container behaviour.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
- Rotation: **Admin → Encryption → Rotate now** re-encrypts every
|
||||
ciphertext under the new key without downtime; the script
|
||||
`scripts/rotate-encryption-key.ts` does the same from a source checkout.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md` (the current
|
||||
playbook; the production image strips `tsx`, hence `pnpm dlx`).
|
||||
playbook).
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
|
||||
@@ -41,15 +41,33 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
Restart the app. New writes are now encrypted under `v2`; existing
|
||||
`v1`-keyed and legacy bare rows still decrypt because the `v1` entry is
|
||||
retained.
|
||||
3. **Run the rotation script** to re-encrypt every registered encrypted column
|
||||
under the new active key:
|
||||
3. **Re-encrypt the stored data under the new key.** Open **Admin →
|
||||
Encryption** and press **Rotate now**. The button needs an admin account
|
||||
with a second factor, confirmed recently; it queues one background run
|
||||
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
|
||||
works on the published image with nothing else installed. The same page
|
||||
shows the per-column counts and the result of the last run.
|
||||
|
||||
Without a second factor on any admin account, run the rotation script
|
||||
instead. It imports the application code and the generated database
|
||||
client, so it runs from a source checkout of the same release, not
|
||||
inside the production container (the image has no package manager and
|
||||
does not contain `scripts/rotate-encryption-key.ts`). It needs the same
|
||||
`ENCRYPTION_KEYS` and `ENCRYPTION_ACTIVE_KEY_ID` as the app and a
|
||||
`DATABASE_URL` that reaches the database:
|
||||
|
||||
```
|
||||
git checkout vX.Y.Z # the release the instance runs
|
||||
pnpm install && pnpm db:generate
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates every column in the canonical registry
|
||||
The bundled `db` service publishes no port. On a stock compose stack,
|
||||
either publish `5432` for the duration, or run the checkout in a
|
||||
throwaway `node:22` container attached to the compose network.
|
||||
|
||||
Both paths are idempotent: running again is a no-op for rows already
|
||||
prefixed with `v2.`. Both rotate every column in the canonical registry
|
||||
(`src/lib/crypto/encrypted-columns.ts`), which covers the `*Encrypted`
|
||||
columns plus the ones whose names say nothing about their contents:
|
||||
`IntegrationStatus.lastError`, `CoachMessage.encryptedContent`,
|
||||
@@ -58,7 +76,8 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
`IdempotencyKey.responseBody`, and `DataBackup.data` — the whole-account
|
||||
backup blob.
|
||||
|
||||
Read three things off the output before going further:
|
||||
When you used the script, read three things off its output before going
|
||||
further:
|
||||
|
||||
- the per-column line, `scanned` / `rotated` / `errors` / `dropped`.
|
||||
`scanned` counts the rows that hold ciphertext in that column, not every
|
||||
@@ -78,10 +97,10 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
|
||||
4. **Confirm nothing is left on the old key, THEN drop it.** This is the step
|
||||
that destroys data if it is taken on a bad signal, so check the corpus
|
||||
rather than the absence of complaints. Either re-run the script — a clean
|
||||
second pass reports `rotated=0`, `errors=0` and a full `Columns walked`
|
||||
line — or open **Admin → Encryption** and confirm the status view reports
|
||||
zero rows outside the active key. Both read the same registry the rotation
|
||||
rather than the absence of complaints. Open **Admin → Encryption** and
|
||||
confirm the status view reports zero rows outside the active key, or
|
||||
re-run the script: a clean second pass reports `rotated=0`, `errors=0` and
|
||||
a full `Columns walked` line. Both read the same registry the rotation
|
||||
walks.
|
||||
|
||||
A zero only means "nothing left" when the run also says it walked every
|
||||
@@ -117,13 +136,14 @@ remaining.
|
||||
```
|
||||
ENCRYPTION_KEYS='{"v2":"<old>","v3":"<new>"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID="v3"
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v3
|
||||
# restart, then Admin → Encryption → Rotate now
|
||||
# (or: pnpm dlx tsx scripts/rotate-encryption-key.ts v3 from a checkout)
|
||||
ENCRYPTION_KEYS='{"v3":"<new>"}'
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
> **Important.** Once the rotation script has run, ciphertexts in the
|
||||
> **Important.** Once a rotation has run, ciphertexts in the
|
||||
> database start with `v2.` (or whatever the active id is). The pre-PR
|
||||
> v1.3.x image cannot read that prefix — it expects bare base64 — and
|
||||
> calling `decrypt()` on those rows will throw.
|
||||
@@ -133,7 +153,7 @@ If you need to revert to the pre-rotation image:
|
||||
- Either keep the new image. The new code reads both formats, so most
|
||||
rollback scenarios don't need to undo rotation.
|
||||
- Or, if you must run the old code, restore a database backup taken
|
||||
_before_ the rotation script ran. There is no script to convert
|
||||
_before_ the rotation ran. There is no script to convert
|
||||
`v2.`-prefixed rows back to legacy format — by design, rotation is a
|
||||
forward-only operation.
|
||||
|
||||
@@ -145,10 +165,10 @@ at least 24 hours.
|
||||
|
||||
- `Encryption key id 'v1' is not configured` — the database still contains
|
||||
`v1.`-prefixed rows but `v1` was removed from `ENCRYPTION_KEYS`. Re-add
|
||||
the key, run the rotation script, then remove again.
|
||||
the key, run the rotation again, then remove it.
|
||||
- `Found a legacy-format ciphertext but no v1 key is configured` — same
|
||||
cause for legacy bare-base64 rows. Restore `ENCRYPTION_KEY` (or add a
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation script before
|
||||
`v1` entry to `ENCRYPTION_KEYS`) and run the rotation again before
|
||||
removing it.
|
||||
- `Refusing to rotate: argv key id ... does not match the currently active
|
||||
id ...` — pass the same id you set in `ENCRYPTION_ACTIVE_KEY_ID`. The
|
||||
|
||||
@@ -18,10 +18,12 @@ instance).
|
||||
|
||||
## Run it
|
||||
|
||||
The reset CLI ships in the image and runs under plain `node` against the built
|
||||
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
|
||||
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
|
||||
parameters):
|
||||
The reset CLI ships in the image from v1.39.3 on. It runs under plain `node`
|
||||
against the built runtime: a `.mjs` file that uses the in-image `pg` and
|
||||
`@node-rs/argon2` directly and reuses the app's exact Argon2id parameters.
|
||||
Images up to and including v1.39.2 did not contain the script, so on those
|
||||
the command below fails with "Cannot find module"; use the source-checkout
|
||||
form at the end of this page instead.
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
@@ -32,7 +34,7 @@ confirmation that names only the user — never the password:
|
||||
|
||||
```
|
||||
New password (input hidden):
|
||||
reset-password: password updated for "alice"
|
||||
reset-password: password updated for "alice"; revoked 2 sessions, 0 API tokens, 1 refresh tokens, 0 trusted devices, and 0 step-up elevations
|
||||
```
|
||||
|
||||
You can also pass the password as a second argument for non-interactive use,
|
||||
@@ -57,8 +59,10 @@ docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphra
|
||||
|
||||
## Source checkout
|
||||
|
||||
From a source checkout (not the production image) the same script runs under
|
||||
Node directly:
|
||||
From a source checkout of the same release (after `pnpm install`) the same
|
||||
script runs under Node directly. It needs a `DATABASE_URL` that reaches the
|
||||
database; the bundled `db` service publishes no port, so on a stock compose
|
||||
stack run it inside the compose network or publish the port temporarily:
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
|
||||
|
||||
@@ -127,6 +127,9 @@ addresses they could not place. To remove that remainder too:
|
||||
IP_GEO_LOOKUP_DISABLED="1"
|
||||
```
|
||||
|
||||
`true` and `yes` work as well. Any other value, including an empty one, leaves
|
||||
the lookup on.
|
||||
|
||||
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.
|
||||
|
||||
@@ -21,6 +21,13 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
weather and daylight; it stays off until you
|
||||
enable it and set a home location, and every outbound call goes
|
||||
through the SSRF-guarded egress wrapper.
|
||||
Three requests happen by default without anything connected: the
|
||||
HaveIBeenPwned range query when a password is set (only the first five
|
||||
characters of the password's SHA-1 are sent), a daily check of the
|
||||
GitHub releases API for the update badge, and the online IP
|
||||
geolocation lookup for login events. `PASSWORD_BREACH_CHECK_DISABLED`,
|
||||
`UPDATE_CHECK_DISABLED` and `IP_GEO_LOOKUP_DISABLED` turn each off; see
|
||||
`.env.production.example`.
|
||||
- **Optional but recommended:** a reverse proxy that terminates TLS
|
||||
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
|
||||
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
|
||||
@@ -33,9 +40,11 @@ cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env.example` is the canonical reference for every env var the app
|
||||
reads. Anything you do not uncomment falls back to a sane default or
|
||||
disables the optional subsystem.
|
||||
`.env.example` is a short starter with the variables most self-hosts
|
||||
touch. The complete reference, with every variable the app reads and what
|
||||
it does, is `.env.production.example`; CI checks that file against
|
||||
`scripts/env-manifest.json`. Anything you do not set falls back to a sane
|
||||
default or leaves the optional subsystem off.
|
||||
|
||||
## 2. Generate the three required secrets
|
||||
|
||||
|
||||
Reference in new issue
Block a user