From 3cc839e79900aeac386556dda7299adc4bd737e5 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:09:53 +0800 Subject: [PATCH 1/3] fix(entrypoint): stop touching /app and /config, and share one write probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four issues, three of them found by testing the paths rather than reading them. `adduser -h /app` made the application directory the service user's home, so adduser chowned it and /app came out owned by PUID:PGID on every start — the property the read-only-app-dir work set out to remove. Moving the home to /config was worse: adduser then chmod'd the operator's config directory from 600 to 2755. `-H` stops it creating or touching any home at all, which is safe because HOME is set explicitly on both exec paths. The ownership sweep used `find | chown -h`, which resolves each path afresh — `-h` covers only the final component, so an intermediate directory swapped for a symlink mid-sweep redirects root's chown outside /config. scripts/fix_ownership.py walks with os.fwalk, which owns the directory descriptors and does not follow symlinks. The write probe existed only on the root path; rootless still used `test -w`, which passes on a mode-600 directory. Both paths now call one require_writable_config, so the same rejection applies either way. Rootless never reached any of that: `ln -sf … /etc/localtime` cannot run as a non-root user and `set -e` killed the script on line 14. The symlink is now best-effort — TZ in the environment still applies. Verified in containers across all four combinations: root and rootless each start on a normal /config and refuse a mode-600 one, /app stays 0:0, and the config directory's mode is left untouched in every case. --- Dockerfile | 1 + scripts/entrypoint.sh | 45 ++++++++++++++++++++++++---------------- scripts/fix_ownership.py | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 18 deletions(-) create mode 100644 scripts/fix_ownership.py diff --git a/Dockerfile b/Dockerfile index d74de0b..c771c8b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,7 @@ WORKDIR /app COPY VERSION /app/VERSION COPY scripts/entrypoint.sh /app/ +COPY scripts/fix_ownership.py /app/ COPY scripts/delete.sh /app/ COPY scripts/rescan.sh /app/ COPY scripts/reset-webui-password.sh /app/ diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 5900d2e..b687696 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -11,10 +11,25 @@ TZ=${TZ:-UTC} if [ -f "/etc/localtime" ] && [ ! -L "/etc/localtime" ]; then : # bind-mounted regular file from host elif [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then - ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime + # Rootless cannot write /etc/localtime; TZ in the environment still applies. + ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime 2>/dev/null || true export TZ fi +# Creating a file is the only reliable test: `test -w` passes on a directory the +# process cannot use — mode 600 satisfies it while the missing search bit blocks +# everything below — and it never sees a read-only mount. RUN_AS is empty when we +# are already the target uid, so it must stay unquoted. +require_writable_config() { + probe="/config/.beatscheck-write-probe.$$" + if ! ${RUN_AS} touch "${probe}" 2>/dev/null; then + echo "FATAL: /config is not writable by ${PUID}:${PGID}." + echo "Pre-chown it on the host: sudo chown -R ${PUID}:${PGID} /path/to/config" + exit 1 + fi + ${RUN_AS} rm -f "${probe}" +} + # Detect rootless mode (`docker run --user uid:gid`). PUID/PGID env vars # are ignored — we can't usermod/chown without root, and the supplied uid # is already what the operator wants. @@ -23,13 +38,9 @@ if [ "$(id -u)" != "0" ]; then PGID=$(id -g) USER_NAME=$(id -un 2>/dev/null || echo "uid-${PUID}") - # Operator must pre-chown /config on the host. Fail fast with a clear - # message if they haven't. - if [ ! -w /config ]; then - echo "Rootless mode but /config is not writable by uid:gid ${PUID}:${PGID}" - echo "Pre-chown the host config dir: sudo chown -R ${PUID}:${PGID} /path/to/config" - exit 1 - fi + # No chown is possible here, so the operator must pre-chown /config. + RUN_AS="" + require_writable_config for cmd in ffmpeg python3; do command -v "$cmd" >/dev/null 2>&1 || { echo "Missing required tool: $cmd"; exit 1; } @@ -55,7 +66,9 @@ GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1) # Create user if it doesn't exist if ! getent passwd "${PUID}" > /dev/null 2>&1; then - adduser -D -u "${PUID}" -G "${GROUP_NAME}" -h /app -s /sbin/nologin checker + # -H: never create or chmod the home dir. -h /app made adduser chown /app; + # -h /config made it chmod the operator's config dir to 2755. + adduser -D -H -h /config -u "${PUID}" -G "${GROUP_NAME}" -s /sbin/nologin checker fi USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1) @@ -70,18 +83,14 @@ done # Ensure writable dirs exist and are owned correctly mkdir -p /config # Chown only what is wrong; an already-correct tree costs a stat pass. -find /config \( ! -user "${PUID}" -o ! -group "${PGID}" \) \ - -exec chown -h "${PUID}:${PGID}" {} + 2>/dev/null || true +# Walks through NOFOLLOW directory fds: a path-based chown follows an +# intermediate directory swapped for a symlink mid-sweep. +python3 /app/fix_ownership.py /config "${PUID}" "${PGID}" || true # Fail closed on the thing that matters. A per-file chown error can be benign # (foreign uids on a network mount); an unwritable /config is not. -probe="/config/.beatscheck-write-probe.$$" -if ! su-exec "${PUID}:${PGID}" touch "${probe}" 2>/dev/null; then - echo "FATAL: /config is not writable by ${PUID}:${PGID} after ownership correction." - echo "Pre-chown it on the host: sudo chown -R ${PUID}:${PGID} /path/to/config" - exit 1 -fi -su-exec "${PUID}:${PGID}" rm -f "${probe}" +RUN_AS="su-exec ${PUID}:${PGID}" +require_writable_config umask "${UMASK}" diff --git a/scripts/fix_ownership.py b/scripts/fix_ownership.py new file mode 100644 index 0000000..e5d72d8 --- /dev/null +++ b/scripts/fix_ownership.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Chown a directory tree to uid:gid without descending through symlinks.""" + +import os +import sys + + +def _chown(name: str, uid: int, gid: int, dir_fd: int) -> None: + try: + info = os.stat(name, dir_fd=dir_fd, follow_symlinks=False) + except OSError: + return # vanished mid-walk + if info.st_uid == uid and info.st_gid == gid: + return + try: + os.chown(name, uid, gid, dir_fd=dir_fd, follow_symlinks=False) + except OSError: + # Benign on a mount with foreign uids; the write probe fails closed. + pass + + +def main(root: str, uid: int, gid: int) -> int: + # fwalk owns the directory fds and defaults to follow_symlinks=False, so a + # subdirectory swapped for a symlink is never descended. + try: + root_fd = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + except OSError: + return 1 + try: + for _, dirnames, filenames, dir_fd in os.fwalk( + ".", dir_fd=root_fd, follow_symlinks=False + ): + for name in dirnames + filenames: + _chown(name, uid, gid, dir_fd) + finally: + os.close(root_fd) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1], int(sys.argv[2]), int(sys.argv[3]))) From eba60a1dd6c3c8a16bdd74857f101a318f68a8db Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:26:53 +0800 Subject: [PATCH 2/3] refactor(entrypoint): drop the unused USER_NAME assignments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assigned on both the rootless and root paths and never read — not exported, not referenced anywhere else in the repo, not consumed by the application. Removing both clears the only shellcheck warning in the file. --- scripts/entrypoint.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index b687696..f5258f9 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -36,7 +36,6 @@ require_writable_config() { if [ "$(id -u)" != "0" ]; then PUID=$(id -u) PGID=$(id -g) - USER_NAME=$(id -un 2>/dev/null || echo "uid-${PUID}") # No chown is possible here, so the operator must pre-chown /config. RUN_AS="" @@ -70,7 +69,6 @@ if ! getent passwd "${PUID}" > /dev/null 2>&1; then # -h /config made it chmod the operator's config dir to 2755. adduser -D -H -h /config -u "${PUID}" -G "${GROUP_NAME}" -s /sbin/nologin checker fi -USER_NAME=$(getent passwd "${PUID}" | cut -d: -f1) # Validate dependencies for cmd in ffmpeg python3; do From 05dbd87481153b3606d0bd5656ca1c1d3bd6d04d Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:34:55 +0800 Subject: [PATCH 3/3] fix(ownership): chown the config directory itself, not only its contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.fwalk yields the entries within each directory and never the root, so switching from `find /config …` — which includes its starting point — quietly stopped correcting /config's own ownership. A root-owned config directory then survived the sweep, the probe could not create its file, and the container exited instead of self-healing. Reproduced against the previous commit: FATAL, with the directory left at 0:0. It now starts and the directory becomes 1000:1000. Also trims four comment blocks to the two-line cap. --- scripts/entrypoint.sh | 19 +++++++------------ scripts/fix_ownership.py | 7 +++++++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index f5258f9..2e12612 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -16,10 +16,8 @@ elif [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then export TZ fi -# Creating a file is the only reliable test: `test -w` passes on a directory the -# process cannot use — mode 600 satisfies it while the missing search bit blocks -# everything below — and it never sees a read-only mount. RUN_AS is empty when we -# are already the target uid, so it must stay unquoted. +# `test -w` passes on a mode-600 dir and never sees a read-only mount. +# RUN_AS is empty when we are already the target uid, so leave it unquoted. require_writable_config() { probe="/config/.beatscheck-write-probe.$$" if ! ${RUN_AS} touch "${probe}" 2>/dev/null; then @@ -30,9 +28,8 @@ require_writable_config() { ${RUN_AS} rm -f "${probe}" } -# Detect rootless mode (`docker run --user uid:gid`). PUID/PGID env vars -# are ignored — we can't usermod/chown without root, and the supplied uid -# is already what the operator wants. +# Rootless (`docker run --user uid:gid`): PUID/PGID are ignored because we +# cannot usermod without root. if [ "$(id -u)" != "0" ]; then PUID=$(id -u) PGID=$(id -g) @@ -65,8 +62,7 @@ GROUP_NAME=$(getent group "${PGID}" | cut -d: -f1) # Create user if it doesn't exist if ! getent passwd "${PUID}" > /dev/null 2>&1; then - # -H: never create or chmod the home dir. -h /app made adduser chown /app; - # -h /config made it chmod the operator's config dir to 2755. + # -H so adduser never creates or chmods the home dir. adduser -D -H -h /config -u "${PUID}" -G "${GROUP_NAME}" -s /sbin/nologin checker fi @@ -80,9 +76,8 @@ done # Ensure writable dirs exist and are owned correctly mkdir -p /config -# Chown only what is wrong; an already-correct tree costs a stat pass. -# Walks through NOFOLLOW directory fds: a path-based chown follows an -# intermediate directory swapped for a symlink mid-sweep. +# Chowns only what is wrong, through NOFOLLOW directory fds: a path-based +# chown follows an intermediate dir swapped for a symlink mid-sweep. python3 /app/fix_ownership.py /config "${PUID}" "${PGID}" || true # Fail closed on the thing that matters. A per-file chown error can be benign diff --git a/scripts/fix_ownership.py b/scripts/fix_ownership.py index e5d72d8..58a7bdf 100644 --- a/scripts/fix_ownership.py +++ b/scripts/fix_ownership.py @@ -27,6 +27,13 @@ def main(root: str, uid: int, gid: int) -> int: except OSError: return 1 try: + # fwalk yields entries within each directory, never the root itself. + info = os.fstat(root_fd) + if info.st_uid != uid or info.st_gid != gid: + try: + os.fchown(root_fd, uid, gid) + except OSError: + pass for _, dirnames, filenames, dir_fd in os.fwalk( ".", dir_fd=root_fd, follow_symlinks=False ):