diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398bc9e09f..608a6093b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,34 @@ jobs: docker exec frigate /usr/local/nginx/sbin/nginx -t docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600 docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640 + - name: Assert PUID/PGID remapping + run: | + mkdir -p /tmp/frigate-config-puid + printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-puid/config.yml + docker run -d --name frigate-puid --shm-size 256m \ + -e PUID=1500 -e PGID=1500 \ + -v /tmp/frigate-config-puid:/config \ + ${{ steps.setup.outputs.image-name }}-amd64 + up=0 + for i in $(seq 1 60); do + docker exec frigate-puid curl -fs http://127.0.0.1:5000/api/version && up=1 && break + sleep 5 + done + if [ "$up" -ne 1 ]; then echo "PUID container never became healthy"; docker logs frigate-puid; exit 1; fi + docker exec frigate-puid id -u frigate | grep -qx 1500 + docker exec frigate-puid id -g frigate | grep -qx 1500 + docker exec frigate-puid cat /config/.permissions_version | grep -qx "1:1500:1500" + # second boot must skip the sweep (sentinel hit). Poll rather than + # sleep: the string can only come from the second boot (the first + # had no sentinel), so grepping the full log is unambiguous. + docker restart frigate-puid + ok=0 + for i in $(seq 1 30); do + docker logs frigate-puid 2>&1 | grep -q "already applied" && ok=1 && break + sleep 2 + done + if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi + docker rm -f frigate-puid - name: Teardown if: always() run: docker rm -f frigate || true diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 095eec91af..c714fda963 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -265,6 +265,23 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \ /deps/install_deps.sh +# Runtime users. frigate may be remapped at start via PUID/PGID (init-usermod) +# or replaced entirely with docker's --user. go2rtc is intentionally separate +# and more restricted. frigate-data is the shared group for /config access. +# -o tolerates variant base images that already contain uid/gid 1000. +RUN groupadd -o --gid 1000 frigate \ + && useradd -o --uid 1000 --gid frigate --no-create-home --shell /usr/sbin/nologin frigate \ + && groupadd --system go2rtc \ + && useradd --system --gid go2rtc --no-create-home --shell /usr/sbin/nologin go2rtc \ + && groupadd --system frigate-data \ + && usermod -aG frigate-data frigate \ + && usermod -aG frigate-data go2rtc \ + && for grp in video render plugdev audio; do \ + if getent group "$grp" >/dev/null; then \ + usermod -aG "$grp" frigate && usermod -aG "$grp" go2rtc; \ + fi; \ + done + ENV DEFAULT_FFMPEG_VERSION="8.0" ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0" @@ -307,6 +324,11 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s # Frigate deps with Node.js and NPM for devcontainer FROM deps AS devcontainer +# /config here is the developer's bind-mounted checkout, not a data volume, so +# the prepare ownership sweep must not run: it would chown the source tree to +# the runtime uid and lock out any container user that isn't 1000. +ENV FRIGATE_RUN_AS_ROOT=true + # Do not start the actual Frigate service on devcontainer as it will be started by VS Code # But start a fake service for simulating the logs COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run index 7d66e2c812..ff5eaead53 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync-log/run @@ -1,4 +1,12 @@ #!/command/with-contenv bash # shellcheck shell=bash -exec logutil-service /dev/shm/logs/certsync +if [[ "$(id -u)" -eq 0 ]]; then + # logutil-service drops to nobody and applies S6_LOGGING_SCRIPT + exec logutil-service /dev/shm/logs/certsync +fi + +# Non-root (--user) fallback: logutil-service cannot change UID, so run +# s6-log directly with the same directives S6_LOGGING_SCRIPT configures. +# shellcheck disable=SC2086 +exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/certsync diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate-log/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate-log/run index c102848620..5532150d8f 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate-log/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/frigate-log/run @@ -1,4 +1,12 @@ #!/command/with-contenv bash # shellcheck shell=bash -exec logutil-service /dev/shm/logs/frigate +if [[ "$(id -u)" -eq 0 ]]; then + # logutil-service drops to nobody and applies S6_LOGGING_SCRIPT + exec logutil-service /dev/shm/logs/frigate +fi + +# Non-root (--user) fallback: logutil-service cannot change UID, so run +# s6-log directly with the same directives S6_LOGGING_SCRIPT configures. +# shellcheck disable=SC2086 +exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/frigate diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc-log/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc-log/run index 96a204b9d1..59eb9ab60d 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc-log/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/go2rtc-log/run @@ -1,4 +1,12 @@ #!/command/with-contenv bash # shellcheck shell=bash -exec logutil-service /dev/shm/logs/go2rtc +if [[ "$(id -u)" -eq 0 ]]; then + # logutil-service drops to nobody and applies S6_LOGGING_SCRIPT + exec logutil-service /dev/shm/logs/go2rtc +fi + +# Non-root (--user) fallback: logutil-service cannot change UID, so run +# s6-log directly with the same directives S6_LOGGING_SCRIPT configures. +# shellcheck disable=SC2086 +exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/go2rtc diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/dependencies.d/base b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/dependencies.d/base new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run new file mode 100755 index 0000000000..0168bdb01c --- /dev/null +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run @@ -0,0 +1,61 @@ +#!/command/with-contenv bash +# shellcheck shell=bash +# Remap the frigate user to PUID/PGID and register EXTRA_GROUPS. +# No-op when: started with --user (euid != 0), FRIGATE_RUN_AS_ROOT=true, +# or PUID/PGID already match. + +set -o errexit -o nounset -o pipefail + +if [[ "$(id -u)" -ne 0 ]]; then + # Started with docker --user; the host owns UID mapping entirely. + exit 0 +fi + +if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then + echo "[INFO] FRIGATE_RUN_AS_ROOT=true: skipping user remapping" + exit 0 +fi + +puid="${PUID:-1000}" +pgid="${PGID:-1000}" + +if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then + echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2 + exit 1 +fi + +# Remapping to 0 would make the frigate user root, so every service would keep +# full privilege while reporting a successful migration. +if [[ "$puid" -eq 0 || "$pgid" -eq 0 ]]; then + echo "[ERROR] PUID/PGID 0 would run the services as root and defeat the privilege separation." >&2 + echo "[ERROR] Set FRIGATE_RUN_AS_ROOT=true if you want to keep running as root." >&2 + exit 1 +fi + +current_uid="$(id -u frigate)" +current_gid="$(id -g frigate)" + +if [[ "$puid" != "$current_uid" || "$pgid" != "$current_gid" ]]; then + if [[ ! -w /etc/passwd ]]; then + echo "[ERROR] PUID/PGID remapping needs a writable /etc and is not compatible with read_only: true." >&2 + echo "[ERROR] Either remove read_only and keep PUID, or drop PUID/PGID and use docker's user: ${puid}:${pgid} instead." >&2 + echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2 + exit 1 + fi + echo "[INFO] Remapping frigate user to ${puid}:${pgid}" + groupmod -o -g "$pgid" frigate + usermod -o -u "$puid" frigate +fi + +# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video) +if [[ -n "${EXTRA_GROUPS:-}" ]]; then + for gid in ${EXTRA_GROUPS//,/ }; do + if ! getent group "$gid" >/dev/null; then + groupadd -o -g "$gid" "frigate-extra-${gid}" + fi + group_name="$(getent group "$gid" | cut -d: -f1)" + usermod -aG "$group_name" frigate + usermod -aG "$group_name" go2rtc + echo "[INFO] Added frigate and go2rtc to supplementary group ${group_name} (gid ${gid})" + done +fi diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/type b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/type new file mode 100644 index 0000000000..bdd22a1850 --- /dev/null +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/type @@ -0,0 +1 @@ +oneshot diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/up b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/up new file mode 100644 index 0000000000..5bff2e7017 --- /dev/null +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/up @@ -0,0 +1 @@ +/etc/s6-overlay/s6-rc.d/init-usermod/run diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/log-prepare/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/log-prepare/run index c493e320ee..e3e86cb682 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/log-prepare/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/log-prepare/run @@ -7,5 +7,12 @@ set -o errexit -o nounset -o pipefail dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync) mkdir -p "${dirs[@]}" -chown nobody:nogroup "${dirs[@]}" + +# logutil-service drops s6-log to nobody, so the dirs must stay nobody-owned +# in root mode. Under docker --user we are already the (only) target user, +# chown would fail, and the plain s6-log fallback in the *-log services +# writes as us (the mkdir above is sufficient, /dev/shm is 1777). +if [[ "$(id -u)" -eq 0 ]]; then + chown nobody:nogroup "${dirs[@]}" +fi chmod 02755 "${dirs[@]}" diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx-log/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx-log/run index 50057d1d79..5a0e7050ea 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx-log/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx-log/run @@ -1,4 +1,12 @@ #!/command/with-contenv bash # shellcheck shell=bash -exec logutil-service /dev/shm/logs/nginx +if [[ "$(id -u)" -eq 0 ]]; then + # logutil-service drops to nobody and applies S6_LOGGING_SCRIPT + exec logutil-service /dev/shm/logs/nginx +fi + +# Non-root (--user) fallback: logutil-service cannot change UID, so run +# s6-log directly with the same directives S6_LOGGING_SCRIPT configures. +# shellcheck disable=SC2086 +exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/nginx diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/dependencies.d/init-usermod b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/dependencies.d/init-usermod new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run index 27b1d6326e..affe98a1f1 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run @@ -144,3 +144,16 @@ rm -f /dev/shm/.frigate-is-stopping migrate_addon_config_dir migrate_db_from_media_to_config + +# Align volume ownership with the runtime user (one sweep per PUID/schema +# change, guarded by the sentinel; see fix-ownership). The escape hatch +# deletes the sentinel instead: ownership is never mutated while it is on, +# so the next non-root boot must re-sweep whatever root created meanwhile. +if [[ "$(id -u)" -eq 0 ]]; then + if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then + rm -f /config/.permissions_version + else + /usr/local/bin/fix-ownership --sentinel /config/.permissions_version \ + "${PUID:-1000}" "${PGID:-1000}" /config /media/frigate + fi +fi diff --git a/docker/main/rootfs/usr/local/bin/fix-ownership b/docker/main/rootfs/usr/local/bin/fix-ownership new file mode 100755 index 0000000000..1df73b36c5 --- /dev/null +++ b/docker/main/rootfs/usr/local/bin/fix-ownership @@ -0,0 +1,129 @@ +#!/bin/bash +# Single source of truth for aligning volume ownership with the runtime user. +# +# Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH [PATH...] +# +# --dry-run report what would change, touch nothing +# --sentinel skip entirely when FILE already records "SCHEMA:UID:GID"; +# write it after a successful run (used by the boot path so +# multi-TB volumes are swept once per UID/schema change, not +# on every boot) +# +# Only files whose uid OR gid differs are touched, so re-runs are cheap. +# Top-level /config additionally grants group frigate-data TRAVERSE ONLY +# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file +# on hosts where /config is mounted 0700. Never g+w: directory write means +# unlink rights over frigate.db/config.yml, and would let a compromised +# go2rtc plant /config/go2rtc, which the go2rtc run script executes +# preferentially, as root under the escape hatch. + +set -o errexit -o nounset -o pipefail + +# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade +# (e.g. when the privilege-drop release must capture files created as root +# since the previous sweep). +schema=1 + +dry_run=0 +sentinel="" + +while [[ "${1:-}" == --* ]]; do + case "$1" in + --dry-run) dry_run=1; shift ;; + --sentinel) + if [[ -z "${2:-}" ]]; then + echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2 + exit 2 + fi + sentinel="$2"; shift 2 ;; + *) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;; + esac +done + +if [[ $# -lt 3 ]]; then + echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH..." >&2 + exit 2 +fi + +target_uid="$1" +target_gid="$2" +shift 2 + +if [[ "$(id -u)" -ne 0 ]]; then + echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)" + exit 0 +fi + +# A dry run always inspects: the sentinel records what a past sweep did, not +# what the volume looks like now, and reporting from it would hide later drift. +if [[ "$dry_run" -eq 0 && -n "$sentinel" && -f "$sentinel" && "$(cat "$sentinel")" == "${schema}:${target_uid}:${target_gid}" ]]; then + echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping" + exit 0 +fi + +# A sweep that could not chown everything must not be recorded as complete: +# the sentinel would make every later boot skip it and the entries would stay +# unreachable once services run unprivileged. +swept_clean=1 + +for path in "$@"; do + # An absent root is an incomplete sweep, not a finished one: /media/frigate + # is not in the image, so a boot before the volume is mounted would + # otherwise record success and the volume would never be swept once added. + if [[ ! -d "$path" ]]; then + swept_clean=0 + echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot" + continue + fi + + # find may fail mid-walk on a live volume (file deleted under it) or on a + # stale mount. Tolerate it rather than aborting under errexit, but never + # read a failed scan as "nothing to do": that would record the sweep as + # complete without having looked. + if ! count=$(find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) -printf '.' 2>/dev/null | wc -c); then + swept_clean=0 + echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot" + continue + fi + + if [[ "$count" -eq 0 ]]; then + echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do" + continue + fi + + # find does not descend symlinks and chown -h retargets the link itself, so + # anything behind a symlinked directory is outside this sweep. Following + # them is not an option: a link could walk the chown out of the volume. + if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then + echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand" + fi + + echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}; on large recordings volumes this can take a long time" + if [[ "$dry_run" -eq 1 ]]; then + echo "[INFO] fix-ownership: dry run, not changing ${path}" + continue + fi + + find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \ + -exec chown -h "${target_uid}:${target_gid}" {} + || { + swept_clean=0 + echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch" + } +done + +# go2rtc (separate user) must be able to REACH its HomeKit state in /config. +# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites +# the first -config file via os.WriteFile (in-place truncate, no rename, +# verified against go2rtc v1.9.14 internal/app/config.go), and the file is +# always pre-created by setup_homekit_config before go2rtc starts, so +# O_CREATE never needs directory write. See header comment for why g+w is +# forbidden here. +if [[ "$dry_run" -eq 0 && -d /config ]]; then + chgrp frigate-data /config 2>/dev/null || true + chmod g+rx /config 2>/dev/null || true +fi + +if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then + echo "${schema}:${target_uid}:${target_gid}" > "$sentinel" || \ + echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot" +fi diff --git a/docker/migration/fix-permissions.sh b/docker/migration/fix-permissions.sh new file mode 100755 index 0000000000..0e135a3e7a --- /dev/null +++ b/docker/migration/fix-permissions.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Ahead-of-time volume ownership migration for switching Frigate to non-root. +# Run from the host BEFORE enabling PUID/PGID or --user: +# +# ./fix-permissions.sh [--dry-run] [PUID] [PGID] +# +# Wraps the image's fix-ownership helper so there is exactly one +# implementation of the chown logic. Requires an image that contains the +# helper (any release that includes non-root support). + +set -o errexit -o nounset -o pipefail + +IMAGE="${FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:stable}" + +dry_run_flag="" +if [[ "${1:-}" == "--dry-run" ]]; then + dry_run_flag="--dry-run" + shift +fi + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [--dry-run] [PUID] [PGID]" >&2 + exit 2 +fi + +config_dir="$1" +media_dir="$2" +puid="${3:-1000}" +pgid="${4:-1000}" + +# The ids are interpolated into the container's bash -c source below, so +# anything but digits would be reparsed as shell rather than passed through +if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then + echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2 + exit 2 +fi + +echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)" +# shellcheck disable=SC2086 +docker run --rm \ + -v "${config_dir}:/config" \ + -v "${media_dir}:/media/frigate" \ + --entrypoint bash \ + "${IMAGE}" \ + -c "command -v fix-ownership >/dev/null || { echo '[ERROR] this Frigate image predates non-root support; set FRIGATE_IMAGE to a release that includes it' >&2; exit 1; }; exec fix-ownership ${dry_run_flag} ${puid} ${pgid} /config /media/frigate" diff --git a/frigate/app.py b/frigate/app.py index e3ffe46d93..b6d493162e 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -86,6 +86,7 @@ from frigate.timeline import TimelineProcessor from frigate.track.object_processing import TrackedObjectProcessor from frigate.util.builtin import empty_and_close_queue from frigate.util.image import UntrackedSharedMemory +from frigate.util.ownership import chown_to_runtime from frigate.util.process import FrigateProcess from frigate.util.services import set_file_limit from frigate.version import VERSION @@ -149,6 +150,7 @@ class FrigateApp: if not os.path.exists(d) and not os.path.islink(d): logger.info(f"Creating directory: {d}") os.makedirs(d, exist_ok=True) + chown_to_runtime(d) else: logger.debug(f"Skipping directory: {d}") diff --git a/frigate/test/test_ownership.py b/frigate/test/test_ownership.py new file mode 100644 index 0000000000..19087c7f6c --- /dev/null +++ b/frigate/test/test_ownership.py @@ -0,0 +1,59 @@ +"""Tests for runtime ownership helpers.""" + +import unittest +from unittest.mock import patch + +from frigate.util import ownership + + +class FakePwEntry: + pw_uid = 1500 + pw_gid = 1500 + + +# The devcontainer image exports FRIGATE_RUN_AS_ROOT, so any test that has to +# reach past the escape-hatch check pins the variable instead of inheriting it. +class TestGetRuntimeIds(unittest.TestCase): + @patch("frigate.util.ownership.os.geteuid", return_value=1000) + def test_returns_none_when_not_root(self, _): + assert ownership.get_runtime_ids() is None + + @patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "true"}) + @patch("frigate.util.ownership.os.geteuid", return_value=0) + def test_returns_none_with_escape_hatch(self, _): + assert ownership.get_runtime_ids() is None + + @patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"}) + @patch("frigate.util.ownership.pwd.getpwnam", side_effect=KeyError) + @patch("frigate.util.ownership.os.geteuid", return_value=0) + def test_returns_none_outside_frigate_image(self, *_): + assert ownership.get_runtime_ids() is None + + @patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"}) + @patch("frigate.util.ownership.pwd.getpwnam", return_value=FakePwEntry()) + @patch("frigate.util.ownership.os.geteuid", return_value=0) + def test_returns_frigate_ids_as_root(self, *_): + assert ownership.get_runtime_ids() == (1500, 1500) + + +class TestChownToRuntime(unittest.TestCase): + @patch("frigate.util.ownership.os.chown") + @patch("frigate.util.ownership.get_runtime_ids", return_value=None) + def test_noop_when_no_runtime_ids(self, _, chown): + ownership.chown_to_runtime("/config/test") + chown.assert_not_called() + + @patch("frigate.util.ownership.os.chown") + @patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500)) + def test_chowns_to_runtime_ids(self, _, chown): + ownership.chown_to_runtime("/config/test") + chown.assert_called_once_with("/config/test", 1500, 1500) + + @patch("frigate.util.ownership.os.chown", side_effect=OSError("ro fs")) + @patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500)) + def test_swallows_oserror(self, *_): + ownership.chown_to_runtime("/config/test") # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/util/ownership.py b/frigate/util/ownership.py new file mode 100644 index 0000000000..8ace34ca18 --- /dev/null +++ b/frigate/util/ownership.py @@ -0,0 +1,43 @@ +"""Helpers for aligning created files with the non-root runtime user.""" + +import logging +import os +import pwd + +logger = logging.getLogger(__name__) + +RUNTIME_USER = "frigate" + + +def get_runtime_ids() -> tuple[int, int] | None: + """Return (uid, gid) that services run as, or None when chown is not applicable. + + None when: not root (docker --user, so the host already mapped us), + FRIGATE_RUN_AS_ROOT=true (escape hatch must not mutate ownership), + or outside the Frigate container image (no frigate user). + """ + if os.geteuid() != 0: + return None + + if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true": + return None + + try: + user = pwd.getpwnam(RUNTIME_USER) + except KeyError: + return None + + return (user.pw_uid, user.pw_gid) + + +def chown_to_runtime(path: str) -> None: + """Best-effort chown of path to the runtime user.""" + ids = get_runtime_ids() + + if ids is None: + return + + try: + os.chown(path, *ids) + except OSError as err: + logger.warning(f"Unable to set ownership of {path}: {err}")