mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-30 20:06:51 +03:00
Compare commits
3
Commits
468258a7c3
...
2347f954bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2347f954bb | ||
|
|
2638729c56 | ||
|
|
7a49eb4bbb |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Executable
+19
@@ -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"
|
||||
@@ -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 -
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
Executable
+45
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user