Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
50 changes: 26 additions & 24 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,32 @@ 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

# 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.
# `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
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}"
}

# 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)
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; }
Expand All @@ -55,9 +62,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 so adduser never creates or chmods the home dir.
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
Expand All @@ -69,19 +76,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
# 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
# (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}"

Expand Down
48 changes: 48 additions & 0 deletions scripts/fix_ownership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/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:
# 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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
".", 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])))
Loading