Compare commits

..
Author SHA1 Message Date
Josh Hawkins c1543f587c only skip stored push rows that break the vapid audience
Running the full registration validator over stored rows dropped self-hosted endpoints registered before 0.18 with a non-443 port or a bare / path. _build_web_pushers now skips only non-dict rows, rows with no endpoint, unparsable endpoints and hosts with empty labels, and the warning names the host so the row can be found. Registration still uses the full validator.
2026-10-18 13:13:17 -05:00
Josh Hawkins 7e2d04f851 drop the send-time public address check
Refusing non-global resolved addresses broke Tailscale (100.64.0.0/10), split-horizon DNS and HTTPS_PROXY-only setups, and added little once redirects are no longer followed and TLS verification stays on.
2026-10-18 13:12:48 -05:00
Josh Hawkins 6ad2367a27 keep the previous region grid when the refresh request fails
The IPC reader now replies [] when a handler raises, and process_frames assigned that reply to region_grid unconditionally. The next region lookup then indexed into an empty list and killed the camera's detect process. An empty reply now keeps the current grid until the next daily refresh.
2026-10-18 13:12:19 -05:00
Josh Hawkins d015c4510b stop push sends from reaching internal addresses
pywebpush posts through requests with redirects enabled, so an endpoint could answer 307 to http://127.0.0.1:5000/api/restart and the sender would follow it into the internal admin port on every alert. Pushers now use a session that never follows redirects, and each send first resolves the endpoint host and skips it unless every address is global.
2026-10-18 12:11:08 -05:00
Josh Hawkins 7095e21af9 keep the ipc thread alive when a handler raises
An exception from any dispatcher handler escaped InterProcessCommunicator.read, which only caught ZMQError. The REP thread died owing a reply, so every InterProcessRequestor blocked forever. read now logs the error and replies with an empty response, and Dispatcher.publish and publish_local catch per communicator so one failing communicator can't take down the rest.
2026-10-18 12:09:38 -05:00
Josh Hawkins caf79dc668 reject push endpoints that crash the vapid audience parse
The validator accepted hostnames like "." so https://./x passed, and check_registrations then raised ValueError slicing the endpoint, killing the dispatcher's IPC thread. The validator now rejects empty hostname labels and lives in webpush.py, the audience comes from urlparse, and _build_web_pushers skips stored rows whose endpoint fails validation.
2026-10-18 12:08:55 -05:00
23 changed files with 901 additions and 846 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
numpy
openvino >= 2026.4.1
openvino >= 2026.2.0
+4 -4
View File
@@ -23,7 +23,7 @@ pandas == 3.0.*
peewee == 4.5.*
peewee_migrate == 2.3.*
psutil == 7.2.*
pydantic == 2.14.*
pydantic == 2.13.*
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 == 2026.4.*
openvino == 2025.4.*
onnxruntime == 1.30.*
# Embeddings
transformers == 5.19.*
# Generative AI
google-genai == 2.31.*
google-genai == 2.29.*
ollama == 0.6.*
openai == 3.26.*
# push notifications
py-vapid == 1.9.6
py-vapid == 1.9.4
pywebpush == 2.5.*
# alpr
pyclipper == 1.4.*
+1 -1
View File
@@ -1 +1 @@
scikit-build == 0.19.*
scikit-build == 0.18.*
+50 -11
View File
@@ -2,9 +2,32 @@
# 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
@@ -19,35 +42,51 @@ 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 --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
RUN pip3 uninstall -y onnxruntime \
&& pip3 install -r /requirements.txt
WORKDIR /opt/frigate
COPY --from=rootfs / /
#######################################################################
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/ /
#######################################################################
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
-63
View File
@@ -1,63 +0,0 @@
#!/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 -19
View File
@@ -1,19 +1 @@
# 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
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
+11
View File
@@ -1,3 +1,6 @@
variable "ROCM" {
default = "7.2.3"
}
variable "HSA_OVERRIDE_GFX_VERSION" {
default = ""
}
@@ -5,6 +8,12 @@ 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"]
@@ -21,10 +30,12 @@ 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
}
+18 -26
View File
@@ -394,22 +394,6 @@ 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.
@@ -436,10 +420,19 @@ For reference on recommended settings see [running ROCm/pytorch in Docker](https
### Docker settings for overriding the GPU chipset
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`:
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:
```bash
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.3.0 \
$ docker run -e HSA_OVERRIDE_GFX_VERSION=10.0.0 \
...
```
@@ -450,17 +443,16 @@ services:
frigate:
...
environment:
HSA_OVERRIDE_GFX_VERSION: "10.3.0"
HSA_OVERRIDE_GFX_VERSION: "10.0.0"
```
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.
Figuring out what version you need can be complicated as you can't tell the chipset name and driver from the AMD brand name.
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
- 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
#### Figuring out if AMD/ROCm is working and found your GPU
+2 -2
View File
@@ -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 and recent integrated GPUs to provide efficient object detection
- [ROCm](#rocm---amd-gpu): ROCm can run on AMD Discrete 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 AMD discrete and integrated GPUs, see the [supported GPUs](../configuration/object_detectors.md#supported-gpus-rocm).
With the [ROCm](../configuration/object_detectors.md#amdrocm-gpu-detector) detector Frigate can take advantage of many discrete AMD GPUs.
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
| -------------- | --------------------------- | ------------------------- | ---------------------- |
+2 -66
View File
@@ -1,10 +1,8 @@
"""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
@@ -13,6 +11,7 @@ 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
@@ -20,76 +19,13 @@ 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
+12 -2
View File
@@ -490,7 +490,12 @@ class Dispatcher:
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
"""Handle publishing to communicators."""
for comm in self.comms:
comm.publish(topic, payload, retain)
try:
comm.publish(topic, payload, retain)
except Exception:
logger.exception(
"Error publishing %s to %s", topic, type(comm).__name__
)
def publish_local(self, topic: str, payload: Any) -> None:
"""Publish to every communicator except MQTT.
@@ -501,7 +506,12 @@ class Dispatcher:
if isinstance(comm, MqttClient):
continue
comm.publish(topic, payload, False)
try:
comm.publish(topic, payload, False)
except Exception:
logger.exception(
"Error publishing %s to %s", topic, type(comm).__name__
)
def _publish_notices(self) -> None:
if self.notice_registry is None:
+7 -1
View File
@@ -50,7 +50,13 @@ class InterProcessCommunicator(Communicator):
if isinstance(raw, list):
(topic, value) = raw
response = self._dispatcher(topic, value)
# 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
else:
logging.warning(
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
+117 -5
View File
@@ -1,6 +1,7 @@
"""Handle sending notifications for Frigate via Firebase."""
import datetime
import ipaddress
import json
import logging
import os
@@ -10,7 +11,9 @@ 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
@@ -41,14 +44,124 @@ 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))
pushers.append(WebPusher(sub, requests_session=_push_session))
except Exception:
logger.warning("Skipping invalid notification subscription for %s", user)
@@ -131,7 +244,7 @@ class WebPushClient(Communicator):
for pushers in self.web_pushers.values():
for push in pushers:
endpoint: str = push.subscription_info["endpoint"]
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
endpoints.add(_vapid_audience(endpoint))
# create new claim
for endpoint in endpoints:
@@ -320,9 +433,8 @@ class WebPushClient(Communicator):
for pusher in self.web_pushers[notification.user]:
endpoint = pusher.subscription_info["endpoint"]
headers = self.claim_headers[
endpoint[: endpoint.index("/", 10)]
].copy()
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
headers["urgency"] = "high"
resp = pusher.send(
+11 -71
View File
@@ -10,7 +10,6 @@ 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
@@ -56,9 +55,6 @@ _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.
@@ -87,42 +83,6 @@ 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()
@@ -796,41 +756,12 @@ 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 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
# 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
if device != "CPU" and is_openvino_gpu_npu_available():
return _record_runner(
model_path,
@@ -859,6 +790,15 @@ 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,
+76
View File
@@ -0,0 +1,76 @@
"""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 -215
View File
@@ -1,13 +1,12 @@
"""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, hardware
from frigate.detectors import detection_runners
from frigate.detectors.detection_runners import (
CudaGraphRunner,
ONNXModelRunner,
@@ -18,7 +17,6 @@ from frigate.detectors.detection_runners import (
record_loaded_device,
snapshot_loaded_devices,
)
from frigate.util import model as model_util
class TestRunnerDeviceName(unittest.TestCase):
@@ -319,215 +317,3 @@ 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}])
+76 -26
View File
@@ -3,8 +3,13 @@
import unittest
from base64 import urlsafe_b64encode
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
from frigate.comms.webpush import _build_web_pushers
from frigate.api.notification import _validate_subscription
from frigate.comms.webpush import (
_build_web_pushers,
_NoRedirectSession,
_vapid_audience,
validate_push_endpoint,
)
VALID_ENDPOINTS = [
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
@@ -26,11 +31,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):
@@ -40,7 +45,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 (
@@ -50,7 +55,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 (
@@ -61,7 +66,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 (
@@ -71,16 +76,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"
)
)
@@ -88,26 +93,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_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_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_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}")
)
@@ -148,10 +153,6 @@ 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])
@@ -163,3 +164,52 @@ 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()
+45
View File
@@ -0,0 +1,45 @@
"""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()
+9 -3
View File
@@ -385,9 +385,15 @@ def get_ort_providers(
}
)
elif provider == "MIGraphXExecutionProvider":
# MIGraphX is a plugin provider, which ONNX Runtime can't create by
# name, so get_optimized_runner selects it through its devices
continue
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,
}
)
elif provider == "CPUExecutionProvider":
providers.append(provider)
options.append(
+6 -1
View File
@@ -338,7 +338,12 @@ def process_frames(
continue
if datetime.now().astimezone(UTC) > next_region_update:
region_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
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
next_region_update = get_tomorrow_at_time(2)
try:
+426 -305
View File
File diff suppressed because it is too large Load Diff
+23 -22
View File
@@ -23,28 +23,28 @@
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.1.2",
"@hookform/resolvers": "^5.9.1",
"@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-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-dialog": "^1.2.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",
"@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",
"@rjsf/core": "^6.10.1",
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
@@ -127,6 +127,7 @@
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-slot": "1.5.0"
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-slot": "1.4.0"
}
}
@@ -210,7 +210,7 @@ export function VideoPreview({
}, [playerRef]);
const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
(event: React.MouseEvent<HTMLDivElement>) => {
if (!sliderRef.current) {
return;
}
@@ -401,7 +401,7 @@ export function InProgressPreview({
);
const onProgressHover = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
(event: React.MouseEvent<HTMLDivElement>) => {
if (!sliderRef.current || !previewFrames) {
return;
}