Files
pad/docker-entrypoint.sh
T
xarmian 92a4931f44 feat(docker): PUID/PGID entrypoint shim for Unraid + LinuxServer-style hosts (TASK-1168) (#425)
Tiny /bin/sh entrypoint shim that, if invoked as root, reads PUID/PGID
env vars (defaulting to 99/100 — Unraid's nobody:users), remaps the
in-image pad user, chowns /data, and execs the binary via su-exec.
If invoked as non-root (caller passed --user), it just execs directly
— caller knows what they want.

Solves the classic Unraid appdata-ownership-mismatch first-run failure
where the in-image pad user (uid 1000) couldn't write to a host volume
owned by nobody:users (uid 99, gid 100). Reusable on Synology / QNAP /
TrueNAS where the host's appdata user is similarly non-1000.

Behavior changes:
- Container starts as root (USER directive removed). Entrypoint drops
  privileges via su-exec before exec'ing pad — standard PUID/PGID
  pattern. Healthcheck adapts: root → su-exec to pad; non-root →
  direct wget.
- chown -R is always-run (warn-and-continue on per-file failures). A
  shallow stat-only check would silently break pad on a restored
  backup with mixed-ownership inner files.
- Healthcheck start-period bumped 10s → 60s to absorb slow chown -R
  on large attachment stores.
- Compose default 1000/1000 for backward compat with existing deploys
  whose volumes were created under the previous USER pad image.
- Raw `docker run` defaults to 99/100 (Unraid convention).

Validation rejects PUID=0 / PGID=0 (would defeat the unprivileged-user
invariant), empty values, and non-numeric values with clear errors.

Goes through 11 rounds of codex pre-implementation design review,
catching:
- gid bug where groupmod alone leaves /etc/passwd's primary-gid stale
- compose $-interpolation gotcha (needs $$( ) not $())
- getent missing from default alpine BusyBox
- shell ${VAR:-} silently masking explicit empty values
- healthcheck running as root after USER drop
- su-exec failing for --user non-root pass-through

Part of PLAN-1166 (Pad on Unraid — Community Apps launch). Unblocks
TASK-1169 (XML template authoring).
2026-05-06 08:40:33 -04:00

104 lines
4.4 KiB
Bash
Executable File

#!/bin/sh
# docker-entrypoint.sh — UID/GID remapping shim for the pad container.
#
# Allows operators to run pad as their host's preferred uid/gid by setting
# PUID and PGID env vars at container start. Mainly for Unraid users
# (default 99/100 = nobody:users) but useful on Synology / TrueNAS / any
# Docker host where the appdata volume is owned by something other than
# uid 1000.
#
# Pattern adopted from LinuxServer.io. See TASK-1168 / PLAN-1166.
#
# Compatibility: /bin/sh (BusyBox-compatible). No bash-isms.
set -e
# Pass-through path: caller specified a uid via `docker run --user`. They
# know what they want; we don't try to outsmart them. No chown, no
# privilege drop — just exec the target program directly.
if [ "$(id -u)" != "0" ]; then
exec "$@"
fi
# Remap path: container started as root (the default).
#
# Use ${VAR-default} (no colon), not ${VAR:-default}. The colon form
# substitutes the default when VAR is unset OR empty, which would silently
# accept `docker run -e PUID= -e PGID=` and mask a real operator typo.
# The colon-less form only substitutes when VAR is unset, so an explicit
# empty value falls through to validation and is rejected.
PUID="${PUID-99}"
PGID="${PGID-100}"
# Validate: must be positive integers. Rejecting 0 explicitly is critical
# — PUID=0 (root) or PGID=0 (root group) would defeat the unprivileged-
# user invariant the whole shim exists to enforce. Empty / non-numeric
# also fail loudly so an operator typo doesn't silently default.
case "$PUID" in
''|*[!0-9]*)
echo "docker-entrypoint: PUID must be a positive integer, got '$PUID'" >&2
exit 1
;;
esac
case "$PGID" in
''|*[!0-9]*)
echo "docker-entrypoint: PGID must be a positive integer, got '$PGID'" >&2
exit 1
;;
esac
if [ "$PUID" = "0" ]; then
echo "docker-entrypoint: PUID=0 (root) is rejected — set a non-zero uid to keep pad unprivileged" >&2
exit 1
fi
if [ "$PGID" = "0" ]; then
echo "docker-entrypoint: PGID=0 (root group) is rejected — set a non-zero gid to keep pad unprivileged" >&2
exit 1
fi
# Remap the in-image `pad` user/group only when values differ — skips
# the noisy "id changed" log entry on every warm restart in the steady
# state.
#
# Use `id -u/-g pad` (BusyBox supports both flags) rather than
# `getent group pad`: getent lives in the optional `musl-utils` package
# on alpine, NOT in BusyBox's default applet list, so the entrypoint
# would crash before chown/exec on a fresh `apk add` install that
# didn't include it.
current_uid="$(id -u pad)"
current_gid="$(id -g pad)"
if [ "$current_gid" != "$PGID" ]; then
groupmod -o -g "$PGID" pad
fi
if [ "$current_uid" != "$PUID" ] || [ "$current_gid" != "$PGID" ]; then
# Combined -u + -g ensures the user's /etc/passwd primary-gid field
# is updated to PGID even when only the group renumber happened.
# `groupmod` alone changes the group's gid but leaves the user's
# /etc/passwd line referencing the OLD gid number — su-exec would
# then launch pad with the wrong process gid.
usermod -o -u "$PUID" -g "$PGID" pad
fi
# Always chown -R the data directory.
#
# Statting only `/data` itself is unsafe: a user who restored a backup
# tarball might have /data owned by the target uid but /data/pad.db,
# /data/encryption.key, or /data/attachments/* owned differently, and a
# shallow check would skip the recursive chown — leaving pad unable to
# read its own DB. The few seconds of chown cost on warm restart with
# large attachment stores is the correct trade for guaranteed correctness.
#
# Warn-and-continue (not fail-fast). chown -R can fail on individual
# files for non-fatal reasons (immutable bits, root-squashed NFS,
# partial permission gaps). Refusing to start pad over any single
# failed file would lock the operator out of an otherwise-fine /data.
# chown's per-file errors hit stderr already (visible in `docker logs`);
# the trailing aggregated warning surfaces a single clear "investigate
# this" line. The `||` form is set-e-safe.
chown -R "$PUID:$PGID" /data \
|| echo "docker-entrypoint: warning: chown -R /data had errors (see above). pad will start anyway; some files may not be writable." >&2
# Drop privileges and exec. su-exec replaces the shell process so signals
# (SIGTERM, SIGINT) propagate to the pad process correctly — without
# this, docker stop's SIGTERM would hit the shell instead of pad.
exec su-exec pad "$@"