mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 01:38:57 +03:00
Container security hardening (phase 4) (#24140)
* Support read-only rootfs with self-signed certs in /config/tls * Support read-only rootfs in s6 and pre-compile bytecode * Assert read-only rootfs support in CI * Document hardened read-only deployment * keep certsync's cert selection identical to nginx's * note the uid trade-off in user: mode * fail fast when EXTRA_GROUPS or a missing media volume meets read_only * keep nosuid and nodev on the /run tmpfs * support read_only in the default mode * don't take go2rtc down when the homekit file isn't writable * lead with the hardware consequence of switching to user: * refuse to write TLS material through a symlink as root * note that memryx writes models to the root filesystem * certsync watches whichever cert path nginx loaded
This commit is contained in:
committed by
Nicolas Mowen
parent
b99c87f272
commit
41bc1a5844
+124
-1
@@ -101,7 +101,7 @@ jobs:
|
||||
# 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 /config/tls/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: |
|
||||
@@ -293,6 +293,129 @@ jobs:
|
||||
done
|
||||
if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi
|
||||
docker rm -f frigate-puid
|
||||
- name: Assert read-only rootfs with --user works
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-ro /tmp/frigate-media-ro
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-ro/config.yml
|
||||
sudo chown -R 1000:1000 /tmp/frigate-config-ro /tmp/frigate-media-ro
|
||||
# /run must allow exec: S6_READ_ONLY_ROOT has s6 copy its service
|
||||
# scripts there and run them, and --tmpfs defaults to noexec
|
||||
docker run -d --name frigate-ro --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--user 1000:1000 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
-v /tmp/frigate-media-ro:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-ro curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "read-only container never healthy"; docker logs frigate-ro; exit 1; fi
|
||||
# an if, not "! grep": bash exempts a negated command from set -e and
|
||||
# the assertion would never fail
|
||||
if docker logs frigate-ro 2>&1 | grep -i "read-only file system"; then
|
||||
echo "a service tried to write to the read-only rootfs"; exit 1
|
||||
fi
|
||||
# the self-signed cert has to land in /config, the only writable path
|
||||
docker exec frigate-ro test -f /config/tls/privkey.pem
|
||||
# and nginx must serve it, which is what proves the templated cert path
|
||||
docker exec frigate-ro curl -ksSI https://127.0.0.1:8971/ >/dev/null
|
||||
# logging must work via the s6-log fallback (no logutil-service as non-root)
|
||||
docker exec frigate-ro test -s /dev/shm/logs/frigate/current
|
||||
# runtime user can write recordings storage
|
||||
docker exec frigate-ro touch /media/frigate/.write-probe
|
||||
docker exec frigate-ro rm /media/frigate/.write-probe
|
||||
docker rm -f frigate-ro
|
||||
- name: Assert PUID with read-only fails fast with clear error
|
||||
run: |
|
||||
docker run -d --name frigate-ro-puid --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
-e PUID=1500 -e PGID=1500 \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
found=0
|
||||
for i in $(seq 1 12); do
|
||||
if docker logs frigate-ro-puid 2>&1 | grep -q "not compatible with read_only"; then found=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "no fail-fast error for PUID with a read-only rootfs"; docker logs frigate-ro-puid; exit 1
|
||||
fi
|
||||
docker rm -f frigate-ro-puid
|
||||
- name: Assert EXTRA_GROUPS with read-only fails fast with clear error
|
||||
run: |
|
||||
docker run -d --name frigate-ro-groups --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
-e EXTRA_GROUPS=44 \
|
||||
-v /tmp/frigate-config-ro:/config \
|
||||
-v /tmp/frigate-media-ro:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
found=0
|
||||
for i in $(seq 1 12); do
|
||||
if docker logs frigate-ro-groups 2>&1 | grep -q "EXTRA_GROUPS needs a writable /etc"; then found=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
if [ "$found" -ne 1 ]; then
|
||||
echo "no fail-fast error for EXTRA_GROUPS with a read-only rootfs"; docker logs frigate-ro-groups; exit 1
|
||||
fi
|
||||
docker rm -f frigate-ro-groups
|
||||
- name: Assert read-only rootfs in the default mode works
|
||||
run: |
|
||||
mkdir -p /tmp/frigate-config-rod /tmp/frigate-media-rod
|
||||
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-rod/config.yml
|
||||
docker run -d --name frigate-rod --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--security-opt no-new-privileges:true \
|
||||
-v /tmp/frigate-config-rod:/config \
|
||||
-v /tmp/frigate-media-rod:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-rod curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "read-only default-mode container never healthy"; docker logs frigate-rod; exit 1; fi
|
||||
if docker logs frigate-rod 2>&1 | grep -i "read-only file system"; then
|
||||
echo "a service tried to write to the read-only rootfs"; exit 1
|
||||
fi
|
||||
# the point of this mode over docker's user:: the drop still happens
|
||||
# and go2rtc still gets its own separate user
|
||||
ps_out=$(docker exec frigate-rod ps -eo user=,comm=)
|
||||
echo "$ps_out"
|
||||
for svc in python3 nginx; do
|
||||
if echo "$ps_out" | grep -w "$svc" | grep -q '^root'; then
|
||||
echo "$svc is running as root"; exit 1
|
||||
fi
|
||||
done
|
||||
echo "$ps_out" | grep -w go2rtc | grep -q '^go2rtc'
|
||||
# the ownership sweep still ran and recorded itself in /config
|
||||
docker exec frigate-rod cat /config/.permissions_version | grep -qx "2:1000:1000"
|
||||
# setfacl under a read-only rootfs, which nothing else covers:
|
||||
# init-devices exits early under --user, so that path is never reached
|
||||
docker exec frigate-rod mknod /dev/apex_9 c 120 99
|
||||
docker exec frigate-rod /etc/s6-overlay/s6-rc.d/init-devices/run
|
||||
docker exec frigate-rod getfacl -p /dev/apex_9 | grep -q "user:frigate:rw-"
|
||||
docker rm -f frigate-rod
|
||||
- name: "Assert switching that install to user: still starts"
|
||||
run: |
|
||||
# the config dir above now holds a go2rtc-owned go2rtc_homekit.yml,
|
||||
# which user: keeps readable but not writable (no supplementary groups)
|
||||
docker run -d --name frigate-rod-user --shm-size 256m \
|
||||
--read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \
|
||||
--user 1000:1000 \
|
||||
-v /tmp/frigate-config-rod:/config \
|
||||
-v /tmp/frigate-media-rod:/media/frigate \
|
||||
${{ steps.setup.outputs.image-name }}-amd64
|
||||
up=0
|
||||
for i in $(seq 1 60); do
|
||||
docker exec frigate-rod-user curl -fs http://127.0.0.1:5000/api/version && up=1 && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$up" -ne 1 ]; then echo "container did not survive the switch to user:"; docker logs frigate-rod-user; exit 1; fi
|
||||
docker logs frigate-rod-user 2>&1 | grep -q "HomeKit pairing changes will not persist"
|
||||
docker rm -f frigate-rod-user
|
||||
- name: Teardown
|
||||
if: always()
|
||||
run: docker rm -f frigate || true
|
||||
|
||||
@@ -316,6 +316,9 @@ EXPOSE 8555/tcp 8555/udp
|
||||
ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T"
|
||||
# Do not fail on long-running download scripts
|
||||
ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0
|
||||
# Allow running with a read-only root filesystem: s6 copies its scan dir into
|
||||
# /run and executes service scripts from there, so /run must allow exec
|
||||
ENV S6_READ_ONLY_ROOT=1
|
||||
|
||||
ENTRYPOINT ["/init"]
|
||||
CMD []
|
||||
@@ -387,3 +390,7 @@ FROM deps AS frigate
|
||||
|
||||
WORKDIR /opt/frigate/
|
||||
COPY --from=rootfs / /
|
||||
|
||||
# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the
|
||||
# source tree on every boot (pip-installed packages are already compiled)
|
||||
RUN python3 -m compileall -q -j0 /opt/frigate/frigate
|
||||
|
||||
@@ -26,7 +26,15 @@ function reload_nginx() {
|
||||
|
||||
echo "[INFO] Starting certsync..."
|
||||
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
# Resolved once, and the condition must stay identical to the nginx run
|
||||
# script's. Testing only fullchain.pem here would pick the mounted cert on a
|
||||
# half-populated mount that nginx rejected, and the two fingerprints would then
|
||||
# never agree, reloading nginx every cycle forever.
|
||||
if [ -f /etc/letsencrypt/live/frigate/privkey.pem ] && [ -f /etc/letsencrypt/live/frigate/fullchain.pem ]; then
|
||||
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
|
||||
else
|
||||
lefile="/config/tls/fullchain.pem"
|
||||
fi
|
||||
|
||||
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||
|
||||
@@ -79,6 +79,15 @@ fi
|
||||
|
||||
# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video)
|
||||
if [[ -n "${EXTRA_GROUPS:-}" ]]; then
|
||||
# groupadd and usermod -aG both write /etc/group. Checked up front so a
|
||||
# read-only rootfs reports the real problem instead of dying mid-loop.
|
||||
if [[ ! -w /etc/group ]]; then
|
||||
echo "[ERROR] EXTRA_GROUPS needs a writable /etc and is not compatible with read_only: true." >&2
|
||||
echo "[ERROR] Use docker's group_add: with the same GIDs instead; it needs no writes inside the container." >&2
|
||||
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
@@ -80,22 +80,49 @@ 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
|
||||
# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented
|
||||
# contract) always win; otherwise fall back to a self-signed cert persisted in
|
||||
# /config/tls, which stays writable under a read-only root filesystem.
|
||||
letsencrypt_path=/etc/letsencrypt/live/frigate
|
||||
mkdir -p $letsencrypt_path
|
||||
selfsigned_path=/config/tls
|
||||
|
||||
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"
|
||||
if [ -f "$letsencrypt_path/privkey.pem" ] && [ -f "$letsencrypt_path/fullchain.pem" ]; then
|
||||
cert_path="$letsencrypt_path"
|
||||
else
|
||||
cert_path="$selfsigned_path"
|
||||
|
||||
# Root writing into /config follows any symlink planted there, and /config
|
||||
# is owned by whoever the host mount says, not by root. Generate as the
|
||||
# runtime user wherever we are going to drop to it; the escape hatch keeps
|
||||
# root all the way through, so that path is refused rather than dropped.
|
||||
gen=()
|
||||
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
gen=(s6-setuidgid frigate)
|
||||
elif [[ "$(id -u)" -eq 0 ]]; then
|
||||
for link in "$cert_path" "$cert_path/privkey.pem" "$cert_path/fullchain.pem"; do
|
||||
if [[ -L "$link" ]]; then
|
||||
echo "[ERROR] ${link} is a symlink; refusing to write TLS material through it as root" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
"${gen[@]}" mkdir -p "$cert_path"
|
||||
|
||||
if [ ! \( -f "$cert_path/privkey.pem" -a -f "$cert_path/fullchain.pem" \) ]; then
|
||||
echo "[INFO] No TLS certificate found. Generating a self signed certificate..."
|
||||
"${gen[@]}" openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \
|
||||
-subj "/O=FRIGATE DEFAULT CERT/CN=*" \
|
||||
-keyout "$cert_path/privkey.pem" -out "$cert_path/fullchain.pem" 2>/dev/null
|
||||
"${gen[@]}" chmod 600 "$cert_path/privkey.pem"
|
||||
"${gen[@]}" chmod 644 "$cert_path/fullchain.pem"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ACME challenges are only served from a writable rootfs; skipping the mkdir
|
||||
# under read_only leaves the location 404ing, which is the same as unused
|
||||
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
||||
|
||||
# nginx settings are read once; both templates consume them
|
||||
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||
|
||||
@@ -104,8 +131,10 @@ 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
|
||||
# build templates for additional network settings; listen.conf is the only
|
||||
# template that needs the resolved cert directory
|
||||
echo "$nginx_settings" | \
|
||||
jq --arg p "$cert_path" '.tls.cert_path = $p' | \
|
||||
tempio -template /usr/local/nginx/templates/listen.gotmpl \
|
||||
-out /tmp/nginx/conf/listen.conf
|
||||
|
||||
@@ -118,9 +147,12 @@ if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||
# 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
|
||||
# Only mounted certs need handing over; the self-signed pair is already
|
||||
# owned by the runtime user that generated it. Never chown the /config copy:
|
||||
# chown follows symlinks, so it would retarget onto any root file the
|
||||
# runtime user pointed it at. Tolerant because mounted certs may be :ro.
|
||||
if [ "$cert_path" = "$letsencrypt_path" ] && [ -f "$cert_path/privkey.pem" ]; then
|
||||
chown frigate:frigate "$cert_path/privkey.pem" "$cert_path/fullchain.pem" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -189,7 +189,14 @@ 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
|
||||
# The image does not ship this directory, so on a read-only rootfs it can
|
||||
# only come from a mount. Report that rather than failing under errexit.
|
||||
if ! mkdir -p /media/frigate 2>/dev/null; then
|
||||
echo "[ERROR] /media/frigate does not exist and could not be created, which is what happens with read_only: true and no recordings volume." >&2
|
||||
echo "[ERROR] Mount a volume at /media/frigate." >&2
|
||||
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
|
||||
chown "${PUID:-1000}:${PGID:-1000}" /media/frigate
|
||||
fi
|
||||
|
||||
@@ -66,7 +66,17 @@ def main() -> int:
|
||||
path = sys.argv[1]
|
||||
do_chown = "--chown" in sys.argv[2:]
|
||||
|
||||
fd = open_nofollow(path)
|
||||
try:
|
||||
fd = open_nofollow(path)
|
||||
except PermissionError:
|
||||
print(
|
||||
f"[WARN] {path} is not writable by uid {os.geteuid()}, so HomeKit "
|
||||
"pairing changes will not persist. It is owned by the go2rtc user "
|
||||
"from an earlier run in the default mode. To fix, on the host run: "
|
||||
f"chown {os.geteuid()}:{os.getegid()} <your config dir>/{os.path.basename(path)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
content = os.read(fd, MAX_BYTES).decode("utf-8", "replace")
|
||||
normalized = normalize(content)
|
||||
|
||||
@@ -8,8 +8,8 @@ listen {{ .listen.internal }};
|
||||
listen {{ .listen.external }} ssl;
|
||||
{{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }}
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem;
|
||||
ssl_certificate {{ .tls.cert_path }}/fullchain.pem;
|
||||
ssl_certificate_key {{ .tls.cert_path }}/privkey.pem;
|
||||
|
||||
# generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP
|
||||
# https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7
|
||||
|
||||
@@ -15,13 +15,13 @@ Most upgrades need nothing. Frigate aligns your volume ownership on the first bo
|
||||
|
||||
| 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 |
|
||||
| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | 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 |
|
||||
| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | 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.
|
||||
`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. `EXTRA_GROUPS` writes to `/etc/group` and stops the same way; use Docker's `group_add:` instead, which needs no writes inside the container. The default mode and Docker's `user:` mode both work with `read_only: true`; see [Hardened deployment](#hardened-deployment).
|
||||
|
||||
`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.
|
||||
|
||||
@@ -263,6 +263,65 @@ What each device needs when you're setting it up by hand. The automatic grant co
|
||||
| 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 |
|
||||
|
||||
## Hardened deployment
|
||||
|
||||
A read-only root filesystem means the container can't modify itself, only the volumes you give it. It works in the default mode and under Docker's `user:`, but not with `PUID`/`PGID` or `EXTRA_GROUPS`, which both need to write to `/etc`.
|
||||
|
||||
Start with the default mode. It keeps go2rtc on its own restricted user and still grants your hardware automatically, at the cost of a short root startup that finishes before any service runs.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
container_name: frigate
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
shm_size: "512mb" # size for your cameras, see the shm-size calculation
|
||||
devices:
|
||||
- /dev/dri/renderD128:/dev/dri/renderD128 # your hardware, granted at startup
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- /path/to/your/config:/config
|
||||
- /path/to/your/storage:/media/frigate
|
||||
tmpfs:
|
||||
- /tmp:size=256m
|
||||
- /tmp/cache:size=1000000000 # recording segments, sized as before
|
||||
- /run:exec,nosuid,nodev,mode=0755,size=16m
|
||||
ports:
|
||||
- "8971:8971"
|
||||
- "8554:8554" # RTSP feeds
|
||||
- "8555:8555/tcp" # WebRTC over tcp
|
||||
- "8555:8555/udp" # WebRTC over udp
|
||||
```
|
||||
|
||||
`/run` has to allow `exec`. With a read-only root filesystem s6 copies its service scripts into `/run` and runs them from there, and tmpfs mounts default to `noexec`. The equivalent for `docker run` is `--tmpfs /run:exec,nosuid,nodev,mode=0755`. Spelling out `nosuid` and `nodev` matters: passing any tmpfs options replaces Docker's defaults instead of adjusting them, so asking for `exec` alone would drop those two as well.
|
||||
|
||||
Size `/tmp` deliberately. It now carries nginx's config copy and its five proxy temp directories as well as the recording cache. Keeping `/tmp/cache` as its own nested tmpfs, as above, leaves your existing [cache sizing](/frigate/installation#storage) untouched and adds a small allowance for nginx. If you'd rather use one tmpfs over all of `/tmp`, size it as your cache budget plus roughly 50MB, or recordings begin failing once the cache fills.
|
||||
|
||||
The self signed certificate is written to `/config/tls`, which stays writable. Certificates you mount at `/etc/letsencrypt/live/frigate` work unchanged and still take precedence.
|
||||
|
||||
Soak a hardened deployment for 24 hours against real cameras before relying on it. A read-only root filesystem turns an occasional write into a failure that startup won't reveal.
|
||||
|
||||
### Never starting as root
|
||||
|
||||
To remove root from the container entirely, add Docker's `user:`:
|
||||
|
||||
```yaml
|
||||
user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table
|
||||
```
|
||||
|
||||
Two things change, and the first one will break a working install if you skip it. The startup device grants can't run, because there is no root left to run them, so every device you pass stops working until you grant that uid access yourself with `group_add:` or a udev rule; see [Manual setup](#manual-setup). Expect this to surface as a driver error rather than a permission error, like `No VA display found` from VAAPI. And every service then runs as that one uid, so go2rtc no longer gets its own restricted user. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way.
|
||||
|
||||
This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants.
|
||||
|
||||
### Per-variant exceptions
|
||||
|
||||
- **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above.
|
||||
- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which gives back most of what this layout removes. MemryX also downloads its models to `/memryx_models` on the root filesystem, so it can't run read-only regardless.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath";
|
||||
|
||||
# TLS
|
||||
|
||||
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
|
||||
Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971, stored in `/config/tls` so it survives container recreation. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates.
|
||||
|
||||
Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. You will likely need to set your reverse proxy to allow self signed certificates or you can disable TLS in Frigate's config. However, if you are running on a dedicated device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS with valid certificates.
|
||||
|
||||
@@ -45,7 +45,9 @@ frigate:
|
||||
...
|
||||
```
|
||||
|
||||
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`.
|
||||
Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`. Mounted certificates take precedence over the self signed pair in `/config/tls`.
|
||||
|
||||
`privkey.pem` must be readable by the runtime user that runs NGINX. Frigate hands it over at startup when the mount is writable; on a `:ro` mount, make it readable by uid 1000 (or your `PUID`) yourself. See [Running as a non-root user](/configuration/non_root).
|
||||
|
||||
Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.:
|
||||
|
||||
@@ -59,7 +61,7 @@ frigate:
|
||||
|
||||
```
|
||||
|
||||
Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
|
||||
Frigate automatically compares the fingerprint of the certificate it loaded, from either location, against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate.
|
||||
|
||||
If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.
|
||||
|
||||
@@ -73,4 +75,4 @@ frigate:
|
||||
|
||||
## ACME Challenge
|
||||
|
||||
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`.
|
||||
Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`. With a read-only root filesystem this has to be a mounted volume, since Frigate cannot create the directory itself.
|
||||
|
||||
@@ -568,7 +568,7 @@ Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) a
|
||||
|
||||
:::
|
||||
|
||||
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.
|
||||
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, what each accelerator needs on the host, and the [hardened deployment](../configuration/non_root.md#hardened-deployment) layout with a read-only root filesystem.
|
||||
|
||||
**Docker CLI**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user