feat(deploy): make the all-in-one image installable without a shell (#1124)

The all-in-one image could not be installed from a GUI at all — the deployment it
exists for. validateEnv treats a missing JWT_SECRET as critical and exits, and the
documented run command supplies it with `openssl rand`, a shell command a Synology
Container Manager or QNAP Container Station form cannot run.

wait-for-db.sh now generates one on first start and persists it next to the database,
extending the existing /run/secrets hydration rather than adding a second mechanism.
Explicit env still wins, then /run/secrets, then the generated file. The write is
load-bearing: JWT_SECRET is exported only when the file actually persisted, because an
unpersisted secret would mint a new one every restart and sign every session out.

Creation writes to a private temp file and hard-links it into place — atomic, fails with
EEXIST when another container won, and the loser adopts the winner's value. Non-regular
paths are rejected before the link, since POSIX ln links INTO a directory rather than
failing, which would make a mistyped -v target unrecoverable.

Also repairs the onboarding paths a new install actually walks: the installer no longer
rotates the secrets of a running install on re-run, deprecates the dead scripts/install.sh
in place, corrects the CONTRIBUTING dev loop, and fixes the vite proxy target that had
been pointing at a stray local port since 0da45e69.

Reviewed over three rounds. Co-authored by @Luca-Timo.
This commit is contained in:
Luca
2026-08-22 18:37:12 +02:00
committed by GitHub
parent 8f23118782
commit 7223118b89
9 changed files with 475 additions and 45 deletions
+127
View File
@@ -60,6 +60,133 @@ DATA_ROOT_DIR="${DATA_ROOT:-}"
# shellcheck disable=SC2086 — intentional word-splitting over the roots
mkdir -p $DATA_ROOT_DIR $DATA_DIRS 2>/dev/null || true
# Last resort for JWT_SECRET, after the /run/secrets pass above: generate one
# and persist it next to the database. Deliberately placed here rather than
# beside that loop because it needs DATA_DIR to exist, and before the chown
# below so the new file is adopted along with everything else.
#
# The compose stack never reaches this — `secrets-init` has already written
# /run/secrets/jwt_secret. It exists for deployments with no shell: the
# all-in-one image mounts no secrets volume, and the documented one-liner asks
# the user to run `openssl rand` on the HOST, which a NAS Container Manager or
# Container Station form cannot do. Without this, JWT_SECRET is unset,
# validateEnv treats it as critical, and the container exits — so the whole
# GUI-driven install path is a dead end (#705).
#
# JWT_SECRET only. DB_PASSWORD must match whatever the Postgres volume was
# initialised with, so inventing one at boot would break the connection rather
# than fix it; the AIO default engine is SQLite and Redis is not used here.
#
# Losing the file invalidates every session and gallery link. Note the built-in
# backup does NOT carry it — backupService archives STORAGE_PATH and the
# database dump, not DATA_DIR — so only a copy of the whole volume preserves it.
if [ -z "${JWT_SECRET:-}" ]; then
_jwt_file="${DATA_DIR:-/app/data}/jwt.secret"
# Read, trim, and length-check rather than testing that the file merely
# exists. A short write (ENOSPC, a SIGKILL mid-boot) leaves a partial file
# that a `-s` test accepts on every later boot, and validateEnv only WARNS
# below 32 characters — so the install would sign with a truncated key
# indefinitely and nothing would say so. Whitespace is stripped first because
# validateEnv rejects an all-blank secret as critical, and an all-blank file
# long enough to pass a naive length test would boot-loop forever.
_jwt_read() {
[ -f "$1" ] || return 0
tr -d '\r\n\t ' < "$1" 2>/dev/null || true
}
# Loop rather than a single pass, because the two failure causes look
# identical on the first read and need opposite responses: a file that is
# absent-then-present is a concurrent boot winning the race (adopt it), while
# a file that is still unusable after we have tried to link and re-read is
# genuine corruption (clear it and retry). Removing on the first pass would
# delete a perfectly good secret another container had just created.
_jwt_made=""
_jwt_cur="$(_jwt_read "$_jwt_file")"
_jwt_try=0
while [ "${#_jwt_cur}" -lt 32 ] && [ "$_jwt_try" -lt 2 ]; do
_jwt_try=$((_jwt_try + 1))
if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then
# A directory (or anything non-regular) has to go BEFORE the link is
# attempted, not after: POSIX `ln src dir` links src INTO the directory
# instead of failing, so the first attempt would create
# jwt.secret/jwt.secret.<sfx>.tmp, leave the directory non-empty, and make
# rmdir impossible from then on — the container could never recover from a
# mistyped `-v` target without host access. Verified against the runtime
# image; busybox ln does exactly this.
echo "[secrets] $_jwt_file is not a regular file — removing it." >&2
rmdir "$_jwt_file" 2>/dev/null || rm -f "$_jwt_file" 2>/dev/null || true
elif [ "$_jwt_try" -gt 1 ] && [ -f "$_jwt_file" ]; then
# Re-read immediately before unlinking. Our earlier read may be stale:
# another container can have linked a perfectly good secret in between,
# and deleting it would leave the two of us signing with different keys.
_jwt_cur="$(_jwt_read "$_jwt_file")"
[ "${#_jwt_cur}" -ge 32 ] && break
echo "[secrets] $_jwt_file is unusable — regenerating." >&2
rm -f "$_jwt_file" 2>/dev/null || true
fi
# Still not a regular file (a non-empty directory we refuse to delete):
# nothing further will work, so stop and let the warning below explain.
if [ -e "$_jwt_file" ] && [ ! -f "$_jwt_file" ]; then
break
fi
# openssl is not in the runtime image; /dev/urandom + base64 always are.
# 48 bytes -> 64 base64 chars, comfortably past the 32 above.
_jwt_new="$(head -c 48 /dev/urandom | base64 | tr -d '\n' || true)"
# Two properties are needed at once, and each idiom alone gives only one:
#
# * the file must never be READABLE half-written, or a concurrent boot
# reads a partial secret — a plain `set -C` redirect creates the inode
# first and writes after, so a loser can read it in between
# * the create must not CLOBBER, or two containers each believe they own
# the secret and sign with different keys — which is what `mv -f` does
#
# Writing the full content to a private temp file and hard-linking it into
# place gives both: the link is atomic, fails with EEXIST when another
# container already won, and the content is complete before the name exists.
#
# The suffix must be random rather than $$: the PID namespace makes $$ equal
# to 1 in every container, so containers sharing a volume would all pick the
# same temp path — and since a hard link shares the INODE, a second process
# truncating that path writes straight through the linked secret.
if [ -n "$_jwt_new" ]; then
_jwt_sfx="$(head -c 12 /dev/urandom | base64 | tr -dc 'a-z0-9' | cut -c1-10 || true)"
[ -n "$_jwt_sfx" ] || _jwt_sfx="$$"
_jwt_tmp="$_jwt_file.$_jwt_sfx.tmp"
if (umask 077; printf '%s\n' "$_jwt_new" > "$_jwt_tmp") 2>/dev/null \
&& ln "$_jwt_tmp" "$_jwt_file" 2>/dev/null; then
_jwt_made=yes
fi
rm -f "$_jwt_tmp" 2>/dev/null || true
fi
# Trust the file, never the value in hand — whoever won the link is the
# source of truth for every container on this volume, and the loser adopts
# it rather than signing with the value it happened to generate.
_jwt_cur="$(_jwt_read "$_jwt_file")"
done
if [ "${#_jwt_cur}" -ge 32 ]; then
export JWT_SECRET="$_jwt_cur"
if [ -n "$_jwt_made" ]; then
echo "[secrets] No JWT_SECRET supplied — generated one and stored it in $_jwt_file."
echo "[secrets] Back up the whole data volume to keep it; the built-in backup does"
echo "[secrets] not include it, and losing it signs every admin session and gallery"
echo "[secrets] link out."
fi
else
echo "WARNING: no JWT_SECRET supplied and $_jwt_file could not be created." >&2
echo " Startup will fail validation. Pass -e JWT_SECRET=... , or check that the" >&2
echo " data directory is writable and that $_jwt_file is not a directory." >&2
fi
unset _jwt_new _jwt_tmp _jwt_sfx _jwt_made _jwt_try
unset _jwt_file _jwt_cur
fi
if [ "$(id -u)" = "0" ]; then
if [ -n "$DATA_ROOT_DIR" ] && ! chown nodejs:nodejs "$DATA_ROOT_DIR" 2>/dev/null; then
echo "ERROR: failed to chown $DATA_ROOT_DIR to nodejs (UID 1001)." >&2