2026-09-03 09:09:15 +08:00
|
|
|
#!/bin/sh
|
2026-09-08 16:28:23 +08:00
|
|
|
# Corrects ownership - host bind mounts, and the image-baked CLI prefix -
|
|
|
|
|
# then drops to PUID:PGID.
|
2026-09-03 09:09:15 +08:00
|
|
|
#
|
|
|
|
|
# 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/<user>/.codeman'
|
|
|
|
|
#
|
|
|
|
|
# Running this as root and dropping afterwards removes that failure mode without
|
2026-09-08 16:28:23 +08:00
|
|
|
# leaving the server privileged. The same root start also lets it re-assert
|
|
|
|
|
# /opt/codeman-cli's ownership on every start, not just at image build time -
|
|
|
|
|
# see the comment at that chown below for why that matters for anyone who
|
|
|
|
|
# runs the compose file directly rather than through Start-Codeman.sh.
|
2026-09-14 23:36:19 +02:00
|
|
|
#
|
|
|
|
|
# Capabilities this script needs against the compose file's `cap_drop: ALL`
|
|
|
|
|
# (test/docker-entrypoint.test.ts pins the list against docker-compose.yaml):
|
|
|
|
|
# CHOWN + DAC_OVERRIDE the chown of a root-owned bind source below
|
|
|
|
|
# SETUID + SETGID the setpriv drop itself
|
|
|
|
|
# KILL NOT used here, but required by the container: with
|
|
|
|
|
# `init: true` tini is PID 1 and runs as root while the
|
|
|
|
|
# server runs as PUID, and signalling a process of a
|
|
|
|
|
# different uid needs CAP_KILL. Without it every
|
|
|
|
|
# `docker compose down`/`restart` ends in tini dying with
|
|
|
|
|
# "Unexpected error when forwarding signal" and the
|
|
|
|
|
# server being SIGKILLed instead of stopping cleanly.
|
2026-09-03 09:09:15 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
# Everything below runs as root and calls stat, chown, id, setpriv and friends
|
|
|
|
|
# by bare name, so the lookup path must not contain a directory the runtime
|
|
|
|
|
# account can write to. /opt/codeman-cli/bin is exactly that (it is chowned to
|
|
|
|
|
# PUID:PGID so sessions can update the agent CLIs in place), and the image
|
|
|
|
|
# appends it to PATH for the server's sake. Resolve root's commands through the
|
|
|
|
|
# system directories only, and hand the image's full PATH back to the server at
|
|
|
|
|
# the exec below, since Codeman resolves the agent CLIs through it.
|
|
|
|
|
runtime_path=$PATH
|
|
|
|
|
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
|
|
|
export PATH
|
|
|
|
|
|
2026-09-03 09:09:15 +08:00
|
|
|
: "${PUID:=1000}"
|
|
|
|
|
: "${PGID:=1000}"
|
|
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
# The capabilities the compose file must grant, named in the diagnosis below so
|
|
|
|
|
# an out-of-tree compose file (Unraid's Compose Manager, a hand-written unit)
|
|
|
|
|
# fails with a one-line fix instead of a restart loop.
|
|
|
|
|
required_caps='CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID'
|
|
|
|
|
|
|
|
|
|
# Pre-flight the drop itself before touching anything. A container started with
|
|
|
|
|
# `cap_drop: ALL` and none of the additions above fails here, and would otherwise
|
|
|
|
|
# die at the final exec with a bare "setpriv: setresuid failed: Operation not
|
|
|
|
|
# permitted" after chown had already failed, or worse, misreport a perfectly
|
|
|
|
|
# writable directory as unwritable because the probe below could not drop
|
|
|
|
|
# privileges to test it.
|
|
|
|
|
if ! setpriv --reuid "$PUID" --regid "$PGID" --clear-groups true 2>/dev/null; then
|
|
|
|
|
printf 'entrypoint: cannot drop privileges to PUID:PGID (%s:%s).\n' "$PUID" "$PGID" >&2
|
|
|
|
|
printf 'entrypoint: this image starts as root and drops with setpriv, which needs\n' >&2
|
|
|
|
|
printf 'entrypoint: cap_add: [%s]\n' "$required_caps" >&2
|
|
|
|
|
printf 'entrypoint: on top of cap_drop: ALL (see docker/docker-compose.yaml). Add them to the\n' >&2
|
|
|
|
|
printf 'entrypoint: compose file that started this container, or set `user:` to skip the drop entirely.\n' >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
|
|
|
|
# Writable as the account the server is about to become? A real probe, run as
|
|
|
|
|
# exactly the identity the final exec below produces (PUID, PGID, the same
|
|
|
|
|
# supplementary groups, capabilities dropped), rather than a comparison of
|
|
|
|
|
# owners: ownership is not writability. A group-writable tree owned by another
|
|
|
|
|
# account, an ACL, or a CIFS/NFS mount that reports some unrelated uid are all
|
|
|
|
|
# fine to run on and would all fail an owner check.
|
|
|
|
|
writable_as_runtime() {
|
|
|
|
|
setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" test -w "$1" 2>/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 09:09:15 +08:00
|
|
|
for target in "${HOME:-}" "${CODEMAN_CASES_PATH:-}"; do
|
|
|
|
|
[ -n "$target" ] && [ -d "$target" ] || continue
|
2026-09-08 15:39:05 +08:00
|
|
|
owner=$(stat -c '%u:%g' "$target")
|
|
|
|
|
[ "$owner" = "${PUID}:${PGID}" ] && continue
|
2026-09-03 09:09:15 +08:00
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
# Only ever correct a directory the DAEMON created: root-owned, because
|
2026-09-08 15:39:05 +08:00
|
|
|
# neither PUID nor PGID existed yet when it materialised the missing bind
|
2026-09-14 23:36:19 +02:00
|
|
|
# source. Anything else - a host tree that legitimately belongs to some
|
2026-09-08 15:39:05 +08:00
|
|
|
# OTHER account, such as an existing CODEMAN_CASES_PATH the README already
|
|
|
|
|
# allows pointing at a normal project directory - is not this container's
|
|
|
|
|
# to reassign; recursively chowning it on every mismatch silently rewrote
|
|
|
|
|
# a credentials tree or a projects directory to PUID:PGID with one log
|
2026-09-14 23:36:19 +02:00
|
|
|
# line to explain it. Such a directory is left alone and only PROBED below.
|
|
|
|
|
#
|
|
|
|
|
# The chown is deliberately not fatal. A bind mount backed by NFS, CIFS or a
|
|
|
|
|
# rootless daemon can refuse chown while still being perfectly writable, and
|
|
|
|
|
# the probe below is what decides whether the server can run on it.
|
|
|
|
|
if [ "${owner%%:*}" = '0' ]; then
|
|
|
|
|
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; checking whether it is writable anyway\n' \
|
|
|
|
|
"$target" "$PUID" "$PGID" >&2
|
|
|
|
|
fi
|
2026-09-08 15:39:05 +08:00
|
|
|
fi
|
|
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
if writable_as_runtime "$target"; then
|
|
|
|
|
if [ "${owner%%:*}" != '0' ]; then
|
|
|
|
|
printf 'entrypoint: %s is owned by %s, not %s:%s, but is writable as the runtime account; leaving its ownership alone\n' \
|
|
|
|
|
"$target" "$owner" "$PUID" "$PGID"
|
|
|
|
|
fi
|
|
|
|
|
continue
|
2026-09-03 09:09:15 +08:00
|
|
|
fi
|
2026-09-14 23:36:19 +02:00
|
|
|
|
|
|
|
|
printf 'entrypoint: %s is not writable as PUID:PGID (%s:%s); it is owned by %s.\n' \
|
|
|
|
|
"$target" "$PUID" "$PGID" "$owner" >&2
|
|
|
|
|
printf 'entrypoint: refusing to change ownership of a directory this container did not create.\n' >&2
|
|
|
|
|
printf 'entrypoint: either chown it on the host, make it writable to %s:%s, or set PUID/PGID to match its owner.\n' \
|
|
|
|
|
"$PUID" "$PGID" >&2
|
|
|
|
|
exit 1
|
2026-09-03 09:09:15 +08:00
|
|
|
done
|
|
|
|
|
|
2026-09-08 16:28:23 +08:00
|
|
|
# /opt/codeman-cli (the four agent CLIs) is chowned to PUID:PGID once, at
|
|
|
|
|
# image BUILD time, from the PUID/PGID build args - server.Dockerfile's own
|
|
|
|
|
# comment on that RUN step explains why it lives in its own prefix rather than
|
|
|
|
|
# /usr/local. Unlike HOME/CODEMAN_CASES_PATH above, that bake happens only
|
|
|
|
|
# when the image is actually rebuilt (`docker compose up --build`, which
|
|
|
|
|
# Start-Codeman.sh always does) - a deployment that instead runs the compose
|
|
|
|
|
# file directly (Unraid's Compose Manager, a native Debian systemd unit, any
|
|
|
|
|
# `docker compose up`/`restart` with no --build) can change PUID/PGID in .env
|
|
|
|
|
# and restart without ever rebuilding, at which point the container runs as
|
|
|
|
|
# the NEW uid while the CLI directory is still owned by the OLD one baked into
|
|
|
|
|
# the image layer - silently breaking the very "self-update a CLI in place"
|
|
|
|
|
# fix this directory exists for. Re-assert it here, every start, unconditionally:
|
|
|
|
|
# unlike the host bind mounts above, this is pure image content Codeman itself
|
|
|
|
|
# populated, never host data that might legitimately belong to someone else,
|
|
|
|
|
# so there is no ownership to be careful about - it is always correct for it
|
|
|
|
|
# to be owned by whoever this container is about to run as.
|
|
|
|
|
if [ -d /opt/codeman-cli ] && [ "$(stat -c '%u:%g' /opt/codeman-cli)" != "${PUID}:${PGID}" ]; then
|
|
|
|
|
chown -R "${PUID}:${PGID}" /opt/codeman-cli
|
|
|
|
|
fi
|
|
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
# Discarding group 0 is right for root's own group, but it also discards a
|
|
|
|
|
# `group_add: 0` that was there to reach a Docker socket owned by root:root.
|
|
|
|
|
# The previous image ran as PUID with that group kept, so say so rather than
|
|
|
|
|
# letting Docker-case support vanish silently on such a host.
|
|
|
|
|
if [ -S /var/run/docker.sock ] && [ "$(stat -c '%g' /var/run/docker.sock)" = '0' ]; then
|
|
|
|
|
printf 'entrypoint: warning: /var/run/docker.sock is owned by group 0, which is dropped along with root;\n' >&2
|
|
|
|
|
printf 'entrypoint: warning: Docker cases will not work from this container. Give the socket a dedicated\n' >&2
|
|
|
|
|
printf 'entrypoint: warning: group on the host and set DOCKER_SOCKET_GID to it.\n' >&2
|
|
|
|
|
fi
|
2026-09-03 09:09:15 +08:00
|
|
|
|
2026-09-14 23:36:19 +02:00
|
|
|
# No `--bounding-set -all` here: it is a silent no-op without CAP_SETPCAP, which
|
|
|
|
|
# the compose file deliberately does not grant, and `no-new-privileges` already
|
|
|
|
|
# makes the bounding set moot. The reuid/regid drop leaves CapPrm/CapEff empty.
|
|
|
|
|
# The image's full PATH goes back to the server here; see the top of the file.
|
|
|
|
|
exec setpriv --reuid "$PUID" --regid "$PGID" --groups "$supplementary" \
|
|
|
|
|
env PATH="$runtime_path" "$@"
|