Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,16 @@ services:
- ./gittensory-config:/config:ro
# Mounted read-only at /run/secrets/<name> (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
Expand Down
28 changes: 22 additions & 6 deletions scripts/selfhost-init-secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
22 changes: 18 additions & 4 deletions secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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.
Loading