mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 13:12:48 +03:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39e9612d0c | ||
|
|
4d89bd3309 | ||
|
|
e7496367e9 | ||
|
|
1b1ee3d7c9 | ||
|
|
03e5ab9d1f | ||
|
|
9eda0b657a | ||
|
|
7c49650575 | ||
|
|
31a18d8771 | ||
|
|
7dc981e5be | ||
|
|
c90e00061a | ||
|
|
daed9ebeb3 | ||
|
|
3356ed4eb5 | ||
|
|
ee2ae1a957 | ||
|
|
ac77d06d83 | ||
|
|
d7bb780ff8 | ||
|
|
893542f4ca | ||
|
|
ecffe9ce25 | ||
|
|
303c6663c5 |
@@ -1,2 +1,2 @@
|
||||
numpy
|
||||
openvino >= 2026.2.0
|
||||
openvino >= 2026.4.1
|
||||
|
||||
@@ -23,7 +23,7 @@ pandas == 3.0.*
|
||||
peewee == 4.5.*
|
||||
peewee_migrate == 2.3.*
|
||||
psutil == 7.2.*
|
||||
pydantic == 2.13.*
|
||||
pydantic == 2.14.*
|
||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.5
|
||||
pyzmq == 27.2.*
|
||||
@@ -42,16 +42,16 @@ opencv-python-headless == 4.11.0.*
|
||||
opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.17.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2025.4.*
|
||||
openvino == 2026.4.*
|
||||
onnxruntime == 1.30.*
|
||||
# Embeddings
|
||||
transformers == 5.19.*
|
||||
# Generative AI
|
||||
google-genai == 2.29.*
|
||||
google-genai == 2.31.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.26.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.4
|
||||
py-vapid == 1.9.6
|
||||
pywebpush == 2.5.*
|
||||
# alpr
|
||||
pyclipper == 1.4.*
|
||||
|
||||
@@ -1 +1 @@
|
||||
scikit-build == 0.18.*
|
||||
scikit-build == 0.19.*
|
||||
|
||||
+11
-50
@@ -2,32 +2,9 @@
|
||||
|
||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG ROCM=1
|
||||
ARG HSA_OVERRIDE_GFX_VERSION
|
||||
ARG HSA_OVERRIDE
|
||||
|
||||
#######################################################################
|
||||
FROM wget AS rocm
|
||||
|
||||
ARG ROCM
|
||||
|
||||
RUN apt update -qq && \
|
||||
apt install -y wget gpg && \
|
||||
wget -O rocm.deb https://repo.radeon.com/amdgpu-install/7.2.3/ubuntu/jammy/amdgpu-install_7.2.3.70203-1_all.deb && \
|
||||
apt install -y ./rocm.deb && \
|
||||
apt update && \
|
||||
apt install -qq -y rocm
|
||||
|
||||
RUN mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib
|
||||
RUN cd /opt/rocm-$ROCM/lib && \
|
||||
cp -dpr libMIOpen*.so* libamd*.so* libhip*.so* libhsa*.so* libmigraphx*.so* librocm*.so* librocblas*.so* libroctracer*.so* librocsolver*.so* librocfft*.so* librocprofiler*.so* libroctx*.so* librocroller.so* /opt/rocm-dist/opt/rocm-$ROCM/lib/ && \
|
||||
mkdir -p /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib && \
|
||||
cp -dpr migraphx/lib/* /opt/rocm-dist/opt/rocm-$ROCM/lib/migraphx/lib
|
||||
RUN cd /opt/rocm-dist/opt/ && ln -s rocm-$ROCM rocm
|
||||
|
||||
RUN mkdir -p /opt/rocm-dist/etc/ld.so.conf.d/
|
||||
RUN echo /opt/rocm/lib|tee /opt/rocm-dist/etc/ld.so.conf.d/rocm.conf
|
||||
|
||||
#######################################################################
|
||||
FROM deps AS deps-prelim
|
||||
|
||||
@@ -42,51 +19,35 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.
|
||||
rm -f /etc/apt/sources.list.d/trixie.list && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
|
||||
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
|
||||
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||
&& python3 get-pip.py "pip" --break-system-packages
|
||||
RUN python3 -m pip config set global.break-system-packages true
|
||||
|
||||
# ROCm installs from AMD's wheels ahead of Frigate's code so code changes
|
||||
# reuse this layer, and is pruned in the same layer so the removed files never
|
||||
# reach the image. /opt/rocm links to the runtime for rocminfo and HIP headers.
|
||||
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
|
||||
RUN pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install -r /requirements.txt
|
||||
RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \
|
||||
pip3 uninstall -y onnxruntime \
|
||||
&& pip3 install --no-cache-dir -r /requirements.txt \
|
||||
&& ROCM_SDK_CORE="$(python3 -c 'import _rocm_sdk_core, os; print(os.path.dirname(_rocm_sdk_core.__file__))')" \
|
||||
&& /deps/prune_rocm.sh "$(dirname "${ROCM_SDK_CORE}")" \
|
||||
&& ln -s "${ROCM_SDK_CORE}" /opt/rocm
|
||||
|
||||
#######################################################################
|
||||
FROM scratch AS rocm-dist
|
||||
|
||||
ARG ROCM
|
||||
|
||||
# Copy HIP headers required for MIOpen JIT (BuildHip) / HIPRTC at runtime
|
||||
COPY --from=rocm /opt/rocm-${ROCM}/include/ /opt/rocm-${ROCM}/include/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/bin/rocminfo /opt/rocm-$ROCM/bin/migraphx-driver /opt/rocm-$ROCM/bin/
|
||||
# Copy MIOpen database files for gfx10xx, gfx11xx, and gfx12xx only (RDNA2/RDNA3/RDNA4)
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx10* /opt/rocm-$ROCM/share/miopen/db/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx11* /opt/rocm-$ROCM/share/miopen/db/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/share/miopen/db/*gfx12* /opt/rocm-$ROCM/share/miopen/db/
|
||||
# Copy rocBLAS library files for gfx10xx, gfx11xx, and gfx12xx only
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx10* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx11* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-$ROCM/lib/rocblas/library/*gfx12* /opt/rocm-$ROCM/lib/rocblas/library/
|
||||
COPY --from=rocm /opt/rocm-dist/ /
|
||||
WORKDIR /opt/frigate
|
||||
COPY --from=rootfs / /
|
||||
|
||||
#######################################################################
|
||||
FROM deps-prelim AS rocm-prelim-hsa-override0
|
||||
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
||||
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
||||
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
||||
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
|
||||
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
||||
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
||||
ENV AMD_COMGR_CACHE=1
|
||||
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
|
||||
|
||||
COPY --from=rocm-dist / /
|
||||
|
||||
RUN ldconfig
|
||||
|
||||
#######################################################################
|
||||
FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1
|
||||
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Remove the parts of AMD's ROCm wheels that MIGraphX inference never loads:
|
||||
# compilers, debuggers, profilers, management tools, and the math libraries
|
||||
# (sparse, solver, FFT, random, RCCL, image processing) outside MIGraphX's
|
||||
# dependencies. Runs in the same layer as the install so the files never
|
||||
# reach the image.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
site_packages="$1"
|
||||
core="${site_packages}/_rocm_sdk_core"
|
||||
libraries="${site_packages}/_rocm_sdk_libraries"
|
||||
|
||||
# rocminfo stays for checking that the GPU is visible in the container
|
||||
find "${core}/bin" -mindepth 1 -maxdepth 1 \
|
||||
! -name rocminfo ! -name rocm_agent_enumerator -exec rm -rf {} +
|
||||
|
||||
# hiprtc compiles MIGraphX's kernels through comgr, which needs libLLVM,
|
||||
# libclang-cpp, the clang resource headers, and the amdgcn device libraries
|
||||
rm -rf "${core}/lib/llvm/bin" "${core}/lib/llvm/include"
|
||||
find "${core}/lib/llvm/lib" -mindepth 1 -maxdepth 1 \
|
||||
! -name 'libLLVM.so*' ! -name 'libclang-cpp.so*' ! -name clang \
|
||||
-exec rm -rf {} +
|
||||
rm -rf "${core}"/lib/llvm/lib/clang/*/lib
|
||||
|
||||
rm -rf \
|
||||
"${core}/lib/host-math" \
|
||||
"${core}"/lib/python3* \
|
||||
"${core}/lib/rocprofiler-sdk" \
|
||||
"${core}"/lib/librocprofiler-sdk*.so* \
|
||||
"${core}"/lib/librocprof-trace-decoder.so* \
|
||||
"${core}"/lib/librocm-dbgapi.so* \
|
||||
"${core}"/lib/librocm-debug-agent.so* \
|
||||
"${core}"/lib/libamd_smi*.so* \
|
||||
"${core}"/lib/libgoamdsmi_shim64.so* \
|
||||
"${core}"/lib/librocdecode.so* \
|
||||
"${core}"/lib/librocjpeg.so* \
|
||||
"${core}"/lib/librocdxg.so* \
|
||||
"${core}"/lib/librocjitsu_*.so* \
|
||||
"${core}"/lib/libhsa_hotswap_rocjitsu.so* \
|
||||
"${core}"/lib/*.a
|
||||
|
||||
rm -rf \
|
||||
"${libraries}"/lib/librpp.so* \
|
||||
"${libraries}"/lib/librocsparse.so* \
|
||||
"${libraries}"/lib/libhipsparse.so* \
|
||||
"${libraries}"/lib/libhipsparselt.so* \
|
||||
"${libraries}/lib/rocsparse" \
|
||||
"${libraries}"/lib/librocsolver.so* \
|
||||
"${libraries}"/lib/libhipsolver.so* \
|
||||
"${libraries}"/lib/libhipblas.so* \
|
||||
"${libraries}"/lib/librocrand.so* \
|
||||
"${libraries}"/lib/libhiprand.so* \
|
||||
"${libraries}"/lib/librocfft.so* \
|
||||
"${libraries}"/lib/libhipfft.so* \
|
||||
"${libraries}"/lib/libhipfftw.so* \
|
||||
"${libraries}"/lib/librccl.so* \
|
||||
"${libraries}"/.kpack/sparse_lib_*.kpack \
|
||||
"${libraries}"/.kpack/solver_lib_*.kpack \
|
||||
"${libraries}"/.kpack/rand_lib_*.kpack \
|
||||
"${libraries}"/.kpack/fft_lib_*.kpack \
|
||||
"${libraries}"/.kpack/rccl_lib_*.kpack
|
||||
@@ -1 +1,19 @@
|
||||
onnxruntime-migraphx @ https://github.com/NickM-27/frigate-onnxruntime-rocm/releases/download/v7.2.3-1/onnxruntime_migraphx-1.24.4-cp311-cp311-linux_x86_64.whl
|
||||
# ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels.
|
||||
onnxruntime == 1.29.*
|
||||
onnxruntime-ep-migraphx @ https://stable.repo.amd.com/rocm/onnxruntime/whl-next/onnxruntime-ep-migraphx/onnxruntime_ep_migraphx-1.0.0%2Brocm10.1.0-cp311-cp311-manylinux_2_28_x86_64.whl
|
||||
migraphx-libs @ https://stable.repo.amd.com/rocm/migraphx/whl-next/migraphx-libs/migraphx_libs-2.18.0%2Brocm10.1.0-py3-none-manylinux_2_28_x86_64.whl
|
||||
rocm-sdk-core @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-core/rocm_sdk_core-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-libraries @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-libraries/rocm_sdk_libraries-10.1.0-py3-none-linux_x86_64.whl
|
||||
# Kernels for each supported GPU target. MIGraphX has no gfx1031-gfx1036 kernels,
|
||||
# so those GPUs run as gfx1030 with HSA_OVERRIDE_GFX_VERSION=10.3.0
|
||||
rocm-sdk-device-gfx1030 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1030/rocm_sdk_device_gfx1030-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1100 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1100/rocm_sdk_device_gfx1100-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1101 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1101/rocm_sdk_device_gfx1101-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1102 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1102/rocm_sdk_device_gfx1102-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1103 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1103/rocm_sdk_device_gfx1103-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1150 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1150/rocm_sdk_device_gfx1150-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1151 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1151/rocm_sdk_device_gfx1151-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1152 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1152/rocm_sdk_device_gfx1152-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1153 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1153/rocm_sdk_device_gfx1153-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1200 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1200/rocm_sdk_device_gfx1200-10.1.0-py3-none-linux_x86_64.whl
|
||||
rocm-sdk-device-gfx1201 @ https://stable.repo.amd.com/rocm/whl-next/rocm-sdk-device-gfx1201/rocm_sdk_device_gfx1201-10.1.0-py3-none-linux_x86_64.whl
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
variable "ROCM" {
|
||||
default = "7.2.3"
|
||||
}
|
||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||
default = ""
|
||||
}
|
||||
@@ -8,12 +5,6 @@ variable "HSA_OVERRIDE" {
|
||||
default = "1"
|
||||
}
|
||||
|
||||
target wget {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
target = "wget"
|
||||
}
|
||||
|
||||
target deps {
|
||||
dockerfile = "docker/main/Dockerfile"
|
||||
platforms = ["linux/amd64"]
|
||||
@@ -30,12 +21,10 @@ target rocm {
|
||||
dockerfile = "docker/rocm/Dockerfile"
|
||||
contexts = {
|
||||
deps = "target:deps",
|
||||
wget = "target:wget",
|
||||
rootfs = "target:rootfs"
|
||||
}
|
||||
platforms = ["linux/amd64"]
|
||||
args = {
|
||||
ROCM = ROCM,
|
||||
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
|
||||
HSA_OVERRIDE = HSA_OVERRIDE
|
||||
}
|
||||
|
||||
@@ -394,6 +394,22 @@ Note that the labelmap uses a subset of the complete COCO label set that has onl
|
||||
|
||||
Support for AMD GPUs is provided using the [ONNX detector](#onnx). In order to utilize the AMD GPU for object detection use a frigate docker image with `-rocm` suffix, for example `ghcr.io/blakeblackshear/frigate:stable-rocm`.
|
||||
|
||||
### Supported GPUs {#supported-gpus-rocm}
|
||||
|
||||
The `-rocm` image includes ROCm support for these GPU chipsets, which work without any chipset override:
|
||||
|
||||
| Chipset | Example GPUs |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| gfx1030 (RDNA2) | Radeon RX 6800 / 6800 XT / 6900 XT / 6950 XT, Radeon PRO W6800 |
|
||||
| gfx1100, gfx1101, gfx1102 (RDNA3) | Radeon RX 7900 / 7800 / 7700 / 7600 series, Radeon PRO W7000 series |
|
||||
| gfx1103 (RDNA3 integrated) | Radeon 780M / 760M in Ryzen 7040, 8040, 8000G, and 200 series processors |
|
||||
| gfx1150, gfx1151, gfx1152, gfx1153 (RDNA3.5) | Integrated GPUs in Ryzen AI 300 / 400 series and Ryzen AI Max processors |
|
||||
| gfx1200, gfx1201 (RDNA4) | Radeon RX 9060 / 9070 series, Radeon AI PRO R9700 |
|
||||
|
||||
Other RDNA2 GPUs (gfx1031 through gfx1036), such as the Radeon RX 6700 / 6600 / 6500 series and the Radeon 680M / 660M integrated GPUs, run on the gfx1030 support by [overriding the chipset](#docker-settings-for-overriding-the-gpu-chipset) to `10.3.0`.
|
||||
|
||||
Older GPUs, including RDNA1 (Radeon RX 5000 series) and Vega based dedicated and integrated GPUs, are not supported.
|
||||
|
||||
### Docker settings for GPU access
|
||||
|
||||
ROCm needs access to the `/dev/kfd` and `/dev/dri` devices. When docker or frigate is not run under root then also `video` (and possibly `render` and `ssl/_ssl`) groups should be added.
|
||||
@@ -420,19 +436,10 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
|
||||
|
||||
### Docker settings for overriding the GPU chipset
|
||||
|
||||
Your GPU might work just fine without any special configuration but in many cases they need manual settings. AMD/ROCm software stack comes with a limited set of GPU drivers and for newer or missing models you will have to override the chipset version to an older/generic version to get things working.
|
||||
|
||||
Also AMD/ROCm does not "officially" support integrated GPUs. It still does work with most of them just fine but requires special settings. One has to configure the `HSA_OVERRIDE_GFX_VERSION` environment variable. See the [ROCm bug report](https://github.com/ROCm/ROCm/issues/1743) for context and examples.
|
||||
|
||||
For the rocm frigate build there is some automatic detection:
|
||||
|
||||
- gfx1031 -> 10.3.0
|
||||
- gfx1103 -> 11.0.0
|
||||
|
||||
If you have something else you might need to override the `HSA_OVERRIDE_GFX_VERSION` at Docker launch. Suppose the version you want is `10.0.0`, then you should configure it from command line as:
|
||||
A GPU whose chipset isn't in the [supported GPUs](#supported-gpus-rocm) list can run on the support for a closely related chipset by setting the `HSA_OVERRIDE_GFX_VERSION` environment variable. RDNA2 GPUs other than gfx1030 (gfx1031 through gfx1036) need this, using `10.3.0`:
|
||||
|
||||
```bash
|
||||
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
|
||||
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
|
||||
...
|
||||
```
|
||||
|
||||
@@ -443,16 +450,17 @@ services:
|
||||
frigate:
|
||||
...
|
||||
environment:
|
||||
HSA_OVERRIDE_GFX_VERSION: "10.0.0"
|
||||
HSA_OVERRIDE_GFX_VERSION: "10.3.0"
|
||||
```
|
||||
|
||||
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name.
|
||||
GPUs in the supported list don't need an override. An override that was set for the Radeon 780M / 760M (gfx1103) on an earlier version of Frigate is no longer needed and can be removed.
|
||||
|
||||
- first make sure that rocm environment is running properly by running `/opt/rocm/bin/rocminfo` in the frigate container -- it should list both the CPU and the GPU with their properties
|
||||
- find the chipset version you have (gfxNNN) from the output of the `rocminfo` (see below)
|
||||
- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION")
|
||||
- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value
|
||||
- if things are not working check the frigate docker logs
|
||||
You can't tell the chipset from the AMD brand name, so check it in the container:
|
||||
|
||||
- first make sure that the ROCm environment is running properly by running `/opt/rocm/bin/rocminfo` in the Frigate container. It should list both the CPU and the GPU with their properties
|
||||
- find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below)
|
||||
- if the chipset isn't in the supported list, set `HSA_OVERRIDE_GFX_VERSION` to the closest supported chipset, written as major.minor.stepping (for example gfx1030 is `10.3.0`)
|
||||
- if things are not working check the Frigate docker logs
|
||||
|
||||
#### Figuring out if AMD/ROCm is working and found your GPU
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Frigate supports multiple different detectors that work on different types of ha
|
||||
|
||||
**AMD**
|
||||
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete GPUs to provide efficient object detection
|
||||
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD discrete and recent integrated GPUs to provide efficient object detection
|
||||
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||
- Runs best on discrete AMD GPUs
|
||||
- <CommunityBadge /> [XDNA2 (Ryzen AI)](#amd-xdna2): AMD XDNA2 NPU (sub-watt power AI/ML processor separate to the GPU) inside Strix and other "AI" branded AMD platforms
|
||||
@@ -240,7 +240,7 @@ Apple Silicon can not run within a container, so a ZMQ proxy is utilized to comm
|
||||
|
||||
### ROCm - AMD GPU
|
||||
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
|
||||
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
|
||||
|
||||
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||
|
||||
+172
-33
@@ -61,6 +61,7 @@ from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
deep_merge,
|
||||
flatten_config_data,
|
||||
is_masked_camera_path,
|
||||
load_labels,
|
||||
process_config_query_string,
|
||||
split_config_key_path,
|
||||
@@ -71,6 +72,7 @@ from frigate.util.config import (
|
||||
find_config_file,
|
||||
redact_credential,
|
||||
)
|
||||
from frigate.util.env import substitute_frigate_vars
|
||||
from frigate.util.live_streams import (
|
||||
generated_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
@@ -402,6 +404,8 @@ def config(request: Request):
|
||||
|
||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||
|
||||
_redact_go2rtc_credentials(config["go2rtc"])
|
||||
|
||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||
|
||||
for index, model in enumerate(config_obj.models):
|
||||
@@ -428,6 +432,28 @@ def config(request: Request):
|
||||
return JSONResponse(content=config)
|
||||
|
||||
|
||||
def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None:
|
||||
"""Redact go2rtc secrets outside of stream URLs, in place."""
|
||||
for section in ("rtsp", "api", "mqtt"):
|
||||
if isinstance(go2rtc.get(section), dict):
|
||||
redact_credential(go2rtc[section], "username")
|
||||
redact_credential(go2rtc[section], "password")
|
||||
|
||||
if isinstance(go2rtc.get("ngrok"), dict):
|
||||
redact_credential(go2rtc["ngrok"], "command")
|
||||
|
||||
if isinstance(go2rtc.get("homekit"), dict):
|
||||
for accessory in go2rtc["homekit"].values():
|
||||
if isinstance(accessory, dict):
|
||||
for key in ("pin", "device_private", "pairings"):
|
||||
redact_credential(accessory, key)
|
||||
|
||||
# publish destinations carry stream keys in the URL path
|
||||
if isinstance(go2rtc.get("publish"), dict):
|
||||
for name in list(go2rtc["publish"]):
|
||||
redact_credential(go2rtc["publish"], name)
|
||||
|
||||
|
||||
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_profiles(request: Request):
|
||||
"""List all available profiles and the currently active profile."""
|
||||
@@ -742,17 +768,24 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
||||
)
|
||||
|
||||
|
||||
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
||||
"""Substitute incoming masked camera paths with the saved ones.
|
||||
|
||||
The /config response masks ffmpeg input credentials, so the settings UI
|
||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||
the real credentials. Mutates `config_data` in place.
|
||||
edited. Each masked path is matched to the saved input that masks to the
|
||||
same value, with roles breaking a tie, so deleting or reordering inputs
|
||||
keeps each path's own credentials. The saved path is restored as written,
|
||||
keeping any {FRIGATE_*} placeholder. Mutates `config_data` in place and
|
||||
returns an error message when a masked path has no single match.
|
||||
"""
|
||||
cameras = config_data.get("cameras")
|
||||
if not isinstance(cameras, dict):
|
||||
return
|
||||
return None
|
||||
|
||||
saved_cameras = saved.get("cameras")
|
||||
if not isinstance(saved_cameras, dict):
|
||||
saved_cameras = {}
|
||||
|
||||
for camera_name, camera_data in cameras.items():
|
||||
if not isinstance(camera_data, dict):
|
||||
@@ -760,20 +793,122 @@ def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> No
|
||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||
if not isinstance(inputs, list):
|
||||
continue
|
||||
existing = config.cameras.get(camera_name)
|
||||
if existing is None:
|
||||
continue
|
||||
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||
|
||||
saved_camera = saved_cameras.get(camera_name)
|
||||
saved_ffmpeg = (
|
||||
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
|
||||
)
|
||||
saved_inputs = [
|
||||
inp
|
||||
for inp in (
|
||||
saved_ffmpeg.get("inputs") if isinstance(saved_ffmpeg, dict) else None
|
||||
)
|
||||
or []
|
||||
if isinstance(inp, dict) and isinstance(inp.get("path"), str)
|
||||
]
|
||||
|
||||
for index, input_obj in enumerate(inputs):
|
||||
if not isinstance(input_obj, dict):
|
||||
continue
|
||||
path = input_obj.get("path")
|
||||
if not isinstance(path, str):
|
||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
||||
continue
|
||||
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||
existing_paths
|
||||
):
|
||||
input_obj["path"] = existing_paths[index]
|
||||
candidates = [
|
||||
inp
|
||||
for inp in saved_inputs
|
||||
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
|
||||
]
|
||||
if len({inp["path"] for inp in candidates}) > 1:
|
||||
candidates = [
|
||||
inp
|
||||
for inp in candidates
|
||||
if inp.get("roles") == input_obj.get("roles")
|
||||
]
|
||||
real_paths = {inp["path"] for inp in candidates}
|
||||
if len(real_paths) != 1:
|
||||
return (
|
||||
f"Input {index} of camera {camera_name} has masked credentials "
|
||||
"that could not be matched; resend it with real credentials"
|
||||
)
|
||||
input_obj["path"] = real_paths.pop()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_frigate_vars(value: str) -> str:
|
||||
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
|
||||
try:
|
||||
return substitute_frigate_vars(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
|
||||
"""Shape the in-memory camera inputs like the saved YAML."""
|
||||
return {
|
||||
"cameras": {
|
||||
name: {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": inp.path, "roles": [role.value for role in inp.roles]}
|
||||
for inp in camera.ffmpeg.inputs
|
||||
]
|
||||
}
|
||||
}
|
||||
for name, camera in config.cameras.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _config_data_to_updates(
|
||||
config_data: dict, saved: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Flatten a /config/set payload after restoring masked credentials.
|
||||
|
||||
`saved` is the config the masked values were taken from. Returns the flat
|
||||
updates, or an error message when a masked value can't be restored.
|
||||
"""
|
||||
error = _restore_masked_camera_paths(config_data, saved)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
updates = flatten_config_data(config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential placeholders don't
|
||||
# clobber the saved YAML value.
|
||||
updates = {k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL}
|
||||
|
||||
error = _find_masked_update(updates)
|
||||
if error:
|
||||
return {}, error
|
||||
|
||||
return updates, None
|
||||
|
||||
|
||||
def _holds_masked_value(value: Any) -> bool:
|
||||
if isinstance(value, str):
|
||||
return REDACTED_CREDENTIAL_SENTINEL in value or is_masked_camera_path(value)
|
||||
if isinstance(value, dict):
|
||||
return any(_holds_masked_value(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_holds_masked_value(v) for v in value)
|
||||
return False
|
||||
|
||||
|
||||
def _find_masked_update(updates: dict[str, Any]) -> str | None:
|
||||
"""Return an error naming the first update that still holds a masked value.
|
||||
|
||||
Runs after the restores so a masked path or redaction sentinel that could
|
||||
not be restored is never written over the real credential.
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
if _holds_masked_value(value):
|
||||
return (
|
||||
f"{key} contains masked or saved credentials; "
|
||||
"resend it with real values"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||
@@ -786,13 +921,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
||||
try:
|
||||
updates = {}
|
||||
if body.config_data:
|
||||
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop any field whose value is still the redaction sentinel
|
||||
updates = {
|
||||
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data, _saved_config_from_memory(request.app.frigate_config)
|
||||
)
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error}, status_code=400
|
||||
)
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
@@ -939,22 +1074,26 @@ def config_set(request: Request, body: AppConfigSetBody):
|
||||
# Filter out empty keys but keep blank values for non-empty keys
|
||||
query_string = {k: v for k, v in query_string.items() if k}
|
||||
|
||||
error = None
|
||||
|
||||
if query_string:
|
||||
updates = process_config_query_string(query_string)
|
||||
error = _find_masked_update(updates)
|
||||
elif body.config_data:
|
||||
_restore_masked_camera_paths(
|
||||
body.config_data, request.app.frigate_config
|
||||
try:
|
||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
||||
except ruamel.yaml.YAMLError:
|
||||
saved_config = None
|
||||
updates, error = _config_data_to_updates(
|
||||
body.config_data,
|
||||
saved_config if isinstance(saved_config, dict) else {},
|
||||
)
|
||||
|
||||
if error:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": error},
|
||||
status_code=400,
|
||||
)
|
||||
updates = flatten_config_data(body.config_data)
|
||||
# Convert None values to empty strings for deletion (e.g., when deleting masks)
|
||||
updates = {k: ("" if v is None else v) for k, v in updates.items()}
|
||||
# Drop sentinel-valued fields so untouched credential
|
||||
# placeholders don't clobber the saved YAML value.
|
||||
updates = {
|
||||
k: v
|
||||
for k, v in updates.items()
|
||||
if v != REDACTED_CREDENTIAL_SENTINEL
|
||||
}
|
||||
|
||||
if not updates:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Notification apis."""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@@ -11,7 +13,6 @@ from py_vapid import Vapid01, utils
|
||||
|
||||
from frigate.api.auth import allow_any_authenticated
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.comms.webpush import validate_push_endpoint
|
||||
from frigate.const import CONFIG_DIR
|
||||
from frigate.models import User
|
||||
|
||||
@@ -19,13 +20,76 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=[Tags.notifications])
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def _validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path, and webpush.py assumes there is
|
||||
# a separator after the host when it builds the VAPID audience
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_subscription(sub: Any) -> str | None:
|
||||
"""Return a reason the subscription is unusable, or None when it is valid."""
|
||||
if not isinstance(sub, dict):
|
||||
return "subscription must be an object"
|
||||
|
||||
reason = validate_push_endpoint(sub.get("endpoint"))
|
||||
reason = _validate_push_endpoint(sub.get("endpoint"))
|
||||
|
||||
if reason:
|
||||
return reason
|
||||
|
||||
@@ -490,12 +490,7 @@ class Dispatcher:
|
||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
"""Handle publishing to communicators."""
|
||||
for comm in self.comms:
|
||||
try:
|
||||
comm.publish(topic, payload, retain)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
@@ -506,12 +501,7 @@ class Dispatcher:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
try:
|
||||
comm.publish(topic, payload, False)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Error publishing %s to %s", topic, type(comm).__name__
|
||||
)
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
|
||||
@@ -50,13 +50,7 @@ class InterProcessCommunicator(Communicator):
|
||||
|
||||
if isinstance(raw, list):
|
||||
(topic, value) = raw
|
||||
|
||||
# REP owes a reply, so a handler error must not end the thread
|
||||
try:
|
||||
response = self._dispatcher(topic, value)
|
||||
except Exception:
|
||||
logger.exception("Error handling IPC topic %s", topic)
|
||||
response = None
|
||||
response = self._dispatcher(topic, value)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
|
||||
|
||||
+5
-117
@@ -1,7 +1,6 @@
|
||||
"""Handle sending notifications for Frigate via Firebase."""
|
||||
|
||||
import datetime
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -11,9 +10,7 @@ from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from multiprocessing.synchronize import Event as MpEvent
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from py_vapid import Vapid01
|
||||
from pywebpush import WebPusher
|
||||
from titlecase import titlecase
|
||||
@@ -44,124 +41,14 @@ class PushNotification:
|
||||
ttl: int = 0
|
||||
|
||||
|
||||
# Push endpoints are opaque URLs but stay well under this in practice
|
||||
MAX_ENDPOINT_LENGTH = 2048
|
||||
|
||||
# Suffixes that only ever resolve on the local network
|
||||
INTERNAL_HOST_SUFFIXES = (".local", ".localdomain", ".internal", ".home.arpa")
|
||||
|
||||
|
||||
def validate_push_endpoint(endpoint: Any) -> str | None:
|
||||
"""Return a reason the endpoint is unusable, or None when it is valid.
|
||||
|
||||
Subscriptions are issued by the browser vendor's push service, so a valid
|
||||
endpoint is always a public https URL. Anything else is either a broken
|
||||
registration or an attempt to aim the notification sender somewhere it
|
||||
should not reach.
|
||||
"""
|
||||
if not isinstance(endpoint, str) or not endpoint:
|
||||
return "endpoint must be a url"
|
||||
|
||||
if len(endpoint) > MAX_ENDPOINT_LENGTH:
|
||||
return "endpoint is too long"
|
||||
|
||||
try:
|
||||
parsed = urlparse(endpoint)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return "endpoint is not a valid url"
|
||||
|
||||
if parsed.scheme != "https":
|
||||
return "endpoint must use https"
|
||||
|
||||
if parsed.username or parsed.password:
|
||||
return "endpoint must not include credentials"
|
||||
|
||||
if port is not None and port != 443:
|
||||
return "endpoint must use the default https port"
|
||||
|
||||
hostname = parsed.hostname
|
||||
|
||||
if not hostname:
|
||||
return "endpoint must include a hostname"
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
|
||||
if address is not None:
|
||||
# A push service is never reachable at an address only this network can
|
||||
# route, so anything non-global is a misconfiguration at best
|
||||
if not address.is_global:
|
||||
return "endpoint must not use a private address"
|
||||
elif hostname == "localhost" or "." not in hostname:
|
||||
return "endpoint must use a fully qualified hostname"
|
||||
elif "" in hostname.split("."):
|
||||
return "endpoint hostname must not contain empty labels"
|
||||
elif hostname.endswith(INTERNAL_HOST_SUFFIXES):
|
||||
return "endpoint must not use an internal hostname"
|
||||
|
||||
# The subscription token lives in the path
|
||||
if len(parsed.path) <= 1:
|
||||
return "endpoint must include a subscription path"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class _NoRedirectSession(requests.Session):
|
||||
"""Session that never follows redirects.
|
||||
|
||||
A push endpoint could otherwise redirect the send to an internal URL.
|
||||
"""
|
||||
|
||||
def request(self, *args: Any, **kwargs: Any) -> requests.Response:
|
||||
kwargs["allow_redirects"] = False
|
||||
return super().request(*args, **kwargs)
|
||||
|
||||
|
||||
_push_session = _NoRedirectSession()
|
||||
|
||||
|
||||
def _vapid_audience(endpoint: str) -> str:
|
||||
parsed = urlparse(endpoint)
|
||||
return f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
||||
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
||||
pushers: list[WebPusher] = []
|
||||
|
||||
for sub in subs:
|
||||
# Stored rows may predate registration validation, so only skip the
|
||||
# endpoints that would break building the VAPID audience
|
||||
endpoint = sub.get("endpoint") if isinstance(sub, dict) else None
|
||||
|
||||
if not isinstance(endpoint, str):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: no endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
hostname = urlparse(endpoint).hostname or ""
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: unparsable endpoint", user
|
||||
)
|
||||
continue
|
||||
|
||||
if "" in hostname.split("."):
|
||||
logger.warning(
|
||||
"Skipping notification subscription for %s: invalid endpoint host %r",
|
||||
user,
|
||||
hostname,
|
||||
)
|
||||
continue
|
||||
|
||||
# WebPusher decodes the stored keys and raises on malformed ones
|
||||
try:
|
||||
pushers.append(WebPusher(sub, requests_session=_push_session))
|
||||
pushers.append(WebPusher(sub))
|
||||
except Exception:
|
||||
logger.warning("Skipping invalid notification subscription for %s", user)
|
||||
|
||||
@@ -244,7 +131,7 @@ class WebPushClient(Communicator):
|
||||
for pushers in self.web_pushers.values():
|
||||
for push in pushers:
|
||||
endpoint: str = push.subscription_info["endpoint"]
|
||||
endpoints.add(_vapid_audience(endpoint))
|
||||
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
||||
|
||||
# create new claim
|
||||
for endpoint in endpoints:
|
||||
@@ -433,8 +320,9 @@ class WebPushClient(Communicator):
|
||||
|
||||
for pusher in self.web_pushers[notification.user]:
|
||||
endpoint = pusher.subscription_info["endpoint"]
|
||||
|
||||
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
|
||||
headers = self.claim_headers[
|
||||
endpoint[: endpoint.index("/", 10)]
|
||||
].copy()
|
||||
headers["urgency"] = "high"
|
||||
|
||||
resp = pusher.send(
|
||||
|
||||
+13
-1
@@ -117,7 +117,19 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
|
||||
|
||||
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
|
||||
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
|
||||
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
|
||||
# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
|
||||
# last "@" before the next URL. Anything else stops at the authority end. Group 1
|
||||
# is the userinfo and group 2 the rest of the URL.
|
||||
REGEX_URL_USERINFO = (
|
||||
r"://((?:[^\s/?#:\[\]]*:(?:(?!://)\S)*|[^\s/?#]+))@((?:(?!://)\S)*)"
|
||||
)
|
||||
_CREDENTIAL_QUERY_KEYS = (
|
||||
r"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd"
|
||||
r"|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)"
|
||||
)
|
||||
REGEX_CREDENTIAL_QUERY_KEY = rf"(?i)[?&]{_CREDENTIAL_QUERY_KEYS}="
|
||||
REGEX_CREDENTIAL_QUERY_PARAM = rf"(?i)([?&]{_CREDENTIAL_QUERY_KEYS}=)[^&\s]*"
|
||||
REGEX_MASKED_CREDENTIAL = rf"(?i)://\*:\*@|[?&]{_CREDENTIAL_QUERY_KEYS}=\*(?![^&\s])"
|
||||
REGEX_JSON = re.compile(r"^\s*\{")
|
||||
|
||||
# Known Driver Names
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
|
||||
from frigate.const import MODEL_CACHE_DIR
|
||||
from frigate.util.model import get_ort_providers
|
||||
from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible
|
||||
|
||||
@@ -55,6 +56,9 @@ _PROVIDER_LABELS = {
|
||||
LIGHTER_ANE_EP_NAME = "LighterANE"
|
||||
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
|
||||
|
||||
# AMD's MIGraphX plugin execution provider, installed in the ROCm image
|
||||
MIGRAPHX_EP_NAME = "MIGraphXExecutionProvider"
|
||||
|
||||
|
||||
def get_lighter_ane_devices() -> list[Any]:
|
||||
"""Get the Neural Engine devices lighter's provider offers, registering it once.
|
||||
@@ -83,6 +87,42 @@ def get_lighter_ane_devices() -> list[Any]:
|
||||
return devices
|
||||
|
||||
|
||||
def get_migraphx_devices() -> list[Any]:
|
||||
"""Get the AMD GPU devices the MIGraphX provider offers, registering it once.
|
||||
|
||||
Returns:
|
||||
The provider's ONNX Runtime devices, or an empty list outside the ROCm
|
||||
image or without an AMD GPU
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from frigate.detectors.hardware import detect_amd_gpu
|
||||
|
||||
try:
|
||||
import onnxruntime_ep_migraphx
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
# ONNX Runtime finds the provider's GPUs in the same /sys/class/drm entries,
|
||||
# so an AMD GPU here means the provider lists it once registered
|
||||
if detect_amd_gpu() is None:
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
|
||||
|
||||
if not devices:
|
||||
try:
|
||||
ort.register_execution_provider_library(
|
||||
MIGRAPHX_EP_NAME, onnxruntime_ep_migraphx.get_library_path()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load the MIGraphX provider: %s", e)
|
||||
return []
|
||||
|
||||
devices = [d for d in ort.get_ep_devices() if d.ep_name == MIGRAPHX_EP_NAME]
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
def is_arm64_platform() -> bool:
|
||||
"""Check if we're running on an ARM platform."""
|
||||
machine = platform.machine().lower()
|
||||
@@ -756,12 +796,41 @@ def get_optimized_runner(
|
||||
ONNXModelRunner(session, model_type=model_type),
|
||||
)
|
||||
|
||||
if (
|
||||
device != "CPU"
|
||||
and not ONNXModelRunner.is_migraphx_complex_model(model_type)
|
||||
and (migraphx_devices := get_migraphx_devices())
|
||||
):
|
||||
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx/ort")
|
||||
os.makedirs(migraphx_cache_dir, exist_ok=True)
|
||||
sess_options = get_ort_session_options(model_type) or ort.SessionOptions()
|
||||
# only the first AMD GPU is used, matching detect_amd_gpu
|
||||
sess_options.add_provider_for_devices(
|
||||
migraphx_devices[:1], {"cache_dir": migraphx_cache_dir}
|
||||
)
|
||||
|
||||
try:
|
||||
session = ort.InferenceSession(model_path, sess_options=sess_options)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to load %s with MIGraphX, using the default providers: %s",
|
||||
model_path,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
ONNXModelRunner(session, model_type=model_type),
|
||||
)
|
||||
|
||||
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||
|
||||
if providers[0] == "CPUExecutionProvider":
|
||||
# In the default image, ONNXRuntime is used so we will only get CPUExecutionProvider
|
||||
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
|
||||
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
|
||||
# In other images we will get CUDA which is preferred over OpenVINO, while
|
||||
# MIGraphX in the ROCm image is already handled above
|
||||
# There is currently no way to prioritize OpenVINO over CUDA in these images
|
||||
if device != "CPU" and is_openvino_gpu_npu_available():
|
||||
return _record_runner(
|
||||
model_path,
|
||||
@@ -790,15 +859,6 @@ def get_optimized_runner(
|
||||
),
|
||||
)
|
||||
|
||||
if (
|
||||
providers
|
||||
and providers[0] == "MIGraphXExecutionProvider"
|
||||
and ONNXModelRunner.is_migraphx_complex_model(model_type)
|
||||
):
|
||||
# Don't use MIGraphX for models that are not supported
|
||||
providers.pop(0)
|
||||
options.pop(0)
|
||||
|
||||
return _record_runner(
|
||||
model_path,
|
||||
model_type,
|
||||
|
||||
@@ -14,6 +14,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.holder import ConfigHolder
|
||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
@@ -503,6 +504,358 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
camera_path = "rtsp://10.0.0.1:554/video?password=s3cret&user=admin"
|
||||
|
||||
def _add_credentials(self):
|
||||
"""Give the config a credentialed camera path and go2rtc secrets."""
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"] = (
|
||||
self.camera_path
|
||||
)
|
||||
self.minimal_config["go2rtc"] = {
|
||||
"rtsp": {"username": "rtspuser", "password": "rtsppass"},
|
||||
"api": {"username": "apiuser", "password": "apipass"},
|
||||
"mqtt": {"username": "mquser", "password": "mqpass"},
|
||||
"ngrok": {"command": "ngrok tcp 8555 --authtoken ngroksecret"},
|
||||
"homekit": {"front_door": {"pin": "19550224"}},
|
||||
"publish": {"front_door": "rtmp://a.rtmp.youtube.com/live2/streamkey"},
|
||||
"webrtc": {
|
||||
"candidates": ["10.0.0.5:8555"],
|
||||
"ice_servers": [
|
||||
{
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"username": "turnuser",
|
||||
"credential": "turnpass",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def _read_config_file(self, config_path):
|
||||
with open(config_path) as f:
|
||||
raw = f.read()
|
||||
return raw, ruamel.yaml.YAML().load(raw)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_camera_path_round_trips(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
inputs = camera["ffmpeg"]["inputs"]
|
||||
self.assertNotIn("s3cret", inputs[0]["path"])
|
||||
|
||||
resp = client.put(
|
||||
"/config/set",
|
||||
json={
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": inputs},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
self.camera_path,
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def _save(self, client, config_data):
|
||||
return client.put(
|
||||
"/config/set",
|
||||
json={"config_data": config_data, "requires_restart": 0},
|
||||
)
|
||||
|
||||
def _set_inputs(self, paths):
|
||||
self.minimal_config["cameras"]["front_door"]["ffmpeg"]["inputs"] = [
|
||||
{"path": path, "roles": [role]} for path, role in paths
|
||||
]
|
||||
|
||||
# config parsing rebuilds FRIGATE_ENV_VARS from its sources
|
||||
@patch.dict("frigate.util.env._CONTAINER_ENV", {"FRIGATE_RTSP_PASSWORD": "s3cret"})
|
||||
@patch.dict(
|
||||
"frigate.util.env.FRIGATE_ENV_VARS", {"FRIGATE_RTSP_PASSWORD": "s3cret"}
|
||||
)
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_path_keeps_env_placeholder(self, mock_find_config):
|
||||
path = "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.0.1:554/live"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
camera = client.get("/config").json()["cameras"]["front_door"]
|
||||
self.assertNotIn("s3cret", str(camera))
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {"inputs": camera["ffmpeg"]["inputs"]},
|
||||
"detect": {"fps": 7},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("s3cret", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_over_masked_path_round_trips(self, mock_find_config):
|
||||
path = "rtsp://10.0.0.1:554/live?x=a@b"
|
||||
self._set_inputs([(path, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
self.assertNotEqual(inputs[0]["path"], path)
|
||||
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"], path
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_unrestorable_masked_values_are_rejected(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for name, request in (
|
||||
(
|
||||
"index-keyed inputs",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": {
|
||||
"0": {"path": "rtsp://*:*@h/x"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"go2rtc streams",
|
||||
{
|
||||
"json": {
|
||||
"config_data": {
|
||||
"go2rtc": {"streams": {"cam": ["rtsp://*:*@h/x"]}}
|
||||
},
|
||||
"requires_restart": 0,
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
"query string",
|
||||
{
|
||||
"params": {
|
||||
"cameras.front_door.ffmpeg.inputs.0.path": (
|
||||
"rtsp://*:*@h/x"
|
||||
),
|
||||
"go2rtc.rtsp.password": REDACTED_CREDENTIAL_SENTINEL,
|
||||
},
|
||||
"json": {"requires_restart": 0},
|
||||
},
|
||||
),
|
||||
):
|
||||
with self.subTest(name):
|
||||
resp = client.put("/config/set", **request)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
self.assertIn("credentials", resp.json()["message"])
|
||||
|
||||
raw, _ = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
|
||||
self._add_credentials()
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.get("/config").json()
|
||||
body = str(response)
|
||||
for secret in (
|
||||
"rtsppass",
|
||||
"apipass",
|
||||
"mqpass",
|
||||
"ngroksecret",
|
||||
"19550224",
|
||||
"streamkey",
|
||||
):
|
||||
self.assertNotIn(secret, body)
|
||||
|
||||
go2rtc = response["go2rtc"]
|
||||
self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"])
|
||||
# browsers need the TURN account to relay WebRTC
|
||||
self.assertEqual(
|
||||
go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
|
||||
go2rtc.pop("ngrok")
|
||||
go2rtc["webrtc"]["candidates"] = ["10.0.0.9:8555"]
|
||||
self.assertEqual(
|
||||
self._save(client, {"go2rtc": go2rtc}).status_code, 200
|
||||
)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw)
|
||||
self.assertEqual(saved["go2rtc"]["rtsp"]["password"], "rtsppass")
|
||||
self.assertEqual(saved["go2rtc"]["mqtt"]["password"], "mqpass")
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["homekit"]["front_door"]["pin"], "19550224"
|
||||
)
|
||||
self.assertIn("streamkey", saved["go2rtc"]["publish"]["front_door"])
|
||||
self.assertEqual(saved["go2rtc"]["webrtc"]["candidates"], ["10.0.0.9:8555"])
|
||||
self.assertEqual(
|
||||
saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "turnpass"
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_masked_inputs_restore_by_value(self, mock_find_config):
|
||||
main = "rtsp://u:MAIN@10.0.0.1:554/main"
|
||||
sub = "rtsp://u:SUB@10.0.0.1:554/sub"
|
||||
|
||||
for name, edit, expected in (
|
||||
("delete first", lambda inputs: [inputs[1]], [sub]),
|
||||
("reorder", lambda inputs: [inputs[1], inputs[0]], [sub, main]),
|
||||
):
|
||||
with self.subTest(name):
|
||||
self._set_inputs([(main, "record"), (sub, "detect")])
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
resp = self._save(
|
||||
client,
|
||||
{
|
||||
"cameras": {
|
||||
"front_door": {"ffmpeg": {"inputs": edit(inputs)}}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
_, saved = self._read_config_file(config_path)
|
||||
saved_inputs = saved["cameras"]["front_door"]["ffmpeg"]["inputs"]
|
||||
self.assertEqual([i["path"] for i in saved_inputs], expected)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch("frigate.api.app.find_config_file")
|
||||
def test_ambiguous_or_unmatched_masked_input(self, mock_find_config):
|
||||
self._set_inputs(
|
||||
[
|
||||
("rtsp://u:one@10.0.0.1:554/live", "record"),
|
||||
("rtsp://u:two@10.0.0.1:554/live", "detect"),
|
||||
]
|
||||
)
|
||||
config_path = self._write_config_file()
|
||||
mock_find_config.return_value = config_path
|
||||
|
||||
try:
|
||||
app, _ = self._create_app_with_publisher()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
inputs = client.get("/config").json()["cameras"]["front_door"][
|
||||
"ffmpeg"
|
||||
]["inputs"]
|
||||
|
||||
# both inputs mask to the same value and roles no longer tell
|
||||
# them apart
|
||||
remaining = dict(inputs[1], roles=["detect", "record"])
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [remaining]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
inputs[0]["path"] = "rtsp://*:*@10.0.0.99:554/live"
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": inputs}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
# roles break the tie when the second input stays as it was
|
||||
resp = self._save(
|
||||
client,
|
||||
{"cameras": {"front_door": {"ffmpeg": {"inputs": [inputs[1]]}}}},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
raw, saved = self._read_config_file(config_path)
|
||||
self.assertNotIn("*:*", raw)
|
||||
self.assertEqual(
|
||||
saved["cameras"]["front_door"]["ffmpeg"]["inputs"][0]["path"],
|
||||
"rtsp://u:two@10.0.0.1:554/live",
|
||||
)
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||
from frigate.util.builtin import (
|
||||
clean_camera_user_pass,
|
||||
escape_special_characters,
|
||||
is_masked_camera_path,
|
||||
)
|
||||
|
||||
|
||||
class TestUserPassCleanup(unittest.TestCase):
|
||||
@@ -45,3 +50,90 @@ class TestUserPassMasking(unittest.TestCase):
|
||||
escaped = clean_camera_user_pass(self.rtsp_log_message)
|
||||
print(f"The escaped is {escaped}")
|
||||
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
|
||||
|
||||
def test_masks_userinfo_and_credential_query_forms(self):
|
||||
cases = {
|
||||
"rtsp://first.last@mail.com:p%40w@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://user+tag:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://:pw@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"tapo://cloudpassword@10.0.0.2": "tapo://*:*@10.0.0.2",
|
||||
"rtsp://u:p@ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://user:pa/ss@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://u:p@ss#x@10.0.0.2/live": "rtsp://*:*@10.0.0.2/live",
|
||||
"rtsp://admin:2024/pw@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsp://admin:12#abc@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsp://admin:7?x@10.0.0.2/x": "rtsp://*:*@10.0.0.2/x",
|
||||
"rtsps://u:1/2@h/x": "rtsps://*:*@h/x",
|
||||
"rtsp://admin:pre&token=suf@10.0.0.2/x": "rtsp://*:*@",
|
||||
"rtsp://admin:a?pwd=b@10.0.0.2/x": "rtsp://*:*@",
|
||||
"rtsp://10.0.0.2:554/live?password=pre@suf": "rtsp://*:*@",
|
||||
"http://10.0.0.2:88/cgi?cmd=snap&usr=admin&pwd=P@ss": "http://*:*@",
|
||||
"ffmpeg:http://h:80/flv?user=u&password=p@ss#video=copy": (
|
||||
"ffmpeg:http://*:*@"
|
||||
),
|
||||
"rtsp://u:p@10.0.0.2:554/live?token=t": "rtsp://*:*@10.0.0.2:554/live?token=*",
|
||||
"http://h/x?api_key=a&apikey=b&access_token=c": (
|
||||
"http://h/x?api_key=*&apikey=*&access_token=*"
|
||||
),
|
||||
"http://h/cgi?user=admin&password=x": "http://h/cgi?user=*&password=*",
|
||||
"http://h/cgi?password=x&user=admin&channel=1": (
|
||||
"http://h/cgi?password=*&user=*&channel=1"
|
||||
),
|
||||
"http://h/x?usr=a&pwd=b": "http://h/x?usr=*&pwd=*",
|
||||
"http://h/x?username=a&password=b": "http://h/x?username=*&password=*",
|
||||
"rtsp://host/live?user=admin&password=p@ss": (
|
||||
"rtsp://host/live?user=*&password=*"
|
||||
),
|
||||
"ring:?refresh_token=abc.def": "ring:?refresh_token=*",
|
||||
"nest:?client_id=1&client_secret=s&refresh_token=t&project_id=p": (
|
||||
"nest:?client_id=1&client_secret=*&refresh_token=*&project_id=p"
|
||||
),
|
||||
"homekit://10.0.0.2:51827?client_id=c&client_private=k&device_id=d": (
|
||||
"homekit://10.0.0.2:51827?client_id=c&client_private=*&device_id=d"
|
||||
),
|
||||
"exec:ffmpeg -i rtsp://u:p@h/s -c copy -f rtsp {output}": (
|
||||
"exec:ffmpeg -i rtsp://*:*@h/s -c copy -f rtsp {output}"
|
||||
),
|
||||
"ffmpeg -i rtsp://u:p@h/1 -i http://h/snap?user=a&password=b -f null": (
|
||||
"ffmpeg -i rtsp://*:*@h/1 -i http://h/snap?user=*&password=* -f null"
|
||||
),
|
||||
}
|
||||
|
||||
for line, expected in cases.items():
|
||||
with self.subTest(line=line):
|
||||
masked = clean_camera_user_pass(line)
|
||||
self.assertEqual(masked, expected)
|
||||
self.assertTrue(is_masked_camera_path(masked))
|
||||
self.assertEqual(clean_camera_user_pass(masked), masked)
|
||||
|
||||
def test_safe_strings_unchanged(self):
|
||||
for line in (
|
||||
"rtsp://10.0.0.2:554/live",
|
||||
"ffmpeg:front_door#video=copy",
|
||||
"rtsp://127.0.0.1:8554/front_door?video=copy&audio=aac",
|
||||
"homekit://10.0.0.2:51827?device_id=d",
|
||||
"http://10.0.0.1/snap.jpg?email=me@x.com",
|
||||
"http://h/x?auth=a&author=b&authorization=c&user_agent=d&autoken=e",
|
||||
"http://h/x?private=1&passthrough=1",
|
||||
"Camera front_door is not responding",
|
||||
):
|
||||
with self.subTest(line=line):
|
||||
self.assertEqual(clean_camera_user_pass(line), line)
|
||||
self.assertFalse(is_masked_camera_path(line))
|
||||
|
||||
def test_commas_outside_credentials_survive(self):
|
||||
"""ffprobe splits multiple paths on commas left after masking."""
|
||||
for line in (
|
||||
"rtsp://a/x,rtsp://u:p@b/y",
|
||||
"rtsp://a:554/x,rtsp://u:p@b/y",
|
||||
):
|
||||
with self.subTest(line=line):
|
||||
self.assertIn(",", clean_camera_user_pass(line))
|
||||
self.assertNotIn(",", clean_camera_user_pass("rtsp://u:p,q@b/y"))
|
||||
|
||||
def test_long_line_masks_in_linear_time(self):
|
||||
"""Log lines run through the mask, so a long one must stay fast."""
|
||||
start = time.perf_counter()
|
||||
clean_camera_user_pass("rtsp://u:p" * 20000)
|
||||
clean_camera_user_pass("rtsp://" + "a." * 40000)
|
||||
self.assertLess(time.perf_counter() - start, 1)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Tests that IPC handler errors cannot stall inter-process communication."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import zmq
|
||||
|
||||
from frigate.comms.dispatcher import Dispatcher
|
||||
from frigate.comms.inter_process import InterProcessCommunicator
|
||||
|
||||
|
||||
class TestInterProcessHandlerErrors(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.address = f"ipc://{os.path.join(self.tmp.name, 'comms')}"
|
||||
|
||||
with patch("frigate.comms.inter_process.SOCKET_REP_REQ", self.address):
|
||||
self.communicator = InterProcessCommunicator()
|
||||
|
||||
self.context = zmq.Context()
|
||||
self.requestor = self.context.socket(zmq.REQ)
|
||||
self.requestor.setsockopt(zmq.RCVTIMEO, 5000)
|
||||
self.requestor.connect(self.address)
|
||||
|
||||
def tearDown(self):
|
||||
self.requestor.close(linger=0)
|
||||
self.context.destroy(linger=0)
|
||||
self.communicator.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_handler_exception_still_replies_and_keeps_reading(self):
|
||||
def dispatcher(topic, value):
|
||||
if topic == "boom":
|
||||
raise ValueError("handler failed")
|
||||
return value
|
||||
|
||||
self.communicator.subscribe(dispatcher)
|
||||
self.communicator.start()
|
||||
|
||||
with self.assertLogs("frigate.comms.inter_process", level="ERROR"):
|
||||
self.requestor.send_json(("boom", None))
|
||||
self.assertEqual(self.requestor.recv_json(), [])
|
||||
|
||||
self.requestor.send_json(("echo", "ok"))
|
||||
self.assertEqual(self.requestor.recv_json(), "ok")
|
||||
self.assertTrue(self.communicator.reader_thread.is_alive())
|
||||
|
||||
|
||||
class TestDispatcherPublishErrors(unittest.TestCase):
|
||||
def test_failing_communicator_does_not_block_the_others(self):
|
||||
failing = MagicMock()
|
||||
failing.publish.side_effect = ValueError("publish failed")
|
||||
healthy = MagicMock()
|
||||
|
||||
with (
|
||||
patch("frigate.comms.dispatcher.CameraActivityManager"),
|
||||
patch("frigate.comms.dispatcher.AudioActivityManager"),
|
||||
):
|
||||
dispatcher = Dispatcher(
|
||||
MagicMock(), MagicMock(), MagicMock(), {}, [failing, healthy]
|
||||
)
|
||||
|
||||
healthy.publish.reset_mock()
|
||||
|
||||
with self.assertLogs("frigate.comms.dispatcher", level="ERROR"):
|
||||
dispatcher.publish("reviews", "{}")
|
||||
dispatcher.publish_local("notices", "[]")
|
||||
|
||||
healthy.publish.assert_any_call("reviews", "{}", False)
|
||||
healthy.publish.assert_any_call("notices", "[]", False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Tests for the device each model runner reports after loading."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.detectors import detection_runners
|
||||
from frigate.detectors import detection_runners, hardware
|
||||
from frigate.detectors.detection_runners import (
|
||||
CudaGraphRunner,
|
||||
ONNXModelRunner,
|
||||
@@ -17,6 +18,7 @@ from frigate.detectors.detection_runners import (
|
||||
record_loaded_device,
|
||||
snapshot_loaded_devices,
|
||||
)
|
||||
from frigate.util import model as model_util
|
||||
|
||||
|
||||
class TestRunnerDeviceName(unittest.TestCase):
|
||||
@@ -317,3 +319,215 @@ class TestLighterANE(unittest.TestCase):
|
||||
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
|
||||
|
||||
ane.assert_not_called()
|
||||
|
||||
|
||||
class TestMIGraphX(unittest.TestCase):
|
||||
"""AMD's MIGraphX plugin provider, selected through its devices in the ROCm image."""
|
||||
|
||||
def setUp(self):
|
||||
loaded_devices.clear()
|
||||
self.root = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.root.cleanup)
|
||||
|
||||
for patcher in (
|
||||
patch.object(hardware, "detect_amd_gpu", return_value=MagicMock()),
|
||||
patch.object(detection_runners, "MODEL_CACHE_DIR", self.root.name),
|
||||
patch.object(detection_runners, "is_rknn_compatible", return_value=False),
|
||||
patch.object(detection_runners, "get_lighter_ane_devices", return_value=[]),
|
||||
):
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.plugin = MagicMock()
|
||||
self.plugin.get_library_path.return_value = "/site/libmigraphx-ep.so"
|
||||
|
||||
def _device(self) -> MagicMock:
|
||||
device = MagicMock()
|
||||
device.ep_name = "MIGraphXExecutionProvider"
|
||||
return device
|
||||
|
||||
def test_no_devices_outside_the_rocm_image(self):
|
||||
with (
|
||||
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": None}),
|
||||
patch.object(
|
||||
detection_runners.ort, "register_execution_provider_library"
|
||||
) as register,
|
||||
):
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [])
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [])
|
||||
|
||||
register.assert_not_called()
|
||||
|
||||
def test_no_devices_without_an_amd_gpu(self):
|
||||
with (
|
||||
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
|
||||
patch.object(hardware, "detect_amd_gpu", return_value=None),
|
||||
patch.object(detection_runners.ort, "get_ep_devices") as get_ep_devices,
|
||||
patch.object(
|
||||
detection_runners.ort, "register_execution_provider_library"
|
||||
) as register,
|
||||
):
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [])
|
||||
|
||||
get_ep_devices.assert_not_called()
|
||||
register.assert_not_called()
|
||||
|
||||
def test_the_provider_is_registered_once(self):
|
||||
device = self._device()
|
||||
other = MagicMock()
|
||||
other.ep_name = "CPUExecutionProvider"
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"get_ep_devices",
|
||||
side_effect=[[other], [other, device], [other, device]],
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "register_execution_provider_library"
|
||||
) as register,
|
||||
):
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [device])
|
||||
|
||||
register.assert_called_once_with(
|
||||
"MIGraphXExecutionProvider", "/site/libmigraphx-ep.so"
|
||||
)
|
||||
|
||||
def test_a_provider_that_will_not_load_is_skipped(self):
|
||||
with (
|
||||
patch.dict(sys.modules, {"onnxruntime_ep_migraphx": self.plugin}),
|
||||
patch.object(detection_runners.ort, "get_ep_devices", return_value=[]),
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"register_execution_provider_library",
|
||||
side_effect=RuntimeError("libamdhip64.so.7: cannot open shared object"),
|
||||
),
|
||||
self.assertLogs(detection_runners.logger, level="WARNING"),
|
||||
):
|
||||
self.assertEqual(detection_runners.get_migraphx_devices(), [])
|
||||
|
||||
def test_the_session_runs_on_the_first_gpu(self):
|
||||
device = self._device()
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = [
|
||||
"MIGraphXExecutionProvider",
|
||||
"CPUExecutionProvider",
|
||||
]
|
||||
options = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_migraphx_devices",
|
||||
return_value=[device, self._device()],
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=options
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "InferenceSession", return_value=session
|
||||
) as inference_session,
|
||||
):
|
||||
runner = get_optimized_runner("/models/yolo.onnx", "AUTO", "yolo-generic")
|
||||
|
||||
cache_dir = os.path.join(self.root.name, "migraphx/ort")
|
||||
options.add_provider_for_devices.assert_called_once_with(
|
||||
[device], {"cache_dir": cache_dir}
|
||||
)
|
||||
inference_session.assert_called_once_with(
|
||||
"/models/yolo.onnx", sess_options=options
|
||||
)
|
||||
self.assertTrue(os.path.isdir(cache_dir))
|
||||
self.assertIsInstance(runner, ONNXModelRunner)
|
||||
self.assertEqual(
|
||||
loaded_devices["/models/yolo.onnx"], ("yolo-generic", "MIGraphX")
|
||||
)
|
||||
|
||||
def test_a_model_migraphx_cannot_load_uses_the_default_providers(self):
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = ["CPUExecutionProvider"]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_migraphx_devices",
|
||||
return_value=[self._device()],
|
||||
),
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_ort_providers",
|
||||
return_value=(["CPUExecutionProvider"], [{}]),
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "is_openvino_gpu_npu_available", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort,
|
||||
"InferenceSession",
|
||||
side_effect=[RuntimeError("compile failed"), session],
|
||||
) as inference_session,
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=MagicMock()
|
||||
),
|
||||
self.assertLogs(detection_runners.logger, level="WARNING"),
|
||||
):
|
||||
runner = get_optimized_runner("/models/arcface.onnx", "AUTO", "arcface")
|
||||
|
||||
self.assertEqual(inference_session.call_count, 2)
|
||||
self.assertIsInstance(runner, ONNXModelRunner)
|
||||
self.assertEqual(loaded_devices["/models/arcface.onnx"], ("arcface", "CPU"))
|
||||
|
||||
def test_unsupported_models_and_cpu_models_skip_migraphx(self):
|
||||
for model_path, device, model_type in [
|
||||
("/models/paddleocr.onnx", "AUTO", "paddleocr"),
|
||||
("/models/dfine.onnx", "AUTO", "dfine"),
|
||||
("/models/yolo.onnx", "CPU", "yolo-generic"),
|
||||
]:
|
||||
session = MagicMock()
|
||||
session.get_providers.return_value = ["CPUExecutionProvider"]
|
||||
|
||||
with (
|
||||
self.subTest(model_type=model_type, device=device),
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_migraphx_devices",
|
||||
return_value=[self._device()],
|
||||
) as migraphx,
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"get_ort_providers",
|
||||
return_value=(["CPUExecutionProvider"], [{}]),
|
||||
),
|
||||
patch.object(
|
||||
detection_runners,
|
||||
"is_openvino_gpu_npu_available",
|
||||
return_value=False,
|
||||
),
|
||||
patch.object(
|
||||
detection_runners.ort, "InferenceSession", return_value=session
|
||||
),
|
||||
patch.object(
|
||||
detection_runners, "get_ort_session_options", return_value=None
|
||||
),
|
||||
):
|
||||
get_optimized_runner(model_path, device, model_type)
|
||||
|
||||
migraphx.assert_not_called()
|
||||
self.assertEqual(loaded_devices[model_path], (model_type, "CPU"))
|
||||
|
||||
def test_migraphx_is_never_requested_by_name(self):
|
||||
with patch.object(
|
||||
model_util.ort,
|
||||
"get_available_providers",
|
||||
return_value=[
|
||||
"AzureExecutionProvider",
|
||||
"CPUExecutionProvider",
|
||||
"MIGraphXExecutionProvider",
|
||||
],
|
||||
):
|
||||
providers, options = model_util.get_ort_providers()
|
||||
|
||||
self.assertEqual(providers, ["CPUExecutionProvider"])
|
||||
self.assertEqual(options, [{"enable_cpu_mem_arena": False}])
|
||||
|
||||
@@ -3,13 +3,8 @@
|
||||
import unittest
|
||||
from base64 import urlsafe_b64encode
|
||||
|
||||
from frigate.api.notification import _validate_subscription
|
||||
from frigate.comms.webpush import (
|
||||
_build_web_pushers,
|
||||
_NoRedirectSession,
|
||||
_vapid_audience,
|
||||
validate_push_endpoint,
|
||||
)
|
||||
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
|
||||
from frigate.comms.webpush import _build_web_pushers
|
||||
|
||||
VALID_ENDPOINTS = [
|
||||
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
||||
@@ -31,11 +26,11 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
def test_accepts_real_push_service_endpoints(self):
|
||||
for endpoint in VALID_ENDPOINTS:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_http(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
||||
_validate_push_endpoint("http://fcm.googleapis.com/fcm/send/token")
|
||||
)
|
||||
|
||||
def test_rejects_non_http_schemes(self):
|
||||
@@ -45,7 +40,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"//example.com/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_localhost(self):
|
||||
for endpoint in (
|
||||
@@ -55,7 +50,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://[::1]/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_private_addresses(self):
|
||||
for endpoint in (
|
||||
@@ -66,7 +61,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://0.0.0.0/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_internal_hostnames(self):
|
||||
for endpoint in (
|
||||
@@ -76,16 +71,16 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
"https://host.home.arpa/token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_non_default_port(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
||||
_validate_push_endpoint("https://fcm.googleapis.com:8080/fcm/send/token")
|
||||
)
|
||||
|
||||
def test_rejects_embedded_credentials(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint(
|
||||
_validate_push_endpoint(
|
||||
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
||||
)
|
||||
)
|
||||
@@ -93,26 +88,26 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
||||
def test_rejects_endpoint_without_path(self):
|
||||
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_empty_hostname_labels(self):
|
||||
for endpoint in (
|
||||
"https://./x",
|
||||
"https://fcm..googleapis.com/token",
|
||||
"https://.fcm.googleapis.com/token",
|
||||
"https://fcm.googleapis.com./token",
|
||||
):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
def test_rejects_endpoint_that_breaks_audience_parsing(self):
|
||||
# webpush.py locates the host by searching for a separator after index
|
||||
# 10, which raises ValueError when the url has no path at all
|
||||
endpoint = "https://fcm.googleapis.com"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
endpoint.index("/", 10)
|
||||
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_missing_or_non_string_endpoint(self):
|
||||
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
||||
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||
|
||||
def test_rejects_overlong_endpoint(self):
|
||||
self.assertIsNotNone(
|
||||
validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
||||
_validate_push_endpoint(f"https://fcm.googleapis.com/{'a' * 4096}")
|
||||
)
|
||||
|
||||
|
||||
@@ -153,6 +148,10 @@ class TestValidateSubscription(unittest.TestCase):
|
||||
self.assertIsNotNone(_validate_subscription(sub))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestBuildWebPushers(unittest.TestCase):
|
||||
def test_skips_subscriptions_with_malformed_keys(self):
|
||||
bad = _subscription(VALID_ENDPOINTS[0])
|
||||
@@ -164,52 +163,3 @@ class TestBuildWebPushers(unittest.TestCase):
|
||||
|
||||
self.assertEqual(len(pushers), 1)
|
||||
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
||||
|
||||
def _valid_keys_subscription(self, endpoint: str) -> dict:
|
||||
sub = _subscription(endpoint)
|
||||
sub["keys"]["p256dh"] = urlsafe_b64encode(b"\x04" + bytes(64)).decode()
|
||||
return sub
|
||||
|
||||
def test_skips_stored_rows_that_break_the_audience(self):
|
||||
good = self._valid_keys_subscription(VALID_ENDPOINTS[0])
|
||||
rows = [
|
||||
self._valid_keys_subscription("https://./secret-token"),
|
||||
self._valid_keys_subscription("https://[::1/secret-token"),
|
||||
{"keys": good["keys"]},
|
||||
"not-a-dict",
|
||||
good,
|
||||
]
|
||||
|
||||
with self.assertLogs("frigate.comms.webpush", level="WARNING") as logs:
|
||||
pushers = _build_web_pushers("viewer", rows)
|
||||
|
||||
self.assertEqual(len(logs.output), 4)
|
||||
self.assertIn("'.'", logs.output[0])
|
||||
self.assertNotIn("secret-token", "".join(logs.output))
|
||||
self.assertEqual(len(pushers), 1)
|
||||
self.assertEqual(pushers[0].subscription_info["endpoint"], VALID_ENDPOINTS[0])
|
||||
self.assertIsInstance(pushers[0].mod_or_session, _NoRedirectSession)
|
||||
|
||||
def test_keeps_stored_self_hosted_endpoints(self):
|
||||
rows = [
|
||||
self._valid_keys_subscription("https://ntfy.example.com:8443/up123"),
|
||||
self._valid_keys_subscription("https://ntfy.example.com/"),
|
||||
]
|
||||
|
||||
pushers = _build_web_pushers("viewer", rows)
|
||||
|
||||
self.assertEqual(len(pushers), 2)
|
||||
|
||||
|
||||
class TestVapidAudience(unittest.TestCase):
|
||||
def test_uses_scheme_and_host(self):
|
||||
self.assertEqual(
|
||||
_vapid_audience(VALID_ENDPOINTS[0]), "https://fcm.googleapis.com"
|
||||
)
|
||||
self.assertEqual(
|
||||
_vapid_audience(VALID_ENDPOINTS[4]), "https://fcm.googleapis.com:443"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Tests that push sends never follow redirects."""
|
||||
|
||||
import unittest
|
||||
|
||||
import requests
|
||||
from requests.adapters import BaseAdapter
|
||||
|
||||
from frigate.comms.webpush import _NoRedirectSession
|
||||
|
||||
ENDPOINT = "https://push.example.com/token"
|
||||
|
||||
|
||||
class RedirectAdapter(BaseAdapter):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def send(self, request, **kwargs):
|
||||
self.calls += 1
|
||||
response = requests.Response()
|
||||
response.status_code = 307
|
||||
response.headers["Location"] = "http://127.0.0.1:5000/api/restart"
|
||||
response.url = request.url
|
||||
response.request = request
|
||||
return response
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class TestNoRedirectSession(unittest.TestCase):
|
||||
def test_does_not_follow_redirects(self):
|
||||
adapter = RedirectAdapter()
|
||||
session = _NoRedirectSession()
|
||||
session.mount("https://", adapter)
|
||||
session.mount("http://", adapter)
|
||||
|
||||
response = session.post(ENDPOINT, data=b"payload")
|
||||
|
||||
self.assertEqual(response.status_code, 307)
|
||||
self.assertEqual(adapter.calls, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+22
-4
@@ -21,8 +21,11 @@ import numpy as np
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from frigate.const import (
|
||||
REGEX_HTTP_CAMERA_USER_PASS,
|
||||
REGEX_CREDENTIAL_QUERY_KEY,
|
||||
REGEX_CREDENTIAL_QUERY_PARAM,
|
||||
REGEX_MASKED_CREDENTIAL,
|
||||
REGEX_RTSP_CAMERA_USER_PASS,
|
||||
REGEX_URL_USERINFO,
|
||||
STREAM_TYPE_MAIN,
|
||||
STREAM_TYPE_SUB,
|
||||
)
|
||||
@@ -117,9 +120,24 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
||||
|
||||
|
||||
def clean_camera_user_pass(line: str) -> str:
|
||||
"""Removes user and password from line."""
|
||||
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned)
|
||||
"""Mask URL userinfo and credential query parameters in line."""
|
||||
|
||||
line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line)
|
||||
return re.sub(REGEX_CREDENTIAL_QUERY_PARAM, r"\1*", line)
|
||||
|
||||
|
||||
def _mask_userinfo(match: re.Match[str]) -> str:
|
||||
# A credential key in the userinfo means a query password holds "@" or a
|
||||
# password holds a query key. Neither split is safe, so mask the whole URL.
|
||||
if re.search(REGEX_CREDENTIAL_QUERY_KEY, match.group(1)):
|
||||
return "://*:*@"
|
||||
|
||||
return f"://*:*@{match.group(2)}"
|
||||
|
||||
|
||||
def is_masked_camera_path(path: str) -> bool:
|
||||
"""Return True if path holds credentials masked by clean_camera_user_pass."""
|
||||
return re.search(REGEX_MASKED_CREDENTIAL, path) is not None
|
||||
|
||||
|
||||
def escape_special_characters(path: str) -> str:
|
||||
|
||||
@@ -385,15 +385,9 @@ def get_ort_providers(
|
||||
}
|
||||
)
|
||||
elif provider == "MIGraphXExecutionProvider":
|
||||
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx")
|
||||
os.makedirs(migraphx_cache_dir, exist_ok=True)
|
||||
|
||||
providers.append(provider)
|
||||
options.append(
|
||||
{
|
||||
"migraphx_model_cache_dir": migraphx_cache_dir,
|
||||
}
|
||||
)
|
||||
# MIGraphX is a plugin provider, which ONNX Runtime can't create by
|
||||
# name, so get_optimized_runner selects it through its devices
|
||||
continue
|
||||
elif provider == "CPUExecutionProvider":
|
||||
providers.append(provider)
|
||||
options.append(
|
||||
|
||||
@@ -338,12 +338,7 @@ def process_frames(
|
||||
continue
|
||||
|
||||
if datetime.now().astimezone(UTC) > next_region_update:
|
||||
updated_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
|
||||
# A failed request replies empty, so keep the current grid
|
||||
if updated_grid:
|
||||
region_grid = updated_grid
|
||||
|
||||
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
||||
next_region_update = get_tomorrow_at_time(2)
|
||||
|
||||
try:
|
||||
|
||||
@@ -32,7 +32,11 @@ type CameraInput = {
|
||||
input_args?: string;
|
||||
};
|
||||
|
||||
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
frontDoorInputs: CameraInput[],
|
||||
rawInputs: CameraInput[] = frontDoorInputs,
|
||||
) {
|
||||
const config = configFactory({
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
cameras: {
|
||||
@@ -56,7 +60,7 @@ async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
|
||||
go2rtc: { streams: GO2RTC_STREAMS },
|
||||
},
|
||||
}),
|
||||
@@ -200,4 +204,64 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
|
||||
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
||||
expect(input?.input_args).not.toBe("preset-rtsp-restream");
|
||||
});
|
||||
|
||||
test("a masked path reveals its own saved path after an earlier input is deleted", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const main = "rtsp://admin:mainpw@10.0.0.1:554/main";
|
||||
const sub = "rtsp://admin:subpw@10.0.0.1:554/sub";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/main", roles: ["record"] },
|
||||
{ path: "rtsp://*:*@10.0.0.1:554/sub", roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: main, roles: ["record"] },
|
||||
{ path: sub, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.first()
|
||||
.click();
|
||||
await expect(paths).toHaveCount(1);
|
||||
|
||||
await paths.focus();
|
||||
await expect(paths).toHaveValue(sub);
|
||||
});
|
||||
|
||||
test("inputs with the same masked path each reveal their own path by role", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const record = "rtsp://admin:one@10.0.0.1:554/live";
|
||||
const detect = "rtsp://viewer:two@10.0.0.1:554/live";
|
||||
const masked = "rtsp://*:*@10.0.0.1:554/live";
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ path: masked, roles: ["record"] },
|
||||
{ path: masked, roles: ["detect"] },
|
||||
],
|
||||
[
|
||||
{ path: record, roles: ["record"] },
|
||||
{ path: detect, roles: ["detect"] },
|
||||
],
|
||||
);
|
||||
await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door");
|
||||
|
||||
const paths = frigateApp.page.getByRole("textbox", { name: "Input path" });
|
||||
await expect(paths).toHaveCount(2);
|
||||
|
||||
await paths.nth(0).focus();
|
||||
await expect(paths.nth(0)).toHaveValue(record);
|
||||
|
||||
await paths.nth(1).focus();
|
||||
await expect(paths.nth(1)).toHaveValue(detect);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+304
-425
File diff suppressed because it is too large
Load Diff
+22
-23
@@ -23,28 +23,28 @@
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.24",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.16",
|
||||
"@radix-ui/react-checkbox": "^1.3.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.21",
|
||||
"@radix-ui/react-context-menu": "^2.3.8",
|
||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.4.1",
|
||||
"@radix-ui/react-collapsible": "^1.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.4.1",
|
||||
"@radix-ui/react-dialog": "^1.2.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.25",
|
||||
"@radix-ui/react-hover-card": "^1.1.24",
|
||||
"@radix-ui/react-label": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.2.0",
|
||||
"@radix-ui/react-progress": "^1.1.17",
|
||||
"@radix-ui/react-radio-group": "^1.4.8",
|
||||
"@radix-ui/react-scroll-area": "^1.3.0",
|
||||
"@radix-ui/react-select": "^2.3.8",
|
||||
"@radix-ui/react-separator": "^1.1.16",
|
||||
"@radix-ui/react-slider": "^1.5.0",
|
||||
"@radix-ui/react-slot": "1.4.0",
|
||||
"@radix-ui/react-switch": "^1.3.8",
|
||||
"@radix-ui/react-tabs": "^1.1.22",
|
||||
"@radix-ui/react-toggle": "^1.1.19",
|
||||
"@radix-ui/react-toggle-group": "^1.1.20",
|
||||
"@radix-ui/react-tooltip": "^1.3.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
||||
"@radix-ui/react-hover-card": "^1.2.1",
|
||||
"@radix-ui/react-label": "^2.1.17",
|
||||
"@radix-ui/react-popover": "^1.3.1",
|
||||
"@radix-ui/react-progress": "^1.2.1",
|
||||
"@radix-ui/react-radio-group": "^1.5.1",
|
||||
"@radix-ui/react-scroll-area": "^1.4.0",
|
||||
"@radix-ui/react-select": "^2.4.1",
|
||||
"@radix-ui/react-separator": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.6.1",
|
||||
"@radix-ui/react-slot": "1.5.0",
|
||||
"@radix-ui/react-switch": "^1.4.1",
|
||||
"@radix-ui/react-tabs": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.2.1",
|
||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.4.1",
|
||||
"@rjsf/core": "^6.10.1",
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
@@ -127,7 +127,6 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-slot": "1.4.0"
|
||||
"@radix-ui/react-slot": "1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import useSWR from "swr";
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { useMemo, useState, type FocusEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
@@ -21,6 +23,7 @@ type RawPathsResponse = {
|
||||
ffmpeg?: {
|
||||
inputs?: Array<{
|
||||
path?: string;
|
||||
roles?: string[];
|
||||
}>;
|
||||
};
|
||||
}
|
||||
@@ -66,17 +69,35 @@ export function CameraPathWidget(props: WidgetProps) {
|
||||
shouldFetchRawPaths ? "config/raw_paths" : null,
|
||||
);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
|
||||
const roles =
|
||||
inputIndex === undefined
|
||||
? undefined
|
||||
: get(formContext?.formData, ["inputs", inputIndex, "roles"]);
|
||||
|
||||
// Match by masked value with roles breaking a tie, as the backend restores
|
||||
// on save, so deleting or reordering inputs never reveals another input's path
|
||||
const rawPath = useMemo(() => {
|
||||
if (!cameraName || inputIndex === undefined) {
|
||||
if (!cameraName || !isMaskedPath(rawValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const path =
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||
return typeof path === "string" ? path : undefined;
|
||||
}, [cameraName, inputIndex, rawPaths]);
|
||||
let matches = (
|
||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
|
||||
).filter(
|
||||
(input): input is { path: string; roles?: string[] } =>
|
||||
typeof input.path === "string" &&
|
||||
maskCredentials(input.path) === rawValue,
|
||||
);
|
||||
if (new Set(matches.map((input) => input.path)).size > 1) {
|
||||
matches = matches.filter((input) => isEqual(input.roles, roles));
|
||||
}
|
||||
|
||||
const paths = new Set(matches.map((input) => input.path));
|
||||
return paths.size === 1 ? [...paths][0] : undefined;
|
||||
}, [cameraName, rawPaths, rawValue, roles]);
|
||||
|
||||
const rawValue = typeof value === "string" ? value : "";
|
||||
const resolvedValue =
|
||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||
const canReveal =
|
||||
|
||||
@@ -210,7 +210,7 @@ export function VideoPreview({
|
||||
}, [playerRef]);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!sliderRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -401,7 +401,7 @@ export function InProgressPreview({
|
||||
);
|
||||
|
||||
const onProgressHover = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!sliderRef.current || !previewFrames) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||
// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
|
||||
const CREDENTIAL_QUERY_KEYS =
|
||||
"(?:user(?:name)?|usr|login|pass(?:word|wd)?|pwd|(?:[a-z_]*_)?token|(?:[a-z_]*_)?secret|client_private|api_?key)";
|
||||
const URL_USERINFO_PATTERN =
|
||||
/:\/\/((?:[^\s/?#:[\]]*:(?:(?!:\/\/)\S)*|[^\s/?#]+))@((?:(?!:\/\/)\S)*)/g;
|
||||
const CREDENTIAL_QUERY_KEY_PATTERN = new RegExp(
|
||||
`[?&]${CREDENTIAL_QUERY_KEYS}=`,
|
||||
"i",
|
||||
);
|
||||
const CREDENTIAL_QUERY_PATTERN = new RegExp(
|
||||
`([?&]${CREDENTIAL_QUERY_KEYS}=)[^&\\s]*`,
|
||||
"gi",
|
||||
);
|
||||
const MASKED_PATTERN = new RegExp(
|
||||
`://\\*:\\*@|[?&]${CREDENTIAL_QUERY_KEYS}=\\*(?![^&\\s])`,
|
||||
"i",
|
||||
);
|
||||
|
||||
export const isMaskedPath = (value: string): boolean =>
|
||||
MASKED_AUTH_PATTERN.test(value) || MASKED_QUERY_PATTERN.test(value);
|
||||
|
||||
export const hasCredentials = (value: string): boolean => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isMaskedPath(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.username || parsed.password) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
parsed.searchParams.has("user") && parsed.searchParams.has("password")
|
||||
);
|
||||
} catch {
|
||||
return /:\/\/[^:@/\s]+:[^@/\s]+@/.test(value);
|
||||
}
|
||||
};
|
||||
MASKED_PATTERN.test(value);
|
||||
|
||||
export const maskCredentials = (value: string): string => {
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||
|
||||
return maskedAuth
|
||||
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||
return value
|
||||
.replace(URL_USERINFO_PATTERN, (_match, userinfo: string, rest: string) =>
|
||||
CREDENTIAL_QUERY_KEY_PATTERN.test(userinfo)
|
||||
? "://*:*@"
|
||||
: `://*:*@${rest}`,
|
||||
)
|
||||
.replace(CREDENTIAL_QUERY_PATTERN, "$1*");
|
||||
};
|
||||
|
||||
export const hasCredentials = (value: string): boolean =>
|
||||
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
|
||||
|
||||
Reference in New Issue
Block a user