diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index ca61796a..1e888676 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -91,6 +91,13 @@ services: - no-new-privileges:true cap_drop: - ALL + cap_add: + # The entrypoint corrects bind-mount ownership as root before dropping to + # PUID:PGID. Everything not listed here remains dropped by cap_drop above. + - CHOWN + - DAC_OVERRIDE + - SETGID + - SETUID healthcheck: test: - CMD-SHELL diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..45cf8d5a --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Corrects the ownership of the host bind mounts, then drops to PUID:PGID. +# +# Compose binds CODEMAN_APPDATA_PATH and CODEMAN_CASES_PATH from the host. When +# either path does not exist yet - a first run, a cleared application-data +# directory, a restored backup - the Docker daemon creates it owned by root, +# and an unprivileged server cannot then create its own state directory. The +# result is a container that restarts forever on: +# +# Failed to start web server: EACCES: permission denied, mkdir '/home//.codeman' +# +# Running this as root and dropping afterwards removes that failure mode without +# leaving the server privileged. + +set -eu + +# Honour an explicit `user:` in Compose: when the container was not started as +# root there is nothing to correct and no privilege to drop. +if [ "$(id -u)" -ne 0 ]; then + exec "$@" +fi + +: "${PUID:=1000}" +: "${PGID:=1000}" + +for target in "${HOME:-}" "${CODEMAN_CASES_PATH:-}"; do + [ -n "$target" ] && [ -d "$target" ] || continue + [ "$(stat -c '%u:%g' "$target")" = "${PUID}:${PGID}" ] && continue + + # Deliberately not fatal. A bind mount backed by NFS, CIFS or a rootless + # daemon can refuse chown while still being perfectly writable, and those + # deployments must keep working. A warning is more useful than a container + # that will not start. + if chown -R "${PUID}:${PGID}" "$target" 2>/dev/null; then + printf 'entrypoint: corrected ownership of %s to %s:%s\n' "$target" "$PUID" "$PGID" + else + printf 'entrypoint: warning: cannot change ownership of %s to %s:%s\n' \ + "$target" "$PUID" "$PGID" >&2 + printf 'entrypoint: warning: continuing; set the ownership on the host if startup fails\n' >&2 + fi +done + +# Preserve the supplementary groups Compose granted through group_add - that is +# how the Docker socket stays reachable - while discarding root's own group. +supplementary=$(id -G | tr ' ' '\n' | grep -vx 0 | paste -sd, -) +[ -n "$supplementary" ] || supplementary="$PGID" + +exec setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" "$@" diff --git a/docker/server.Dockerfile b/docker/server.Dockerfile index ecb00d14..79663a27 100644 --- a/docker/server.Dockerfile +++ b/docker/server.Dockerfile @@ -135,8 +135,19 @@ ENV CODEMAN_IN_CONTAINER=1 \ HOME=/home/${CODEMAN_RUNTIME_USER} \ NODE_ENV=production +# Runtime defaults for the entrypoint, matching the account created above. +ENV PGID=${PGID} PUID=${PUID} + EXPOSE 3000 -USER ${CODEMAN_RUNTIME_USER} +# The container starts as root so the entrypoint can correct the ownership of +# the host bind mounts, which the daemon creates as root whenever they do not +# already exist. The entrypoint then drops to PUID:PGID with setpriv, so the +# server itself never runs privileged. Setting `user:` in Compose bypasses both +# steps, leaving the caller in full control. +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod 0755 /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["node", "dist/index.js", "web"]