diff --git a/docker-compose.yml b/docker-compose.yml index ba2f71385..b54a92fea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -148,6 +148,16 @@ services: - ./gittensory-config:/config:ro # Mounted read-only at /run/secrets/ (Compose's default target). See the top-level `secrets:` # block above and secrets/README.md. + # + # NOT uid/gid/mode-pinned (#secrets-uid-mismatch, confirmed against a real Compose v5.3.1 daemon, not + # just docs): standalone (non-Swarm) Compose secrets are a plain bind mount under the hood, and a bind + # mount cannot remap ownership -- Compose itself warns "secrets `uid`, `gid` and `mode` are not + # supported, they will be ignored" and silently no-ops them, so the in-container permissions are + # whatever the HOST file has. This is WHY scripts/selfhost-init-secrets.sh chmod 644s (not 600s) each + # placeholder -- see that script's own comment for the full reasoning (the Dockerfile's `USER node` + # runs as uid 1000/gid 1000, essentially never the deploying host user's own uid, so a host-owner-only + # file is unreadable to the container process; load-file-secrets.ts's readFileSync throws, surfaced as + # a Sentry-visible selfhost_secret_file_unreadable error -- first caught live on edge-nl-01). secrets: - github_app_private_key - github_webhook_secret diff --git a/scripts/selfhost-init-secrets.sh b/scripts/selfhost-init-secrets.sh index 8192d3477..0e6ea04b6 100755 --- a/scripts/selfhost-init-secrets.sh +++ b/scripts/selfhost-init-secrets.sh @@ -5,9 +5,21 @@ # and is relying entirely on inline .env values (see secrets/README.md: an inline value always wins # over the file, so a placeholder here is a pure no-op for that operator). # -# IDEMPOTENT AND NON-DESTRUCTIVE: only ever creates a MISSING file, empty, chmod 600. Never touches a -# file that already exists (whether it's still an empty placeholder or a real secret an operator has -# since populated) -- safe to run on every deploy, unconditionally. +# MODE 644, NOT 600 (#secrets-uid-mismatch, a real incident on edge-nl-01 -- see docker-compose.yml's +# own secrets: comment for the full "why"): standalone Compose secrets are a plain bind mount, which +# cannot remap in-container ownership the way Swarm secrets can -- the container reads this file AS +# ITS OWN uid (the Dockerfile's `USER node`, 1000), essentially never the deploying host user's uid, so +# an owner-only 600 file is unreadable to the app and load-file-secrets.ts's readFileSync throws. 644 +# is the minimum that works portably across arbitrary host/container uid pairs without requiring the +# operator's host to have a matching uid or group -- this trades host-local-user readability (a lower +# bar: requires an actual shell on this machine) for what the original hardening was actually about: +# no longer visible via `docker inspect`/`docker compose config`/full env-var dumps. +# +# IDEMPOTENT AND NON-DESTRUCTIVE: creates any MISSING file empty at 644. For a file that already exists, +# self-heals the mode to 644 ONLY while it is still empty (a placeholder, never populated, so nothing to +# protect via 600 in the first place) -- the instant an operator writes a real secret into it, its size is +# no longer zero, so this leaves both its content AND whatever permissions they set entirely alone. Safe +# to run on every deploy, unconditionally. # # Usage: # ./scripts/selfhost-init-secrets.sh @@ -35,17 +47,21 @@ SECRET_FILES=( mkdir -p "$SECRETS_DIR" created=0 +healed=0 for name in "${SECRET_FILES[@]}"; do path="$SECRETS_DIR/$name" if [ ! -e "$path" ]; then : >"$path" - chmod 600 "$path" + chmod 644 "$path" created=$((created + 1)) + elif [ ! -s "$path" ]; then + chmod 644 "$path" + healed=$((healed + 1)) fi done -if [ "$created" -gt 0 ]; then - echo "selfhost init-secrets: created $created empty placeholder file(s) in $SECRETS_DIR/" +if [ "$created" -gt 0 ] || [ "$healed" -gt 0 ]; then + echo "selfhost init-secrets: created $created, mode-healed $healed empty placeholder file(s) in $SECRETS_DIR/" else echo "selfhost init-secrets: all secret files already present, nothing to do" fi diff --git a/secrets/README.md b/secrets/README.md index 723224a30..ab786c697 100644 --- a/secrets/README.md +++ b/secrets/README.md @@ -11,6 +11,19 @@ Putting a real secret's *value* directly in `.env` means it's readable via `dock Mounting it as a **file** instead keeps the value out of both — the container only ever sees a path, and the app reads the file's contents itself at startup. +**Tradeoff, stated plainly:** these files are `chmod 644` (world-readable on the host), not `600`. +Standalone Docker Compose's `secrets:` is a plain bind mount under the hood — it cannot remap +in-container ownership the way Swarm secrets can, and the container reads the file as its own uid +(the image's `node` user), which is essentially never the uid of whoever deployed it. `600` would +just make the file unreadable to the app itself (confirmed against a real deploy — this is exactly +what happened the first time this shipped: every secret read failed with +`selfhost_secret_file_unreadable`). `644` is the minimum that works without requiring your host to +have a matching uid/group. In exchange you get: not visible via `docker inspect` / `docker compose +config` / a full container env dump, at the cost of: readable by any OTHER local user with a shell +on this host, not just the deploying account — a genuinely wider bar than `.env` itself (typically +`600`, owner-only). If that tradeoff is unacceptable for your threat model (a shared/multi-tenant +host), keep using inline `.env` values instead — this feature is entirely optional, see below. + ## How it works Every secret below is optional and additive. **Nothing here is required** — if you're not ready to @@ -36,9 +49,8 @@ To use a secret file instead of an inline `.env` value: 3. Restart the `gittensory` service (`docker compose up -d --no-deps gittensory`, or run `./scripts/selfhost-update.sh`). -Every file below is `chmod 600`-worthy — the init script that creates the empty placeholders -(`scripts/selfhost-init-secrets.sh`) does this for you; keep that permission if you edit a file by -hand afterward. +Leave each file at the `644` the init script (`scripts/selfhost-init-secrets.sh`) sets by default — +see the tradeoff explained above for why `600` breaks the app's own ability to read it back. ## Files @@ -65,4 +77,6 @@ of those too; add a matching `secrets:` entry in `docker-compose.yml` (or a Everything in this directory except this README is gitignored. `scripts/selfhost-init-secrets.sh` only ever creates **empty** placeholder files (so `docker compose build`/`up` never fails on a -missing file) — it never overwrites a file that already exists, so it's always safe to re-run. +missing file) and only ever touches the *permissions* of a file that is still empty, never its +content — the moment you write a real value into one, both the content and whatever mode you set +are left alone on every future run. Always safe to re-run.