mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-27 08:54:42 +03:00
Compare commits
41
Commits
0f733b5ee5
...
9c2658298a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2658298a | ||
|
|
1632f315d1 | ||
|
|
c3a4cbb872 | ||
|
|
302a6e21ba | ||
|
|
ac5e5fb186 | ||
|
|
a44b69c643 | ||
|
|
a29421b325 | ||
|
|
6ec5659260 | ||
|
|
96b9ff145e | ||
|
|
d7dc7ce3bc | ||
|
|
d02771f07a | ||
|
|
8ed9f34981 | ||
|
|
cdd5d3ecff | ||
|
|
9ef7f57cad | ||
|
|
516de3b347 | ||
|
|
8b7aa5fe42 | ||
|
|
126cc0ba31 | ||
|
|
16d342ac7f | ||
|
|
a697718b22 | ||
|
|
5bf37a4ea7 | ||
|
|
fcfed6f66c | ||
|
|
2a3242ad35 | ||
|
|
866dccb052 | ||
|
|
5b57682d07 | ||
|
|
3fe026456a | ||
|
|
dfb26fa257 | ||
|
|
d53631ab0e | ||
|
|
0f356f39e5 | ||
|
|
9ee78693e3 | ||
|
|
bc88c3bcaa | ||
|
|
eccb171128 | ||
|
|
14b8b13514 | ||
|
|
5ad578b870 | ||
|
|
ff5669cefc | ||
|
|
65c46716fb | ||
|
|
9ee939750d | ||
|
|
fce7552717 | ||
|
|
e283893d2b | ||
|
|
f95785c2a9 | ||
|
|
25adcffd72 | ||
|
|
c87a7ca181 |
+141
-4
@@ -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,147 @@ 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 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 +247,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
|
||||
|
||||
@@ -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,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 go2rtc; then
|
||||
runs_as_root=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Logs should be sent to stdout so that s6 can collect them
|
||||
|
||||
function get_ip_and_port_from_supervisor() {
|
||||
@@ -110,6 +117,14 @@ fi
|
||||
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
|
||||
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||
# go2rtc rewrites this in place, so owning the file is enough. Tolerated so
|
||||
# a chown-refusing mount (NFS root_squash) degrades pairing, not the service
|
||||
chown go2rtc:frigate-data "${homekit_config_path}" 2>/dev/null && chmod 664 "${homekit_config_path}" 2>/dev/null || \
|
||||
echo "[WARN] Could not hand ${homekit_config_path} to the go2rtc user; HomeKit pairing changes may not persist"
|
||||
fi
|
||||
|
||||
readonly config_path="/config"
|
||||
|
||||
if [[ -x "${config_path}/go2rtc" ]]; then
|
||||
@@ -125,4 +140,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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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,9 +63,16 @@ if [[ "$(id -u)" -ne 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
if [[ "$dry_run" -eq 0 && -n "$sentinel" && -f "$sentinel" && "$(cat "$sentinel")" == "$sentinel_content" ]]; then
|
||||
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
|
||||
exit 0
|
||||
fi
|
||||
@@ -76,11 +92,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 \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) -printf '.' 2>/dev/null | wc -c); then
|
||||
swept_clean=0
|
||||
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
|
||||
continue
|
||||
@@ -98,17 +116,40 @@ 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}" {} + || {
|
||||
# -print feeds the progress counter; -exec {} + keeps the chown batched
|
||||
started=$SECONDS
|
||||
if find "$path" -name lost+found -prune -o \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
|
||||
-print -exec 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 +165,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" || \
|
||||
echo "$sentinel_content" > "$sentinel" || \
|
||||
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
|
||||
fi
|
||||
|
||||
+18
@@ -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
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
id: non_root
|
||||
title: 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.
|
||||
|
||||
By default the runtime user is uid/gid `1000:1000`. You can change it with `PUID`/`PGID`, or bypass Frigate's user handling entirely with Docker's own `user:`.
|
||||
|
||||
## 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 fails fast at startup with a message pointing here rather than failing obscurely later.
|
||||
|
||||
`FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored.
|
||||
|
||||
### Keeping individual services root
|
||||
|
||||
`FRIGATE_ROOT_SERVICES` takes a comma-separated list of `frigate`, `go2rtc`, and `nginx`. A listed service keeps running as root while everything else about non-root operation still applies: `PUID`/`PGID` remapping, the ownership sweep, and ownership of the files those services create.
|
||||
|
||||
The two cases this exists for:
|
||||
|
||||
- Your detector hardware won't cooperate with [device access](#hardware-device-access) as an unprivileged user. `FRIGATE_ROOT_SERVICES=frigate` keeps the main process (and every detector it spawns) root while nginx and go2rtc stay unprivileged.
|
||||
- You want root behavior but still want your volumes owned by `PUID`/`PGID` instead of root. `FRIGATE_ROOT_SERVICES=frigate,go2rtc,nginx` runs everything as root and still keeps file ownership aligned.
|
||||
|
||||
Prefer solving device access with `EXTRA_GROUPS` before reaching for this. The `frigate` service owns the API and every ffmpeg process decoding your camera streams, so listing it runs those as root too, not just the detectors.
|
||||
|
||||
Recordings and exports are owned by `PUID`/`PGID` the moment they're written, even by a root service. Everything else (snapshots, thumbnails, and other files under `clips/`) is realigned on each restart, so files written mid-run can show as root-owned from the host until the next one. A listed service also keeps root's home directory, so library caches land in the container layer instead of `/config`, same as the escape hatch.
|
||||
|
||||
Listing all three services is not the same as `FRIGATE_RUN_AS_ROOT=true`. The escape hatch never touches ownership at all; the list keeps the ownership machinery running. Unknown names in the list stop the container at startup rather than silently dropping a service you meant to keep root. Changing the list re-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 entirely. With Docker's own `user:` the list has no effect, since the container never has root to keep.
|
||||
|
||||
## Migrating an existing install
|
||||
|
||||
Volumes created by earlier versions of Frigate are owned by root. Ownership has to be aligned with the runtime user once.
|
||||
|
||||
There are two independent prerequisites to upgrading, and doing one without the other is the most common way this goes wrong. This section covers volume ownership. If you use a Coral, a GPU, or any other accelerator, read [Hardware device access](#hardware-device-access) as well: those devices are reachable today because Frigate runs as root, and they need host side work that has nothing to do with your volumes.
|
||||
|
||||
This happens automatically on the first boot after upgrading, but on large recordings volumes it's much better to do it from the host beforehand. 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 react to 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` from `docker/migration/` in 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
|
||||
```
|
||||
|
||||
Both the script and the boot sweep report progress as they go, so you can tell a slow sweep apart 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 behind it because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
|
||||
|
||||
Pass `PUID` and `PGID` as the third and fourth arguments if you're not using the default `1000:1000`. The script wraps the same `fix-ownership` helper the container uses, so it's the same logic either way. Override the image it pulls with `FRIGATE_IMAGE=...` if you're not on `stable`.
|
||||
|
||||
Once the volumes are aligned, start Frigate normally. A sentinel at `/config/.permissions_version` records what was done, so later boots skip the sweep entirely unless you change `PUID`/`PGID`.
|
||||
|
||||
`lost+found` is left alone. It belongs to the filesystem rather than to Frigate, and `fsck` recovers fragments of arbitrary files into it under root-only permissions, so handing it to the runtime user would expose whatever ends up there. Expect to see it still owned by root afterward, on any volume that's a dedicated mount.
|
||||
|
||||
If something under your volumes genuinely can't be chowned, a read-only btrfs snapshot directory for example, the sweep warns and names the path, and it deliberately doesn't write the sentinel. That means it retries on the next boot rather than recording a migration that didn't finish. Either move those paths outside `/media/frigate` or expect the scan to repeat.
|
||||
|
||||
### Network storage
|
||||
|
||||
Storing recordings on a NAS is common, and ownership behaves differently there. Check what you have before migrating anything:
|
||||
|
||||
```bash
|
||||
findmnt -T /path/to/your/storage -o TARGET,FSTYPE,OPTIONS
|
||||
```
|
||||
|
||||
**SMB and CIFS** don't store ownership per file at all. It's synthesized from the mount options, so a per-file `chown` fails, and you don't need one. Mount the share as the uid and gid Frigate will run 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 recursive chown then fails outright, you get `[WARN] fix-ownership: some entries under /media/frigate could not be updated`, and because the sweep didn't finish it deliberately doesn't write the sentinel, so it retries on the next boot and every boot after.
|
||||
|
||||
The best fix is to not chown over NFS at all. Do it on the server, locally, 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 own sweep then finds nothing to change and records the sentinel normally. If you can't get a shell on the server, the alternatives are to export temporarily with `no_root_squash`, migrate, and put it back, or to 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 simply uid 1000 on the server no matter what the usernames are.
|
||||
|
||||
Expect the first boot to be slow even when nothing needs changing, because verifying ownership costs a round trip per file. The sentinel makes that a one-time price. **If the sweep runs on every boot rather than once, ownership isn't actually being applied**, and the warning above will tell you so.
|
||||
|
||||
Keep `/config` on local storage regardless. Frigate's database is SQLite and network shares handle its locking poorly. That's a pre-existing 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 work out a device permission problem, and it's the recommended fallback for hardware you can't grant access to.
|
||||
|
||||
The escape hatch never changes ownership, and it deletes the sweep sentinel on startup, so switching back to non-root later re-sweeps whatever root created in the meantime. Toggling in either direction is safe.
|
||||
|
||||
## Hardware device access
|
||||
|
||||
This is the part most likely to need work on your host, and it catches people out for a specific reason: your accelerator almost certainly works today *because* Frigate runs as root. Device nodes are commonly owned by `root:root`, and root either matches the file's group or bypasses the check entirely. The runtime user does neither, so a node that was fine yesterday can become unreadable with no change to your Frigate config at all.
|
||||
|
||||
Nothing inside the container can fix that. Device node permissions are set by the host, so the fix belongs there too.
|
||||
|
||||
### Read what your device actually 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 ask which of the three permission sets the runtime user lands in. It isn't the owner (that's root), so it gets the group bits only 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 cannot open the node at all.
|
||||
|
||||
The trap is 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
|
||||
```
|
||||
|
||||
That's group `0`, so "other" applies to everyone else, 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 is not 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 at startup, which matters because go2rtc needs render and video access of its own to run 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 agree 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 and don't:
|
||||
|
||||
- Docker's `group_add` has no effect in the default or `PUID` modes. `s6-setuidgid` rebuilds the supplementary group list from `/etc/group` when it drops privileges, which discards whatever Docker gave the init process. It *is* the right tool with Docker-native `user:`, where no privilege drop happens and `EXTRA_GROUPS` in turn does nothing.
|
||||
- `privileged: true` doesn't help. It grants capabilities to root, and the runtime user isn't root, so ordinary file permissions on the node still apply.
|
||||
|
||||
If the node's group is `root` or the mode denies the group, no `EXTRA_GROUPS` value can help. You need a udev rule first.
|
||||
|
||||
### Verify before you rely on it
|
||||
|
||||
Check the group landed, then check the runtime user can actually 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'
|
||||
```
|
||||
|
||||
Permission probes are still 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 and harmless. OpenVINO must list `GPU`; if it returns only `CPU`, detection has quietly fallen back and inference will be far slower without any error in the log.
|
||||
|
||||
If something isn't working, the fastest way to tell a permissions problem from anything else is to start the container once with `FRIGATE_RUN_AS_ROOT=true`. If the device appears 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 it 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
|
||||
```
|
||||
|
||||
An already-connected device 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 firmware load. It appears as Global Unichip `1a6e` cold and Google `18d1` once running, with a different node each time. A rule covering only `1a6e` gives you a Coral that initializes 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 re-enumeration reason. Most hosts already put `plugdev` at GID 46 and the image agrees, so you often need no `EXTRA_GROUPS` entry for a USB Coral. Confirm with `getent group plugdev` and add the number if your host differs.
|
||||
|
||||
**Coral PCIe** is frequently `crw------- root root`, which nothing but 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** follows the same shape. 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 distributions ship a `render` group owning `/dev/dri/renderD128` already. Note the GID often differs between host and image, so pass the host's number rather than assuming the name resolves. Debian based images have no `render` group at all.
|
||||
|
||||
### Quick reference
|
||||
|
||||
| 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 |
|
||||
| DeepStack, CodeProject.AI | none | Nothing, inference happens over the network |
|
||||
| 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 disabled 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. Frigate won't change ownership of a certificate you supplied, since the mount may be read-only.
|
||||
|
||||
If you're debugging nginx, run the config check as the runtime user with stdout discarded: `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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user