mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-31 07:27:57 +00:00
Container security hardening (phase 3, breaking) (#24081)
* Run the frigate service as the frigate user * Run go2rtc as its own restricted user * Run nginx as the frigate user with writable state in /tmp/nginx * Disable bandwidth stats gracefully when not running as root * Hand TensorRT model cache ownership to the runtime user * Document non-root operation and per-hardware device access * Create /media/frigate after the ownership sweep * Assert non-root services, JWT migration, and escape hatch in CI * only write the sweep sentinel when a media volume is mounted * tolerate homekit config chown failures in the go2rtc run script * chown the s6 log pipe so non-root nginx can reopen /dev/stdout * set HOME to /config for non-root services * run smoke nginx -t and the write probe as the runtime user * re-own the nginx shm cache on service restart * discard stdout for the unprivileged smoke nginx -t * unwrap hard-wrapped prose in the installation docs * report progress during the ownership sweep * document EXTRA_GROUPS as the only device access path for dropped services * expand the non-root device access docs with diagnosis steps and udev rules * document network storage ownership and the remaining detector hardware * skip lost+found during the ownership sweep * hand /tmp/cache to the runtime user before services start * make bundled models readable by the runtime user * reload nginx by signaling the master instead of parsing its config as root * harden root writes into unprivileged-owned paths Restrict the sweep sentinel to a mount at or below /media/frigate so a parent /media mount cannot bless a later-shadowed volume. Rebuild /tmp/nginx root-owned each start so root's cp and tempio writes cannot follow a symlink an unprivileged nginx planted in the previous run. * collapse the duplicated sentinel comment * add a service-runs-as-root helper for granular root services * validate FRIGATE_ROOT_SERVICES and fail fast on unknown names * let services listed in FRIGATE_ROOT_SERVICES skip the privilege drop * record the root-services mode in the sentinel and sweep small trees each boot * cache the runtime ids in the ownership helper * chown recordings, previews, and exports to the runtime user at create * chown the database files after init * recommend FRIGATE_ROOT_SERVICES in the bandwidth stats warning * assert granular root services in CI * document FRIGATE_ROOT_SERVICES * own every directory level created for a recording segment * clear the cached runtime ids when ownership tests finish * skip missing media paths in the per-boot ownership sweep * clarify granular root services docs * clean up * install acl for device access grants * grant runtime users access to mapped device nodes at boot * assert device access grants in CI * document automatic device access grants * stop telling users device access needs host side setup * clarify the non-root docs * link the migration script to the repo * group the manual device setup under one section * harden against symlink attacks /config is owned by the unprivileged runtime user after the ownership sweep, so root operations on files there could be redirected by a planted symlink. - go2rtc HomeKit setup: replace the root yq/jq normalization and chown with an O_NOFOLLOW helper (prepare_homekit.py), so a symlink at go2rtc_homekit.yml can't redirect a root write or chown onto another file - go2rtc binary override: ignore /config/go2rtc whenever the service runs as root, so a planted binary can't exec as root under FRIGATE_ROOT_SERVICES - sweep sentinel: read and write it through safe-sentinel, which trusts only a root-owned regular file and never follows a symlink, so it can't be forged to skip the migration or symlinked to clobber a root file - ownership sweep: chown with -execdir so a parent directory swapped for a symlink mid-walk can't redirect the chown out of the volume - validate inputs: restrict DEVICE_ACL_PATHS to /dev, require nonzero numeric EXTRA_GROUPS, and reject PUID/PGID that collide with the go2rtc ids - docs: correct the TLS key ownership note to match what actually happens * tweak docs * stop the ownership sweep chasing entries other mechanisms own * keep custom binaries out of root services only under granular root
This commit is contained in:
parent
a1fd978cab
commit
890512b054
179
.github/workflows/ci.yml
vendored
179
.github/workflows/ci.yml
vendored
@ -59,10 +59,16 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Start container
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config
|
||||
mkdir -p /tmp/frigate-config /tmp/frigate-media
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml
|
||||
# simulate a root-era install: root-owned 0600 jwt secret pre-exists
|
||||
docker run --rm -v /tmp/frigate-config:/config --entrypoint bash \
|
||||
${{ steps.setup.outputs.image-name }}-amd64 \
|
||||
-c "python3 -c 'import secrets; open(\"/config/.jwt_secret\",\"w\").write(secrets.token_hex(64))' && chmod 600 /config/.jwt_secret && chown 0:0 /config/.jwt_secret"
|
||||
docker run -d --name frigate --shm-size 256m \
|
||||
-v /tmp/frigate-config:/config \
|
||||
-v /tmp/frigate-media:/media/frigate \
|
||||
--mount type=tmpfs,target=/tmp/cache,tmpfs-size=100000000 \
|
||||
-p 5000:5000 -p 8971:8971 \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
- name: Wait for API
|
||||
@ -91,16 +97,181 @@ jobs:
|
||||
echo "response carries frame-ancestors, which breaks cross-origin iframe embedding"
|
||||
exit 1
|
||||
fi
|
||||
docker exec frigate /usr/local/nginx/sbin/nginx -t
|
||||
# -t as root would chown the live cache and temp dirs to the `user`
|
||||
# directive user; stdout discarded because -t reopens the config's
|
||||
# /dev/stdout logs and the docker exec pipe is root-owned
|
||||
docker exec frigate /command/s6-setuidgid frigate bash -c '/usr/local/nginx/sbin/nginx -e stderr -t -c /tmp/nginx/conf/nginx.conf >/dev/null'
|
||||
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 services run as non-root
|
||||
run: |
|
||||
ps_out=$(docker exec frigate ps -eo user=,comm=)
|
||||
echo "$ps_out"
|
||||
assert_nonroot() {
|
||||
# the process must exist AND no instance of it may run as root
|
||||
echo "$ps_out" | grep -qw "$1" || { echo "$1 is not running"; exit 1; }
|
||||
if echo "$ps_out" | grep -w "$1" | grep -q '^root'; then
|
||||
echo "$1 is running as root"; exit 1
|
||||
fi
|
||||
}
|
||||
assert_nonroot python3
|
||||
assert_nonroot go2rtc
|
||||
assert_nonroot nginx
|
||||
# root-era jwt secret must have been captured by the sweep and the
|
||||
# auth stack must be functional: wrong creds => clean 401, not 500
|
||||
docker exec frigate stat -c %u /config/.jwt_secret | grep -qx "$(docker exec frigate id -u frigate)"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:5000/api/login \
|
||||
-H 'content-type: application/json' -d '{"user":"admin","password":"definitely-wrong"}')
|
||||
[ "$code" = "401" ] || { echo "login endpoint returned $code"; exit 1; }
|
||||
# a root nginx -t above would have chowned the runtime dirs to root
|
||||
owners=$(docker exec frigate stat -c %U /tmp/nginx /dev/shm/nginx_cache)
|
||||
echo "$owners"
|
||||
if echo "$owners" | grep -qvx frigate; then
|
||||
echo "nginx runtime dirs are not owned by frigate"; exit 1
|
||||
fi
|
||||
# runtime user can write recordings storage
|
||||
docker exec frigate /command/s6-setuidgid frigate touch /media/frigate/.write-probe
|
||||
docker exec frigate rm /media/frigate/.write-probe
|
||||
# tmpfs mount per the docs: arrives root-owned, holds the ZMQ IPC sockets
|
||||
docker exec frigate /command/s6-setuidgid frigate touch /tmp/cache/.write-probe
|
||||
docker exec frigate rm /tmp/cache/.write-probe
|
||||
# models are baked in as root and archive members can carry root-only modes
|
||||
docker exec frigate /command/s6-setuidgid frigate sh -c '
|
||||
for f in /cpu_model.tflite /edgetpu_model.tflite /cpu_audio_model.tflite \
|
||||
/labelmap.txt /audio-labelmap.txt /openvino-model/*; do
|
||||
[ -e "$f" ] || continue
|
||||
test -r "$f" || { echo "$f is not readable by the runtime user"; exit 1; }
|
||||
done'
|
||||
- name: Assert device access grants
|
||||
run: |
|
||||
# a fake accelerator node created after boot, then the oneshot re-run
|
||||
docker exec frigate mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
acl=$(docker exec frigate getfacl -p /dev/apex_9)
|
||||
echo "$acl"
|
||||
echo "$acl" | grep -q "user:frigate:rw-"
|
||||
echo "$acl" | grep -q "user:go2rtc:rw-"
|
||||
# the usb tree gets recursive grants plus a default ACL that
|
||||
# newly created nodes inherit (the Coral re-enumeration path)
|
||||
docker exec frigate sh -c 'mkdir -p /dev/bus/usb/001 && mknod /dev/bus/usb/001/002 c 189 1'
|
||||
docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
docker exec frigate getfacl -p /dev/bus/usb/001 | grep -q "user:frigate:rwx"
|
||||
docker exec frigate sh -c 'mknod /dev/bus/usb/001/099 c 189 98 && chmod 664 /dev/bus/usb/001/099'
|
||||
inherited=$(docker exec frigate getfacl -p /dev/bus/usb/001/099)
|
||||
echo "$inherited"
|
||||
echo "$inherited" | grep -q "user:frigate:rw-"
|
||||
# getfacl prints granted perms even when the mask clamps them to
|
||||
# nothing, with a trailing "#effective:" comment; a clamped ACL must
|
||||
# fail this assertion, not sneak past it. The check is scoped to the
|
||||
# runtime users because the inherited group:: entry is always clamped
|
||||
# on a non-directory, so an unscoped grep could never pass.
|
||||
if echo "$inherited" | grep -E "^user:(frigate|go2rtc):" | grep -q "effective"; then
|
||||
echo "inherited ACL is mask-clamped and grants no real access"; exit 1
|
||||
fi
|
||||
# hardware that is absent must stay silent: the literal table entries
|
||||
# are not globs, so nullglob does not drop them and only an existence
|
||||
# check keeps them from warning on every boot
|
||||
out=$(docker exec frigate /etc/s6-overlay/s6-rc.d/init-devices/run)
|
||||
echo "$out"
|
||||
if echo "$out" | grep -q "WARN"; then
|
||||
echo "grant warned about device nodes that do not exist"; exit 1
|
||||
fi
|
||||
- name: Assert escape hatch restores root
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-root
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-root/config.yml
|
||||
# pre-seed so the absence check proves the rm -f, not a vacuous pass
|
||||
echo "2:1000:1000" > /tmp/frigate-config-root/.permissions_version
|
||||
docker run -d --name frigate-root --shm-size 256m \
|
||||
-e FRIGATE_RUN_AS_ROOT=true \
|
||||
-v /tmp/frigate-config-root:/config \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-root curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "escape hatch container never healthy"; docker logs frigate-root; exit 1; fi
|
||||
ps_out=$(docker exec frigate-root ps -eo user=,comm=)
|
||||
echo "$ps_out"
|
||||
echo "$ps_out" | grep -w python3 | grep -q '^root'
|
||||
echo "$ps_out" | grep -w go2rtc | grep -q '^root'
|
||||
echo "$ps_out" | grep -w nginx | grep -q '^root'
|
||||
# an if, not ! test: bash exempts negated commands from set -e
|
||||
if docker exec frigate-root test -f /config/.permissions_version; then
|
||||
echo "escape hatch did not delete the sweep sentinel"; exit 1
|
||||
fi
|
||||
docker rm -f frigate-root
|
||||
- name: Assert granular root services
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-granular /tmp/frigate-media-granular
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-granular/config.yml
|
||||
docker run -d --name frigate-granular --shm-size 256m \
|
||||
-e FRIGATE_ROOT_SERVICES=frigate \
|
||||
-v /tmp/frigate-config-granular:/config \
|
||||
-v /tmp/frigate-media-granular:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-granular curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "granular container never became healthy"; docker logs frigate-granular; exit 1; fi
|
||||
ps_out=$(docker exec frigate-granular ps -eo user=,comm=)
|
||||
echo "$ps_out"
|
||||
# the listed service runs as root
|
||||
echo "$ps_out" | grep -w python3 | grep -q '^root'
|
||||
# unlisted services still drop; ifs because set -e exempts negated commands
|
||||
if echo "$ps_out" | grep -w go2rtc | grep -q '^root'; then
|
||||
echo "go2rtc is unexpectedly running as root"; exit 1
|
||||
fi
|
||||
if echo "$ps_out" | grep -w nginx | grep -q '^root'; then
|
||||
echo "nginx is unexpectedly running as root"; exit 1
|
||||
fi
|
||||
# the sweep still ran and the sentinel records the mode
|
||||
docker exec frigate-granular cat /config/.permissions_version | grep -qx "2:1000:1000:frigate"
|
||||
# the root frigate process chowns the db it creates (first-boot immediacy)
|
||||
docker exec frigate-granular stat -c %u /config/frigate.db | grep -qx 1000
|
||||
# plant a root-owned straggler; the per-boot sweep must reclaim it on restart
|
||||
docker exec frigate-granular sh -c 'mkdir -p /media/frigate/clips && touch /media/frigate/clips/straggler.webp'
|
||||
docker restart frigate-granular
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-granular curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "granular container never came back after restart"; docker logs frigate-granular; exit 1; fi
|
||||
docker exec frigate-granular stat -c %u /media/frigate/clips/straggler.webp | grep -qx 1000
|
||||
docker rm -f frigate-granular
|
||||
- name: Assert unknown root service fails fast
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-badsvc
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-badsvc/config.yml
|
||||
docker run -d --name frigate-badsvc --shm-size 256m \
|
||||
-e FRIGATE_ROOT_SERVICES=frigatee \
|
||||
-v /tmp/frigate-config-badsvc:/config \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
found=0
|
||||
for i in $(seq 1 12); do
|
||||
if docker logs frigate-badsvc 2>&1 | grep -q "unknown service 'frigatee'"; then found=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "no fail-fast error for an unknown service name"; docker logs frigate-badsvc; exit 1
|
||||
fi
|
||||
# the failed oneshot blocks startup through the dependency chain
|
||||
if docker exec frigate-badsvc curl -fs http://127.0.0.1:5000/api/version; then
|
||||
echo "container came up despite an invalid FRIGATE_ROOT_SERVICES"; exit 1
|
||||
fi
|
||||
docker rm -f frigate-badsvc
|
||||
- name: Assert PUID/PGID remapping
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-puid
|
||||
mkdir -p /tmp/frigate-config-puid /tmp/frigate-media-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 \
|
||||
-v /tmp/frigate-media-puid:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
@ -110,7 +281,7 @@ jobs:
|
||||
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"
|
||||
docker exec frigate-puid cat /config/.permissions_version | grep -qx "2: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.
|
||||
|
||||
@ -146,6 +146,8 @@ RUN wget -q https://github.com/openvinotoolkit/open_model_zoo/raw/master/data/da
|
||||
RUN wget -qO - https://www.kaggle.com/api/v1/models/google/yamnet/tfLite/classification-tflite/1/download | tar xvz && mv 1.tflite cpu_audio_model.tflite
|
||||
COPY audio-labelmap.txt .
|
||||
|
||||
RUN chmod -R a+rX /rootfs
|
||||
|
||||
|
||||
FROM wget AS s6-overlay
|
||||
ARG TARGETARCH
|
||||
|
||||
@ -10,7 +10,7 @@ apt-get -qq install --no-install-recommends -y \
|
||||
gnupg \
|
||||
wget \
|
||||
lbzip2 \
|
||||
procps vainfo \
|
||||
procps vainfo acl \
|
||||
unzip locales tzdata libxml2 xz-utils \
|
||||
python3.11 \
|
||||
curl \
|
||||
|
||||
@ -6,6 +6,24 @@ set -o errexit -o nounset -o pipefail
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
# Not `nginx -s reload`: that has root parse /tmp/nginx/conf, which the
|
||||
# unprivileged nginx user can rewrite, and nginx chowns path directives on load.
|
||||
function reload_nginx() {
|
||||
local pid
|
||||
|
||||
if ! pid=$(cat /tmp/nginx/nginx.pid 2>/dev/null); then
|
||||
echo "[ERROR] No nginx pid file found, not reloading"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! "$pid" =~ ^[0-9]+$ ]] || [[ "$(cat "/proc/${pid}/comm" 2>/dev/null)" != "nginx" ]]; then
|
||||
echo "[ERROR] nginx pid file does not name a running nginx process, not reloading"
|
||||
return 0
|
||||
fi
|
||||
|
||||
kill -HUP "$pid"
|
||||
}
|
||||
|
||||
echo "[INFO] Starting certsync..."
|
||||
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
@ -49,7 +67,7 @@ do
|
||||
then
|
||||
echo "[INFO] Reloading nginx to refresh TLS certificate"
|
||||
echo "$lefile: $leprint"
|
||||
/usr/local/nginx/sbin/nginx -s reload
|
||||
reload_nginx
|
||||
fi
|
||||
|
||||
sleep 60
|
||||
|
||||
@ -4,6 +4,19 @@
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
runs_as_root=0
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]] || /usr/local/bin/service-runs-as-root frigate; then
|
||||
runs_as_root=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# /root survives s6-setuidgid and breaks cache writes after the drop; set
|
||||
# before opt_in_out so the opt-out marker lands where the service will look
|
||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||
export HOME=/config
|
||||
fi
|
||||
|
||||
# opt out of openvino telemetry
|
||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
|
||||
@ -30,4 +43,8 @@ cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frig
|
||||
|
||||
# Replace the bash process with the Frigate process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
exec python3 -u -m frigate
|
||||
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||
exec python3 -u -m frigate
|
||||
else
|
||||
exec s6-setuidgid frigate python3 -u -m frigate
|
||||
fi
|
||||
|
||||
@ -4,6 +4,20 @@
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
runs_as_root=0
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]] || /usr/local/bin/service-runs-as-root go2rtc; then
|
||||
runs_as_root=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Root via FRIGATE_ROOT_SERVICES only; the escape hatch sweeps nothing and
|
||||
# leaves no unprivileged service, so /config/go2rtc stays as safe as pre-drop.
|
||||
granular_root=0
|
||||
if [[ "$runs_as_root" -eq 1 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
granular_root=1
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
function get_ip_and_port_from_supervisor() {
|
||||
@ -50,42 +64,6 @@ function set_libva_version() {
|
||||
export LIBAVFORMAT_VERSION_MAJOR
|
||||
}
|
||||
|
||||
function setup_homekit_config() {
|
||||
local config_path="$1"
|
||||
|
||||
if [[ ! -f "${config_path}" ]]; then
|
||||
echo "[INFO] Creating empty config file for HomeKit..."
|
||||
: > "${config_path}"
|
||||
fi
|
||||
|
||||
# Convert YAML to JSON for jq processing
|
||||
local temp_json="/tmp/cache/homekit_config.json"
|
||||
yq eval -o=json "${config_path}" > "${temp_json}" 2>/dev/null || {
|
||||
echo "[WARNING] Failed to convert HomeKit config to JSON, skipping cleanup"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Use jq to extract the homekit section, if it exists
|
||||
local homekit_json
|
||||
homekit_json=$(jq '
|
||||
if has("homekit") then {homekit: .homekit} else null end
|
||||
' "${temp_json}" 2>/dev/null) || homekit_json="null"
|
||||
|
||||
# If no homekit section, write an empty config file
|
||||
if [[ "${homekit_json}" == "null" ]]; then
|
||||
: > "${config_path}"
|
||||
else
|
||||
# Convert homekit JSON back to YAML and write to the config file
|
||||
echo "${homekit_json}" | yq eval -P - > "${config_path}" 2>/dev/null || {
|
||||
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
||||
: > "${config_path}"
|
||||
}
|
||||
fi
|
||||
|
||||
# Clean up temp files
|
||||
rm -f "${temp_json}"
|
||||
}
|
||||
|
||||
set_libva_version
|
||||
|
||||
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||
@ -106,13 +84,23 @@ else
|
||||
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
|
||||
fi
|
||||
|
||||
# HomeKit configuration persistence setup
|
||||
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
|
||||
readonly homekit_config_path="/config/go2rtc_homekit.yml"
|
||||
setup_homekit_config "${homekit_config_path}"
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
else
|
||||
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||
fi
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
if [[ -x "${config_path}/go2rtc" ]]; then
|
||||
# the sweep hands /config to uid 1000, so a root service must not exec from it
|
||||
if [[ "$granular_root" -eq 1 && -x "${config_path}/go2rtc" ]]; then
|
||||
echo "[WARN] Ignoring '${config_path}/go2rtc' because FRIGATE_ROOT_SERVICES runs this service as root and /config is owned by the runtime user; using the embedded binary"
|
||||
echo "[WARN] Use FRIGATE_RUN_AS_ROOT=true instead if you need both a custom go2rtc build and root"
|
||||
readonly binary_path="/usr/local/go2rtc/bin/go2rtc"
|
||||
elif [[ -x "${config_path}/go2rtc" ]]; then
|
||||
readonly binary_path="${config_path}/go2rtc"
|
||||
echo "[WARN] Using go2rtc binary from '${binary_path}' instead of the embedded one"
|
||||
else
|
||||
@ -125,4 +113,8 @@ echo "[INFO] Starting go2rtc..."
|
||||
# Use HomeKit config as the primary config so writebacks go there
|
||||
# The main config from Frigate will be loaded as a secondary config
|
||||
exec 2>&1
|
||||
exec "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
|
||||
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||
exec "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
|
||||
else
|
||||
exec s6-setuidgid go2rtc "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
|
||||
fi
|
||||
|
||||
103
docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-devices/run
Executable file
103
docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-devices/run
Executable file
@ -0,0 +1,103 @@
|
||||
#!/command/with-contenv bash
|
||||
# shellcheck shell=bash
|
||||
# Grant the runtime users access to mapped-in device nodes with POSIX ACLs,
|
||||
# so --device works without host-side group or udev setup.
|
||||
# No-op when: started with --user (euid != 0), FRIGATE_RUN_AS_ROOT=true,
|
||||
# or FRIGATE_DEVICE_ACLS=false.
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${FRIGATE_DEVICE_ACLS:-true}" == "false" ]]; then
|
||||
echo "[INFO] FRIGATE_DEVICE_ACLS=false: skipping device access grants"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
device_globs=(
|
||||
"/dev/dri/*"
|
||||
"/dev/apex_*"
|
||||
"/dev/hailo*"
|
||||
"/dev/video*"
|
||||
"/dev/kfd"
|
||||
"/dev/rknpu*"
|
||||
"/dev/mpp_service"
|
||||
"/dev/rga"
|
||||
"/dev/dma_heap/*"
|
||||
"/dev/nvhost*"
|
||||
"/dev/nvmap"
|
||||
"/dev/nvidia*"
|
||||
"/dev/memx*"
|
||||
)
|
||||
|
||||
IFS=',' read -ra extra_globs <<< "${DEVICE_ACL_PATHS:-}"
|
||||
for extra in "${extra_globs[@]}"; do
|
||||
extra="${extra//[[:space:]]/}"
|
||||
if [[ -z "$extra" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$extra" != /dev/* || "$extra" == *..* ]]; then
|
||||
echo "[ERROR] DEVICE_ACL_PATHS entries must be under /dev, got '${extra}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
device_globs+=("$extra")
|
||||
done
|
||||
|
||||
granted=0
|
||||
failed=0
|
||||
|
||||
grant() {
|
||||
local node="$1"
|
||||
# nullglob only drops patterns that hold a metacharacter, so a literal
|
||||
# table entry for absent hardware arrives here verbatim. Warn only about
|
||||
# nodes that exist and could not be granted.
|
||||
if [[ ! -e "$node" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local spec="u:frigate:rw,u:go2rtc:rw"
|
||||
# directories need traverse or nothing under them is reachable
|
||||
if [[ -d "$node" ]]; then
|
||||
spec="u:frigate:rwx,u:go2rtc:rwx"
|
||||
fi
|
||||
if setfacl -m "$spec" "$node" 2>/dev/null; then
|
||||
granted=$((granted + 1))
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
echo "[WARN] could not grant device access on ${node}; see EXTRA_GROUPS in the non-root docs for the fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
for glob in "${device_globs[@]}"; do
|
||||
# shellcheck disable=SC2231
|
||||
for node in $glob; do
|
||||
grant "$node"
|
||||
done
|
||||
done
|
||||
|
||||
# USB devices re-enumerate (the Coral uploads firmware and reattaches as a new
|
||||
# node), so the directories also get a default ACL new nodes inherit. The
|
||||
# inherited grant is clamped by the creating mode's group bits, which is rw on
|
||||
# udev hosts (0664) and nothing on raw devtmpfs (0600); hardware-verified.
|
||||
if [[ -d /dev/bus/usb ]]; then
|
||||
while IFS= read -r -d '' node; do
|
||||
grant "$node"
|
||||
done < <(find /dev/bus/usb -mindepth 1 -print0)
|
||||
while IFS= read -r -d '' dir; do
|
||||
setfacl -d -m "u:frigate:rw,u:go2rtc:rw" "$dir" 2>/dev/null || \
|
||||
echo "[WARN] could not set a default ACL on ${dir}; a re-enumerating USB device may lose access"
|
||||
done < <(find /dev/bus/usb -type d -print0)
|
||||
fi
|
||||
|
||||
if [[ "$failed" -gt 0 ]]; then
|
||||
echo "[INFO] device access: granted ${granted} node(s), ${failed} failed"
|
||||
elif [[ "$granted" -gt 0 ]]; then
|
||||
echo "[INFO] device access: granted ${granted} node(s) to the runtime users"
|
||||
fi
|
||||
@ -0,0 +1 @@
|
||||
oneshot
|
||||
@ -0,0 +1 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
@ -2,7 +2,7 @@
|
||||
# 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.
|
||||
# or PUID/PGID already match. FRIGATE_ROOT_SERVICES is validated here too.
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
@ -12,10 +12,31 @@ if [[ "$(id -u)" -ne 0 ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
|
||||
if [[ -n "${FRIGATE_ROOT_SERVICES:-}" ]]; then
|
||||
echo "[INFO] FRIGATE_RUN_AS_ROOT=true: ignoring FRIGATE_ROOT_SERVICES"
|
||||
fi
|
||||
echo "[INFO] FRIGATE_RUN_AS_ROOT=true: skipping user remapping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# a typo must fail the boot, not silently drop a service to non-root
|
||||
if [[ -n "${FRIGATE_ROOT_SERVICES:-}" ]]; then
|
||||
IFS=',' read -ra root_services <<< "${FRIGATE_ROOT_SERVICES}"
|
||||
for entry in "${root_services[@]}"; do
|
||||
entry="${entry//[[:space:]]/}"
|
||||
if [[ -z "$entry" ]]; then
|
||||
continue
|
||||
fi
|
||||
case "$entry" in
|
||||
frigate|go2rtc|nginx) ;;
|
||||
*)
|
||||
echo "[ERROR] FRIGATE_ROOT_SERVICES contains unknown service '${entry}'; valid names are frigate, go2rtc, nginx" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
|
||||
puid="${PUID:-1000}"
|
||||
pgid="${PGID:-1000}"
|
||||
|
||||
@ -32,6 +53,15 @@ if [[ "$puid" -eq 0 || "$pgid" -eq 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Colliding with the go2rtc ids would merge the two users and collapse the
|
||||
# separation between the main process and the network-facing restreamer.
|
||||
go2rtc_uid="$(id -u go2rtc)"
|
||||
go2rtc_gid="$(id -g go2rtc)"
|
||||
if [[ "$puid" -eq "$go2rtc_uid" || "$pgid" -eq "$go2rtc_gid" ]]; then
|
||||
echo "[ERROR] PUID/PGID must not equal the go2rtc service ids (${go2rtc_uid}:${go2rtc_gid})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current_uid="$(id -u frigate)"
|
||||
current_gid="$(id -g frigate)"
|
||||
|
||||
@ -50,6 +80,10 @@ 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 ! [[ "$gid" =~ ^[0-9]+$ ]] || [[ "$gid" -eq 0 ]]; then
|
||||
echo "[ERROR] EXTRA_GROUPS must be nonzero numeric GIDs, got '${gid}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! getent group "$gid" >/dev/null; then
|
||||
groupadd -o -g "$gid" "frigate-extra-${gid}"
|
||||
fi
|
||||
|
||||
@ -2,4 +2,4 @@
|
||||
set -e
|
||||
|
||||
# Wait for PID file to exist.
|
||||
while ! test -f /run/nginx.pid; do sleep 1; done
|
||||
while ! test -f /tmp/nginx/nginx.pid; do sleep 1; done
|
||||
|
||||
@ -4,6 +4,13 @@
|
||||
|
||||
set -o errexit -o nounset -o pipefail
|
||||
|
||||
runs_as_root=0
|
||||
if [[ "$(id -u)" -eq 0 ]]; then
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]] || /usr/local/bin/service-runs-as-root nginx; then
|
||||
runs_as_root=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
echo "[INFO] Starting NGINX..."
|
||||
@ -59,10 +66,18 @@ function set_worker_processes() {
|
||||
cpus=4
|
||||
fi
|
||||
|
||||
# we need to catch any errors because sed will fail if user has bind mounted a custom nginx file
|
||||
sed -i "s/worker_processes auto;/worker_processes ${cpus};/" /usr/local/nginx/conf/nginx.conf || true
|
||||
sed -i "s/worker_processes auto;/worker_processes ${cpus};/" /tmp/nginx/conf/nginx.conf
|
||||
}
|
||||
|
||||
# Rebuilt root-owned every start: a symlink planted by the previously
|
||||
# unprivileged nginx would redirect the root cp/tempio writes below onto any
|
||||
# root file. rm does not traverse symlinks; the bare mkdir fails closed if raced.
|
||||
rm -rf /tmp/nginx
|
||||
mkdir /tmp/nginx
|
||||
mkdir -p /tmp/nginx/conf /tmp/nginx/client_body /tmp/nginx/proxy \
|
||||
/tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi
|
||||
cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
|
||||
|
||||
set_worker_processes
|
||||
|
||||
# ensure the directory for ACME challenges exists
|
||||
@ -87,15 +102,37 @@ nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||
echo "$nginx_settings" | \
|
||||
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
|
||||
-out /usr/local/nginx/conf/base_path.conf
|
||||
-out /tmp/nginx/conf/base_path.conf
|
||||
|
||||
# build templates for additional network settings
|
||||
echo "$nginx_settings" | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /usr/local/nginx/conf/listen.conf
|
||||
-out /tmp/nginx/conf/listen.conf
|
||||
|
||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
chown -R frigate:frigate /tmp/nginx
|
||||
# heal the cache: a root `nginx -t` chowns every cycle path to the `user` directive user
|
||||
if [ -d /dev/shm/nginx_cache ]; then
|
||||
chown -R frigate:frigate /dev/shm/nginx_cache
|
||||
fi
|
||||
# nginx reopens /dev/stdout by path for its logs, and s6 made the pipe
|
||||
# root-owned 0600; without this the non-root master exits EACCES
|
||||
chown frigate /dev/stdout
|
||||
# self-signed certs are root-generated; tolerant because mounted certs may be :ro
|
||||
if [ -f "$letsencrypt_path/privkey.pem" ]; then
|
||||
chown frigate:frigate "$letsencrypt_path/privkey.pem" "$letsencrypt_path/fullchain.pem" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Replace the bash process with the NGINX process, redirecting stderr to stdout
|
||||
exec 2>&1
|
||||
exec \
|
||||
s6-notifyoncheck -t 30000 -n 1 \
|
||||
nginx
|
||||
# -e stderr: the compiled-in error log path is not writable by the runtime user
|
||||
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||
exec \
|
||||
s6-notifyoncheck -t 30000 -n 1 \
|
||||
nginx -e stderr -c /tmp/nginx/conf/nginx.conf
|
||||
else
|
||||
exec \
|
||||
s6-notifyoncheck -t 30000 -n 1 \
|
||||
s6-setuidgid frigate nginx -e stderr -c /tmp/nginx/conf/nginx.conf
|
||||
fi
|
||||
|
||||
@ -153,7 +153,50 @@ 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 \
|
||||
# Only when a mount backs /media/frigate itself: under a parent /media
|
||||
# mount, a dedicated volume added later would be shadowed and skipped
|
||||
sentinel_args=(--sentinel /config/.permissions_version)
|
||||
root_services_mode=""
|
||||
if [[ -n "${FRIGATE_ROOT_SERVICES:-}" ]]; then
|
||||
# || true: an all-empty list (",") fails grep -v and errexit would kill the boot
|
||||
root_services_mode=$(tr ',' '\n' <<< "${FRIGATE_ROOT_SERVICES//[[:space:]]/}" | grep -v '^$' | sort -u | paste -sd, - || true)
|
||||
if [[ -n "$root_services_mode" ]]; then
|
||||
sentinel_args+=(--mode "$root_services_mode")
|
||||
fi
|
||||
fi
|
||||
if ! awk '$2 == "/media/frigate" || $2 ~ /^\/media\/frigate\//' /proc/mounts | grep -q .; then
|
||||
sentinel_args=()
|
||||
fi
|
||||
/usr/local/bin/fix-ownership "${sentinel_args[@]}" \
|
||||
"${PUID:-1000}" "${PGID:-1000}" /config /media/frigate
|
||||
|
||||
# Root services write clips stragglers and caches mid-run; realign the
|
||||
# small trees every boot. Recordings are chowned at create instead.
|
||||
if [[ -n "$root_services_mode" ]]; then
|
||||
# only sweep what exists; clips and exports appear after the first run
|
||||
boot_sweep_paths=(/config)
|
||||
for extra in /media/frigate/clips /media/frigate/exports; do
|
||||
if [[ -d "$extra" ]]; then
|
||||
boot_sweep_paths+=("$extra")
|
||||
fi
|
||||
done
|
||||
/usr/local/bin/fix-ownership \
|
||||
"${PUID:-1000}" "${PGID:-1000}" "${boot_sweep_paths[@]}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Must stay after the sweep, which reads an absent /media/frigate as an
|
||||
# unmounted volume rather than a swept one
|
||||
if [[ "$(id -u)" -eq 0 && ! -d /media/frigate ]]; then
|
||||
mkdir -p /media/frigate
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /media/frigate
|
||||
fi
|
||||
fi
|
||||
|
||||
# usually a tmpfs mount: root-owned on arrival and outside the swept volumes
|
||||
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
mkdir -p /tmp/cache
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /tmp/cache
|
||||
fi
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
#!/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...]
|
||||
# Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] 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)
|
||||
# --mode append STRING to the sentinel, so changing it re-sweeps once
|
||||
#
|
||||
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
|
||||
# lost+found is skipped: fsck fills it with root-only recovered fragments.
|
||||
# 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
|
||||
@ -22,10 +24,11 @@ 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
|
||||
schema=2
|
||||
|
||||
dry_run=0
|
||||
sentinel=""
|
||||
mode=""
|
||||
|
||||
while [[ "${1:-}" == --* ]]; do
|
||||
case "$1" in
|
||||
@ -36,12 +39,18 @@ while [[ "${1:-}" == --* ]]; do
|
||||
exit 2
|
||||
fi
|
||||
sentinel="$2"; shift 2 ;;
|
||||
--mode)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "[ERROR] fix-ownership: --mode requires a value" >&2
|
||||
exit 2
|
||||
fi
|
||||
mode="$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
|
||||
echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] [--mode STRING] UID GID PATH..." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
@ -54,11 +63,21 @@ if [[ "$(id -u)" -ne 0 ]]; then
|
||||
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
|
||||
# The list folds into the sentinel so entering or leaving a granular root mode
|
||||
# re-sweeps once, catching whatever the other ownership mechanisms missed.
|
||||
sentinel_content="${schema}:${target_uid}:${target_gid}"
|
||||
if [[ -n "$mode" ]]; then
|
||||
sentinel_content="${sentinel_content}:${mode}"
|
||||
fi
|
||||
|
||||
# safe-sentinel reports only a root-owned regular file, so a forged or
|
||||
# symlinked sentinel in the runtime-user-owned /config can't suppress the sweep
|
||||
if [[ "$dry_run" -eq 0 && -n "$sentinel" ]]; then
|
||||
if existing=$(/usr/local/bin/safe-sentinel read "$sentinel" 2>/dev/null) && \
|
||||
[[ "$existing" == "$sentinel_content" ]]; then
|
||||
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# A sweep that could not chown everything must not be recorded as complete:
|
||||
@ -66,6 +85,26 @@ fi
|
||||
# unreachable once services run unprivileged.
|
||||
swept_clean=1
|
||||
|
||||
# Entries another mechanism deliberately owns. Chowning them undoes that work
|
||||
# and leaves the same "mismatch" waiting for the next boot, so /config could
|
||||
# never report itself clean: /config is chgrp'd to frigate-data below so go2rtc
|
||||
# can traverse it, and the HomeKit file is handed to the go2rtc user by the
|
||||
# go2rtc service. Only the GROUP on /config is exempt; a root-owned /config
|
||||
# must still be chowned or the runtime user cannot write there at all.
|
||||
# Shared by the counting and the chowning walk so the two cannot disagree.
|
||||
mismatch_expr=(
|
||||
"(" -not -uid "$target_uid"
|
||||
-o "(" -not -gid "$target_gid" -a ! -path /config ")"
|
||||
")"
|
||||
-a ! -path /config/go2rtc_homekit.yml
|
||||
)
|
||||
if [[ -n "$sentinel" ]]; then
|
||||
# safe-sentinel keeps the sentinel root-owned on purpose and rejects one
|
||||
# owned by anybody else, so chowning it here would suppress the skip and
|
||||
# make every boot re-sweep. Only the trailing write puts it back today.
|
||||
mismatch_expr+=(-a ! -path "$sentinel")
|
||||
fi
|
||||
|
||||
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
|
||||
@ -76,11 +115,13 @@ for path in "$@"; do
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[INFO] fix-ownership: scanning ${path} for ownership mismatches; this may take a while on large filesystems"
|
||||
|
||||
# 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
|
||||
if ! count=$(find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" -printf '.' 2>/dev/null | wc -c); then
|
||||
swept_clean=0
|
||||
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
||||
continue
|
||||
@ -98,17 +139,41 @@ for path in "$@"; do
|
||||
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"
|
||||
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}"
|
||||
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}" {} + || {
|
||||
# -execdir chowns from the entry's own directory, so a parent swapped for a
|
||||
# symlink mid-walk can't redirect the chown out of the volume
|
||||
started=$SECONDS
|
||||
if find "$path" -name lost+found -prune -o "${mismatch_expr[@]}" \
|
||||
-print -execdir chown -h "${target_uid}:${target_gid}" {} + \
|
||||
| awk -v total="$count" -v path="$path" '
|
||||
BEGIN { next_pct = 5 }
|
||||
{
|
||||
pct = int(NR * 100 / total)
|
||||
if (pct > 100) pct = 100
|
||||
if (pct >= next_pct) {
|
||||
printf "[INFO] fix-ownership: %s %d%% (%d/%d entries)\n", path, pct, NR, total
|
||||
# mawk block-buffers to a pipe; without fflush the whole
|
||||
# progress log arrives at once
|
||||
fflush()
|
||||
while (next_pct <= pct) next_pct += 5
|
||||
}
|
||||
}'; then
|
||||
elapsed=$((SECONDS - started))
|
||||
if [[ "$elapsed" -ge 60 ]]; then
|
||||
elapsed="$((elapsed / 60))m $((elapsed % 60))s"
|
||||
else
|
||||
elapsed="${elapsed}s"
|
||||
fi
|
||||
echo "[INFO] fix-ownership: finished ${path} in ${elapsed}"
|
||||
else
|
||||
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"
|
||||
}
|
||||
fi
|
||||
done
|
||||
|
||||
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
|
||||
@ -124,6 +189,6 @@ if [[ "$dry_run" -eq 0 && -d /config ]]; then
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
|
||||
echo "${schema}:${target_uid}:${target_gid}" > "$sentinel" || \
|
||||
/usr/local/bin/safe-sentinel write "$sentinel" "$sentinel_content" || \
|
||||
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
|
||||
fi
|
||||
|
||||
74
docker/main/rootfs/usr/local/bin/safe-sentinel
Executable file
74
docker/main/rootfs/usr/local/bin/safe-sentinel
Executable file
@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read or write the ownership sweep sentinel without following symlinks.
|
||||
|
||||
The sentinel lives in /config, which the unprivileged runtime user owns, so it
|
||||
can be swapped for a symlink. read trusts only a root-owned regular file; write
|
||||
never follows a symlink or fifo onto another file.
|
||||
|
||||
Usage:
|
||||
safe-sentinel read PATH print content, exit 0 only if root-owned regular file
|
||||
safe-sentinel write PATH CONTENT write CONTENT to a regular file at PATH
|
||||
"""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
MODE = 0o644
|
||||
|
||||
|
||||
def do_read(path: str) -> int:
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
except OSError:
|
||||
return 1
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_uid != 0:
|
||||
return 1
|
||||
sys.stdout.buffer.write(os.read(fd, 4096))
|
||||
finally:
|
||||
os.close(fd)
|
||||
return 0
|
||||
|
||||
|
||||
def do_write(path: str, content: str) -> int:
|
||||
# O_NONBLOCK so a fifo fails fast (ENXIO) instead of blocking the open.
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||
replace = (errno.ELOOP, errno.ENXIO)
|
||||
try:
|
||||
fd = os.open(path, flags, MODE)
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
os.close(fd)
|
||||
raise OSError(errno.ELOOP, "not a regular file")
|
||||
except OSError as err:
|
||||
if err.errno not in replace:
|
||||
raise
|
||||
os.unlink(path)
|
||||
fd = os.open(path, flags | os.O_EXCL, MODE)
|
||||
try:
|
||||
os.ftruncate(fd, 0)
|
||||
os.write(fd, content.encode())
|
||||
# keep it root-owned so a later sweep that chowned the old sentinel to
|
||||
# the runtime user can't make the next read reject and re-sweep
|
||||
os.fchown(fd, 0, 0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) == 3 and argv[1] == "read":
|
||||
return do_read(argv[2])
|
||||
if len(argv) == 4 and argv[1] == "write":
|
||||
try:
|
||||
return do_write(argv[2], argv[3])
|
||||
except OSError:
|
||||
return 1
|
||||
print("usage: safe-sentinel read PATH | write PATH CONTENT", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
18
docker/main/rootfs/usr/local/bin/service-runs-as-root
Executable file
18
docker/main/rootfs/usr/local/bin/service-runs-as-root
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Exit 0 when FRIGATE_ROOT_SERVICES names the given service. Membership only:
|
||||
# the euid and FRIGATE_RUN_AS_ROOT checks stay in the callers.
|
||||
#
|
||||
# Usage: service-runs-as-root SERVICE
|
||||
|
||||
set -o nounset
|
||||
|
||||
service="${1:?usage: service-runs-as-root SERVICE}"
|
||||
|
||||
IFS=',' read -ra entries <<< "${FRIGATE_ROOT_SERVICES:-}"
|
||||
for entry in "${entries[@]}"; do
|
||||
entry="${entry//[[:space:]]/}"
|
||||
if [[ "$entry" == "$service" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exit 1
|
||||
97
docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py
Normal file
97
docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Normalize the go2rtc HomeKit file and hand it to go2rtc, as root.
|
||||
|
||||
Runs before the drop. The file is in the runtime-user-owned /config, so a
|
||||
planted symlink could redirect the root write or chown onto another file;
|
||||
every operation goes through an O_NOFOLLOW fd to prevent that.
|
||||
|
||||
Usage: prepare_homekit.py PATH [--chown]
|
||||
"""
|
||||
|
||||
import errno
|
||||
import grp
|
||||
import io
|
||||
import os
|
||||
import pwd
|
||||
import stat
|
||||
import sys
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
RUNTIME_OWNER = "go2rtc"
|
||||
SHARED_GROUP = "frigate-data"
|
||||
MODE = 0o664
|
||||
MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def open_nofollow(path: str) -> int:
|
||||
"""Return an fd to a regular file at path, never following a symlink."""
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW
|
||||
try:
|
||||
fd = os.open(path, flags, MODE)
|
||||
except OSError as err:
|
||||
if err.errno != errno.ELOOP:
|
||||
raise
|
||||
os.unlink(path)
|
||||
return os.open(path, flags | os.O_EXCL, MODE)
|
||||
|
||||
# A fifo or other non-regular file would hang or misbehave on read; replace it.
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
os.close(fd)
|
||||
os.unlink(path)
|
||||
return os.open(path, flags | os.O_EXCL, MODE)
|
||||
return fd
|
||||
|
||||
|
||||
def normalize(content: str) -> str:
|
||||
"""Keep only the homekit section, matching the previous yq/jq behavior."""
|
||||
yaml = YAML(typ="safe")
|
||||
try:
|
||||
data = yaml.load(content)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(data, dict) or "homekit" not in data:
|
||||
return ""
|
||||
|
||||
buf = io.StringIO()
|
||||
yaml.dump({"homekit": data["homekit"]}, buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("[ERROR] prepare_homekit: PATH is required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = sys.argv[1]
|
||||
do_chown = "--chown" in sys.argv[2:]
|
||||
|
||||
fd = open_nofollow(path)
|
||||
try:
|
||||
content = os.read(fd, MAX_BYTES).decode("utf-8", "replace")
|
||||
normalized = normalize(content)
|
||||
os.ftruncate(fd, 0)
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.write(fd, normalized.encode("utf-8"))
|
||||
|
||||
if do_chown:
|
||||
# tolerate a chown-refusing mount (NFS root_squash): pairing
|
||||
# persistence degrades, the service does not
|
||||
try:
|
||||
uid = pwd.getpwnam(RUNTIME_OWNER).pw_uid
|
||||
gid = grp.getgrnam(SHARED_GROUP).gr_gid
|
||||
os.fchown(fd, uid, gid)
|
||||
os.fchmod(fd, MODE)
|
||||
except (KeyError, OSError):
|
||||
print(
|
||||
f"[WARN] Could not hand {path} to the go2rtc user; "
|
||||
"HomeKit pairing changes may not persist"
|
||||
)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -1,9 +1,13 @@
|
||||
# Loaded with -c from the /tmp/nginx/conf copy: relative includes follow the -c
|
||||
# file, all other path directives follow --prefix and must stay absolute.
|
||||
|
||||
daemon off;
|
||||
# ignored by a non-root master; keeps workers root under FRIGATE_RUN_AS_ROOT
|
||||
user root;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /dev/stdout warn;
|
||||
pid /var/run/nginx.pid;
|
||||
pid /tmp/nginx/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
@ -13,6 +17,12 @@ http {
|
||||
map_hash_bucket_size 256;
|
||||
server_tokens off;
|
||||
|
||||
client_body_temp_path /tmp/nginx/client_body;
|
||||
proxy_temp_path /tmp/nginx/proxy;
|
||||
fastcgi_temp_path /tmp/nginx/fastcgi;
|
||||
uwsgi_temp_path /tmp/nginx/uwsgi;
|
||||
scgi_temp_path /tmp/nginx/scgi;
|
||||
|
||||
include mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
|
||||
@ -36,6 +36,16 @@ if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
|
||||
fi
|
||||
|
||||
echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)"
|
||||
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||
echo "[INFO] ${IMAGE} is not present locally and has to be pulled first; this may take a while"
|
||||
fi
|
||||
|
||||
if [[ -n "$dry_run_flag" ]]; then
|
||||
echo "[INFO] Dry run: reporting what would change under ${config_dir} and ${media_dir}, changing nothing"
|
||||
else
|
||||
echo "[INFO] Aligning ${config_dir} and ${media_dir} to ${puid}:${pgid}; this may take a while on large filesystems"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
docker run --rm \
|
||||
-v "${config_dir}:/config" \
|
||||
|
||||
@ -13,6 +13,16 @@ TRT_VER=${TRT_VER:-$(cat /etc/TENSORRT_VER)}
|
||||
OUTPUT_FOLDER="${MODEL_CACHE_DIR}/${TRT_VER}"
|
||||
YOLO_MODELS=${YOLO_MODELS:-""}
|
||||
|
||||
# This runs as root after prepare's sentinel-guarded sweep, so the dirs and
|
||||
# engines it creates below are the runtime user's to fix up, on every exit path
|
||||
function hand_off_ownership() {
|
||||
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
/usr/local/bin/fix-ownership "${PUID:-1000}" "${PGID:-1000}" \
|
||||
/config/model_cache "${MODEL_CACHE_DIR}"
|
||||
fi
|
||||
}
|
||||
trap hand_off_ownership EXIT
|
||||
|
||||
# Create output folder
|
||||
mkdir -p ${OUTPUT_FOLDER}
|
||||
|
||||
|
||||
@ -397,6 +397,10 @@ To do this:
|
||||
2. Update the `ffmpeg.path` in your Frigate config to `/config/custom-ffmpeg`.
|
||||
3. Restart Frigate and the custom version will be used if the steps above were done correctly.
|
||||
|
||||
Both binaries have to be executable by Frigate's unprivileged runtime user, so `chmod 755` them after extracting. The startup ownership sweep runs only once, so anything you add to `/config` later keeps whatever ownership and mode you gave it.
|
||||
|
||||
There is one exception, and it only affects [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `frigate`. That mode runs Frigate as root while still handing `/config` to the unprivileged runtime user, so anything running as that user could swap the binary and gain root. A build inside any of Frigate's writable volumes (`/config`, `/media/frigate`, the cache and shm dirs) is ignored there and the bundled one is used, with a warning in the log. Keep the build somewhere root-owned (any absolute `ffmpeg.path` works, so a read-only bind mount such as `/opt/custom-ffmpeg` is enough) if you need both. The default mode and `FRIGATE_RUN_AS_ROOT=true` are unaffected and behave exactly as they always have.
|
||||
|
||||
### Custom go2rtc version
|
||||
|
||||
Frigate currently includes go2rtc v1.9.14, there may be certain cases where you want to run a different version of go2rtc.
|
||||
@ -405,9 +409,11 @@ To do this:
|
||||
|
||||
1. Download the go2rtc build to the `/config` folder.
|
||||
2. Rename the build to `go2rtc`.
|
||||
3. Give `go2rtc` execute permission.
|
||||
3. Give `go2rtc` execute permission for all users (`chmod 755`). It runs as its own `go2rtc` user, which doesn't own the file, so owner-only execute permission isn't enough.
|
||||
4. Restart Frigate and the custom version will be used, you can verify by checking go2rtc logs.
|
||||
|
||||
The same exception applies, and again only to [`FRIGATE_ROOT_SERVICES`](/configuration/non_root#keeping-individual-services-root) listing `go2rtc`: the binary is ignored there and the embedded one is used, with a warning in the log. Unlike `ffmpeg.path`, the go2rtc binary location is not configurable, so there is no outside-`/config` alternative. Use `FRIGATE_RUN_AS_ROOT=true` instead if you need both a custom go2rtc build and root. The default mode and the escape hatch both honor `/config/go2rtc` exactly as they always have.
|
||||
|
||||
## Validating your config.yml file updates
|
||||
|
||||
When frigate starts up, it checks whether your config file is valid, and if it is not, the process exits. To minimize interruptions when updating your config, you have three options -- you can edit the config via the WebUI which has built in validation, use the config API, or you can validate on the command line using the frigate docker container.
|
||||
|
||||
280
docs/docs/configuration/non_root.md
Normal file
280
docs/docs/configuration/non_root.md
Normal file
@ -0,0 +1,280 @@
|
||||
---
|
||||
id: non_root
|
||||
title: Running as a non-root user
|
||||
---
|
||||
|
||||
# Running as a non-root user
|
||||
|
||||
Frigate's services run as an unprivileged user inside the container. The main Frigate process and nginx run as `frigate`, and go2rtc runs as its own more restricted `go2rtc` user. Only the s6 init system and the certsync helper stay root.
|
||||
|
||||
The runtime user is uid/gid `1000:1000` by default. You can change it with `PUID`/`PGID`, or bypass Frigate's user handling entirely with Docker's own `user:`.
|
||||
|
||||
Most upgrades need nothing. Frigate aligns your volume ownership on the first boot and grants access to your hardware at startup. The sections below cover the cases that need attention: large storage volumes, network storage, and hardware the automatic grant can't reach.
|
||||
|
||||
## Run modes
|
||||
|
||||
| Mode | How to enable | Ownership of `/config` and `/media/frigate` | `read_only: true` |
|
||||
| ------------------- | ------------------------------- | ------------------------------------------------------ | ----------------- |
|
||||
| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | Not supported |
|
||||
| `PUID`/`PGID` | `PUID=1001`, `PGID=1001` | Aligned to the values you set, on first boot | Not supported |
|
||||
| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | Not supported |
|
||||
| Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported |
|
||||
| Granular root | `FRIGATE_ROOT_SERVICES=frigate` | Aligned at boot; recordings and exports also at create | Not supported |
|
||||
|
||||
`PUID`/`PGID` remapping runs `usermod` at startup, which writes to `/etc/passwd`, so it can't work with a read-only root filesystem. That combination stops at startup with a message pointing here.
|
||||
|
||||
`FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored. `FRIGATE_DEVICE_ACLS` works the same way: only the lowercase string `false` turns off the automatic device grants.
|
||||
|
||||
### Keeping individual services root
|
||||
|
||||
`FRIGATE_ROOT_SERVICES` takes a comma separated list of `frigate`, `go2rtc`, and `nginx`. A listed service keeps running as root, and everything else about non-root operation still applies: `PUID`/`PGID` remapping, the ownership sweep, and ownership of the files those services create.
|
||||
|
||||
There are two reasons to use it:
|
||||
|
||||
- Your detector hardware won't work as an unprivileged user, even after reading [Hardware device access](#hardware-device-access). `FRIGATE_ROOT_SERVICES=frigate` keeps the main process and its detectors as root while nginx and go2rtc stay unprivileged.
|
||||
- You want everything to run as root but still want your files owned by `PUID`/`PGID` instead of root. `FRIGATE_ROOT_SERVICES=frigate,go2rtc,nginx` does that.
|
||||
|
||||
Try the device grants and `EXTRA_GROUPS` first. The `frigate` service runs the API and every ffmpeg process that decodes your camera streams, so listing it puts those back on root as well, not just your detectors.
|
||||
|
||||
A listed service also stops honoring a [custom ffmpeg or go2rtc build](/configuration/advanced/system#custom-dependencies) kept in `/config`, since that directory stays owned by the unprivileged user and a binary there would run as root. `FRIGATE_RUN_AS_ROOT=true` has no such restriction.
|
||||
|
||||
Recordings and exports are owned by `PUID`/`PGID` as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under `clips/` are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of `/config`.
|
||||
|
||||
Listing all three services is not the same as `FRIGATE_RUN_AS_ROOT=true`. The escape hatch never touches ownership; the list keeps the ownership handling active. A few more details:
|
||||
|
||||
- An unknown name in the list stops the container at startup, rather than silently leaving a service unprivileged.
|
||||
- Changing the list runs the full ownership sweep once on the next boot.
|
||||
- If both are set, `FRIGATE_RUN_AS_ROOT=true` wins and the list is ignored.
|
||||
- With Docker's `user:`, the list does nothing, since the container never has root to keep.
|
||||
|
||||
## Migrating an existing install
|
||||
|
||||
Volumes from earlier versions of Frigate are owned by root, so ownership has to be aligned with the runtime user once. This happens automatically on the first boot after upgrading.
|
||||
|
||||
On large recordings volumes, do it from the host beforehand instead. The boot sweep runs before any service starts, so a multi-terabyte `/media/frigate` can hold the container in startup long enough for Docker's healthcheck to mark it unhealthy, and orchestrators that watch health will restart it mid-sweep. If you'd rather not run the script, raise the healthcheck start period instead (`--start-period=1800s`, or `start_period: 1800s` under `healthcheck:` in compose).
|
||||
|
||||
Grab [`fix-permissions.sh`](https://github.com/blakeblackshear/frigate/blob/dev/docker/migration/fix-permissions.sh) from the Frigate repo and dry run it first:
|
||||
|
||||
```bash
|
||||
./fix-permissions.sh --dry-run /path/to/your/config /path/to/your/storage
|
||||
```
|
||||
|
||||
That reports how many entries would change and touches nothing. When it looks right, run it without `--dry-run`:
|
||||
|
||||
```bash
|
||||
./fix-permissions.sh /path/to/your/config /path/to/your/storage
|
||||
```
|
||||
|
||||
Pass `PUID` and `PGID` as the third and fourth arguments if you're not using the default `1000:1000`. The script wraps the same helper the container uses, so the result is identical either way. Override the image it pulls with `FRIGATE_IMAGE=...` if you're not on `stable`.
|
||||
|
||||
Both the script and the boot sweep report progress, so you can tell a slow sweep from a stuck one:
|
||||
|
||||
```
|
||||
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
|
||||
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
|
||||
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
|
||||
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
|
||||
[INFO] fix-ownership: finished /media/frigate in 12m 4s
|
||||
```
|
||||
|
||||
The scan has no percentage because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
|
||||
|
||||
Once the volumes are aligned, start Frigate normally. A file at `/config/.permissions_version` records what was done, so later boots skip the sweep unless you change `PUID`/`PGID`.
|
||||
|
||||
If something under your volumes can't be chowned, a read-only btrfs snapshot directory for example, the sweep warns and names the path and doesn't record the migration as finished. It retries on the next boot instead. Either move those paths outside `/media/frigate` or expect the scan to repeat.
|
||||
|
||||
### Network storage
|
||||
|
||||
Recordings on a NAS behave differently, so check what you have before migrating:
|
||||
|
||||
```bash
|
||||
findmnt -T /path/to/your/storage -o TARGET,FSTYPE,OPTIONS
|
||||
```
|
||||
|
||||
**SMB and CIFS** don't store per-file ownership at all. It's synthesized from the mount options, so a per-file `chown` fails and isn't needed. Mount the share as the uid and gid Frigate runs as, and every file already looks correct to the sweep:
|
||||
|
||||
```
|
||||
//nas/frigate /media/frigate cifs credentials=/root/.smb,uid=1000,gid=1000,file_mode=0664,dir_mode=0775 0 0
|
||||
```
|
||||
|
||||
**NFS** exports default to `root_squash` on most servers, which maps the container's root to `nobody`. The chown then fails, you get `[WARN] fix-ownership: some entries under /media/frigate could not be updated`, and since the sweep didn't finish it doesn't record the migration, so it retries on every boot.
|
||||
|
||||
The best fix is to not chown over NFS at all. Do it on the server, where there's no squash and no network round trip per file:
|
||||
|
||||
```bash
|
||||
# on the NAS itself, against the exported directory
|
||||
chown -R 1000:1000 /export/frigate
|
||||
```
|
||||
|
||||
Frigate's sweep then finds nothing to change and records the migration normally. If you can't get a shell on the server, you can export temporarily with `no_root_squash`, migrate, and put it back, or leave ownership alone and set `PUID`/`PGID` to whichever uid already owns the files.
|
||||
|
||||
Either way the uid has to mean the same thing on both machines. NFS sends numeric uids, so container uid 1000 is uid 1000 on the server no matter what the usernames are.
|
||||
|
||||
Expect the first boot to be slow even when nothing needs changing, because checking ownership costs a round trip per file. That's a one-time cost. **If the sweep runs on every boot rather than once, ownership isn't actually being applied**, and the warning above will say so.
|
||||
|
||||
Keep `/config` on local storage either way. Frigate's database is SQLite and network shares handle its locking poorly. That's a long-standing recommendation, not something running non-root introduces.
|
||||
|
||||
## Rolling back
|
||||
|
||||
Set `FRIGATE_RUN_AS_ROOT=true` and restart. Everything runs as root again, exactly as it did before. This is the fastest way to get a broken install running while you sort out a device permission problem.
|
||||
|
||||
The escape hatch never changes ownership, and it clears the record of the last sweep on startup, so switching back to non-root later corrects whatever root created in the meantime. Toggling in either direction is safe.
|
||||
|
||||
## Hardware device access
|
||||
|
||||
Frigate grants the runtime user access to your devices at startup. Pass your hardware with `--device` (or `devices:` in compose) and detection and hardware acceleration work with no group or udev setup on the host.
|
||||
|
||||
The grant covers the common accelerator and camera nodes: GPU render nodes, Coral, Hailo, Rockchip, Jetson, `/dev/video*`, and the USB bus. For hardware it misses, add your own paths with `DEVICE_ACL_PATHS`, a comma separated list of globs:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DEVICE_ACL_PATHS: "/dev/mydev*"
|
||||
```
|
||||
|
||||
Set `FRIGATE_DEVICE_ACLS=false` if you manage device permissions yourself and want Frigate to leave them alone.
|
||||
|
||||
Frigate grants access by adding an ACL entry for the runtime users. The device's owner and mode are unchanged, and nothing is made world accessible. One thing to know: `--device` nodes belong to the container, but a bind mounted `/dev/bus/usb` (the usual Coral USB setup) shares the host's device nodes, so the entry is visible on the host until udev recreates the node.
|
||||
|
||||
### Manual setup
|
||||
|
||||
You only need this for hardware the automatic grant can't reach, or for Docker's `user:` mode, where there's no root startup to do the granting.
|
||||
|
||||
Your accelerator most likely worked in older versions because Frigate ran as root. Device nodes are usually owned by `root:root`, and root either matches the group or skips the check entirely. The runtime user does neither, so a device that worked before can become unreadable with no change to your Frigate config.
|
||||
|
||||
#### Read what your device requires
|
||||
|
||||
Find the node and look at its owner, group, and mode:
|
||||
|
||||
```bash
|
||||
ls -ln /dev/dri/renderD128
|
||||
crw-rw---- 1 0 105 226, 128 Jul 5 10:12 /dev/dri/renderD128
|
||||
# ^ ^ ^
|
||||
# | | group GID 105
|
||||
# | owner UID 0 (root)
|
||||
# mode: owner rw, group rw, other none
|
||||
```
|
||||
|
||||
Then work out which of the three permission sets applies to the runtime user. It isn't the owner, since that's root, so it gets the group bits if it belongs to that GID and otherwise falls through to "other". In the example above "other" is empty, so without membership in group 105 the runtime user can't open the node.
|
||||
|
||||
Watch for a node that looks permissive but isn't. A USB Coral defaults to this:
|
||||
|
||||
```bash
|
||||
ls -ln /dev/bus/usb/004/003
|
||||
crw-rw-r-- 1 0 0 189, 386 Jul 5 10:12 /dev/bus/usb/004/003
|
||||
```
|
||||
|
||||
The group is `0`, so "other" applies to the runtime user, and "other" here is read only. `libedgetpu` needs to write to the node, so detection fails with `No EdgeTPU was detected` as though no Coral were attached. Read access alone isn't enough for most accelerators.
|
||||
|
||||
#### Grant access
|
||||
|
||||
Give the runtime user the GID with `EXTRA_GROUPS`, a comma separated list of numeric host GIDs. They're added to both the `frigate` and `go2rtc` users, which matters because go2rtc needs its own render and video access for hardware accelerated restreams.
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
EXTRA_GROUPS: "105,44" # host render and video GIDs
|
||||
```
|
||||
|
||||
Use numeric GIDs from the host, not names. Group names don't have to match between the host and the container, and the kernel only checks the number. If the GID doesn't exist in the image, Frigate creates a placeholder group for it.
|
||||
|
||||
Two things that look like they should work but don't:
|
||||
|
||||
- Docker's `group_add` has no effect in the default or `PUID` modes. Frigate rebuilds the supplementary group list from `/etc/group` when it drops privileges, which discards what Docker passed in. It is the right tool with Docker's `user:`, where no privilege drop happens and `EXTRA_GROUPS` does nothing.
|
||||
- `privileged: true` doesn't help. It grants capabilities to root, and the runtime user isn't root, so the file permissions on the node still apply.
|
||||
|
||||
If the node's group is `root` or the mode denies the group, no `EXTRA_GROUPS` value will help. You need a udev rule first.
|
||||
|
||||
#### Verify access
|
||||
|
||||
Check the group landed, then check the runtime user can open the node. Test for write, not just read:
|
||||
|
||||
```bash
|
||||
docker exec frigate id frigate
|
||||
docker exec frigate /command/s6-setuidgid frigate sh -c 'test -w /dev/dri/renderD128 && echo ok'
|
||||
docker exec frigate /command/s6-setuidgid go2rtc sh -c 'test -w /dev/dri/renderD128 && echo ok'
|
||||
```
|
||||
|
||||
A permission check is only a proxy for the driver working. These exercise the real libraries as the runtime user:
|
||||
|
||||
```bash
|
||||
docker exec frigate /command/s6-setuidgid frigate vainfo
|
||||
docker exec frigate /command/s6-setuidgid frigate python3 -c "import openvino as ov; print(ov.Core().available_devices)"
|
||||
```
|
||||
|
||||
`vainfo` should reach `va_openDriver() returns 0` and list profiles. Complaints about `XDG_RUNTIME_DIR` or an X server above that are normal. OpenVINO should list `GPU`; if it returns only `CPU`, detection has fallen back and inference will be much slower without an error in the log.
|
||||
|
||||
To tell a permissions problem from anything else, start the container once with `FRIGATE_RUN_AS_ROOT=true`. If the device works as root and not otherwise, it's node permissions and a udev rule is the fix. If it's missing either way, the problem is your device mapping or the host, and isn't related to running non-root.
|
||||
|
||||
#### udev rules by device
|
||||
|
||||
Rules go in `/etc/udev/rules.d/` on the host and take effect after:
|
||||
|
||||
```bash
|
||||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||||
```
|
||||
|
||||
A device that's already connected sometimes keeps its original ownership through a trigger. If `ls -ln` doesn't show the new group, replug it, or reboot for a built-in device.
|
||||
|
||||
**Coral USB** needs two rules, because the device re-enumerates after loading firmware. It appears as Global Unichip `1a6e` before and Google `18d1` after, with a different node each time. A rule covering only `1a6e` gives you a Coral that starts up once and then disappears mid-run.
|
||||
|
||||
```
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", GROUP="plugdev", MODE="0664"
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", GROUP="plugdev", MODE="0664"
|
||||
```
|
||||
|
||||
Map the whole `/dev/bus/usb` rather than a single node, for the same reason. Most hosts put `plugdev` at GID 46 and the image agrees, so a USB Coral often needs no `EXTRA_GROUPS` entry. Confirm with `getent group plugdev` and add the number if your host differs.
|
||||
|
||||
**Coral PCIe** is often `crw------- root root`, which only root can open:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="apex", MODE="0660", GROUP="apex"
|
||||
```
|
||||
|
||||
Create the group with `sudo groupadd -f apex`, then add its GID to `EXTRA_GROUPS`.
|
||||
|
||||
**Hailo** works the same way. Grant `/dev/hailo0` a group and add that GID:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hailo_chardev", MODE="0660", GROUP="hailo"
|
||||
```
|
||||
|
||||
**Intel and AMD GPUs** usually need nothing beyond `EXTRA_GROUPS`, since most distributions ship a `render` group that owns `/dev/dri/renderD128`. The GID often differs between the host and the image, so pass the host's number rather than assuming the name resolves. Debian based images have no `render` group at all.
|
||||
|
||||
#### Quick reference
|
||||
|
||||
What each device needs when you're setting it up by hand. The automatic grant covers most of these already, so start here only if it didn't.
|
||||
|
||||
| Hardware | Device(s) | What non-root needs |
|
||||
| ------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Intel/AMD GPU (VAAPI/QSV) | `/dev/dri/renderD128` | Host render GID in `EXTRA_GROUPS`, from `getent group render` |
|
||||
| Intel/AMD NPU | `/dev/accel` | udev rule granting a group, then that GID in `EXTRA_GROUPS` |
|
||||
| Coral USB | `/dev/bus/usb` | udev rules for both `1a6e` and `18d1`; usually already covered by `plugdev` 46 |
|
||||
| Coral PCIe | `/dev/apex_0` | udev rule granting a group, then that GID in `EXTRA_GROUPS` |
|
||||
| Hailo | `/dev/hailo0` | udev rule granting a group, then that GID in `EXTRA_GROUPS` |
|
||||
| NVIDIA | nvidia runtime | Nothing, works with the nvidia-container-toolkit defaults |
|
||||
| AMD ROCm | `/dev/kfd`, `/dev/dri` | Host `video` and `render` GIDs in `EXTRA_GROUPS` |
|
||||
| Raspberry Pi | `/dev/video11` | Host `video` GID in `EXTRA_GROUPS` |
|
||||
| Rockchip | `/dev/dri`, `/dev/dma_heap`, `/dev/rga`, `/dev/mpp_service` | Commonly `root:root` `0600`, so all four need udev rules. If you can't grant all four, use `FRIGATE_RUN_AS_ROOT` |
|
||||
| Axera (AXCL) | `/dev/ax_*` per the AXCL driver docs | Unverified. Check node ownership on your hardware before assuming this works |
|
||||
| Synaptics SL1680 | per the Synaptics docs | Unverified |
|
||||
| MemryX | per the MemryX docs | Still requires `privileged: true`, which means root. Out of scope for non-root operation |
|
||||
| Nvidia Jetson | nvidia runtime plus Jetson nodes | Unverified. The nvidia runtime handles mapping, but check `/dev/nvhost-*` ownership on your board |
|
||||
| VeriSilicon NPU (Teflon) | per the driver, commonly `/dev/galcore` | Unverified. Check node ownership on your hardware before assuming this works |
|
||||
| CPU detector | none | Nothing, no device is opened |
|
||||
| ZMQ detector | none | Nothing, inference happens over a socket |
|
||||
| Apple Silicon | none | Nothing, the NPU client runs on the host and Frigate reaches it over the network |
|
||||
|
||||
## Known limitations
|
||||
|
||||
`telemetry.stats.network_bandwidth` uses nethogs, which needs `CAP_NET_ADMIN` and `CAP_NET_RAW` and therefore root. The stat is turned off automatically when Frigate isn't running as root, with one warning in the log. Use `FRIGATE_ROOT_SERVICES=frigate` (or `FRIGATE_RUN_AS_ROOT=true`) if you need it.
|
||||
|
||||
go2rtc's ffmpeg processes no longer appear in Intel GPU stats. Frigate reads per-process GPU usage from `/proc/<pid>/fdinfo`, which the kernel won't let one user read for another user's processes, so anything go2rtc spawns is invisible to it. Overall GPU utilization is unaffected.
|
||||
|
||||
If you mount your own TLS certificate at `/etc/letsencrypt/live/frigate`, the private key has to be readable by the runtime user, which runs nginx. Frigate hands the key to that user at startup if the mount is writable; on a read-only mount, make the key readable by uid 1000 (or your `PUID`) yourself.
|
||||
|
||||
If you're debugging nginx, run the config check as the runtime user with stdout discarded:
|
||||
|
||||
```bash
|
||||
docker exec frigate /command/s6-setuidgid frigate bash -c 'nginx -t -c /tmp/nginx/conf/nginx.conf >/dev/null'
|
||||
```
|
||||
|
||||
Running `nginx -t` as root hands nginx's runtime directories to root as a side effect, which breaks the running workers until the service restarts, and the config's `/dev/stdout` logs can't be reopened through a root-owned `docker exec` pipe. The results print on stderr either way.
|
||||
@ -548,9 +548,7 @@ services:
|
||||
|
||||
### Recommended security options
|
||||
|
||||
Frigate does not need elevated container privileges for most setups. The
|
||||
following hardens the container; add the `devices`/`group_add` entries your
|
||||
hardware requires (see the hardware acceleration docs):
|
||||
Frigate does not need elevated container privileges for most setups. The following hardens the container; add the `devices`/`group_add` entries your hardware requires (see the hardware acceleration docs):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@ -564,15 +562,14 @@ services:
|
||||
|
||||
:::note
|
||||
|
||||
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with
|
||||
NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]`
|
||||
or add `cap_add: [NET_ADMIN, NET_RAW]`.
|
||||
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]` or add `cap_add: [NET_ADMIN, NET_RAW]`.
|
||||
|
||||
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups)
|
||||
are called out in their own sections and are unaffected by this guidance.
|
||||
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) are called out in their own sections and are unaffected by this guidance.
|
||||
|
||||
:::
|
||||
|
||||
Frigate's services run as an unprivileged user inside the container. See [Running as a non-root user](../configuration/non_root.md) for the run modes, the one time volume ownership migration, and what each accelerator needs on the host.
|
||||
|
||||
**Docker CLI**
|
||||
|
||||
If you can't use Docker Compose, you can run the container with something similar to this:
|
||||
|
||||
@ -122,6 +122,7 @@ const sidebars: SidebarsConfig = {
|
||||
"configuration/ffmpeg_presets",
|
||||
"configuration/pwa",
|
||||
"configuration/tls",
|
||||
"configuration/non_root",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -231,6 +231,17 @@ class FrigateApp:
|
||||
|
||||
migrate_db.close()
|
||||
|
||||
# a root frigate service creates these as root; wal and shm recreated
|
||||
# later in the run are realigned by the per-boot /config sweep
|
||||
for db_file in (
|
||||
self.config.database.path,
|
||||
f"{self.config.database.path}-wal",
|
||||
f"{self.config.database.path}-shm",
|
||||
self.config.database.path.replace("frigate.db", "backup.db"),
|
||||
):
|
||||
if os.path.exists(db_file):
|
||||
chown_to_runtime(db_file)
|
||||
|
||||
def init_go2rtc(self) -> None:
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
if proc.info["name"] == "go2rtc":
|
||||
|
||||
@ -23,6 +23,7 @@ from frigate.ffmpeg_presets import (
|
||||
)
|
||||
from frigate.models import Previews
|
||||
from frigate.util.image import copy_yuv_to_position, get_blank_yuv_frame, get_yuv_crop
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -185,6 +186,7 @@ class FFMpegConverter(threading.Thread):
|
||||
|
||||
if p.returncode == 0:
|
||||
logger.debug("successfully saved preview")
|
||||
chown_to_runtime(self.path)
|
||||
self.requestor.send_data(
|
||||
INSERT_PREVIEW,
|
||||
{
|
||||
|
||||
@ -35,6 +35,7 @@ from frigate.ffmpeg_presets import (
|
||||
)
|
||||
from frigate.models import Export, Previews, Recordings, ReviewSegment
|
||||
from frigate.util.ffmpeg import run_ffmpeg_with_progress
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.time import is_current_hour
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -940,6 +941,8 @@ class RecordingExporter(threading.Thread):
|
||||
else:
|
||||
video_path = f"{EXPORT_DIR}/{self.camera}_{filename_start_datetime}-{filename_end_datetime}_{cleaned_export_id}.mp4"
|
||||
thumb_path = self.save_thumbnail(self.export_id)
|
||||
if thumb_path:
|
||||
chown_to_runtime(thumb_path)
|
||||
|
||||
export_values = {
|
||||
Export.id: self.export_id,
|
||||
@ -1014,6 +1017,7 @@ class RecordingExporter(threading.Thread):
|
||||
Path(thumb_path).unlink(missing_ok=True)
|
||||
return
|
||||
else:
|
||||
chown_to_runtime(video_path)
|
||||
self._emit_progress("finalizing", 100.0)
|
||||
Export.update({Export.in_progress: False}).where(
|
||||
Export.id == self.export_id
|
||||
|
||||
@ -43,6 +43,7 @@ from frigate.const import (
|
||||
from frigate.models import Recordings, ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.media import get_keyframe_offsets
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
from frigate.util.services import get_video_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -928,6 +929,11 @@ class RecordingMaintainer(threading.Thread):
|
||||
)
|
||||
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
# own every level makedirs creates so the host user can prune recordings
|
||||
level = directory
|
||||
while level != RECORD_DIR:
|
||||
chown_to_runtime(level)
|
||||
level = os.path.dirname(level)
|
||||
|
||||
# file will be in utc due to path_time being in utc
|
||||
file_name = f"{path_time.strftime('%M.%S.mp4')}"
|
||||
@ -966,6 +972,8 @@ class RecordingMaintainer(threading.Thread):
|
||||
f"Copied {file_path} in {datetime.datetime.now().timestamp() - start_frame} seconds."
|
||||
)
|
||||
|
||||
chown_to_runtime(file_path)
|
||||
|
||||
try:
|
||||
# get the segment size of the cache file
|
||||
# file without faststart is same size
|
||||
|
||||
26
frigate/test/test_bandwidth_stats.py
Normal file
26
frigate/test/test_bandwidth_stats.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Tests for bandwidth stats privilege handling."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.util import services
|
||||
|
||||
|
||||
class TestBandwidthStatsPrivileges(unittest.TestCase):
|
||||
def setUp(self):
|
||||
services._bandwidth_warning_logged = False
|
||||
|
||||
@patch("frigate.util.services.sp.run")
|
||||
@patch("frigate.util.services.os.geteuid", return_value=1000)
|
||||
def test_returns_empty_and_warns_once_without_root(self, _, sp_run):
|
||||
config = MagicMock()
|
||||
with self.assertLogs("frigate.util.services", level="WARNING") as logs:
|
||||
assert services.get_bandwidth_stats(config) == {}
|
||||
assert services.get_bandwidth_stats(config) == {}
|
||||
sp_run.assert_not_called()
|
||||
warnings = [m for m in logs.output if "require root" in m]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
frigate/test/test_ffmpeg_path_resolution.py
Normal file
154
frigate/test/test_ffmpeg_path_resolution.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""Tests for custom ffmpeg path resolution and the root-mode guard."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.const import DEFAULT_FFMPEG_VERSION
|
||||
from frigate.util.config import (
|
||||
_warn_ignored_ffmpeg_path,
|
||||
frigate_service_is_granular_root,
|
||||
resolve_ffmpeg_path,
|
||||
)
|
||||
|
||||
BUNDLED = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
|
||||
CUSTOM = "/config/custom-ffmpeg"
|
||||
|
||||
|
||||
class TestConfigFfmpegRootGuard(unittest.TestCase):
|
||||
"""A user-writable ffmpeg must not run as root under FRIGATE_ROOT_SERVICES."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
# the warning is memoized so it fires once per path, not per camera
|
||||
_warn_ignored_ffmpeg_path.cache_clear()
|
||||
|
||||
def _resolve(self, path: str, *, euid: int, env: dict, binary: str = "ffmpeg"):
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return resolve_ffmpeg_path(path, binary)
|
||||
|
||||
def test_custom_path_used_when_service_is_unprivileged(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=1000, env={}), f"{CUSTOM}/bin/ffmpeg"
|
||||
)
|
||||
|
||||
def test_escape_hatch_keeps_working_exactly_as_before(self) -> None:
|
||||
# FRIGATE_RUN_AS_ROOT never sweeps /config and leaves no unprivileged
|
||||
# service, so a custom build there is as safe as it was pre-drop
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=0, env={"FRIGATE_RUN_AS_ROOT": "true"}),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_custom_path_ignored_when_frigate_is_a_root_service(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_ffprobe_is_guarded_too(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
CUSTOM,
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "frigate"},
|
||||
binary="ffprobe",
|
||||
),
|
||||
f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe",
|
||||
)
|
||||
|
||||
def test_another_root_service_does_not_trigger_the_guard(self) -> None:
|
||||
# go2rtc running as root says nothing about who spawns ffmpeg
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc"}),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_root_services_has_no_effect_under_docker_user(self) -> None:
|
||||
# docker's own user: means the service never had root to keep, which is
|
||||
# also the case for get_ffmpeg_path.py in a --user container
|
||||
self.assertEqual(
|
||||
self._resolve(CUSTOM, euid=1000, env={"FRIGATE_ROOT_SERVICES": "frigate"}),
|
||||
f"{CUSTOM}/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_path_outside_config_is_left_alone(self) -> None:
|
||||
# only /config is runtime-user-owned; a root-owned tree stays usable
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
"/opt/custom-ffmpeg", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}
|
||||
),
|
||||
"/opt/custom-ffmpeg/bin/ffmpeg",
|
||||
)
|
||||
|
||||
def test_traversal_out_of_config_does_not_evade_the_guard(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
"/config/../config/custom-ffmpeg",
|
||||
euid=0,
|
||||
env={"FRIGATE_ROOT_SERVICES": "frigate"},
|
||||
),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_media_tree_is_guarded_too(self) -> None:
|
||||
# config.yml is uid-1000-writable, so ffmpeg.path can be pointed at any
|
||||
# writable tree; /config alone would be an evasion, not a guard
|
||||
self.assertEqual(
|
||||
self._resolve(
|
||||
"/media/frigate/evil", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}
|
||||
),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_config_dir_itself_is_guarded(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve("/config", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
def test_default_alias_is_unaffected(self) -> None:
|
||||
self.assertEqual(
|
||||
self._resolve("default", euid=0, env={"FRIGATE_ROOT_SERVICES": "frigate"}),
|
||||
BUNDLED,
|
||||
)
|
||||
|
||||
|
||||
class TestFrigateServiceIsGranularRoot(unittest.TestCase):
|
||||
"""Root via FRIGATE_ROOT_SERVICES only, never via the escape hatch."""
|
||||
|
||||
def _check(self, *, euid: int, env: dict) -> bool:
|
||||
with (
|
||||
patch("os.geteuid", return_value=euid),
|
||||
patch.dict("os.environ", env, clear=True),
|
||||
):
|
||||
return frigate_service_is_granular_root()
|
||||
|
||||
def test_false_without_any_root_signal(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={}))
|
||||
|
||||
def test_escape_hatch_is_not_granular_root(self) -> None:
|
||||
# the escape hatch restores old behavior wholesale, sweep included
|
||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_RUN_AS_ROOT": "true"}))
|
||||
self.assertFalse(
|
||||
self._check(
|
||||
euid=0,
|
||||
env={"FRIGATE_RUN_AS_ROOT": "true", "FRIGATE_ROOT_SERVICES": "frigate"},
|
||||
)
|
||||
)
|
||||
|
||||
def test_membership_ignores_whitespace_and_other_entries(self) -> None:
|
||||
self.assertTrue(
|
||||
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc, frigate"})
|
||||
)
|
||||
self.assertFalse(
|
||||
self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "go2rtc,nginx"})
|
||||
)
|
||||
|
||||
def test_substring_of_a_service_name_does_not_match(self) -> None:
|
||||
self.assertFalse(self._check(euid=0, env={"FRIGATE_ROOT_SERVICES": "frigatee"}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -14,6 +14,11 @@ class FakePwEntry:
|
||||
# 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):
|
||||
def setUp(self) -> None:
|
||||
ownership.get_runtime_ids.cache_clear()
|
||||
# a value cached under this test's patches must not leak into later modules
|
||||
self.addCleanup(ownership.get_runtime_ids.cache_clear)
|
||||
|
||||
@patch("frigate.util.ownership.os.geteuid", return_value=1000)
|
||||
def test_returns_none_when_not_root(self, _):
|
||||
assert ownership.get_runtime_ids() is None
|
||||
@ -35,8 +40,21 @@ class TestGetRuntimeIds(unittest.TestCase):
|
||||
def test_returns_frigate_ids_as_root(self, *_):
|
||||
assert ownership.get_runtime_ids() == (1500, 1500)
|
||||
|
||||
@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_caches_lookup(self, _geteuid, getpwnam):
|
||||
assert ownership.get_runtime_ids() == (1500, 1500)
|
||||
assert ownership.get_runtime_ids() == (1500, 1500)
|
||||
getpwnam.assert_called_once()
|
||||
|
||||
|
||||
class TestChownToRuntime(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
ownership.get_runtime_ids.cache_clear()
|
||||
# a value cached under this test's patches must not leak into later modules
|
||||
self.addCleanup(ownership.get_runtime_ids.cache_clear)
|
||||
|
||||
@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):
|
||||
|
||||
@ -442,6 +442,71 @@ class TestSegmentPathTime(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(paths[1].endswith("30.23.mp4"), paths[1])
|
||||
|
||||
|
||||
class TestMoveSegmentOwnership(unittest.IsolatedAsyncioTestCase):
|
||||
"""Every directory level makedirs creates must be handed to the runtime user."""
|
||||
|
||||
def _build_maintainer(self) -> RecordingMaintainer:
|
||||
camera_config = MagicMock()
|
||||
camera_config.record.enabled = True
|
||||
camera_config.record.continuous.days = 1
|
||||
camera_config.record.motion.days = 0
|
||||
|
||||
config = MagicMock()
|
||||
config.cameras = {"test_cam": camera_config}
|
||||
|
||||
maintainer = RecordingMaintainer.__new__(RecordingMaintainer)
|
||||
maintainer.config = config
|
||||
maintainer.end_time_cache = {}
|
||||
maintainer.object_recordings_info = defaultdict(list)
|
||||
maintainer.audio_recordings_info = defaultdict(list)
|
||||
maintainer.recordings_publisher = MagicMock()
|
||||
maintainer.last_segment_end = {("test_cam", "main"): 0.0}
|
||||
return maintainer
|
||||
|
||||
async def test_move_segment_chowns_all_created_levels(self):
|
||||
maintainer = self._build_maintainer()
|
||||
maintainer.config.ffmpeg.ffmpeg_path = "ffmpeg"
|
||||
|
||||
start_time = datetime.datetime(2026, 6, 10, 14, 30, 22, tzinfo=datetime.UTC)
|
||||
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock(return_value=0)
|
||||
chown = MagicMock()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
record_dir = os.path.join(tmpdir, "recordings")
|
||||
cache_path = os.path.join(tmpdir, "test_cam@20260610143022+0000.mp4")
|
||||
with open(cache_path, "wb") as f:
|
||||
f.write(b"\x00" * 16)
|
||||
|
||||
with (
|
||||
patch("frigate.record.maintainer.RECORD_DIR", record_dir),
|
||||
patch(
|
||||
"frigate.record.maintainer.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=proc),
|
||||
),
|
||||
patch("frigate.record.maintainer.chown_to_runtime", chown),
|
||||
):
|
||||
result = await maintainer.move_segment(
|
||||
"test_cam",
|
||||
"main",
|
||||
start_time,
|
||||
start_time + datetime.timedelta(seconds=10),
|
||||
10.0,
|
||||
cache_path,
|
||||
SegmentInfo(0, 0, 0, 0),
|
||||
)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
camera_dir = os.path.join(record_dir, "2026-06-10", "14", "test_cam")
|
||||
hour_dir = os.path.dirname(camera_dir)
|
||||
date_dir = os.path.dirname(hour_dir)
|
||||
file_path = os.path.join(camera_dir, "30.22.mp4")
|
||||
chowned = [call.args[0] for call in chown.call_args_list]
|
||||
self.assertEqual(chowned, [camera_dir, hour_dir, date_dir, file_path])
|
||||
|
||||
|
||||
class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
||||
"""Contiguous segments must chain start times across filename truncation.
|
||||
|
||||
|
||||
@ -4,11 +4,14 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import (
|
||||
BASE_DIR,
|
||||
CACHE_DIR,
|
||||
CONFIG_DIR,
|
||||
DEFAULT_FFMPEG_VERSION,
|
||||
EXPORT_DIR,
|
||||
@ -43,13 +46,56 @@ DROPPED_DETECTOR_OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Trees the unprivileged runtime user can write. A root frigate service must
|
||||
# not execute a binary from any of them; a compromised uid-1000 process could
|
||||
# plant one and be root after the next restart.
|
||||
RUNTIME_USER_WRITABLE_DIRS = (CONFIG_DIR, BASE_DIR, CACHE_DIR, "/dev/shm", "/tmp")
|
||||
|
||||
|
||||
def frigate_service_is_granular_root() -> bool:
|
||||
"""Report whether FRIGATE_ROOT_SERVICES runs frigate as root.
|
||||
|
||||
The escape hatch is excluded: it never sweeps /config and leaves no
|
||||
unprivileged service running, so custom binaries stay as safe as they
|
||||
were before the privilege drop.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return False
|
||||
|
||||
if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true":
|
||||
return False
|
||||
|
||||
entries = os.environ.get("FRIGATE_ROOT_SERVICES", "").split(",")
|
||||
return any("".join(entry.split()) == "frigate" for entry in entries)
|
||||
|
||||
|
||||
def _is_runtime_user_writable(path: str) -> bool:
|
||||
"""Report whether a path resolves inside a runtime-user-writable tree."""
|
||||
resolved = os.path.realpath(path)
|
||||
return any(
|
||||
resolved == root or resolved.startswith(f"{root}{os.sep}")
|
||||
for root in RUNTIME_USER_WRITABLE_DIRS
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def _warn_ignored_ffmpeg_path(path: str) -> None:
|
||||
"""Warn once per path; resolution runs per camera and per binary."""
|
||||
logger.warning(
|
||||
"Ignoring ffmpeg.path %s because FRIGATE_ROOT_SERVICES runs frigate as root and that location is writable by the unprivileged user; using the bundled build",
|
||||
path,
|
||||
)
|
||||
|
||||
|
||||
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
"""Resolve an ffmpeg version alias or custom path to a binary path.
|
||||
|
||||
A bare version alias that is no longer bundled (for example one that was
|
||||
dropped when the default version changed) falls back to the default
|
||||
bundled version so existing configs keep working across an upgrade or a
|
||||
revert. Custom install paths (anything absolute) are used as-is.
|
||||
revert. Custom install paths (anything absolute) are used as-is, except
|
||||
one in a runtime-user-writable tree while FRIGATE_ROOT_SERVICES makes
|
||||
frigate root; see RUNTIME_USER_WRITABLE_DIRS.
|
||||
"""
|
||||
if path == "default" or (
|
||||
not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS
|
||||
@ -58,7 +104,11 @@ def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
|
||||
elif path in INCLUDED_FFMPEG_VERSIONS:
|
||||
version = path
|
||||
else:
|
||||
return f"{path}/bin/{binary}"
|
||||
if not (frigate_service_is_granular_root() and _is_runtime_user_writable(path)):
|
||||
return f"{path}/bin/{binary}"
|
||||
|
||||
_warn_ignored_ffmpeg_path(path)
|
||||
version = DEFAULT_FFMPEG_VERSION
|
||||
|
||||
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""Helpers for aligning created files with the non-root runtime user."""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
@ -9,12 +10,15 @@ logger = logging.getLogger(__name__)
|
||||
RUNTIME_USER = "frigate"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
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).
|
||||
The result is cached for the process lifetime because the runtime user
|
||||
cannot change after boot.
|
||||
"""
|
||||
if os.geteuid() != 0:
|
||||
return None
|
||||
|
||||
@ -187,8 +187,23 @@ def get_physical_interfaces(interfaces) -> list:
|
||||
return physical_interfaces
|
||||
|
||||
|
||||
_bandwidth_warning_logged = False
|
||||
|
||||
|
||||
def get_bandwidth_stats(config) -> dict[str, dict]:
|
||||
"""Get bandwidth usages for each ffmpeg process id"""
|
||||
global _bandwidth_warning_logged
|
||||
|
||||
if os.geteuid() != 0:
|
||||
if not _bandwidth_warning_logged:
|
||||
logger.warning(
|
||||
"Network bandwidth stats require root (nethogs needs CAP_NET_ADMIN/CAP_NET_RAW) "
|
||||
"and are disabled; set FRIGATE_ROOT_SERVICES=frigate (or FRIGATE_RUN_AS_ROOT=true) "
|
||||
"or disable telemetry.stats.network_bandwidth to silence this warning"
|
||||
)
|
||||
_bandwidth_warning_logged = True
|
||||
return {}
|
||||
|
||||
usages = {}
|
||||
top_command = ["nethogs", "-t", "-v0", "-c5", "-d1"] + get_physical_interfaces(
|
||||
config.telemetry.network_interfaces
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user