Files
frigate/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run
T
Josh HawkinsandNicolas Mowen 3be59c9c18 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
2026-09-12 07:30:04 -06:00

139 lines
4.8 KiB
Plaintext
Executable File

#!/command/with-contenv bash
# shellcheck shell=bash
# Start the NGINX service
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..."
# Taken from https://github.com/felipecrs/cgroup-scripts/commits/master/get_cpus.sh
function get_cpus() {
local quota=""
local period=""
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
if [ -f /sys/fs/cgroup/cpu.max ]; then
read -r quota period </sys/fs/cgroup/cpu.max
if [ "$quota" = "max" ]; then
quota=""
period=""
fi
else
echo "[WARN] /sys/fs/cgroup/cpu.max not found. Falling back to /proc/cpuinfo." >&2
fi
else
if [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ] && [ -f /sys/fs/cgroup/cpu/cpu.cfs_period_us ]; then
quota=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us)
period=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us)
if [ "$quota" = "-1" ]; then
quota=""
period=""
fi
else
echo "[WARN] /sys/fs/cgroup/cpu/cpu.cfs_quota_us or /sys/fs/cgroup/cpu/cpu.cfs_period_us not found. Falling back to /proc/cpuinfo." >&2
fi
fi
local cpus
if [ "${period}" != "0" ] && [ -n "${quota}" ] && [ -n "${period}" ]; then
cpus=$((quota / period))
if [ "$cpus" -eq 0 ]; then
cpus=1
fi
else
cpus=$(grep -c ^processor /proc/cpuinfo)
fi
printf '%s' "$cpus"
}
function set_worker_processes() {
# Capture number of assigned CPUs to calculate worker processes
local cpus
cpus=$(get_cpus)
if [[ "${cpus}" -gt 4 ]]; then
cpus=4
fi
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
mkdir -p /etc/letsencrypt/www
# Create self signed certs if needed
letsencrypt_path=/etc/letsencrypt/live/frigate
mkdir -p $letsencrypt_path
if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
-keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null
chmod 600 "$letsencrypt_path/privkey.pem"
chmod 644 "$letsencrypt_path/fullchain.pem"
fi
# nginx settings are read once; both templates consume them
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 /tmp/nginx/conf/base_path.conf
# build templates for additional network settings
echo "$nginx_settings" | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \
-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
# -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