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
+128 -13
View File
@@ -317,6 +317,55 @@ generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
}
# Read one KEY=value out of an existing .env, ignoring commented lines. Prints
# the empty string when the file or the key is absent, so callers can fall back
# to generating a fresh value with `[[ -n "$x" ]] || x=$(generate...)`.
# Emit `KEY=value`, or a commented placeholder when the value is empty — an
# empty `KEY=` is NOT equivalent: wait-for-db.sh only falls back to
# /run/secrets when the variable is unset or empty, but compose would still
# define it, and any future reader that treats "defined" as "configured" gets
# the wrong answer. Commented out matches what .env.example ships.
env_secret_line() {
local key="$1" value="$2"
if [[ -n "$value" ]]; then
printf '%s=%s' "$key" "$value"
else
printf '# %s is auto-generated into the picpeak-secrets volume on first run.\n#%s=' "$key" "$key"
fi
}
read_env_value() {
local file="$1" key="$2"
[[ -f "$file" ]] || return 0
# An unreadable file is NOT the same as a missing key: returning empty here
# would regenerate the secrets and then overwrite the file that held them.
# Say so loudly instead of silently rotating.
if [[ ! -r "$file" ]]; then
log_warn "Cannot read $file — existing secrets cannot be reused. Fix its permissions and re-run; the install will stop rather than overwrite it."
return 0
fi
# Tolerant of the shapes a hand-edited .env actually takes — leading
# whitespace, an `export` prefix, spaces around the `=`. Reading one of
# those as "absent" would regenerate the secrets and then overwrite the
# evidence, which is the exact failure this helper exists to prevent.
# Never fails: an unreadable file must not abort the installer under
# `set -e` mid-run.
# Trailing whitespace and a CR are stripped too. A .env edited on Windows
# yields `abc\r`; docker compose treats the CR as a line terminator, so the
# running stack uses `abc`, but re-emitting `abc\r` mid-line into a fresh
# LF file makes it part of the value. The secret then silently differs from
# the one in use — every session dies for JWT_SECRET, and Postgres refuses
# the connection for DB_PASSWORD. That is the exact outcome this reuse
# exists to prevent, so it must not be reachable through it.
# Matching surrounding quotes are stripped too. Compose strips them on read,
# so `KEY="abc"` round-trips harmlessly there, but the native path is read by
# dotenv into the process env — re-emitting `KEY=\"abc\"` would make the
# quotes part of the secret on one path and not the other.
sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*//p" \
"$file" 2>/dev/null | head -n1 \
| sed -E -e 's/[[:space:]]+$//' -e 's/^"(.*)"$/\1/' -e "s/^'(.*)'\$/\1/" || true
}
generate_jwt_secret() {
openssl rand -base64 64 | tr -d "\n"
}
@@ -589,16 +638,66 @@ setup_docker_installation() {
fi
# Generate machine secrets. Written to .env so they are stable across
# restarts; once PR #714's secrets-init service is present it reuses these
# exact values (explicit env always wins), so this stays correct either way.
local jwt_secret=$(generate_jwt_secret)
local db_password=$(generate_password)
local redis_password=$(generate_password)
# restarts; the compose secrets-init service reuses these exact values
# (explicit env always wins), so this stays correct either way.
#
# Reuse whatever an existing .env already holds. The file is rewritten
# wholesale below, so generating unconditionally would rotate the secrets of
# an install that is already running: a new JWT_SECRET invalidates every
# admin session and every gallery link, and a new DB_PASSWORD no longer
# matches the password baked into the initialised Postgres volume, so the
# stack stops booting entirely. `--update` has always been the safe path,
# but re-running the installer is an easy mistake to make and it should not
# cost the user their data access.
local jwt_secret db_password redis_password
jwt_secret=$(read_env_value "$app_dir/.env" JWT_SECRET)
db_password=$(read_env_value "$app_dir/.env" DB_PASSWORD)
redis_password=$(read_env_value "$app_dir/.env" REDIS_PASSWORD)
# Reading nothing back does NOT mean "no secrets exist". The documented
# install leaves all three commented out (.env.example) and lets the
# compose `secrets-init` service generate them into the picpeak-secrets
# volume, which is then the only place the real values live. Writing freshly
# generated ones into .env for that install is the worst possible outcome:
# secrets-init keeps the OLD values (it never overwrites), Postgres is still
# running on the old password, but explicit env now wins in the backend — so
# it can no longer authenticate, and JWT_SECRET rotates every session and
# gallery link out. Exactly what this reuse exists to prevent.
#
# So blank stays blank whenever the volume is there to own it. Only a truly
# fresh install — no .env value and no volume — gets generated secrets.
local secrets_volume_exists="no"
if command_exists docker && docker volume ls --format '{{.Name}}' 2>/dev/null \
| grep -qE '(^|_)picpeak-secrets$'; then
secrets_volume_exists="yes"
fi
if [[ "$secrets_volume_exists" == "yes" ]]; then
[[ -n "$jwt_secret" ]] || log_info "JWT_SECRET is managed by the picpeak-secrets volume — leaving it unset."
[[ -n "$db_password" ]] || log_info "DB_PASSWORD is managed by the picpeak-secrets volume — leaving it unset."
[[ -n "$redis_password" ]] || log_info "REDIS_PASSWORD is managed by the picpeak-secrets volume — leaving it unset."
else
[[ -n "$jwt_secret" ]] || jwt_secret=$(generate_jwt_secret)
[[ -n "$db_password" ]] || db_password=$(generate_password)
[[ -n "$redis_password" ]] || redis_password=$(generate_password)
fi
local frontend_port="${CUSTOM_PORT:-3000}"
local site_url; site_url="$(base_url)"
# Create .env for docker-compose.production.yml (prebuilt GHCR images).
# The write is wholesale, so anything else the operator hand-edited (extra
# PICPEAK_* flags, S3 settings, TZ) is replaced, not merged — reusing the
# three secrets above does not make that safe on its own. Keep a copy first,
# the same way update_docker_installation already does.
if [ -f "$app_dir/.env" ]; then
# The rewrite below is wholesale, so losing this copy loses every other
# hand-edit in the file. Fail loudly rather than proceeding without it.
if ! cp "$app_dir/.env" "$app_dir/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"; then
log_error "Could not back up $app_dir/.env before rewriting it — aborting rather than overwriting it."
exit 1
fi
fi
log_step "Creating configuration..."
cat > "$app_dir/.env" <<EOF
# PicPeak Configuration — generated by picpeak-setup.sh on $(date)
@@ -611,10 +710,12 @@ COMPOSE_FILE=docker-compose.production.yml
PICPEAK_CHANNEL=$PICPEAK_CHANNEL
NODE_ENV=production
# Machine secrets (generated; reused by the compose secrets-init service).
JWT_SECRET=$jwt_secret
DB_PASSWORD=$db_password
REDIS_PASSWORD=$redis_password
# Machine secrets. Written explicitly only when this file already pinned them or
# there is no picpeak-secrets volume to own them; otherwise left commented so the
# secrets-init service stays the single source of truth (see .env.example).
$(env_secret_line JWT_SECRET "$jwt_secret")
$(env_secret_line DB_PASSWORD "$db_password")
$(env_secret_line REDIS_PASSWORD "$redis_password")
# Database
DB_HOST=postgres
@@ -837,11 +938,25 @@ setup_native_installation() {
log_warn "Frontend directory not found; admin UI will not be served by backend"
fi
# Generate secrets
local jwt_secret=$(generate_jwt_secret)
# Generate secrets — reusing an existing one, for the same reason as the
# Docker path above: this heredoc rewrites .env wholesale, so generating
# unconditionally would rotate JWT_SECRET on a re-run and sign out every
# admin session and gallery link on a live install.
local jwt_secret
jwt_secret=$(read_env_value "$NATIVE_APP_DIR/app/backend/.env" JWT_SECRET)
[[ -n "$jwt_secret" ]] || jwt_secret=$(generate_jwt_secret)
# Create .env file
log_step "Creating configuration..."
if [ -f "$NATIVE_APP_DIR/app/backend/.env" ]; then
# The rewrite below is wholesale, so losing this copy loses every other
# hand-edit in the file. Fail loudly rather than proceeding without it.
if ! cp "$NATIVE_APP_DIR/app/backend/.env" \
"$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"; then
log_error "Could not back up $NATIVE_APP_DIR/app/backend/.env before rewriting it — aborting rather than overwriting it."
exit 1
fi
fi
cat > "$NATIVE_APP_DIR/app/backend/.env" <<EOF
# PicPeak Native Configuration
# Generated: $(date)
@@ -1280,7 +1395,7 @@ update_native_installation() {
# Backup current configuration
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)"
cp "$NATIVE_APP_DIR/app/backend/.env" "$NATIVE_APP_DIR/app/backend/.env.backup-$(date +%Y%m%d-%H%M%S)-$$"
fi
# Pull latest code