Compare commits

...
3 Commits
Author SHA1 Message Date
Josh HawkinsandGitHub 2347f954bb Container security hardening (phase 2) (#24068)
* Create frigate and go2rtc runtime users in the image

* Add single fix-ownership helper for volume permission migration

* Add init-usermod oneshot for PUID and PGID remapping

* Chown newly created runtime directories to the frigate user

* Run sentinel-guarded ownership sweep during prepare

* Add host-side volume permission migration script

* Guard log directory ownership for user-mode startup

* Fall back to plain s6-log when running without root

* Assert PUID remapping and sweep sentinel in CI smoke test

* Skip the ownership sweep in the devcontainer

* Pin FRIGATE_RUN_AS_ROOT in ownership tests

* Do not record the sweep as complete when a chown failed

* Validate PUID and PGID in the migration script

* Treat a failed ownership scan as an incomplete sweep

* Reject PUID and PGID of 0 during remapping

* Handle symlinks, dry runs, and sentinel write failures in the sweep

* Treat an absent sweep root as an incomplete sweep
2026-08-23 14:40:32 -06:00
Josh HawkinsandGitHub 2638729c56 Container security hardening (phase 1) (#24061)
* Verify s6-overlay downloads against pinned checksums

* Verify go2rtc download against pinned checksums

The v1.9.14 release publishes no checksums file, just the bare per-platform binaries, so these digests come from a one-time fetch rather than upstream. That pins the artifact against later substitution, which is the realistic threat for a version we stay on for months, but it does not verify the original download. The stage moves from `ADD --link` to a script because `ADD --checksum` can't express an architecture-dependent URL.

* Verify main image downloads against pinned checksums

Covers everything the main image downloads on the default path: tempio, the hailort runtime tarball and wheel, the six ffmpeg builds, the libedgetpu deb, and the thirteen Intel driver debs. The hailort tarball was streamed straight into `tar`, which can't be verified before extraction, so it downloads to `/tmp` first. The three ffmpeg blocks per arch collapse into one `install_ffmpeg` helper since they only differed by URL and install dir, and the Intel debs go through a `fetch_intel_deb` helper for the same reason.

The Intel debs are the ones that mattered most here. They're installed as root with `dpkg` on the default amd64 path and had no verification at all. compute-runtime publishes a `ww<week>.sum` asset with every release and npu-driver published `checksum.sha256` on v1.19.0, so those eight digests came from upstream rather than from us. intel-graphics-compiler and level-zero publish none, so those five and everything else here come from a one-time fetch, which pins the artifact against later substitution but doesn't verify the original download. The comment above the map says which is which and how to refresh them, since npu-driver has stopped publishing sums since v1.19.0 and that provenance won't survive the next bump.

Still unpinned: `get-pip.py`, which is a rolling URL where a digest would just break the build on pypa's next edit, and the per-variant artifacts for Axera, Synaptics, and Jetson. apt repositories are out of scope since apt already verifies signatures.

* Restrict generated TLS key permissions

OpenSSL 3.x already writes the key at 600 on its own, so this pins the guarantee rather than fixing an observed leak: the mode no longer depends on the openssl version or the umask the service happens to start with. Only the generated pair is touched. User-mounted certs take the other branch and are never chmod'd, which matters when they're mounted read-only.

* Add security headers and server_tokens off

Adds `X-Content-Type-Options: nosniff` and `Referrer-Policy: strict-origin-when-cross-origin`, and turns off nginx version disclosure.

No `X-Frame-Options` and no CSP `frame-ancestors`. HA's Webpage card and iframe panels frame Frigate's own address cross-origin, and either header would break them silently with nothing in Frigate's logs to explain it. Ingress is same-origin and would survive `SAMEORIGIN`, but Frigate can't tell the two apart from inside the container. `security_headers.conf` is a plain file in the image rather than a generated one, so anyone who does want framing restrictions can bind-mount it.

`add_header` doesn't inherit into a block that declares its own, so the include goes in per block, all nine of them, including the four nested static-asset locations that serve the JS bundles. Those are the ones nosniff actually matters for.

The run script now reads `get_nginx_settings.py` once into a variable instead of shelling out per template. That script imports the frigate config machinery, which is noticeable on an SBC.

Not fixed here: `listen.conf` is included at server level and carries `Strict-Transport-Security`, so those same nine blocks already drop HSTS under TLS today. Folding it into this file would change existing TLS behavior on nine paths, so it needs its own PR.

* Restrict go2rtc config file permissions

* Log failed login attempts with source address

Failed logins returned a bare 401 and left nothing behind, so credential stuffing was invisible unless you were already watching nginx access logs. Both failure branches now log a warning with the attempted username and the client address.

The address comes from `get_remote_addr()`, the same helper the login rate limiter keys on, so the two agree on who the client is and the trusted-proxy handling is consistent. Logging a raw `x-forwarded-for` instead would let an attacker forge the source address in the very log line meant to catch them.

The response is unchanged and identical either way. Which factor failed is only visible in the log, never to the client, and the password is never logged.

* Recommend least-privilege container options in install docs

The compose generator pushed `privileged: true` into every file it produced, no matter what hardware you picked, and it's the default tab on the install page so it's what most people copy. It now emits `security_opt: no-new-privileges:true` instead, and only adds `privileged: true` for hardware that actually needs it, with the reason inline. MemryX is the only one today, since it needs to reach the max-manager. Rockchip and Synaptics only want privileged during initial setup and their documented end state is device mappings, so neither gets it.

`no-new-privileges` merges into the same `security_opt` block as any device-specific entries, so Rockchip still gets its `apparmor=unconfined` and `systempaths=unconfined` without a duplicate key.

The static example now has `privileged` commented out, and there's a short section on the options worth adding, with a note that `cap_drop: ALL` breaks `telemetry.stats.network_bandwidth` since nethogs needs NET_ADMIN/NET_RAW.

* Add amd64 container smoke test to CI

Boots the built amd64 image against a minimal config and asserts the two security headers, that the Server header no longer carries a version, that no frame-ancestors is present, that nginx accepts its own config, and the two file modes. This is also the harness the rest of the hardening work extends.

The two negative assertions are written as `if grep; then exit 1; fi` rather than `! grep`. Bash exempts a negated command from `set -e`, so the `!` form would have passed even with the version and frame-ancestors both present, which is the opposite of what a regression net is for.
2026-08-23 10:50:03 -06:00
Josh HawkinsandGitHub 7a49eb4bbb Tweaks (#24067)
* improve keyframes messages

* don't pad the labelmap with unknown

`load_labels()` prefilled 91 `unknown` entries before reading the label file, so any model with fewer than 91 classes kept that padding in `merged_labelmap` and `unknown` showed up as a selectable object type in the objects settings UI. The padding only existed so `RemoteObjectDetector.detect` could index the labelmap without a KeyError, and it didn't even cover the empty-file case or Frigate+, which never had a prefill. Both lookups now skip class ids the labelmap doesn't name and warn once per id.
2026-08-23 10:31:31 -06:00
42 changed files with 956 additions and 80 deletions
+83
View File
@@ -42,6 +42,89 @@ jobs:
tags: ${{ steps.setup.outputs.image-name }}-amd64
cache-from: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64
cache-to: type=registry,ref=${{ steps.setup.outputs.cache-name }}-amd64,mode=max
smoke_test:
runs-on: ubuntu-22.04
name: AMD64 Smoke Test
needs:
- amd64_build
steps:
- name: Check out code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up QEMU and Buildx
id: setup
uses: ./.github/actions/setup
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Start container
run: |
mkdir -p /tmp/frigate-config
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml
docker run -d --name frigate --shm-size 256m \
-v /tmp/frigate-config:/config \
-p 5000:5000 -p 8971:8971 \
${{ steps.setup.outputs.image-name }}-amd64
- name: Wait for API
run: |
for i in $(seq 1 60); do
curl -fs http://127.0.0.1:5000/api/version && exit 0
sleep 5
done
echo "API never came up"; docker logs frigate; exit 1
- name: Assert security headers and permissions
run: |
headers=$(curl -ksI https://127.0.0.1:8971/)
echo "$headers"
echo "$headers" | grep -qi "x-content-type-options: nosniff"
echo "$headers" | grep -qi "referrer-policy: strict-origin-when-cross-origin"
# server_tokens off: Server header must not include a version.
# written as an if rather than "! grep", because bash exempts a
# negated command from set -e and the assertion would never fail
if echo "$headers" | grep -qiE "^server: nginx/[0-9]"; then
echo "Server header leaks the nginx version; server_tokens is not off"
exit 1
fi
# Frigate never ships frame-ancestors: HA's Webpage card and iframe
# panels frame it cross-origin and it would break them silently
if echo "$headers" | grep -qi "frame-ancestors"; then
echo "response carries frame-ancestors, which breaks cross-origin iframe embedding"
exit 1
fi
docker exec frigate /usr/local/nginx/sbin/nginx -t
docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600
docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640
- name: Assert PUID/PGID remapping
run: |
mkdir -p /tmp/frigate-config-puid
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-puid/config.yml
docker run -d --name frigate-puid --shm-size 256m \
-e PUID=1500 -e PGID=1500 \
-v /tmp/frigate-config-puid:/config \
${{ steps.setup.outputs.image-name }}-amd64
up=0
for i in $(seq 1 60); do
docker exec frigate-puid curl -fs http://127.0.0.1:5000/api/version && up=1 && break
sleep 5
done
if [ "$up" -ne 1 ]; then echo "PUID container never became healthy"; docker logs frigate-puid; exit 1; fi
docker exec frigate-puid id -u frigate | grep -qx 1500
docker exec frigate-puid id -g frigate | grep -qx 1500
docker exec frigate-puid cat /config/.permissions_version | grep -qx "1:1500:1500"
# second boot must skip the sweep (sentinel hit). Poll rather than
# sleep: the string can only come from the second boot (the first
# had no sentinel), so grepping the full log is unambiguous.
docker restart frigate-puid
ok=0
for i in $(seq 1 30); do
docker logs frigate-puid 2>&1 | grep -q "already applied" && ok=1 && break
sleep 2
done
if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi
docker rm -f frigate-puid
- name: Teardown
if: always()
run: docker rm -f frigate || true
arm64_build:
runs-on: ubuntu-22.04-arm
name: ARM Build
+25 -3
View File
@@ -60,10 +60,10 @@ ARG DEBIAN_FRONTEND
RUN --mount=type=bind,source=docker/main/build_intel_media_driver.sh,target=/deps/build_intel_media_driver.sh \
/deps/build_intel_media_driver.sh
FROM scratch AS go2rtc
FROM wget AS go2rtc
ARG TARGETARCH
WORKDIR /rootfs/usr/local/go2rtc/bin
ADD --link --chmod=755 "https://github.com/AlexxIT/go2rtc/releases/download/v1.9.14/go2rtc_linux_${TARGETARCH}" go2rtc
RUN --mount=type=bind,source=docker/main/install_go2rtc.sh,target=/deps/install_go2rtc.sh \
/deps/install_go2rtc.sh
FROM wget AS tempio
ARG TARGETARCH
@@ -265,6 +265,23 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
/deps/install_deps.sh
# Runtime users. frigate may be remapped at start via PUID/PGID (init-usermod)
# or replaced entirely with docker's --user. go2rtc is intentionally separate
# and more restricted. frigate-data is the shared group for /config access.
# -o tolerates variant base images that already contain uid/gid 1000.
RUN groupadd -o --gid 1000 frigate \
&& useradd -o --uid 1000 --gid frigate --no-create-home --shell /usr/sbin/nologin frigate \
&& groupadd --system go2rtc \
&& useradd --system --gid go2rtc --no-create-home --shell /usr/sbin/nologin go2rtc \
&& groupadd --system frigate-data \
&& usermod -aG frigate-data frigate \
&& usermod -aG frigate-data go2rtc \
&& for grp in video render plugdev audio; do \
if getent group "$grp" >/dev/null; then \
usermod -aG "$grp" frigate && usermod -aG "$grp" go2rtc; \
fi; \
done
ENV DEFAULT_FFMPEG_VERSION="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
@@ -307,6 +324,11 @@ HEALTHCHECK --start-period=300s --start-interval=5s --interval=15s --timeout=5s
# Frigate deps with Node.js and NPM for devcontainer
FROM deps AS devcontainer
# /config here is the developer's bind-mounted checkout, not a data volume, so
# the prepare ownership sweep must not run: it would chown the source tree to
# the runtime uid and lock out any container user that isn't 1000.
ENV FRIGATE_RUN_AS_ROOT=true
# Do not start the actual Frigate service on devcontainer as it will be started by VS Code
# But start a fake service for simulating the logs
COPY docker/main/fake_frigate_run /etc/s6-overlay/s6-rc.d/frigate/run
+77 -37
View File
@@ -28,7 +28,13 @@ update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1
mkdir -p -m 600 /root/.gnupg
# install coral runtime
# sha256 digests of the release debs; update when bumping the libedgetpu release.
declare -A edgetpu_checksums=(
["amd64"]="63fd00989d29160fa9894e115156a9abe456e88751fc9be89d26e4696200441b"
["arm64"]="eab8aa4576b4dbf738135d8094f32270b24117f77147d25cbe0f49d0144d85f2"
)
wget -q -O /tmp/libedgetpu1-max.deb "https://github.com/feranick/libedgetpu/releases/download/16.0TF2.17.1-1/libedgetpu1-max_16.0tf2.17.1-1.bookworm_${TARGETARCH}.deb"
echo "${edgetpu_checksums[${TARGETARCH}]} /tmp/libedgetpu1-max.deb" | sha256sum -c -
unset DEBIAN_FRONTEND
yes | dpkg -i /tmp/libedgetpu1-max.deb && export DEBIAN_FRONTEND=noninteractive
rm /tmp/libedgetpu1-max.deb
@@ -45,36 +51,41 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
fi
fi
# sha256 digests of the ffmpeg builds, keyed "<install dir>-<arch>".
# Upstream publishes no checksums; these come from a one-time fetch and guard
# against later substitution. Update when bumping a build URL.
declare -A ffmpeg_checksums=(
["5.0-amd64"]="377abec133f9d9e8014dee1b91c9684ac8bb0b5b7d80100a57116ff837c4c0d4"
["7.0-amd64"]="e13860eb90409c8218319c928067834ce450128e86f24cfed5cfe91ce6e31037"
["8.0-amd64"]="9bac85054d351cdc89c0a4f45c8ea5c44df94009aabd964b719bbadd56aedae9"
["5.0-arm64"]="57ee475407bad49910ba9b946428396e30cf075ea28a7912fbe1aa2578085af0"
["7.0-arm64"]="16c8b04e9d0ea9c769ad964c4c453fcf05121a1947237329d2e9d8a5e43e2a3c"
["8.0-arm64"]="cd91948468d0f11ce795a2cdaa0c69911bd1db313b49bb19c22512beb88cde69"
)
# the tarballs nest their binaries under a directory named for the arch, which
# matches TARGETARCH for both builds we consume
install_ffmpeg() {
local dir="$1" url="$2"
mkdir -p "/usr/lib/ffmpeg/${dir}"
wget -qO ffmpeg.tar.xz "${url}"
echo "${ffmpeg_checksums[${dir}-${TARGETARCH}]} ffmpeg.tar.xz" | sha256sum -c -
tar -xf ffmpeg.tar.xz -C "/usr/lib/ffmpeg/${dir}" --strip-components 1 "${TARGETARCH}/bin/ffmpeg" "${TARGETARCH}/bin/ffprobe"
rm -f ffmpeg.tar.xz
}
# ffmpeg -> amd64
if [[ "${TARGETARCH}" == "amd64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linux64-gpl-5.1.tar.xz"
install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
fi
# ffmpeg -> arm64
if [[ "${TARGETARCH}" == "arm64" ]]; then
mkdir -p /usr/lib/ffmpeg/5.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
install_ffmpeg 5.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2022-07-31-12-37/ffmpeg-n5.1-2-g915ef932a3-linuxarm64-gpl-5.1.tar.xz"
install_ffmpeg 7.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
install_ffmpeg 8.0 "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
fi
# arch specific packages
@@ -120,27 +131,56 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
apt-get -qq install -y libtbb12
# install legacy and standard intel compute packages
# sha256 digests of the driver debs, taken from the ww<week>.sum asset
# compute-runtime ships per release and the checksum.sha256 on npu-driver
# v1.19.0; intel-graphics-compiler and level-zero publish none, so those
# five are hash-what-you-get. Refresh after a version bump with
# `curl -sL <url> | sha256sum`, cross-checking upstream's sum where the
# release still has one. npu-driver stopped publishing them after v1.19.0.
declare -A intel_checksums=(
["libigdgmm12_22.9.0_amd64.deb"]="9d712f71c18baee076de9961dda71e8089291e1bd0deb5d649ab5ba5de114f97"
["intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb"]="bbe71e4f414259e06a10cde72c29a2bd78d41b2bb2f6f8463b1806797fe66e85"
["intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb"]="40dfbd15ab62de036a00824b304a2aa1fa2d81ad60ef83da09cfe3c5a80c429f"
["intel-igc-opencl_1.0.17537.24_amd64.deb"]="dd016400f87fa2b6a9fa9fbcca7eb4a2629174a29de679709f9bec5cede88b0e"
["intel-igc-core_1.0.17537.24_amd64.deb"]="c1e1ecdfe2064c047c552651cfdcdafc504f2033afafba65654338b880048b67"
["intel-opencl-icd_26.14.37833.4-0_amd64.deb"]="2e15eeb4fe9c1bba467a655967373eec6a20dd04cc7159de53c359f17ab53e41"
["libze-intel-gpu1_26.14.37833.4-0_amd64.deb"]="34ce5791160d87ce6d54edb558a4030858ee1dad2afb067b9c5c58d4cde774c6"
["intel-igc-opencl-2_2.32.7+21184_amd64.deb"]="3c9bddbfe558279402bbeaabcf9c63b8de46b956b0ad9625415fd35dda53ad52"
["intel-igc-core-2_2.32.7+21184_amd64.deb"]="64e5230788e3a31e611e8d815a141b1facb91e5f0ef239233ef3f0614bfe3fd6"
["level-zero_1.28.2+u22.04_amd64.deb"]="9015a579abef960166f8e943858d5c81fd4199a960f07260c1da66038257effb"
["intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="8087bfcc0872d7976d0163203c7c783a4176f813c473766587e86c7b34135dff"
["intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="740219c03495f8812c03ab74baf8199acf17d13929001105418d4ba226ba2290"
["intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb"]="f4f5eb97aa7da52c7fec97e4ddfb43aae01703bbadc767bae1f2d4faf342ba42"
)
fetch_intel_deb() {
local url="$1" name
name=$(basename "$url")
wget -q "$url"
echo "${intel_checksums[${name}]} ${name}" | sha256sum -c -
}
# see https://github.com/intel/compute-runtime/blob/master/LEGACY_PLATFORMS.md for more info
# needed core package
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb
dpkg -i libigdgmm12_22.9.0_amd64.deb
rm libigdgmm12_22.9.0_amd64.deb
# legacy compute-runtime packages
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-level-zero-gpu-legacy1_1.5.30872.36_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb
# standard compute-runtime packages
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
wget https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb
fetch_intel_deb https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libze-intel-gpu1_26.14.37833.4-0_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb
fetch_intel_deb https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb
# npu packages
wget https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
wget https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
fetch_intel_deb https://github.com/oneapi-src/level-zero/releases/download/v1.28.2/level-zero_1.28.2+u22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-driver-compiler-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-fw-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
fetch_intel_deb https://github.com/intel/linux-npu-driver/releases/download/v1.19.0/intel-level-zero-npu_1.19.0.20250707-16111289554_ubuntu22.04_amd64.deb
dpkg -i *.deb
rm *.deb
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euxo pipefail
go2rtc_version="1.9.14"
# sha256 digests of the release binaries; update when bumping go2rtc_version.
declare -A go2rtc_checksums=(
["amd64"]="32d616af226bd731678ffde328b94cfb94e30339bfefc469cfb76323144615a6"
["arm64"]="359fabade8a7a51e81a55fe6df6b0ef81764a5e1d63179577534eaaa71904b50"
)
dest_dir="/rootfs/usr/local/go2rtc/bin"
mkdir -p "${dest_dir}"
wget -qO "${dest_dir}/go2rtc" \
"https://github.com/AlexxIT/go2rtc/releases/download/v${go2rtc_version}/go2rtc_linux_${TARGETARCH}"
echo "${go2rtc_checksums[${TARGETARCH}]} ${dest_dir}/go2rtc" | sha256sum -c -
chmod 755 "${dest_dir}/go2rtc"
+20 -2
View File
@@ -4,11 +4,29 @@ set -euxo pipefail
hailo_version="4.21.0"
# sha256 digests of the release artifacts; update when bumping hailo_version.
# The runtime tarball is keyed by TARGETARCH, the wheel by the python arch tag.
declare -A hailort_checksums=(
["amd64"]="0a57ac5f7cc8c2c3668133189d9285b55f498e8cb219797e203f6f5015fec4b3"
["arm64"]="dd840548eb5d0d147c99aee2cb013d39d64be09c5bc63061171fcfacf4547b3f"
["x86_64"]="8112a973ab48095399b29d883f31987828df5861b8553f614c89f098a67b3fb6"
["aarch64"]="658432a43573280d472f6402d7934669effe7f163ba3dffa31c50bbeeaa7c01d"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
arch="aarch64"
fi
wget -qO- "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz" | tar -C / -xzf -
wget -P /wheels/ "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
# downloaded rather than streamed into tar because streaming and verifying the
# digest before extraction are mutually exclusive
wget -qO /tmp/hailort.tar.gz "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-debian12-${TARGETARCH}.tar.gz"
echo "${hailort_checksums[${TARGETARCH}]} /tmp/hailort.tar.gz" | sha256sum -c -
tar -C / -xzf /tmp/hailort.tar.gz
rm -f /tmp/hailort.tar.gz
wheel="/wheels/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
mkdir -p /wheels
wget -qO "${wheel}" "https://github.com/frigate-nvr/hailort/releases/download/v${hailo_version}/hailort-${hailo_version}-cp311-cp311-linux_${arch}.whl"
echo "${hailort_checksums[${arch}]} ${wheel}" | sha256sum -c -
+20 -4
View File
@@ -4,6 +4,15 @@ set -euxo pipefail
s6_version="3.2.1.0"
# sha256 digests of the release artifacts, from the .sha256 files published at
# https://github.com/just-containers/s6-overlay/releases/tag/v3.2.1.0
# Update these when bumping s6_version.
declare -A s6_checksums=(
["noarch"]="42e038a9a00fc0fef70bf0bc42f625a9c14f8ecdfe77d4ad93281edf717e10c5"
["x86_64"]="8bcbc2cada58426f976b159dcc4e06cbb1454d5f39252b3bb0c778ccf71c9435"
["aarch64"]="c8fd6b1f0380d399422fc986a1e6799f6a287e2cfa24813ad0b6a4fb4fa755cc"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
s6_arch="x86_64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -12,8 +21,15 @@ fi
mkdir -p /rootfs/
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-noarch.tar.xz" |
tar -C /rootfs/ -Jxpf -
download_and_extract() {
local arch="$1"
local tarball="/tmp/s6-overlay-${arch}.tar.xz"
wget -qO "${tarball}" \
"https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${arch}.tar.xz"
echo "${s6_checksums[${arch}]} ${tarball}" | sha256sum -c -
tar -C /rootfs/ -Jxpf "${tarball}"
rm -f "${tarball}"
}
wget -qO- "https://github.com/just-containers/s6-overlay/releases/download/v${s6_version}/s6-overlay-${s6_arch}.tar.xz" |
tar -C /rootfs/ -Jxpf -
download_and_extract "noarch"
download_and_extract "${s6_arch}"
+9
View File
@@ -4,6 +4,14 @@ set -euxo pipefail
tempio_version="2021.09.0"
# sha256 digests of the release binaries; update when bumping tempio_version.
# Upstream publishes no checksums, so these come from a one-time fetch and
# guard against later substitution rather than the original download.
declare -A tempio_checksums=(
["amd64"]="b7b93ebfd24c1161cec7aecfad62ab51f2241149358cef354b86cdbc6a60546f"
["aarch64"]="3a5c32981ba68b75ed9b28497429e5a5cecbeb74c3b821b035a48b37609bb895"
)
if [[ "${TARGETARCH}" == "amd64" ]]; then
arch="amd64"
elif [[ "${TARGETARCH}" == "arm64" ]]; then
@@ -13,4 +21,5 @@ fi
mkdir -p /rootfs/usr/local/tempio/bin
wget -q -O /rootfs/usr/local/tempio/bin/tempio "https://github.com/home-assistant/tempio/releases/download/${tempio_version}/tempio_${arch}"
echo "${tempio_checksums[${arch}]} /rootfs/usr/local/tempio/bin/tempio" | sha256sum -c -
chmod 755 /rootfs/usr/local/tempio/bin/tempio
@@ -1,4 +1,12 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/certsync
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/certsync
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/certsync
@@ -1,4 +1,12 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/frigate
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/frigate
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/frigate
@@ -1,4 +1,12 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/go2rtc
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/go2rtc
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/go2rtc
+61
View File
@@ -0,0 +1,61 @@
#!/command/with-contenv bash
# shellcheck shell=bash
# Remap the frigate user to PUID/PGID and register EXTRA_GROUPS.
# No-op when: started with --user (euid != 0), FRIGATE_RUN_AS_ROOT=true,
# or PUID/PGID already match.
set -o errexit -o nounset -o pipefail
if [[ "$(id -u)" -ne 0 ]]; then
# Started with docker --user; the host owns UID mapping entirely.
exit 0
fi
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
echo "[INFO] FRIGATE_RUN_AS_ROOT=true: skipping user remapping"
exit 0
fi
puid="${PUID:-1000}"
pgid="${PGID:-1000}"
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 1
fi
# Remapping to 0 would make the frigate user root, so every service would keep
# full privilege while reporting a successful migration.
if [[ "$puid" -eq 0 || "$pgid" -eq 0 ]]; then
echo "[ERROR] PUID/PGID 0 would run the services as root and defeat the privilege separation." >&2
echo "[ERROR] Set FRIGATE_RUN_AS_ROOT=true if you want to keep running as root." >&2
exit 1
fi
current_uid="$(id -u frigate)"
current_gid="$(id -g frigate)"
if [[ "$puid" != "$current_uid" || "$pgid" != "$current_gid" ]]; then
if [[ ! -w /etc/passwd ]]; then
echo "[ERROR] PUID/PGID remapping needs a writable /etc and is not compatible with read_only: true." >&2
echo "[ERROR] Either remove read_only and keep PUID, or drop PUID/PGID and use docker's user: ${puid}:${pgid} instead." >&2
echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2
exit 1
fi
echo "[INFO] Remapping frigate user to ${puid}:${pgid}"
groupmod -o -g "$pgid" frigate
usermod -o -u "$puid" frigate
fi
# EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video)
if [[ -n "${EXTRA_GROUPS:-}" ]]; then
for gid in ${EXTRA_GROUPS//,/ }; do
if ! getent group "$gid" >/dev/null; then
groupadd -o -g "$gid" "frigate-extra-${gid}"
fi
group_name="$(getent group "$gid" | cut -d: -f1)"
usermod -aG "$group_name" frigate
usermod -aG "$group_name" go2rtc
echo "[INFO] Added frigate and go2rtc to supplementary group ${group_name} (gid ${gid})"
done
fi
@@ -0,0 +1 @@
oneshot
@@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/init-usermod/run
@@ -7,5 +7,12 @@ set -o errexit -o nounset -o pipefail
dirs=(/dev/shm/logs/frigate /dev/shm/logs/go2rtc /dev/shm/logs/nginx /dev/shm/logs/certsync)
mkdir -p "${dirs[@]}"
chown nobody:nogroup "${dirs[@]}"
# logutil-service drops s6-log to nobody, so the dirs must stay nobody-owned
# in root mode. Under docker --user we are already the (only) target user,
# chown would fail, and the plain s6-log fallback in the *-log services
# writes as us (the mkdir above is sufficient, /dev/shm is 1777).
if [[ "$(id -u)" -eq 0 ]]; then
chown nobody:nogroup "${dirs[@]}"
fi
chmod 02755 "${dirs[@]}"
@@ -1,4 +1,12 @@
#!/command/with-contenv bash
# shellcheck shell=bash
exec logutil-service /dev/shm/logs/nginx
if [[ "$(id -u)" -eq 0 ]]; then
# logutil-service drops to nobody and applies S6_LOGGING_SCRIPT
exec logutil-service /dev/shm/logs/nginx
fi
# Non-root (--user) fallback: logutil-service cannot change UID, so run
# s6-log directly with the same directives S6_LOGGING_SCRIPT configures.
# shellcheck disable=SC2086
exec s6-log ${S6_LOGGING_SCRIPT:-T 1 n0 s10000000 T} /dev/shm/logs/nginx
@@ -77,15 +77,20 @@ if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.
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
python3 /usr/local/nginx/get_nginx_settings.py | \
echo "$nginx_settings" | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
# build templates for additional network settings
python3 /usr/local/nginx/get_nginx_settings.py | \
echo "$nginx_settings" | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf
@@ -144,3 +144,16 @@ rm -f /dev/shm/.frigate-is-stopping
migrate_addon_config_dir
migrate_db_from_media_to_config
# Align volume ownership with the runtime user (one sweep per PUID/schema
# change, guarded by the sentinel; see fix-ownership). The escape hatch
# deletes the sentinel instead: ownership is never mutated while it is on,
# so the next non-root boot must re-sweep whatever root created meanwhile.
if [[ "$(id -u)" -eq 0 ]]; then
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
rm -f /config/.permissions_version
else
/usr/local/bin/fix-ownership --sentinel /config/.permissions_version \
"${PUID:-1000}" "${PGID:-1000}" /config /media/frigate
fi
fi
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# Single source of truth for aligning volume ownership with the runtime user.
#
# Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH [PATH...]
#
# --dry-run report what would change, touch nothing
# --sentinel skip entirely when FILE already records "SCHEMA:UID:GID";
# write it after a successful run (used by the boot path so
# multi-TB volumes are swept once per UID/schema change, not
# on every boot)
#
# Only files whose uid OR gid differs are touched, so re-runs are cheap.
# Top-level /config additionally grants group frigate-data TRAVERSE ONLY
# (g+rx) so the separate go2rtc user can reach its pre-created HomeKit file
# on hosts where /config is mounted 0700. Never g+w: directory write means
# unlink rights over frigate.db/config.yml, and would let a compromised
# go2rtc plant /config/go2rtc, which the go2rtc run script executes
# preferentially, as root under the escape hatch.
set -o errexit -o nounset -o pipefail
# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=1
dry_run=0
sentinel=""
while [[ "${1:-}" == --* ]]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--sentinel)
if [[ -z "${2:-}" ]]; then
echo "[ERROR] fix-ownership: --sentinel requires a file argument" >&2
exit 2
fi
sentinel="$2"; shift 2 ;;
*) echo "[ERROR] fix-ownership: unknown option $1" >&2; exit 2 ;;
esac
done
if [[ $# -lt 3 ]]; then
echo "Usage: fix-ownership [--dry-run] [--sentinel FILE] UID GID PATH..." >&2
exit 2
fi
target_uid="$1"
target_gid="$2"
shift 2
if [[ "$(id -u)" -ne 0 ]]; then
echo "[INFO] fix-ownership: not running as root, skipping (ownership is managed by the host in --user mode)"
exit 0
fi
# A dry run always inspects: the sentinel records what a past sweep did, not
# what the volume looks like now, and reporting from it would hide later drift.
if [[ "$dry_run" -eq 0 && -n "$sentinel" && -f "$sentinel" && "$(cat "$sentinel")" == "${schema}:${target_uid}:${target_gid}" ]]; then
echo "[INFO] fix-ownership: ${target_uid}:${target_gid} (schema ${schema}) already applied, skipping"
exit 0
fi
# A sweep that could not chown everything must not be recorded as complete:
# the sentinel would make every later boot skip it and the entries would stay
# unreachable once services run unprivileged.
swept_clean=1
for path in "$@"; do
# An absent root is an incomplete sweep, not a finished one: /media/frigate
# is not in the image, so a boot before the volume is mounted would
# otherwise record success and the volume would never be swept once added.
if [[ ! -d "$path" ]]; then
swept_clean=0
echo "[WARN] fix-ownership: $path does not exist, skipping; will retry on next boot"
continue
fi
# find may fail mid-walk on a live volume (file deleted under it) or on a
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
# complete without having looked.
if ! count=$(find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) -printf '.' 2>/dev/null | wc -c); then
swept_clean=0
echo "[WARN] fix-ownership: could not scan ${path}; will retry on next boot"
continue
fi
if [[ "$count" -eq 0 ]]; then
echo "[INFO] fix-ownership: $path already owned by ${target_uid}:${target_gid}, nothing to do"
continue
fi
# find does not descend symlinks and chown -h retargets the link itself, so
# anything behind a symlinked directory is outside this sweep. Following
# them is not an option: a link could walk the chown out of the volume.
if [[ -n "$(find "$path" -type l -xtype d -print -quit 2>/dev/null)" ]]; then
echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
fi
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}; on large recordings volumes this can take a long time"
if [[ "$dry_run" -eq 1 ]]; then
echo "[INFO] fix-ownership: dry run, not changing ${path}"
continue
fi
find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
-exec chown -h "${target_uid}:${target_gid}" {} + || {
swept_clean=0
echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
}
done
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
# Write access is per-file, not per-directory: go2rtc's PatchConfig rewrites
# the first -config file via os.WriteFile (in-place truncate, no rename,
# verified against go2rtc v1.9.14 internal/app/config.go), and the file is
# always pre-created by setup_homekit_config before go2rtc starts, so
# O_CREATE never needs directory write. See header comment for why g+w is
# forbidden here.
if [[ "$dry_run" -eq 0 && -d /config ]]; then
chgrp frigate-data /config 2>/dev/null || true
chmod g+rx /config 2>/dev/null || true
fi
if [[ "$dry_run" -eq 0 && -n "$sentinel" && "$swept_clean" -eq 1 ]]; then
echo "${schema}:${target_uid}:${target_gid}" > "$sentinel" || \
echo "[WARN] fix-ownership: could not write ${sentinel}; the sweep will run again on next boot"
fi
@@ -189,3 +189,6 @@ if config.get("birdseye", {}).get("restream", False):
# Write go2rtc_config to /dev/shm/go2rtc.yaml
with open("/dev/shm/go2rtc.yaml", "w") as f:
yaml.dump(go2rtc_config, f)
# config contains camera credentials; do not leave it world-readable
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
@@ -11,6 +11,7 @@ events {
http {
map_hash_bucket_size 256;
server_tokens off;
include mime.types;
default_type application/octet-stream;
@@ -62,6 +63,7 @@ http {
server {
include listen.conf;
include security_headers.conf;
# enable HTTP/2 for TLS connections to eliminate browser 6-connection limit
http2 on;
@@ -123,6 +125,7 @@ http {
secure_token $args;
secure_token_types application/vnd.apple.mpegurl;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
@@ -139,6 +142,7 @@ http {
location /stream/ {
include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
@@ -160,6 +164,7 @@ http {
}
expires 7d;
include security_headers.conf;
add_header Cache-Control "public";
autoindex on;
root /media/frigate;
@@ -252,6 +257,7 @@ http {
location /api/ {
include auth_request.conf;
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
proxy_pass http://frigate_api/;
@@ -318,29 +324,34 @@ http {
location / {
# do not require auth for static assets
include security_headers.conf;
add_header Cache-Control "no-store";
expires off;
location /assets/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /fonts/ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
}
location /locales/ {
access_log off;
include security_headers.conf;
add_header Cache-Control "public";
}
location ~ ^/.*-([A-Za-z0-9]+)\.webmanifest$ {
access_log off;
expires 1y;
include security_headers.conf;
add_header Cache-Control "public";
default_type application/json;
proxy_set_header Accept-Encoding "";
@@ -0,0 +1,5 @@
# Deliberately no X-Frame-Options or CSP frame-ancestors: HA's Webpage card and
# iframe panels frame Frigate cross-origin, and either would break them
# silently. Bind-mount this file to add your own.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Ahead-of-time volume ownership migration for switching Frigate to non-root.
# Run from the host BEFORE enabling PUID/PGID or --user:
#
# ./fix-permissions.sh [--dry-run] <config_dir> <media_dir> [PUID] [PGID]
#
# Wraps the image's fix-ownership helper so there is exactly one
# implementation of the chown logic. Requires an image that contains the
# helper (any release that includes non-root support).
set -o errexit -o nounset -o pipefail
IMAGE="${FRIGATE_IMAGE:-ghcr.io/blakeblackshear/frigate:stable}"
dry_run_flag=""
if [[ "${1:-}" == "--dry-run" ]]; then
dry_run_flag="--dry-run"
shift
fi
if [[ $# -lt 2 ]]; then
echo "Usage: $0 [--dry-run] <config_dir> <media_dir> [PUID] [PGID]" >&2
exit 2
fi
config_dir="$1"
media_dir="$2"
puid="${3:-1000}"
pgid="${4:-1000}"
# The ids are interpolated into the container's bash -c source below, so
# anything but digits would be reparsed as shell rather than passed through
if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
echo "[ERROR] PUID and PGID must be numeric, got '${puid}' and '${pgid}'" >&2
exit 2
fi
echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)"
# shellcheck disable=SC2086
docker run --rm \
-v "${config_dir}:/config" \
-v "${media_dir}:/media/frigate" \
--entrypoint bash \
"${IMAGE}" \
-c "command -v fix-ownership >/dev/null || { echo '[ERROR] this Frigate image predates non-root support; set FRIGATE_IMAGE to a release that includes it' >&2; exit 1; }; exec fix-ownership ${dry_run_flag} ${puid} ${pgid} /config /media/frigate"
@@ -312,8 +312,9 @@ ffmpeg:
:::note
If running Frigate through Docker, you either need to run in privileged mode or
map the `/dev/video*` devices to Frigate. With Docker Compose add:
If running Frigate through Docker, map the relevant `/dev/video*` devices into
the container. Running in privileged mode also works but grants far more access
than needed. With Docker Compose add:
```yaml {4-5}
services:
+28 -1
View File
@@ -514,7 +514,7 @@ Generate a Frigate Docker Compose configuration based on your hardware and requi
services:
frigate:
container_name: frigate
privileged: true # this may not be necessary for all setups
# privileged: true # ONLY enable if your hardware requires it (see hardware-specific docs); prefer the device mappings below
restart: unless-stopped
stop_grace_period: 30s # allow enough time to shut down the various services
image: ghcr.io/blakeblackshear/frigate:stable
@@ -546,6 +546,33 @@ services:
</TabItem>
</Tabs>
### 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):
```yaml
services:
frigate:
...
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
:::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]`.
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups)
are called out in their own sections and are unaffected by this guidance.
:::
**Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this:
@@ -219,6 +219,8 @@ hardware:
- host: "/run/mxa_manager"
container: "/run/mxa_manager"
comment: "MemryX manager"
privileged: true
privilegedReason: "required by MemryX to reach the max-manager"
- id: "axera"
label: "AXERA Accelerator"
@@ -104,6 +104,10 @@ export interface DeviceConfig {
extraHosts?: string[];
/** Security options, e.g. ["apparmor=unconfined"] */
securityOpt?: string[];
/** Set only when this device type cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
/** Whether this device type needs the NVIDIA GPU config UI */
needsNvidiaConfig?: boolean;
}
@@ -127,6 +131,10 @@ export interface HardwareOption {
volumes?: VolumeMapping[];
/** Extra environment variables */
env?: Record<string, string>;
/** Set only when this hardware cannot work without full privileged mode */
privileged?: boolean;
/** Why privileged mode is required, rendered as an inline comment */
privilegedReason?: string;
}
/** Port definition */
@@ -1,6 +1,7 @@
import type {
DeviceConfig,
DeviceMapping,
HardwareOption,
VolumeMapping,
} from "../config/types";
import { hardwareMap } from "../config";
@@ -194,13 +195,32 @@ function buildExtraHosts(device: DeviceConfig): string[] {
}
function buildSecurityOpt(device: DeviceConfig): string[] {
if (!device.securityOpt?.length) return [];
// no-new-privileges is the baseline for every setup; device-specific entries
// are appended so only one security_opt key is ever emitted
return [
" security_opt:",
...device.securityOpt.map((s) => ` - ${s}`),
" - no-new-privileges:true",
...(device.securityOpt ?? []).map((s) => ` - ${s}`),
];
}
/**
* Emit privileged mode only for hardware that genuinely cannot work without it.
* Everything else gets device mappings, which grant far less access.
*/
function buildPrivileged(
device: DeviceConfig,
selectedHardware: HardwareOption[]
): string[] {
const requiring = [device, ...selectedHardware].filter((c) => c.privileged);
if (!requiring.length) return [];
const reasons = requiring
.map((c) => c.privilegedReason)
.filter((r): r is string => Boolean(r));
const comment = reasons.length ? ` # ${reasons.join("; ")}` : "";
return [` privileged: true${comment}`];
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -217,11 +237,14 @@ export function generateDockerCompose(input: GeneratorInput): string {
const hwVolumes: VolumeMapping[] = [];
const hwEnv: Record<string, string> = {};
const selectedHw: HardwareOption[] = [];
for (const hwId of input.selectedHardware) {
const hw = hardwareMap.get(hwId);
if (!hw) continue;
// Skip GPU device mapping for tensorrt images (it uses deploy instead)
if (hw.id === "gpu" && device.imageTag === "stable-tensorrt") continue;
selectedHw.push(hw);
hwDevices.push(...(hw.devices ?? []));
hwVolumes.push(...(hw.volumes ?? []));
Object.assign(hwEnv, hw.env ?? {});
@@ -231,7 +254,7 @@ export function generateDockerCompose(input: GeneratorInput): string {
"services:",
" frigate:",
" container_name: frigate",
" privileged: true # This may not be necessary for all setups",
...buildPrivileged(device, selectedHw),
" restart: unless-stopped",
" stop_grace_period: 30s # Allow enough time to shut down the various services",
...buildImage(device),
+7
View File
@@ -858,9 +858,12 @@ def login(request: Request, body: AppPostLoginBody):
user = body.user
password = body.password
remote_addr = get_remote_addr(request)
try:
db_user: User = User.get_by_id(user)
except DoesNotExist:
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
return JSONResponse(content={"message": "Login failed"}, status_code=401)
password_hash = db_user.password_hash
@@ -888,6 +891,10 @@ def login(request: Request, body: AppPostLoginBody):
request.app.frigate_config.auth.admin_first_time_login = False
return response
logger.warning(
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
)
return JSONResponse(content={"message": "Login failed"}, status_code=401)
+2
View File
@@ -86,6 +86,7 @@ from frigate.timeline import TimelineProcessor
from frigate.track.object_processing import TrackedObjectProcessor
from frigate.util.builtin import empty_and_close_queue
from frigate.util.image import UntrackedSharedMemory
from frigate.util.ownership import chown_to_runtime
from frigate.util.process import FrigateProcess
from frigate.util.services import set_file_limit
from frigate.version import VERSION
@@ -149,6 +150,7 @@ class FrigateApp:
if not os.path.exists(d) and not os.path.islink(d):
logger.info(f"Creating directory: {d}")
os.makedirs(d, exist_ok=True)
chown_to_runtime(d)
else:
logger.debug(f"Skipping directory: {d}")
+19 -4
View File
@@ -83,7 +83,7 @@ class BaseLocalDetector(ObjectDetector):
raw_detections = self.detect_raw(tensor_input) # type: ignore[attr-defined]
for d in raw_detections:
if int(d[0]) < 0 or int(d[0]) >= len(self.labels):
if int(d[0]) not in self.labels:
logger.warning(f"Raw Detect returned invalid label: {d}")
continue
if d[1] < threshold:
@@ -395,6 +395,9 @@ class RemoteObjectDetector:
self.labels = labels
self.name = name
self.fps = EventsPerSecond()
# class ids already warned about, so an incomplete labelmap logs once
# per id instead of once per frame
self.unnamed_class_ids: set[int] = set()
self.detection_queue = detection_queue
self.stop_event = stop_event
self.shm = UntrackedSharedMemory(name=self.name, create=False)
@@ -436,9 +439,21 @@ class RemoteObjectDetector:
for d in self.out_np_shm:
if d[1] < threshold:
break
detections.append(
(self.labels[int(d[0])], float(d[1]), (d[2], d[3], d[4], d[5]))
)
class_id = int(d[0])
label = self.labels.get(class_id)
if label is None:
if class_id not in self.unnamed_class_ids:
self.unnamed_class_ids.add(class_id)
logger.warning(
"Detector returned class id %d for %s, which the labelmap does not name. Check that labelmap_path matches the model",
class_id,
self.name,
)
continue
detections.append((label, float(d[1]), (d[2], d[3], d[4], d[5])))
self.fps.update()
return detections
+45
View File
@@ -0,0 +1,45 @@
"""Tests for authentication endpoints."""
import os
from unittest.mock import patch
from frigate.api.auth import hash_password
from frigate.const import JWT_SECRET_ENV_VAR
from frigate.models import User
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
class TestHttpAuth(BaseTestHttp):
def setUp(self):
super().setUp([User])
self.app = super().create_app()
def tearDown(self):
User.delete().execute()
super().tearDown()
def test_login_unknown_user_logs_warning(self):
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
with AuthTestClient(self.app) as client:
response = client.post(
"/login", json={"user": "ghost", "password": "irrelevant"}
)
assert response.status_code == 401
assert any("Login failed" in m and "ghost" in m for m in logs.output)
def test_login_bad_password_logs_warning(self):
password_hash = hash_password("correct-horse-battery", iterations=1000)
User.insert(
username="admin",
password_hash=password_hash,
role="admin",
notification_tokens=[],
).execute()
with self.assertLogs("frigate.api.auth", level="WARNING") as logs:
with AuthTestClient(self.app) as client:
response = client.post(
"/login", json={"user": "admin", "password": "wrong"}
)
assert response.status_code == 401
assert any("Login failed" in m and "admin" in m for m in logs.output)
+21
View File
@@ -26,6 +26,26 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
self.assertEqual(result["severity"], "warning")
self.assertEqual(result["max_gap"], 5.5)
def test_fixed_pattern_for_regular_gop(self):
# a 5s GOP with normal encoder jitter is sparse but not variable
pts = [0.0, 4.98, 10.01, 15.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "warning")
self.assertEqual(result["pattern"], "fixed")
def test_variable_pattern_for_smart_codec(self):
# keyframes bunched up then a long stretch without one
pts = [0.0, 1.0, 2.0, 8.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "warning")
self.assertEqual(result["pattern"], "variable")
def test_fixed_pattern_for_short_regular_gop(self):
pts = [0.0, 1.0, 2.0, 3.0]
result = classify_keyframe_gaps(pts, segment_time=10)
self.assertEqual(result["severity"], "ok")
self.assertEqual(result["pattern"], "fixed")
def test_error_when_gap_exceeds_segment_time(self):
pts = [0.0, 12.0] # 12s gap > 10s segment
result = classify_keyframe_gaps(pts, segment_time=10)
@@ -40,6 +60,7 @@ class TestClassifyKeyframeGaps(unittest.TestCase):
result = classify_keyframe_gaps([1.0], segment_time=10)
self.assertEqual(result["severity"], "unknown")
self.assertIsNone(result["max_gap"])
self.assertIsNone(result["pattern"])
self.assertEqual(result["keyframe_count"], 1)
def test_unknown_with_no_keyframes(self):
+75 -8
View File
@@ -1,7 +1,8 @@
import unittest
from unittest.mock import Mock, patch
from unittest.mock import MagicMock, Mock, patch
import numpy as np
import zmq
from pydantic import parse_obj_as
import frigate.detectors as detectors
@@ -108,13 +109,13 @@ class TestLocalObjectDetector(unittest.TestCase):
("label-2", 0.5, (8, 7, 6, 5)),
]
TEST_LABEL_FILE = "/test_labels.txt"
mock_load_labels.return_value = [
"label-1",
"label-2",
"label-3",
"label-4",
"label-5",
]
mock_load_labels.return_value = {
0: "label-1",
1: "label-2",
2: "label-3",
3: "label-4",
4: "label-5",
}
test_cfg = parse_obj_as(DetectorConfig, {"type": "cpu", "model": {}})
test_cfg.model = ModelConfig()
@@ -136,3 +137,69 @@ class TestLocalObjectDetector(unittest.TestCase):
== np.zeros((1, 32, 32, 3)).shape
)
assert test_result == TEST_DETECT_RESULT
class TestRemoteObjectDetector(unittest.TestCase):
"""Cover the label lookup that turns raw class ids into detections."""
def _build_detector(self, labels, rows):
detector = frigate.object_detection.base.RemoteObjectDetector.__new__(
frigate.object_detection.base.RemoteObjectDetector
)
detector.labels = labels
detector.name = "front_door"
detector.fps = MagicMock()
detector.stop_event = MagicMock()
detector.stop_event.is_set.return_value = False
detector.unnamed_class_ids = set()
detector.np_shm = np.zeros((1, 320, 320, 3), np.uint8)
detector.out_np_shm = np.array(rows, np.float32)
detector.detection_queue = MagicMock()
detector.detector_subscriber = MagicMock()
detector.detector_subscriber.socket.recv_string.side_effect = zmq.Again()
detector.detector_subscriber.check_for_update.return_value = "front_door"
return detector
def test_maps_class_ids_to_labels(self):
rows = [[2, 0.9, 0.1, 0.2, 0.3, 0.4], [0, 0.8, 0.5, 0.6, 0.7, 0.8]] + [
[0, 0, 0, 0, 0, 0]
] * 18
detector = self._build_detector({0: "person", 2: "car"}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual([r[0] for r in results], ["car", "person"])
def test_skips_class_ids_the_labelmap_does_not_name(self):
# a labelmap that names fewer classes than the model emits
rows = [[7, 0.9, 0.1, 0.2, 0.3, 0.4], [0, 0.8, 0.5, 0.6, 0.7, 0.8]] + [
[0, 0, 0, 0, 0, 0]
] * 18
detector = self._build_detector({0: "person"}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual([r[0] for r in results], ["person"])
self.assertEqual(detector.unnamed_class_ids, {7})
def test_warns_once_per_unnamed_class_id(self):
rows = [
[7, 0.9, 0.1, 0.2, 0.3, 0.4],
[7, 0.8, 0.1, 0.2, 0.3, 0.4],
[9, 0.7, 0.1, 0.2, 0.3, 0.4],
] + [[0, 0, 0, 0, 0, 0]] * 17
detector = self._build_detector({0: "person"}, rows)
with self.assertLogs("frigate.object_detection.base", level="WARNING") as logs:
detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual(len(logs.output), 2)
self.assertEqual(detector.unnamed_class_ids, {7, 9})
def test_empty_labelmap_drops_detections_instead_of_raising(self):
rows = [[0, 0.9, 0.1, 0.2, 0.3, 0.4]] + [[0, 0, 0, 0, 0, 0]] * 19
detector = self._build_detector({}, rows)
results = detector.detect(np.zeros((1, 320, 320, 3), np.uint8))
self.assertEqual(results, [])
+59
View File
@@ -0,0 +1,59 @@
"""Tests for runtime ownership helpers."""
import unittest
from unittest.mock import patch
from frigate.util import ownership
class FakePwEntry:
pw_uid = 1500
pw_gid = 1500
# The devcontainer image exports FRIGATE_RUN_AS_ROOT, so any test that has to
# reach past the escape-hatch check pins the variable instead of inheriting it.
class TestGetRuntimeIds(unittest.TestCase):
@patch("frigate.util.ownership.os.geteuid", return_value=1000)
def test_returns_none_when_not_root(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "true"})
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_with_escape_hatch(self, _):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", side_effect=KeyError)
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_none_outside_frigate_image(self, *_):
assert ownership.get_runtime_ids() is None
@patch.dict("os.environ", {"FRIGATE_RUN_AS_ROOT": "false"})
@patch("frigate.util.ownership.pwd.getpwnam", return_value=FakePwEntry())
@patch("frigate.util.ownership.os.geteuid", return_value=0)
def test_returns_frigate_ids_as_root(self, *_):
assert ownership.get_runtime_ids() == (1500, 1500)
class TestChownToRuntime(unittest.TestCase):
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=None)
def test_noop_when_no_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_not_called()
@patch("frigate.util.ownership.os.chown")
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_chowns_to_runtime_ids(self, _, chown):
ownership.chown_to_runtime("/config/test")
chown.assert_called_once_with("/config/test", 1500, 1500)
@patch("frigate.util.ownership.os.chown", side_effect=OSError("ro fs"))
@patch("frigate.util.ownership.get_runtime_ids", return_value=(1500, 1500))
def test_swallows_oserror(self, *_):
ownership.chown_to_runtime("/config/test") # must not raise
if __name__ == "__main__":
unittest.main()
+8 -1
View File
@@ -152,12 +152,19 @@ def get_record_segment_time(config: "CameraConfig") -> int:
def load_labels(
path: str | None, encoding="utf-8", prefill=91, indexed: bool | None = None
path: str | None, encoding="utf-8", prefill=0, indexed: bool | None = None
):
"""Loads labels from file (with or without index numbers).
Only the indices the file defines are returned, so the result describes
exactly the classes a model can name. Callers must treat a missing index
as an unnamed class rather than assuming a contiguous range.
Args:
path: path to label file.
encoding: label file encoding.
prefill: pad indices below this with "unknown" before reading the file.
indexed: whether lines start with an index; auto-detected when None.
Returns:
Dictionary mapping indices to labels.
"""
+43
View File
@@ -0,0 +1,43 @@
"""Helpers for aligning created files with the non-root runtime user."""
import logging
import os
import pwd
logger = logging.getLogger(__name__)
RUNTIME_USER = "frigate"
def get_runtime_ids() -> tuple[int, int] | None:
"""Return (uid, gid) that services run as, or None when chown is not applicable.
None when: not root (docker --user, so the host already mapped us),
FRIGATE_RUN_AS_ROOT=true (escape hatch must not mutate ownership),
or outside the Frigate container image (no frigate user).
"""
if os.geteuid() != 0:
return None
if os.environ.get("FRIGATE_RUN_AS_ROOT", "false") == "true":
return None
try:
user = pwd.getpwnam(RUNTIME_USER)
except KeyError:
return None
return (user.pw_uid, user.pw_gid)
def chown_to_runtime(path: str) -> None:
"""Best-effort chown of path to the runtime user."""
ids = get_runtime_ids()
if ids is None:
return
try:
os.chown(path, *ids)
except OSError as err:
logger.warning(f"Unable to set ownership of {path}: {err}")
+13 -1
View File
@@ -1061,6 +1061,7 @@ def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedPro
KEYFRAME_PROBE_WINDOW_SECONDS = 20
KEYFRAME_GAP_WARNING_SECONDS = 4.0
KEYFRAME_GAP_JITTER_SECONDS = 0.5
def parse_keyframe_packets(output: str) -> tuple[list[float], float | None]:
@@ -1100,6 +1101,10 @@ def classify_keyframe_gaps(
- "error" when the longest gap exceeds the record segment length
- "warning" when the longest gap exceeds the warning threshold
- "ok" otherwise
The "pattern" key separates the two causes so callers can give accurate
advice: "fixed" is a regular GOP that is simply too long, "variable" is
the irregular spacing a smart/+ codec produces.
"""
thresholds = {
"warning": KEYFRAME_GAP_WARNING_SECONDS,
@@ -1112,6 +1117,7 @@ def classify_keyframe_gaps(
"max_gap": None,
"mean_gap": None,
"min_gap": None,
"pattern": None,
"segment_time": segment_time,
"severity": "unknown",
"thresholds": thresholds,
@@ -1119,6 +1125,7 @@ def classify_keyframe_gaps(
gaps = [b - a for a, b in zip(keyframe_pts, keyframe_pts[1:])]
max_gap = max(gaps)
min_gap = min(gaps)
if max_gap > segment_time:
severity = "error"
@@ -1127,11 +1134,16 @@ def classify_keyframe_gaps(
else:
severity = "ok"
# allow for encoder jitter and probe rounding before calling a GOP variable
tolerance = max(KEYFRAME_GAP_JITTER_SECONDS, min_gap * 0.25)
pattern = "variable" if (max_gap - min_gap) > tolerance else "fixed"
return {
"keyframe_count": len(keyframe_pts),
"max_gap": round(max_gap, 2),
"mean_gap": round(sum(gaps) / len(gaps), 2),
"min_gap": round(min(gaps), 2),
"min_gap": round(min_gap, 2),
"pattern": pattern,
"segment_time": segment_time,
"severity": severity,
"thresholds": thresholds,
+4 -2
View File
@@ -184,8 +184,10 @@
"gap": "Keyframe gap (min / avg / max):",
"segmentLength": "Recording segment length:",
"ok": "Keyframes every ~{{seconds}}s, good for recording and playback.",
"warning": "Sparse or variable keyframes (longest gap ~{{seconds}}s), likely a smart codec (H.264+/H.265+), this is not recommended.",
"error": "Keyframe gap (~{{seconds}}s) exceeds the recording segment length ({{segmentTime}}s). Some segments may have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.",
"warningFixed": "Keyframes are evenly spaced but sparse (every ~{{seconds}}s). Recording still works, but live playback and seeking start more slowly. Set the camera's I-frame (keyframe) interval to match its frame rate.",
"warningVariable": "Keyframe spacing is inconsistent ({{minSeconds}}s to {{maxSeconds}}s), which usually means a smart codec (H.264+/H.265+) is enabled. This is not recommended.",
"errorFixed": "Keyframes every ~{{seconds}}s is longer than the recording segment length ({{segmentTime}}s), so some segments have no keyframe and will not play back. Shorten the camera's I-frame (keyframe) interval to match its frame rate.",
"errorVariable": "Keyframe gaps reach ~{{seconds}}s, longer than the recording segment length ({{segmentTime}}s). Some segments will have no keyframe, which breaks playback. Disable the smart/+ codec on the camera or shorten its keyframe interval.",
"unknown": "Couldn't determine keyframe spacing.",
"recordDisabled": "Recording is disabled for this camera."
}
@@ -89,17 +89,29 @@ export default function KeyframeAnalysisSection({
case "warning":
summary = (
<Row icon="warning">
{t("cameras.info.keyframes.warning", { seconds: analysis.max_gap })}
{analysis.pattern === "fixed"
? t("cameras.info.keyframes.warningFixed", {
seconds: analysis.mean_gap,
})
: t("cameras.info.keyframes.warningVariable", {
minSeconds: analysis.min_gap,
maxSeconds: analysis.max_gap,
})}
</Row>
);
break;
case "error":
summary = (
<Row icon="error">
{t("cameras.info.keyframes.error", {
seconds: analysis.max_gap,
segmentTime: analysis.segment_time,
})}
{analysis.pattern === "fixed"
? t("cameras.info.keyframes.errorFixed", {
seconds: analysis.mean_gap,
segmentTime: analysis.segment_time,
})
: t("cameras.info.keyframes.errorVariable", {
seconds: analysis.max_gap,
segmentTime: analysis.segment_time,
})}
</Row>
);
break;
+3
View File
@@ -161,6 +161,8 @@ export type KeyframeSeverity =
| "unknown"
| "record_disabled";
export type KeyframeGapPattern = "fixed" | "variable";
export type KeyframeAnalysis = {
severity: KeyframeSeverity;
stream_index?: number;
@@ -168,6 +170,7 @@ export type KeyframeAnalysis = {
max_gap?: number | null;
mean_gap?: number | null;
min_gap?: number | null;
pattern?: KeyframeGapPattern | null;
duration_observed?: number | null;
segment_time?: number;
thresholds?: { warning: number; error: number };