mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 21:22:50 +03:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96fa39bbf7 | ||
|
|
0fc0170461 | ||
|
|
8dccc937e6 | ||
|
|
3373b1cd05 | ||
|
|
31c3638767 | ||
|
|
0aa7cf9de9 | ||
|
|
4fbde770d7 | ||
|
|
ab8b41934c |
@@ -1,2 +1,2 @@
|
|||||||
numpy
|
numpy
|
||||||
openvino >= 2026.4.1
|
openvino >= 2026.2.0
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pandas == 3.0.*
|
|||||||
peewee == 4.5.*
|
peewee == 4.5.*
|
||||||
peewee_migrate == 2.3.*
|
peewee_migrate == 2.3.*
|
||||||
psutil == 7.2.*
|
psutil == 7.2.*
|
||||||
pydantic == 2.14.*
|
pydantic == 2.13.*
|
||||||
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||||
tzdata == 2026.5
|
tzdata == 2026.5
|
||||||
pyzmq == 27.2.*
|
pyzmq == 27.2.*
|
||||||
@@ -42,16 +42,16 @@ opencv-python-headless == 4.11.0.*
|
|||||||
opencv-contrib-python == 4.11.0.*
|
opencv-contrib-python == 4.11.0.*
|
||||||
scipy == 1.17.*
|
scipy == 1.17.*
|
||||||
# OpenVino & ONNX
|
# OpenVino & ONNX
|
||||||
openvino == 2026.4.*
|
openvino == 2025.4.*
|
||||||
onnxruntime == 1.30.*
|
onnxruntime == 1.30.*
|
||||||
# Embeddings
|
# Embeddings
|
||||||
transformers == 5.19.*
|
transformers == 5.19.*
|
||||||
# Generative AI
|
# Generative AI
|
||||||
google-genai == 2.31.*
|
google-genai == 2.29.*
|
||||||
ollama == 0.6.*
|
ollama == 0.6.*
|
||||||
openai == 3.26.*
|
openai == 3.26.*
|
||||||
# push notifications
|
# push notifications
|
||||||
py-vapid == 1.9.6
|
py-vapid == 1.9.4
|
||||||
pywebpush == 2.5.*
|
pywebpush == 2.5.*
|
||||||
# alpr
|
# alpr
|
||||||
pyclipper == 1.4.*
|
pyclipper == 1.4.*
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
scikit-build == 0.19.*
|
scikit-build == 0.18.*
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ else
|
|||||||
lefile="/config/tls/fullchain.pem"
|
lefile="/config/tls/fullchain.pem"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
tls_enabled=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled`
|
||||||
listen_external_port=`python3 -I /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port`
|
||||||
|
|
||||||
while true
|
while true
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -11,13 +11,30 @@ if [[ "$(id -u)" -eq 0 ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# /root survives s6-setuidgid and breaks cache writes after the drop; set
|
||||||
|
# before opt_in_out so the opt-out marker lands where the service will look
|
||||||
|
if [[ "$runs_as_root" -eq 0 ]]; then
|
||||||
|
export HOME=/config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# detector runtimes installed at first start (pip install --user) live under
|
||||||
|
# $HOME/.local; the dynamic loader only reads LD_LIBRARY_PATH at exec time
|
||||||
|
export LD_LIBRARY_PATH="${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${HOME}/.local/lib"
|
||||||
|
export PATH="${PATH}:${HOME}/.local/bin"
|
||||||
|
|
||||||
|
# opt out of openvino telemetry
|
||||||
|
if [ -e /usr/local/bin/opt_in_out ]; then
|
||||||
|
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Logs should be sent to stdout so that s6 can collect them
|
||||||
|
|
||||||
# Tell S6-Overlay not to restart this service
|
# Tell S6-Overlay not to restart this service
|
||||||
s6-svc -O .
|
s6-svc -O .
|
||||||
|
|
||||||
# Runs as root with root's HOME and LD_LIBRARY_PATH; -I skips user site-packages
|
|
||||||
function set_libva_version() {
|
function set_libva_version() {
|
||||||
local ffmpeg_path
|
local ffmpeg_path
|
||||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||||
export LIBAVFORMAT_VERSION_MAJOR
|
export LIBAVFORMAT_VERSION_MAJOR
|
||||||
}
|
}
|
||||||
@@ -25,32 +42,14 @@ function set_libva_version() {
|
|||||||
echo "[INFO] Preparing Frigate..."
|
echo "[INFO] Preparing Frigate..."
|
||||||
set_libva_version
|
set_libva_version
|
||||||
|
|
||||||
# HOME=/root survives s6-setuidgid, so any run that is not root gets
|
|
||||||
# HOME=/config explicitly. The runtime user owns /config, so these paths go
|
|
||||||
# only to the service process, never to root. Detector runtimes installed at
|
|
||||||
# first start (pip install --user) live under $HOME/.local.
|
|
||||||
run_as=()
|
|
||||||
runtime_home="${HOME}"
|
|
||||||
if [[ "$runs_as_root" -eq 0 ]]; then
|
|
||||||
runtime_home=/config
|
|
||||||
if [[ "$(id -u)" -eq 0 ]]; then
|
|
||||||
run_as=(s6-setuidgid frigate)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
run_as+=(env
|
|
||||||
"HOME=${runtime_home}"
|
|
||||||
"LD_LIBRARY_PATH=${LD_LIBRARY_PATH:+${LD_LIBRARY_PATH}:}${runtime_home}/.local/lib"
|
|
||||||
"PATH=${PATH}:${runtime_home}/.local/bin")
|
|
||||||
|
|
||||||
# opt out of openvino telemetry; the marker lands under the service's HOME
|
|
||||||
if [ -e /usr/local/bin/opt_in_out ]; then
|
|
||||||
"${run_as[@]}" /usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[INFO] Starting Frigate..."
|
echo "[INFO] Starting Frigate..."
|
||||||
|
|
||||||
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frigate"
|
||||||
|
|
||||||
# Replace the bash process with the Frigate process, redirecting stderr to stdout
|
# Replace the bash process with the Frigate process, redirecting stderr to stdout
|
||||||
exec 2>&1
|
exec 2>&1
|
||||||
exec "${run_as[@]}" python3 -u -m frigate
|
if [[ "$(id -u)" -ne 0 || "$runs_as_root" -eq 1 ]]; then
|
||||||
|
exec python3 -u -m frigate
|
||||||
|
else
|
||||||
|
exec s6-setuidgid frigate python3 -u -m frigate
|
||||||
|
fi
|
||||||
|
|||||||
@@ -59,33 +59,38 @@ function get_ip_and_port_from_supervisor() {
|
|||||||
|
|
||||||
function set_libva_version() {
|
function set_libva_version() {
|
||||||
local ffmpeg_path
|
local ffmpeg_path
|
||||||
ffmpeg_path=$(python3 -I /usr/local/ffmpeg/get_ffmpeg_path.py)
|
ffmpeg_path=$(python3 /usr/local/ffmpeg/get_ffmpeg_path.py)
|
||||||
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
LIBAVFORMAT_VERSION_MAJOR=$("$ffmpeg_path" -version | grep -Po "libavformat\W+\K\d+")
|
||||||
export LIBAVFORMAT_VERSION_MAJOR
|
export LIBAVFORMAT_VERSION_MAJOR
|
||||||
}
|
}
|
||||||
|
|
||||||
set_libva_version
|
set_libva_version
|
||||||
|
|
||||||
# create_config.py creates the file exclusively and fails if anything
|
if [[ -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||||
# reappears at this path
|
echo "[INFO] Removing stale config from last run..."
|
||||||
rm -f /dev/shm/go2rtc.yaml
|
rm /dev/shm/go2rtc.yaml
|
||||||
|
|
||||||
echo "[INFO] Preparing new go2rtc config..."
|
|
||||||
|
|
||||||
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
|
||||||
# Running as a Home Assistant Add-on, infer the IP address and port
|
|
||||||
get_ip_and_port_from_supervisor
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
python3 -I /usr/local/go2rtc/create_config.py
|
if [[ ! -f "/dev/shm/go2rtc.yaml" ]]; then
|
||||||
|
echo "[INFO] Preparing new go2rtc config..."
|
||||||
|
|
||||||
|
if [[ -n "${SUPERVISOR_TOKEN:-}" ]]; then
|
||||||
|
# Running as a Home Assistant Add-on, infer the IP address and port
|
||||||
|
get_ip_and_port_from_supervisor
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 /usr/local/go2rtc/create_config.py
|
||||||
|
else
|
||||||
|
echo "[WARNING] Unable to remove existing go2rtc config. Changes made to your frigate config file may not be recognized. Please remove the /dev/shm/go2rtc.yaml from your docker host manually."
|
||||||
|
fi
|
||||||
|
|
||||||
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
|
# HomeKit persistence. The helper is symlink-safe; hand off to go2rtc only when dropping.
|
||||||
readonly homekit_config_path="/config/go2rtc_homekit.yml"
|
readonly homekit_config_path="/config/go2rtc_homekit.yml"
|
||||||
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then
|
||||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}" --chown
|
||||||
chown -h go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
|
||||||
else
|
else
|
||||||
python3 -I /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
python3 /usr/local/go2rtc/prepare_homekit.py "${homekit_config_path}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
readonly config_path="/config"
|
readonly config_path="/config"
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ fi
|
|||||||
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
mkdir -p /etc/letsencrypt/www 2>/dev/null || true
|
||||||
|
|
||||||
# nginx settings are read once; both templates consume them
|
# nginx settings are read once; both templates consume them
|
||||||
nginx_settings=$(python3 -I /usr/local/nginx/get_nginx_settings.py)
|
nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
|
||||||
|
|
||||||
# build templates for optional FRIGATE_BASE_PATH environment variable
|
# build templates for optional FRIGATE_BASE_PATH environment variable
|
||||||
echo "$nginx_settings" | \
|
echo "$nginx_settings" | \
|
||||||
|
|||||||
@@ -198,13 +198,9 @@ if config.get("birdseye", {}).get("restream", False):
|
|||||||
else:
|
else:
|
||||||
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
go2rtc_config["streams"] = {"birdseye": ffmpeg_cmd}
|
||||||
|
|
||||||
# The run script removes the old file first. Exclusive create never follows a
|
# Write go2rtc_config to /dev/shm/go2rtc.yaml
|
||||||
# link the go2rtc user planted in sticky /dev/shm. The config contains camera
|
with open("/dev/shm/go2rtc.yaml", "w") as f:
|
||||||
# credentials, so it is not world-readable.
|
|
||||||
fd = os.open(
|
|
||||||
"/dev/shm/go2rtc.yaml",
|
|
||||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
||||||
0o640,
|
|
||||||
)
|
|
||||||
with os.fdopen(fd, "w") as f:
|
|
||||||
yaml.dump(go2rtc_config, f)
|
yaml.dump(go2rtc_config, f)
|
||||||
|
|
||||||
|
# config contains camera credentials; do not leave it world-readable
|
||||||
|
os.chmod("/dev/shm/go2rtc.yaml", 0o640)
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ location /auth {
|
|||||||
# this cannot be spoofed
|
# this cannot be spoofed
|
||||||
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
proxy_set_header X-Go2rtc-Stream-Proxy $go2rtc_stream_proxy;
|
||||||
proxy_set_header X-Server-Port $server_port;
|
proxy_set_header X-Server-Port $server_port;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
# inputs for the cross-origin check on websocket and go2rtc stream requests
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header X-Original-Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||||
|
proxy_set_header X-Original-Upgrade $http_upgrade;
|
||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
# Pass along auth related info
|
# Pass along auth related info
|
||||||
proxy_set_header Authorization $http_authorization;
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ http {
|
|||||||
default 1;
|
default 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# keep an outer proxy's X-Forwarded-Host so the websocket servers can
|
||||||
|
# match a browser's Origin when that proxy rewrites Host
|
||||||
|
map $http_x_forwarded_host $forwarded_host {
|
||||||
|
"" $http_host;
|
||||||
|
default $http_x_forwarded_host;
|
||||||
|
}
|
||||||
|
|
||||||
upstream frigate_api {
|
upstream frigate_api {
|
||||||
server 127.0.0.1:5001;
|
server 127.0.0.1:5001;
|
||||||
keepalive 1024;
|
keepalive 1024;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ proxy_set_header Upgrade $http_upgrade;
|
|||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
proxy_set_header X-Original-URL $request_uri;
|
proxy_set_header X-Original-URL $request_uri;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Host $http_host;
|
proxy_set_header X-Forwarded-Host $forwarded_host;
|
||||||
proxy_set_header X-Forwarded-URI $request_uri;
|
proxy_set_header X-Forwarded-URI $request_uri;
|
||||||
proxy_set_header X-Forwarded-Ssl on;
|
proxy_set_header X-Forwarded-Ssl on;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
sys.path.insert(0, "/opt/frigate")
|
sys.path.insert(0, "/opt/frigate")
|
||||||
from frigate.util.config import find_config_file, parse_listen_address
|
from frigate.util.config import find_config_file
|
||||||
|
|
||||||
sys.path.remove("/opt/frigate")
|
sys.path.remove("/opt/frigate")
|
||||||
|
|
||||||
@@ -35,29 +35,20 @@ ipv6_config: dict[str, Any] = networking_config.get("ipv6", {})
|
|||||||
ipv6_config.setdefault("enabled", False)
|
ipv6_config.setdefault("enabled", False)
|
||||||
|
|
||||||
listen_config: dict[str, Any] = networking_config.get("listen", {})
|
listen_config: dict[str, Any] = networking_config.get("listen", {})
|
||||||
|
listen_config.setdefault("internal", 5000)
|
||||||
|
listen_config.setdefault("external", 8971)
|
||||||
|
|
||||||
# Frigate's own validation runs elsewhere, and a bad value here must not stop nginx
|
# handle case where internal port is a string with ip:port
|
||||||
# from serving the safe-mode UI. Valid values are kept so a fallback never widens
|
internal_port = listen_config["internal"]
|
||||||
# the unauthenticated internal listener beyond the configured address.
|
if type(internal_port) is str:
|
||||||
for key, default in (("internal", 5000), ("external", 8971)):
|
internal_port = int(internal_port.split(":")[-1])
|
||||||
try:
|
listen_config["internal_port"] = internal_port
|
||||||
value, port = parse_listen_address(listen_config.get(key, default))
|
|
||||||
except ValueError:
|
|
||||||
print(
|
|
||||||
f"[WARN] Invalid networking.listen.{key}, using {default}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
value, port = default, default
|
|
||||||
|
|
||||||
listen_config[key] = value
|
# handle case where external port is a string with ip:port
|
||||||
listen_config[f"{key}_port"] = port
|
external_port = listen_config["external"]
|
||||||
|
if type(external_port) is str:
|
||||||
if listen_config["internal_port"] == listen_config["external_port"]:
|
external_port = int(external_port.split(":")[-1])
|
||||||
print(
|
listen_config["external_port"] = external_port
|
||||||
"[ERROR] networking.listen.internal and external share a port",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
base_path = os.environ.get("FRIGATE_BASE_PATH", "")
|
||||||
|
|
||||||
|
|||||||
+50
-11
@@ -2,9 +2,32 @@
|
|||||||
|
|
||||||
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
# https://askubuntu.com/questions/972516/debian-frontend-environment-variable
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
ARG ROCM=1
|
||||||
ARG HSA_OVERRIDE_GFX_VERSION
|
ARG HSA_OVERRIDE_GFX_VERSION
|
||||||
ARG HSA_OVERRIDE
|
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
|
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 -f /etc/apt/sources.list.d/trixie.list && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
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 \
|
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 \
|
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \
|
||||||
&& python3 get-pip.py "pip" --break-system-packages
|
&& python3 get-pip.py "pip" --break-system-packages
|
||||||
RUN python3 -m pip config set global.break-system-packages true
|
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
|
COPY docker/rocm/requirements-wheels-rocm.txt /requirements.txt
|
||||||
RUN --mount=type=bind,source=docker/rocm/prune_rocm.sh,target=/deps/prune_rocm.sh \
|
RUN pip3 uninstall -y onnxruntime \
|
||||||
pip3 uninstall -y onnxruntime \
|
&& pip3 install -r /requirements.txt
|
||||||
&& 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
|
|
||||||
|
|
||||||
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
|
FROM deps-prelim AS rocm-prelim-hsa-override0
|
||||||
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
ENV MIGRAPHX_DISABLE_MIOPEN_FUSION=1
|
||||||
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
ENV MIGRAPHX_DISABLE_SCHEDULE_PASS=1
|
||||||
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
ENV MIGRAPHX_DISABLE_REDUCE_FUSION=1
|
||||||
|
ENV MIGRAPHX_ENABLE_HIPRTC_WORKAROUNDS=1
|
||||||
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
ENV MIOPEN_CUSTOM_CACHE_DIR=/config/model_cache/migraphx
|
||||||
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
ENV MIOPEN_USER_DB_PATH=/config/model_cache/migraphx
|
||||||
ENV AMD_COMGR_CACHE=1
|
ENV AMD_COMGR_CACHE=1
|
||||||
ENV AMD_COMGR_CACHE_DIR=/config/model_cache/migraphx
|
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
|
FROM rocm-prelim-hsa-override0 as rocm-prelim-hsa-override1
|
||||||
|
|
||||||
|
|||||||
@@ -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 +1 @@
|
|||||||
# ROCm 10.1.0, MIGraphX, and its ONNX Runtime plugin provider from AMD's wheels.
|
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
|
||||||
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,3 +1,6 @@
|
|||||||
|
variable "ROCM" {
|
||||||
|
default = "7.2.3"
|
||||||
|
}
|
||||||
variable "HSA_OVERRIDE_GFX_VERSION" {
|
variable "HSA_OVERRIDE_GFX_VERSION" {
|
||||||
default = ""
|
default = ""
|
||||||
}
|
}
|
||||||
@@ -5,6 +8,12 @@ variable "HSA_OVERRIDE" {
|
|||||||
default = "1"
|
default = "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
target wget {
|
||||||
|
dockerfile = "docker/main/Dockerfile"
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
target = "wget"
|
||||||
|
}
|
||||||
|
|
||||||
target deps {
|
target deps {
|
||||||
dockerfile = "docker/main/Dockerfile"
|
dockerfile = "docker/main/Dockerfile"
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
@@ -21,10 +30,12 @@ target rocm {
|
|||||||
dockerfile = "docker/rocm/Dockerfile"
|
dockerfile = "docker/rocm/Dockerfile"
|
||||||
contexts = {
|
contexts = {
|
||||||
deps = "target:deps",
|
deps = "target:deps",
|
||||||
|
wget = "target:wget",
|
||||||
rootfs = "target:rootfs"
|
rootfs = "target:rootfs"
|
||||||
}
|
}
|
||||||
platforms = ["linux/amd64"]
|
platforms = ["linux/amd64"]
|
||||||
args = {
|
args = {
|
||||||
|
ROCM = ROCM,
|
||||||
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
|
HSA_OVERRIDE_GFX_VERSION = HSA_OVERRIDE_GFX_VERSION,
|
||||||
HSA_OVERRIDE = HSA_OVERRIDE
|
HSA_OVERRIDE = HSA_OVERRIDE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,9 +117,7 @@ auth:
|
|||||||
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
# Optional: Cookie to store the JWT token for native auth (default: shown below)
|
||||||
cookie_name: frigate_token
|
cookie_name: frigate_token
|
||||||
# Optional: Set secure flag on cookie. (default: shown below)
|
# Optional: Set secure flag on cookie. (default: shown below)
|
||||||
# NOTE: Frigate sets the flag automatically when serving TLS. Set this to True
|
# NOTE: This should be set to True if you are using TLS
|
||||||
# when a reverse proxy terminates TLS in front of Frigate. A proxy that connects
|
|
||||||
# to the TLS port must serve Frigate to browsers over HTTPS
|
|
||||||
cookie_secure: False
|
cookie_secure: False
|
||||||
# Optional: Session length in seconds (default: shown below)
|
# Optional: Session length in seconds (default: shown below)
|
||||||
session_length: 86400 # 24 hours
|
session_length: 86400 # 24 hours
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ For example, in Nginx:
|
|||||||
|
|
||||||
```
|
```
|
||||||
location /frigate {
|
location /frigate {
|
||||||
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Ingress-Path /frigate;
|
proxy_set_header X-Ingress-Path /frigate;
|
||||||
proxy_pass http://frigate_backend;
|
proxy_pass http://frigate_backend;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
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
|
### 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.
|
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
|
### 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
|
```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:
|
frigate:
|
||||||
...
|
...
|
||||||
environment:
|
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 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)
|
||||||
- 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
|
- use a search engine to query what `HSA_OVERRIDE_GFX_VERSION` you need for the given gfx name ("gfxNNN ROCm HSA_OVERRIDE_GFX_VERSION")
|
||||||
- find the chipset version you have (gfxNNN) from the output of `rocminfo` (see below)
|
- override the `HSA_OVERRIDE_GFX_VERSION` with relevant value
|
||||||
- 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
|
||||||
- if things are not working check the Frigate docker logs
|
|
||||||
|
|
||||||
#### Figuring out if AMD/ROCm is working and found your GPU
|
#### 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**
|
**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)
|
- [Supports limited model architectures](../../configuration/object_detectors#amdrocm-gpu-detector)
|
||||||
- Runs best on discrete AMD GPUs
|
- 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
|
- <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
|
### 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 |
|
| Name | YOLOv9 Inference Time | YOLO-NAS Inference Time | RF-DETR Inference Time |
|
||||||
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
| -------------- | --------------------------- | ------------------------- | ---------------------- |
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ Your reverse proxy must be configured to forward the `Upgrade` and `Connection`
|
|||||||
|
|
||||||
Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
|
Note that some proxies disable WebSocket support by default. For example, Nginx Proxy Manager has a "Websockets Support" toggle that must be enabled.
|
||||||
|
|
||||||
|
Frigate rejects WebSocket connections opened from a page on another site. Your proxy must either preserve the original `Host` header or send it in `X-Forwarded-Host`, otherwise Frigate cannot tell the connection came from its own UI.
|
||||||
|
|
||||||
## Proxies
|
## Proxies
|
||||||
|
|
||||||
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
|
There are many solutions available to implement reverse proxies and the community is invited to help out documenting others through a contribution to this page.
|
||||||
@@ -161,6 +163,7 @@ The settings below enabled connection upgrade, sets up logging (optional) and pr
|
|||||||
error_log /data/logs/proxy-host-40_error.log warn;
|
error_log /data/logs/proxy-host-40_error.log warn;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
proxy_set_header Host $host;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $http_connection;
|
proxy_set_header Connection $http_connection;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ To install the optional companion Lovelace card, please see the [separate
|
|||||||
installation instructions](https://github.com/dermotduffy/frigate-hass-card) for
|
installation instructions](https://github.com/dermotduffy/frigate-hass-card) for
|
||||||
that card.
|
that card.
|
||||||
|
|
||||||
|
Frigate rejects browser connections to its live streams that come from another
|
||||||
|
origin, such as a Home Assistant dashboard. Configure cards to stream through the
|
||||||
|
integration's proxy rather than pointing them directly at Frigate's port.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`.
|
When configuring the integration, you will be asked for the `URL` of your Frigate instance which can be pointed at the internal unauthenticated port (`5000`) or the authenticated port (`8971`) for your instance. This may look like `http://<host>:5000/`.
|
||||||
|
|||||||
+33
-172
@@ -61,7 +61,6 @@ from frigate.util.builtin import (
|
|||||||
clean_camera_user_pass,
|
clean_camera_user_pass,
|
||||||
deep_merge,
|
deep_merge,
|
||||||
flatten_config_data,
|
flatten_config_data,
|
||||||
is_masked_camera_path,
|
|
||||||
load_labels,
|
load_labels,
|
||||||
process_config_query_string,
|
process_config_query_string,
|
||||||
split_config_key_path,
|
split_config_key_path,
|
||||||
@@ -72,7 +71,6 @@ from frigate.util.config import (
|
|||||||
find_config_file,
|
find_config_file,
|
||||||
redact_credential,
|
redact_credential,
|
||||||
)
|
)
|
||||||
from frigate.util.env import substitute_frigate_vars
|
|
||||||
from frigate.util.live_streams import (
|
from frigate.util.live_streams import (
|
||||||
generated_transcode_streams,
|
generated_transcode_streams,
|
||||||
sync_transcode_streams,
|
sync_transcode_streams,
|
||||||
@@ -404,8 +402,6 @@ def config(request: Request):
|
|||||||
|
|
||||||
config["go2rtc"]["streams"][stream_name] = cleaned
|
config["go2rtc"]["streams"][stream_name] = cleaned
|
||||||
|
|
||||||
_redact_go2rtc_credentials(config["go2rtc"])
|
|
||||||
|
|
||||||
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
config["plus"] = {"enabled": request.app.frigate_config.plus_api.is_active()}
|
||||||
|
|
||||||
for index, model in enumerate(config_obj.models):
|
for index, model in enumerate(config_obj.models):
|
||||||
@@ -432,28 +428,6 @@ def config(request: Request):
|
|||||||
return JSONResponse(content=config)
|
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())])
|
@router.get("/profiles", dependencies=[Depends(allow_any_authenticated())])
|
||||||
def get_profiles(request: Request):
|
def get_profiles(request: Request):
|
||||||
"""List all available profiles and the currently active profile."""
|
"""List all available profiles and the currently active profile."""
|
||||||
@@ -768,24 +742,17 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
def _restore_masked_camera_paths(config_data: dict, config: FrigateConfig) -> None:
|
||||||
"""Substitute incoming masked camera paths with the saved ones.
|
"""Substitute incoming `*:*` masked credentials with the in-memory ones.
|
||||||
|
|
||||||
The /config response masks ffmpeg input credentials, so the settings UI
|
The /config response masks ffmpeg input credentials, so the settings UI
|
||||||
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
sends the masked path back when sibling fields (e.g. hwaccel_args) are
|
||||||
edited. Each masked path is matched to the saved input that masks to the
|
edited. Without this we'd write `rtsp://*:*@host` into YAML and lose
|
||||||
same value, with roles breaking a tie, so deleting or reordering inputs
|
the real credentials. Mutates `config_data` in place.
|
||||||
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")
|
cameras = config_data.get("cameras")
|
||||||
if not isinstance(cameras, dict):
|
if not isinstance(cameras, dict):
|
||||||
return None
|
return
|
||||||
|
|
||||||
saved_cameras = saved.get("cameras")
|
|
||||||
if not isinstance(saved_cameras, dict):
|
|
||||||
saved_cameras = {}
|
|
||||||
|
|
||||||
for camera_name, camera_data in cameras.items():
|
for camera_name, camera_data in cameras.items():
|
||||||
if not isinstance(camera_data, dict):
|
if not isinstance(camera_data, dict):
|
||||||
@@ -793,122 +760,20 @@ def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
|
|||||||
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
inputs = camera_data.get("ffmpeg", {}).get("inputs")
|
||||||
if not isinstance(inputs, list):
|
if not isinstance(inputs, list):
|
||||||
continue
|
continue
|
||||||
|
existing = config.cameras.get(camera_name)
|
||||||
saved_camera = saved_cameras.get(camera_name)
|
if existing is None:
|
||||||
saved_ffmpeg = (
|
continue
|
||||||
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
|
existing_paths = [inp.path for inp in existing.ffmpeg.inputs]
|
||||||
)
|
|
||||||
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):
|
for index, input_obj in enumerate(inputs):
|
||||||
if not isinstance(input_obj, dict):
|
if not isinstance(input_obj, dict):
|
||||||
continue
|
continue
|
||||||
path = input_obj.get("path")
|
path = input_obj.get("path")
|
||||||
if not isinstance(path, str) or not is_masked_camera_path(path):
|
if not isinstance(path, str):
|
||||||
continue
|
continue
|
||||||
candidates = [
|
if ("://*:*@" in path or "user=*&password=*" in path) and index < len(
|
||||||
inp
|
existing_paths
|
||||||
for inp in saved_inputs
|
):
|
||||||
if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
|
input_obj["path"] = existing_paths[index]
|
||||||
]
|
|
||||||
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:
|
def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONResponse:
|
||||||
@@ -921,13 +786,13 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
|
|||||||
try:
|
try:
|
||||||
updates = {}
|
updates = {}
|
||||||
if body.config_data:
|
if body.config_data:
|
||||||
updates, error = _config_data_to_updates(
|
_restore_masked_camera_paths(body.config_data, request.app.frigate_config)
|
||||||
body.config_data, _saved_config_from_memory(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()}
|
||||||
if error:
|
# Drop any field whose value is still the redaction sentinel
|
||||||
return JSONResponse(
|
updates = {
|
||||||
content={"success": False, "message": error}, status_code=400
|
k: v for k, v in updates.items() if v != REDACTED_CREDENTIAL_SENTINEL
|
||||||
)
|
}
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -1074,26 +939,22 @@ def config_set(request: Request, body: AppConfigSetBody):
|
|||||||
# Filter out empty keys but keep blank values for non-empty keys
|
# 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}
|
query_string = {k: v for k, v in query_string.items() if k}
|
||||||
|
|
||||||
error = None
|
|
||||||
|
|
||||||
if query_string:
|
if query_string:
|
||||||
updates = process_config_query_string(query_string)
|
updates = process_config_query_string(query_string)
|
||||||
error = _find_masked_update(updates)
|
|
||||||
elif body.config_data:
|
elif body.config_data:
|
||||||
try:
|
_restore_masked_camera_paths(
|
||||||
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
|
body.config_data, request.app.frigate_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:
|
if not updates:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
+65
-61
@@ -39,6 +39,7 @@ from frigate.config import AuthConfig, ProxyConfig
|
|||||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
from frigate.notices import raise_notice
|
from frigate.notices import raise_notice
|
||||||
|
from frigate.util.origin import origin_allowed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -471,37 +472,8 @@ def create_encoded_jwt(user, role, expiration, secret):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_session(user: User) -> tuple[str, int | None]:
|
|
||||||
changed_at = user.password_changed_at
|
|
||||||
|
|
||||||
# floored like iat, so a login in the same second as the change stays valid
|
|
||||||
return user.role, int(changed_at.timestamp()) if changed_at else None
|
|
||||||
|
|
||||||
|
|
||||||
def load_user_sessions() -> dict[str, tuple[str, int | None]]:
|
|
||||||
"""Map each username to its role and password change time for /auth."""
|
|
||||||
return {user.username: _user_session(user) for user in User.select()}
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_user_session(request: Request, username: str) -> None:
|
|
||||||
"""Sync the /auth session map after a user is created, changed or deleted."""
|
|
||||||
user = User.get_or_none(User.username == username)
|
|
||||||
if user is None:
|
|
||||||
request.app.user_sessions.pop(username, None)
|
|
||||||
else:
|
|
||||||
request.app.user_sessions[username] = _user_session(user)
|
|
||||||
|
|
||||||
|
|
||||||
def is_secure_request(request: Request) -> bool:
|
|
||||||
"""Check the cookie setting and nginx's forwarded scheme for HTTPS."""
|
|
||||||
# nginx sets X-Forwarded-Proto to its own scheme, replacing any client value
|
|
||||||
return (
|
|
||||||
request.app.frigate_config.auth.cookie_secure
|
|
||||||
or request.headers.get("x-forwarded-proto") == "https"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
def set_jwt_cookie(response: Response, cookie_name, encoded_jwt, max_age, secure):
|
||||||
|
# TODO: ideally this would set secure as well, but that requires TLS
|
||||||
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
# Starlette sets SameSite=Lax by default. The cookie is still sent to
|
||||||
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
# same-site iframes (e.g. Home Assistant on the same host or domain), but
|
||||||
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
# not to cross-site ones. CSRF is also mitigated by requiring a custom
|
||||||
@@ -718,6 +690,17 @@ def auth(request: Request):
|
|||||||
|
|
||||||
success_response = Response("", status_code=202)
|
success_response = Response("", status_code=202)
|
||||||
|
|
||||||
|
original_url = request.headers.get("x-original-url")
|
||||||
|
|
||||||
|
if _is_cross_origin_stream_request(original_url, request):
|
||||||
|
logger.warning(
|
||||||
|
"Rejected cross-origin request: origin=%s, host=%s, x-forwarded-host=%s",
|
||||||
|
request.headers.get("origin"),
|
||||||
|
request.headers.get("x-original-host"),
|
||||||
|
request.headers.get("x-forwarded-host"),
|
||||||
|
)
|
||||||
|
return Response("", status_code=403)
|
||||||
|
|
||||||
# dont require auth if the request is on the internal port
|
# dont require auth if the request is on the internal port
|
||||||
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
|
# this header is set by Frigate's nginx proxy, so it cant be spoofed.
|
||||||
# the port is the boot-time snapshot rather than the live config value:
|
# the port is the boot-time snapshot rather than the live config value:
|
||||||
@@ -741,7 +724,6 @@ def auth(request: Request):
|
|||||||
logger.debug("X-Proxy-Secret header does not match configured secret value")
|
logger.debug("X-Proxy-Secret header does not match configured secret value")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
original_url = request.headers.get("x-original-url")
|
|
||||||
frigate_config = request.app.frigate_config
|
frigate_config = request.app.frigate_config
|
||||||
|
|
||||||
# if auth is disabled, just apply the proxy header map and return success
|
# if auth is disabled, just apply the proxy header map and return success
|
||||||
@@ -779,6 +761,7 @@ def auth(request: Request):
|
|||||||
fail_response.headers["location"] = "/login"
|
fail_response.headers["location"] = "/login"
|
||||||
|
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
JWT_REFRESH = request.app.frigate_config.auth.refresh_time
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
@@ -804,27 +787,20 @@ def auth(request: Request):
|
|||||||
if "sub" not in token.claims:
|
if "sub" not in token.claims:
|
||||||
logger.debug("user not set in jwt token")
|
logger.debug("user not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
if "role" not in token.claims:
|
||||||
|
logger.debug("role not set in jwt token")
|
||||||
|
return fail_response
|
||||||
if "exp" not in token.claims:
|
if "exp" not in token.claims:
|
||||||
logger.debug("exp not set in jwt token")
|
logger.debug("exp not set in jwt token")
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
user = token.claims.get("sub")
|
user = token.claims.get("sub")
|
||||||
session = request.app.user_sessions.get(user)
|
role = token.claims.get("role")
|
||||||
if session is None:
|
|
||||||
logger.debug("jwt user %s no longer exists", user)
|
|
||||||
return fail_response
|
|
||||||
|
|
||||||
role, password_changed_at = session
|
|
||||||
if password_changed_at is not None and (
|
|
||||||
int(token.claims.get("iat", 0)) < password_changed_at
|
|
||||||
):
|
|
||||||
logger.debug("jwt token issued before password change")
|
|
||||||
return fail_response
|
|
||||||
|
|
||||||
|
# the token keeps the role it was issued with, so a role removed from
|
||||||
|
# the config since then must send the user back through login
|
||||||
if role not in auth_config.roles:
|
if role not in auth_config.roles:
|
||||||
logger.debug(
|
logger.debug("jwt role %s is not in the config", role)
|
||||||
"jwt user %s has role %s, which is not in the config", user, role
|
|
||||||
)
|
|
||||||
return fail_response
|
return fail_response
|
||||||
|
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
@@ -847,6 +823,25 @@ def auth(request: Request):
|
|||||||
# if the jwt cookie is expiring soon
|
# if the jwt cookie is expiring soon
|
||||||
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
if jwt_source == "cookie" and expiration - JWT_REFRESH <= current_time:
|
||||||
logger.debug("jwt token expiring soon, refreshing cookie")
|
logger.debug("jwt token expiring soon, refreshing cookie")
|
||||||
|
|
||||||
|
# Check if password has been changed since token was issued
|
||||||
|
# If so, force re-login by rejecting the refresh
|
||||||
|
try:
|
||||||
|
user_obj = User.get_by_id(user)
|
||||||
|
if user_obj.password_changed_at is not None:
|
||||||
|
token_iat = int(token.claims.get("iat", 0))
|
||||||
|
password_changed_timestamp = int(
|
||||||
|
user_obj.password_changed_at.timestamp()
|
||||||
|
)
|
||||||
|
if token_iat < password_changed_timestamp:
|
||||||
|
logger.debug(
|
||||||
|
"jwt token issued before password change, rejecting refresh"
|
||||||
|
)
|
||||||
|
return fail_response
|
||||||
|
except DoesNotExist:
|
||||||
|
logger.debug("user not found")
|
||||||
|
return fail_response
|
||||||
|
|
||||||
new_expiration = current_time + JWT_SESSION_LENGTH
|
new_expiration = current_time + JWT_SESSION_LENGTH
|
||||||
new_encoded_jwt = create_encoded_jwt(
|
new_encoded_jwt = create_encoded_jwt(
|
||||||
user, role, new_expiration, request.app.jwt_token
|
user, role, new_expiration, request.app.jwt_token
|
||||||
@@ -856,7 +851,7 @@ def auth(request: Request):
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
new_encoded_jwt,
|
new_encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
is_secure_request(request),
|
JWT_COOKIE_SECURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
success_response.headers["remote-user"] = user
|
success_response.headers["remote-user"] = user
|
||||||
@@ -945,6 +940,7 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
)
|
)
|
||||||
|
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
user = body.user
|
user = body.user
|
||||||
password = body.password
|
password = body.password
|
||||||
@@ -960,16 +956,13 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
|
|
||||||
password_hash = db_user.password_hash
|
password_hash = db_user.password_hash
|
||||||
if verify_password(password, password_hash):
|
if verify_password(password, password_hash):
|
||||||
role = db_user.role
|
role = getattr(db_user, "role", "viewer")
|
||||||
if role not in request.app.frigate_config.auth.roles:
|
config_roles_set = set(request.app.frigate_config.auth.roles.keys())
|
||||||
|
if role not in config_roles_set:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Login refused for user '%s' with role '%s', which is not in the config",
|
f"User {db_user.username} has an invalid role {role}, falling back to 'viewer'."
|
||||||
user,
|
|
||||||
role,
|
|
||||||
)
|
)
|
||||||
failed_logins.record(user, time.time(), known=True)
|
role = "viewer"
|
||||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
|
||||||
|
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
encoded_jwt = create_encoded_jwt(user, role, expiration, request.app.jwt_token)
|
||||||
response = Response("", 200)
|
response = Response("", 200)
|
||||||
@@ -978,7 +971,7 @@ def login(request: Request, body: AppPostLoginBody):
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
encoded_jwt,
|
encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
is_secure_request(request),
|
JWT_COOKIE_SECURE,
|
||||||
)
|
)
|
||||||
# Clear admin_first_time_login flag after successful admin login so the
|
# Clear admin_first_time_login flag after successful admin login so the
|
||||||
# UI stops showing the first-time login documentation link.
|
# UI stops showing the first-time login documentation link.
|
||||||
@@ -1044,11 +1037,9 @@ def create_user(
|
|||||||
User.username: body.username,
|
User.username: body.username,
|
||||||
User.password_hash: password_hash,
|
User.password_hash: password_hash,
|
||||||
User.role: role,
|
User.role: role,
|
||||||
User.password_changed_at: datetime.now(),
|
|
||||||
User.notification_tokens: [],
|
User.notification_tokens: [],
|
||||||
}
|
}
|
||||||
).execute()
|
).execute()
|
||||||
refresh_user_session(request, body.username)
|
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"username": body.username})
|
return JSONResponse(content={"username": body.username})
|
||||||
|
|
||||||
@@ -1067,7 +1058,6 @@ def delete_user(request: Request, username: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.delete_by_id(username)
|
User.delete_by_id(username)
|
||||||
refresh_user_session(request, username)
|
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
@@ -1135,13 +1125,13 @@ async def update_password(
|
|||||||
User.password_changed_at: datetime.now(),
|
User.password_changed_at: datetime.now(),
|
||||||
}
|
}
|
||||||
).where(User.username == username).execute()
|
).where(User.username == username).execute()
|
||||||
refresh_user_session(request, username)
|
|
||||||
|
|
||||||
response = JSONResponse(content={"success": True})
|
response = JSONResponse(content={"success": True})
|
||||||
|
|
||||||
# If user changed their own password, issue a new JWT to keep them logged in
|
# If user changed their own password, issue a new JWT to keep them logged in
|
||||||
if current_username == username:
|
if current_username == username:
|
||||||
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
JWT_COOKIE_NAME = request.app.frigate_config.auth.cookie_name
|
||||||
|
JWT_COOKIE_SECURE = request.app.frigate_config.auth.cookie_secure
|
||||||
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
JWT_SESSION_LENGTH = request.app.frigate_config.auth.session_length
|
||||||
|
|
||||||
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
expiration = int(time.time()) + JWT_SESSION_LENGTH
|
||||||
@@ -1154,7 +1144,7 @@ async def update_password(
|
|||||||
JWT_COOKIE_NAME,
|
JWT_COOKIE_NAME,
|
||||||
encoded_jwt,
|
encoded_jwt,
|
||||||
JWT_SESSION_LENGTH,
|
JWT_SESSION_LENGTH,
|
||||||
is_secure_request(request),
|
JWT_COOKIE_SECURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -1194,7 +1184,6 @@ async def update_role(
|
|||||||
)
|
)
|
||||||
|
|
||||||
User.set_by_id(username, {User.role: body.role})
|
User.set_by_id(username, {User.role: body.role})
|
||||||
refresh_user_session(request, username)
|
|
||||||
request.app.config_publisher.publisher.publish("config/auth", None)
|
request.app.config_publisher.publisher.publish("config/auth", None)
|
||||||
return JSONResponse(content={"success": True})
|
return JSONResponse(content={"success": True})
|
||||||
|
|
||||||
@@ -1307,6 +1296,21 @@ def _is_go2rtc_stream_proxy_request(path: str, request: Request) -> bool:
|
|||||||
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
|
return any(normalized.startswith(p) for p in GO2RTC_STREAM_PROXY_PATHS)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cross_origin_stream_request(original_url: str | None, request: Request) -> bool:
|
||||||
|
# the internal port grants admin, so a browser on another site must be
|
||||||
|
# stopped before that early return
|
||||||
|
upgrade = request.headers.get("x-original-upgrade", "").lower()
|
||||||
|
path = split_request_uri(original_url or "")[0]
|
||||||
|
if upgrade != "websocket" and not _is_go2rtc_stream_proxy_request(path, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not origin_allowed(
|
||||||
|
request.headers.get("origin"),
|
||||||
|
request.headers.get("x-original-host"),
|
||||||
|
request.headers.get("x-forwarded-host"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def deny_response_for_go2rtc_stream(
|
def deny_response_for_go2rtc_stream(
|
||||||
original_url: str | None, role: str | None, request: Request
|
original_url: str | None, role: str | None, request: Request
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
|
|||||||
@@ -196,9 +196,7 @@ def create_fastapi_app(
|
|||||||
key_bytes = str(secret).encode("utf-8")
|
key_bytes = str(secret).encode("utf-8")
|
||||||
|
|
||||||
app.jwt_token = OctKey.import_key(key_bytes)
|
app.jwt_token = OctKey.import_key(key_bytes)
|
||||||
app.user_sessions = auth.load_user_sessions()
|
|
||||||
else:
|
else:
|
||||||
app.jwt_token = None
|
app.jwt_token = None
|
||||||
app.user_sessions = {}
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from frigate.api.auth import require_camera_access
|
from frigate.api.auth import require_camera_access
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.config import FrigateConfig
|
|
||||||
from frigate.jobs.motion_search import (
|
from frigate.jobs.motion_search import (
|
||||||
MotionSearchCapacityError,
|
|
||||||
MotionSearchUserBusyError,
|
|
||||||
cancel_motion_search_job,
|
cancel_motion_search_job,
|
||||||
get_motion_search_job,
|
get_motion_search_job,
|
||||||
start_motion_search_job,
|
start_motion_search_job,
|
||||||
@@ -98,20 +95,6 @@ class MotionSearchStatusResponse(BaseModel):
|
|||||||
progress: float | None = None
|
progress: float | None = None
|
||||||
|
|
||||||
|
|
||||||
def _per_user_identity(request: Request, config: FrigateConfig) -> str | None:
|
|
||||||
"""Return the requesting user, or None when callers share one identity.
|
|
||||||
|
|
||||||
Internal-port requests are all "anonymous", and with auth disabled and no
|
|
||||||
proxy user header every browser is "viewer".
|
|
||||||
"""
|
|
||||||
username = request.headers.get("remote-user")
|
|
||||||
if not username or username == "anonymous":
|
|
||||||
return None
|
|
||||||
if config.auth.enabled or config.proxy.header_map.user:
|
|
||||||
return username
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{camera_name}/search/motion",
|
"/{camera_name}/search/motion",
|
||||||
response_model=MotionSearchStartResponse,
|
response_model=MotionSearchStartResponse,
|
||||||
@@ -155,35 +138,18 @@ async def start_motion_search(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
# Start the job using the jobs module
|
||||||
job_id = start_motion_search_job(
|
job_id = start_motion_search_job(
|
||||||
config=config,
|
config=config,
|
||||||
camera_name=camera_name,
|
camera_name=camera_name,
|
||||||
start_time=body.start_time,
|
start_time=body.start_time,
|
||||||
end_time=body.end_time,
|
end_time=body.end_time,
|
||||||
polygon_points=body.polygon_points,
|
polygon_points=body.polygon_points,
|
||||||
threshold=body.threshold,
|
threshold=body.threshold,
|
||||||
min_area=body.min_area,
|
min_area=body.min_area,
|
||||||
parallel=body.parallel,
|
parallel=body.parallel,
|
||||||
max_results=body.max_results,
|
max_results=body.max_results,
|
||||||
username=_per_user_identity(request, config),
|
)
|
||||||
)
|
|
||||||
except MotionSearchUserBusyError:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "A motion search is already running for this user",
|
|
||||||
},
|
|
||||||
status_code=409,
|
|
||||||
)
|
|
||||||
except MotionSearchCapacityError:
|
|
||||||
return JSONResponse(
|
|
||||||
content={
|
|
||||||
"success": False,
|
|
||||||
"message": "Too many motion searches are running",
|
|
||||||
},
|
|
||||||
status_code=429,
|
|
||||||
)
|
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Notification apis."""
|
"""Notification apis."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from fastapi import APIRouter, Depends, Request
|
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.auth import allow_any_authenticated
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
from frigate.comms.webpush import validate_push_endpoint
|
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
|
|
||||||
@@ -19,13 +20,76 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(tags=[Tags.notifications])
|
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:
|
def _validate_subscription(sub: Any) -> str | None:
|
||||||
"""Return a reason the subscription is unusable, or None when it is valid."""
|
"""Return a reason the subscription is unusable, or None when it is valid."""
|
||||||
if not isinstance(sub, dict):
|
if not isinstance(sub, dict):
|
||||||
return "subscription must be an object"
|
return "subscription must be an object"
|
||||||
|
|
||||||
reason = validate_push_endpoint(sub.get("endpoint"))
|
reason = _validate_push_endpoint(sub.get("endpoint"))
|
||||||
|
|
||||||
if reason:
|
if reason:
|
||||||
return reason
|
return reason
|
||||||
|
|||||||
@@ -622,7 +622,6 @@ class FrigateApp:
|
|||||||
username="admin",
|
username="admin",
|
||||||
role="admin",
|
role="admin",
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
password_changed_at=datetime.datetime.now(),
|
|
||||||
notification_tokens=[],
|
notification_tokens=[],
|
||||||
).execute()
|
).execute()
|
||||||
|
|
||||||
|
|||||||
@@ -490,12 +490,7 @@ class Dispatcher:
|
|||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""Handle publishing to communicators."""
|
"""Handle publishing to communicators."""
|
||||||
for comm in self.comms:
|
for comm in self.comms:
|
||||||
try:
|
comm.publish(topic, payload, retain)
|
||||||
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:
|
def publish_local(self, topic: str, payload: Any) -> None:
|
||||||
"""Publish to every communicator except MQTT.
|
"""Publish to every communicator except MQTT.
|
||||||
@@ -506,12 +501,7 @@ class Dispatcher:
|
|||||||
if isinstance(comm, MqttClient):
|
if isinstance(comm, MqttClient):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
comm.publish(topic, payload, False)
|
||||||
comm.publish(topic, payload, False)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Error publishing %s to %s", topic, type(comm).__name__
|
|
||||||
)
|
|
||||||
|
|
||||||
def _publish_notices(self) -> None:
|
def _publish_notices(self) -> None:
|
||||||
if self.notice_registry is None:
|
if self.notice_registry is None:
|
||||||
|
|||||||
@@ -50,13 +50,7 @@ class InterProcessCommunicator(Communicator):
|
|||||||
|
|
||||||
if isinstance(raw, list):
|
if isinstance(raw, list):
|
||||||
(topic, value) = raw
|
(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:
|
else:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
|
f"Received unexpected data type in ZMQ recv_json: {type(raw)}"
|
||||||
|
|||||||
+5
-117
@@ -1,7 +1,6 @@
|
|||||||
"""Handle sending notifications for Frigate via Firebase."""
|
"""Handle sending notifications for Frigate via Firebase."""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -11,9 +10,7 @@ from collections.abc import Callable, Iterator
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from py_vapid import Vapid01
|
from py_vapid import Vapid01
|
||||||
from pywebpush import WebPusher
|
from pywebpush import WebPusher
|
||||||
from titlecase import titlecase
|
from titlecase import titlecase
|
||||||
@@ -44,124 +41,14 @@ class PushNotification:
|
|||||||
ttl: int = 0
|
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]:
|
def _build_web_pushers(user: str, subs: list[dict[str, Any]]) -> list[WebPusher]:
|
||||||
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
"""Build pushers for a user's stored subscriptions, skipping unusable ones."""
|
||||||
pushers: list[WebPusher] = []
|
pushers: list[WebPusher] = []
|
||||||
|
|
||||||
for sub in subs:
|
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
|
# WebPusher decodes the stored keys and raises on malformed ones
|
||||||
try:
|
try:
|
||||||
pushers.append(WebPusher(sub, requests_session=_push_session))
|
pushers.append(WebPusher(sub))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Skipping invalid notification subscription for %s", user)
|
logger.warning("Skipping invalid notification subscription for %s", user)
|
||||||
|
|
||||||
@@ -244,7 +131,7 @@ class WebPushClient(Communicator):
|
|||||||
for pushers in self.web_pushers.values():
|
for pushers in self.web_pushers.values():
|
||||||
for push in pushers:
|
for push in pushers:
|
||||||
endpoint: str = push.subscription_info["endpoint"]
|
endpoint: str = push.subscription_info["endpoint"]
|
||||||
endpoints.add(_vapid_audience(endpoint))
|
endpoints.add(endpoint[0 : endpoint.index("/", 10)])
|
||||||
|
|
||||||
# create new claim
|
# create new claim
|
||||||
for endpoint in endpoints:
|
for endpoint in endpoints:
|
||||||
@@ -433,8 +320,9 @@ class WebPushClient(Communicator):
|
|||||||
|
|
||||||
for pusher in self.web_pushers[notification.user]:
|
for pusher in self.web_pushers[notification.user]:
|
||||||
endpoint = pusher.subscription_info["endpoint"]
|
endpoint = pusher.subscription_info["endpoint"]
|
||||||
|
headers = self.claim_headers[
|
||||||
headers = self.claim_headers[_vapid_audience(endpoint)].copy()
|
endpoint[: endpoint.index("/", 10)]
|
||||||
|
].copy()
|
||||||
headers["urgency"] = "high"
|
headers["urgency"] = "high"
|
||||||
|
|
||||||
resp = pusher.send(
|
resp = pusher.send(
|
||||||
|
|||||||
+24
-1
@@ -40,6 +40,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
from frigate.output.ws_auth import ws_has_camera_access
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
from frigate.util.origin import origin_allowed
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -472,6 +473,28 @@ def _materialize_for_ws(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class OriginCheckedWebSocketApplication(WebSocketWSGIApplication): # type: ignore[misc]
|
||||||
|
"""ws4py application that rejects cross-origin browser handshakes."""
|
||||||
|
|
||||||
|
def __call__(self, environ: dict[str, Any], start_response: Callable) -> Any:
|
||||||
|
if not origin_allowed(
|
||||||
|
environ.get("HTTP_ORIGIN"),
|
||||||
|
environ.get("HTTP_HOST"),
|
||||||
|
environ.get("HTTP_X_FORWARDED_HOST"),
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Rejected cross-origin websocket handshake: origin=%s, host=%s, "
|
||||||
|
"x-forwarded-host=%s",
|
||||||
|
environ.get("HTTP_ORIGIN"),
|
||||||
|
environ.get("HTTP_HOST"),
|
||||||
|
environ.get("HTTP_X_FORWARDED_HOST"),
|
||||||
|
)
|
||||||
|
start_response("403 Forbidden", [("Content-Length", "0")])
|
||||||
|
return []
|
||||||
|
|
||||||
|
return super().__call__(environ, start_response)
|
||||||
|
|
||||||
|
|
||||||
class WebSocket(WebSocket_): # type: ignore[misc]
|
class WebSocket(WebSocket_): # type: ignore[misc]
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -621,7 +644,7 @@ class WebSocketClient(Communicator):
|
|||||||
5002,
|
5002,
|
||||||
server_class=WSGIServer,
|
server_class=WSGIServer,
|
||||||
handler_class=WebSocketWSGIRequestHandler,
|
handler_class=WebSocketWSGIRequestHandler,
|
||||||
app=WebSocketWSGIApplication(handler_cls=_WebSocketHandler),
|
app=OriginCheckedWebSocketApplication(handler_cls=_WebSocketHandler),
|
||||||
)
|
)
|
||||||
self.websocket_server.initialize_websockets_manager()
|
self.websocket_server.initialize_websockets_manager()
|
||||||
self.websocket_thread = threading.Thread(
|
self.websocket_thread = threading.Thread(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class AuthConfig(FrigateBaseModel):
|
|||||||
cookie_secure: bool = Field(
|
cookie_secure: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
title="Secure cookie flag",
|
title="Secure cookie flag",
|
||||||
description="Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS.",
|
description="Set the secure flag on the auth cookie; should be true when using TLS.",
|
||||||
)
|
)
|
||||||
session_length: int = Field(
|
session_length: int = Field(
|
||||||
default=86400,
|
default=86400,
|
||||||
|
|||||||
+11
-12
@@ -1,12 +1,18 @@
|
|||||||
from pydantic import Field, field_validator, model_validator
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
from frigate.util.config import parse_listen_address
|
|
||||||
|
|
||||||
from .base import FrigateBaseModel
|
from .base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
__all__ = ["IPv6Config", "ListenConfig", "NetworkingConfig"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_listen_port(value: int | str) -> int:
|
||||||
|
"""Return the port number from a bare port or an "address:port" value."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return int(value.split(":")[-1])
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class IPv6Config(FrigateBaseModel):
|
class IPv6Config(FrigateBaseModel):
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -27,20 +33,13 @@ class ListenConfig(FrigateBaseModel):
|
|||||||
description="External listening port for Frigate (default 8971).",
|
description="External listening port for Frigate (default 8971).",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Runs before pydantic coerces a bool to an int port, which
|
|
||||||
# get_nginx_settings.py rejects when it reads the raw YAML
|
|
||||||
@field_validator("internal", "external", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def validate_listen(cls, value: int | str) -> int | str:
|
|
||||||
return parse_listen_address(value)[0]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def internal_port(self) -> int:
|
def internal_port(self) -> int:
|
||||||
return parse_listen_address(self.internal)[1]
|
return parse_listen_port(self.internal)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def external_port(self) -> int:
|
def external_port(self) -> int:
|
||||||
return parse_listen_address(self.external)[1]
|
return parse_listen_port(self.external)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_distinct_ports(self) -> "ListenConfig":
|
def validate_distinct_ports(self) -> "ListenConfig":
|
||||||
|
|||||||
+1
-13
@@ -117,19 +117,7 @@ SUPPORTED_RK_SOCS = ["rk3562", "rk3566", "rk3568", "rk3576", "rk3588"]
|
|||||||
|
|
||||||
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
|
REGEX_CAMERA_NAME = r"^[a-zA-Z0-9_-]+$"
|
||||||
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
|
REGEX_RTSP_CAMERA_USER_PASS = r":\/\/[a-zA-Z0-9_-]+:[\S]+@"
|
||||||
# A "user:" userinfo may hold "/", "#" or "@" in the password, so it runs to the
|
REGEX_HTTP_CAMERA_USER_PASS = r"user=[a-zA-Z0-9_-]+&password=[\S]+"
|
||||||
# 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*\{")
|
REGEX_JSON = re.compile(r"^\s*\{")
|
||||||
|
|
||||||
# Known Driver Names
|
# Known Driver Names
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
|
|
||||||
from frigate.const import MODEL_CACHE_DIR
|
|
||||||
from frigate.util.model import get_ort_providers
|
from frigate.util.model import get_ort_providers
|
||||||
from frigate.util.rknn_converter import auto_convert_model, is_rknn_compatible
|
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_EP_NAME = "LighterANE"
|
||||||
LIGHTER_ANE_LIBRARY = "/usr/lib/lighter/liblighter_ane_ep.so"
|
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]:
|
def get_lighter_ane_devices() -> list[Any]:
|
||||||
"""Get the Neural Engine devices lighter's provider offers, registering it once.
|
"""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
|
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:
|
def is_arm64_platform() -> bool:
|
||||||
"""Check if we're running on an ARM platform."""
|
"""Check if we're running on an ARM platform."""
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
@@ -796,41 +756,12 @@ def get_optimized_runner(
|
|||||||
ONNXModelRunner(session, model_type=model_type),
|
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)
|
providers, options = get_ort_providers(device == "CPU", device, **kwargs)
|
||||||
|
|
||||||
if providers[0] == "CPUExecutionProvider":
|
if providers[0] == "CPUExecutionProvider":
|
||||||
# In the default image, ONNXRuntime is used so we will only get 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
|
# In other images we will get CUDA / ROCm which are preferred over OpenVINO
|
||||||
# MIGraphX in the ROCm image is already handled above
|
# There is currently no way to prioritize OpenVINO over CUDA / ROCm in these images
|
||||||
# There is currently no way to prioritize OpenVINO over CUDA in these images
|
|
||||||
if device != "CPU" and is_openvino_gpu_npu_available():
|
if device != "CPU" and is_openvino_gpu_npu_available():
|
||||||
return _record_runner(
|
return _record_runner(
|
||||||
model_path,
|
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(
|
return _record_runner(
|
||||||
model_path,
|
model_path,
|
||||||
model_type,
|
model_type,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from collections.abc import Callable, Generator, Iterable
|
|||||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -17,6 +17,10 @@ from frigate.comms.inter_process import InterProcessRequestor
|
|||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
from frigate.const import STREAM_TYPE_MAIN, UPDATE_JOB_STATE
|
||||||
from frigate.jobs.job import Job
|
from frigate.jobs.job import Job
|
||||||
|
from frigate.jobs.manager import (
|
||||||
|
get_job_by_id,
|
||||||
|
set_current_job,
|
||||||
|
)
|
||||||
from frigate.jobs.motion_search_batch import (
|
from frigate.jobs.motion_search_batch import (
|
||||||
build_segment_time_map,
|
build_segment_time_map,
|
||||||
coalesce_runs,
|
coalesce_runs,
|
||||||
@@ -49,12 +53,6 @@ PROGRESS_BROADCAST_INTERVAL = 1.0
|
|||||||
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
# Output frame rate for the fixed-cadence fallback used on long-GOP cameras
|
||||||
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
# (where keyframe sampling is too sparse). Keyframe cameras ignore this.
|
||||||
FALLBACK_SAMPLE_FPS = 2.0
|
FALLBACK_SAMPLE_FPS = 2.0
|
||||||
# Each parallel job runs up to 4 decode workers, so this bounds total ffmpeg load.
|
|
||||||
MAX_ACTIVE_JOBS = 3
|
|
||||||
# Finished jobs stay pollable for this long before eviction (seconds).
|
|
||||||
FINISHED_JOB_TTL = 600.0
|
|
||||||
# A cancelled job stops holding a slot after this long, even if its thread is wedged.
|
|
||||||
CANCEL_GRACE_SECONDS = 60.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -100,8 +98,6 @@ class MotionSearchJob(Job):
|
|||||||
min_area: float = 5.0
|
min_area: float = 5.0
|
||||||
parallel: bool = False
|
parallel: bool = False
|
||||||
max_results: int = 25
|
max_results: int = 25
|
||||||
username: str | None = None
|
|
||||||
cancelled_at: float | None = None
|
|
||||||
|
|
||||||
# Track progress
|
# Track progress
|
||||||
total_frames_processed: int = 0
|
total_frames_processed: int = 0
|
||||||
@@ -116,8 +112,6 @@ class MotionSearchJob(Job):
|
|||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Convert to dictionary for WebSocket transmission."""
|
"""Convert to dictionary for WebSocket transmission."""
|
||||||
d = asdict(self)
|
d = asdict(self)
|
||||||
d.pop("username")
|
|
||||||
d.pop("cancelled_at")
|
|
||||||
if self.metrics:
|
if self.metrics:
|
||||||
d["metrics"] = self.metrics.to_dict()
|
d["metrics"] = self.metrics.to_dict()
|
||||||
return d
|
return d
|
||||||
@@ -585,7 +579,7 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
|
|
||||||
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
# Decide keyframe vs fixed-cadence sampling once from the first run's GOP
|
||||||
# (keyframe structure is a per-camera constant).
|
# (keyframe structure is a per-camera constant).
|
||||||
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url, self._should_stop)
|
first_pts = probe_vod_keyframe_pts(ffprobe_path, first_url)
|
||||||
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
self.use_keyframe = keyframe_sampling_eligible(first_pts)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -647,9 +641,7 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
time_map = build_segment_time_map(run)
|
time_map = build_segment_time_map(run)
|
||||||
|
|
||||||
if self.use_keyframe:
|
if self.use_keyframe:
|
||||||
kf_pts = probe_vod_keyframe_pts(
|
kf_pts = probe_vod_keyframe_pts(self.ffprobe_path, vod_url)
|
||||||
self.ffprobe_path, vod_url, self._should_stop
|
|
||||||
)
|
|
||||||
frames = list(
|
frames = list(
|
||||||
iter_vod_frames(
|
iter_vod_frames(
|
||||||
self.ffmpeg_path,
|
self.ffmpeg_path,
|
||||||
@@ -785,15 +777,13 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
run_results, frames = completed.pop(next_idx)
|
run_results, frames = completed.pop(next_idx)
|
||||||
if self._merge_run(runs[next_idx], run_results, frames, state):
|
if self._merge_run(runs[next_idx], run_results, frames, state):
|
||||||
self.internal_stop_event.set()
|
self.internal_stop_event.set()
|
||||||
|
for pending in futures:
|
||||||
|
pending.cancel()
|
||||||
break
|
break
|
||||||
next_idx += 1
|
next_idx += 1
|
||||||
|
|
||||||
if self.internal_stop_event.is_set():
|
if self.internal_stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
# Queued runs would otherwise still execute when the pool exits.
|
|
||||||
for pending in futures:
|
|
||||||
pending.cancel()
|
|
||||||
else:
|
else:
|
||||||
for run in runs:
|
for run in runs:
|
||||||
if self._should_stop():
|
if self._should_stop():
|
||||||
@@ -843,36 +833,11 @@ class MotionSearchRunner(threading.Thread):
|
|||||||
return deduplicated
|
return deduplicated
|
||||||
|
|
||||||
|
|
||||||
class MotionSearchUserBusyError(Exception):
|
# Module-level state for managing per-camera jobs
|
||||||
"""The user already has an active motion search job."""
|
|
||||||
|
|
||||||
|
|
||||||
class MotionSearchCapacityError(Exception):
|
|
||||||
"""The global limit of active motion search jobs is reached."""
|
|
||||||
|
|
||||||
|
|
||||||
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
_motion_search_jobs: dict[str, tuple[MotionSearchJob, threading.Event]] = {}
|
||||||
_jobs_lock = threading.Lock()
|
_jobs_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _evict_finished_jobs(now: float) -> None:
|
|
||||||
"""Drop jobs that finished more than FINISHED_JOB_TTL ago. Caller holds the lock."""
|
|
||||||
expired = [
|
|
||||||
job_id
|
|
||||||
for job_id, (job, _) in _motion_search_jobs.items()
|
|
||||||
if job.end_time is not None and now - job.end_time > FINISHED_JOB_TTL
|
|
||||||
]
|
|
||||||
for job_id in expired:
|
|
||||||
del _motion_search_jobs[job_id]
|
|
||||||
|
|
||||||
|
|
||||||
def _is_active(job: MotionSearchJob, now: float) -> bool:
|
|
||||||
"""True while the job's thread may still be running and holding a slot."""
|
|
||||||
if job.end_time is not None:
|
|
||||||
return False
|
|
||||||
return job.cancelled_at is None or now - job.cancelled_at < CANCEL_GRACE_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
def stop_all_motion_search_jobs() -> None:
|
def stop_all_motion_search_jobs() -> None:
|
||||||
"""Cancel all running motion search jobs for clean shutdown."""
|
"""Cancel all running motion search jobs for clean shutdown."""
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
@@ -892,13 +857,10 @@ def start_motion_search_job(
|
|||||||
min_area: float = 5.0,
|
min_area: float = 5.0,
|
||||||
parallel: bool = False,
|
parallel: bool = False,
|
||||||
max_results: int = 25,
|
max_results: int = 25,
|
||||||
username: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Start a new motion search job.
|
"""Start a new motion search job.
|
||||||
|
|
||||||
Returns the job ID. Raises MotionSearchUserBusyError when ``username`` already
|
Returns the job ID.
|
||||||
has an active job and MotionSearchCapacityError when MAX_ACTIVE_JOBS are active.
|
|
||||||
Pass ``username=None`` when callers share an identity to skip the per-user check.
|
|
||||||
"""
|
"""
|
||||||
job = MotionSearchJob(
|
job = MotionSearchJob(
|
||||||
camera=camera_name,
|
camera=camera_name,
|
||||||
@@ -909,38 +871,17 @@ def start_motion_search_job(
|
|||||||
min_area=min_area,
|
min_area=min_area,
|
||||||
parallel=parallel,
|
parallel=parallel,
|
||||||
max_results=max_results,
|
max_results=max_results,
|
||||||
username=username,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
cancel_event = threading.Event()
|
cancel_event = threading.Event()
|
||||||
|
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
now = datetime.now().timestamp()
|
|
||||||
_evict_finished_jobs(now)
|
|
||||||
|
|
||||||
# A cancelled job keeps its thread until the runner sets end_time, so it
|
|
||||||
# still counts toward the global cap. Its user may start a new search.
|
|
||||||
active = [j for j, _ in _motion_search_jobs.values() if _is_active(j, now)]
|
|
||||||
if username is not None and any(
|
|
||||||
j.username == username and j.status != JobStatusTypesEnum.cancelled
|
|
||||||
for j in active
|
|
||||||
):
|
|
||||||
raise MotionSearchUserBusyError()
|
|
||||||
if len(active) >= MAX_ACTIVE_JOBS:
|
|
||||||
raise MotionSearchCapacityError()
|
|
||||||
|
|
||||||
_motion_search_jobs[job.id] = (job, cancel_event)
|
_motion_search_jobs[job.id] = (job, cancel_event)
|
||||||
|
|
||||||
runner: MotionSearchRunner | None = None
|
set_current_job(job)
|
||||||
try:
|
|
||||||
runner = MotionSearchRunner(job, config, cancel_event)
|
runner = MotionSearchRunner(job, config, cancel_event)
|
||||||
runner.start()
|
runner.start()
|
||||||
except Exception:
|
|
||||||
if runner is not None:
|
|
||||||
runner.requestor.stop()
|
|
||||||
with _jobs_lock:
|
|
||||||
del _motion_search_jobs[job.id]
|
|
||||||
raise
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Started motion search job %s for camera %s: "
|
"Started motion search job %s for camera %s: "
|
||||||
@@ -963,7 +904,10 @@ def get_motion_search_job(job_id: str) -> MotionSearchJob | None:
|
|||||||
"""Get a motion search job by ID."""
|
"""Get a motion search job by ID."""
|
||||||
with _jobs_lock:
|
with _jobs_lock:
|
||||||
job_entry = _motion_search_jobs.get(job_id)
|
job_entry = _motion_search_jobs.get(job_id)
|
||||||
return job_entry[0] if job_entry else None
|
if job_entry:
|
||||||
|
return job_entry[0]
|
||||||
|
# Check completed jobs via manager
|
||||||
|
return cast(MotionSearchJob | None, get_job_by_id("motion_search", job_id))
|
||||||
|
|
||||||
|
|
||||||
def cancel_motion_search_job(job_id: str) -> bool:
|
def cancel_motion_search_job(job_id: str) -> bool:
|
||||||
@@ -984,7 +928,6 @@ def cancel_motion_search_job(job_id: str) -> bool:
|
|||||||
|
|
||||||
cancel_event.set()
|
cancel_event.set()
|
||||||
job.status = JobStatusTypesEnum.cancelled
|
job.status = JobStatusTypesEnum.cancelled
|
||||||
job.cancelled_at = datetime.now().timestamp()
|
|
||||||
job_payload = job.to_dict()
|
job_payload = job.to_dict()
|
||||||
logger.info("Cancelled motion search job %s", job_id)
|
logger.info("Cancelled motion search job %s", job_id)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import subprocess as sp
|
import subprocess as sp
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from typing import IO
|
from typing import IO
|
||||||
|
|
||||||
@@ -265,16 +264,11 @@ def iter_vod_frames(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
KEYFRAME_PROBE_TIMEOUT = 120.0
|
def probe_vod_keyframe_pts(ffprobe_path: str, vod_url: str) -> list[float]:
|
||||||
|
|
||||||
|
|
||||||
def probe_vod_keyframe_pts(
|
|
||||||
ffprobe_path: str, vod_url: str, should_stop: Callable[[], bool]
|
|
||||||
) -> list[float]:
|
|
||||||
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
"""Return keyframe presentation timestamps (VOD stream time) in order.
|
||||||
|
|
||||||
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
Reads packet flags via ffprobe over the VOD URL (no decode). Returns [] on
|
||||||
any failure, timeout, or stop request so the caller can fall back.
|
any failure so the caller can fall back.
|
||||||
"""
|
"""
|
||||||
cmd = [
|
cmd = [
|
||||||
ffprobe_path,
|
ffprobe_path,
|
||||||
@@ -292,27 +286,16 @@ def probe_vod_keyframe_pts(
|
|||||||
"json",
|
"json",
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
proc = sp.Popen(cmd, stdout=sp.PIPE, stderr=sp.DEVNULL, text=True)
|
completed = sp.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
except OSError:
|
except (OSError, sp.SubprocessError):
|
||||||
logger.warning("ffprobe failed for VOD keyframe probe")
|
logger.warning("ffprobe failed for VOD keyframe probe")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
deadline = time.monotonic() + KEYFRAME_PROBE_TIMEOUT
|
if completed.returncode != 0 or not completed.stdout:
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
stdout, _ = proc.communicate(timeout=0.5)
|
|
||||||
break
|
|
||||||
except sp.TimeoutExpired:
|
|
||||||
if should_stop() or time.monotonic() > deadline:
|
|
||||||
proc.kill()
|
|
||||||
proc.communicate()
|
|
||||||
return []
|
|
||||||
|
|
||||||
if proc.returncode != 0 or not stdout:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
packets = json.loads(stdout).get("packets", [])
|
packets = json.loads(completed.stdout).get("packets", [])
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,11 @@ from ws4py.server.wsgirefserver import (
|
|||||||
WebSocketWSGIRequestHandler,
|
WebSocketWSGIRequestHandler,
|
||||||
WSGIServer,
|
WSGIServer,
|
||||||
)
|
)
|
||||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
|
||||||
|
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
from frigate.comms.detections_updater import DetectionSubscriber, DetectionTypeEnum
|
||||||
from frigate.comms.ws import WebSocket
|
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import AuthConfig, FrigateConfig
|
||||||
from frigate.config.camera.updater import (
|
from frigate.config.camera.updater import (
|
||||||
CameraConfigUpdateEnum,
|
CameraConfigUpdateEnum,
|
||||||
CameraConfigUpdateSubscriber,
|
CameraConfigUpdateSubscriber,
|
||||||
@@ -136,7 +135,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
8082,
|
8082,
|
||||||
server_class=WSGIServer,
|
server_class=WSGIServer,
|
||||||
handler_class=WebSocketWSGIRequestHandler,
|
handler_class=WebSocketWSGIRequestHandler,
|
||||||
app=WebSocketWSGIApplication(handler_cls=WebSocket),
|
app=OriginCheckedWebSocketApplication(handler_cls=WebSocket),
|
||||||
)
|
)
|
||||||
websocket_server.initialize_websockets_manager()
|
websocket_server.initialize_websockets_manager()
|
||||||
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
|
websocket_thread = threading.Thread(target=websocket_server.serve_forever)
|
||||||
@@ -154,6 +153,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||||
|
auth_config_subscriber = ConfigSubscriber("config/auth", exact=True)
|
||||||
|
|
||||||
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
jsmpeg_cameras: dict[str, JsmpegCamera] = {}
|
||||||
birdseye: Birdseye | None = None
|
birdseye: Birdseye | None = None
|
||||||
@@ -194,6 +194,15 @@ class OutputProcess(FrigateProcess):
|
|||||||
self.config.birdseye = birdseye_config
|
self.config.birdseye = birdseye_config
|
||||||
logger.debug("Applied dynamic birdseye config update")
|
logger.debug("Applied dynamic birdseye config update")
|
||||||
|
|
||||||
|
# user endpoints publish None on this topic and role edits an AuthConfig
|
||||||
|
while True:
|
||||||
|
auth_topic, auth_config = auth_config_subscriber.check_for_update()
|
||||||
|
if auth_topic is None:
|
||||||
|
break
|
||||||
|
if isinstance(auth_config, AuthConfig):
|
||||||
|
self.config.auth = auth_config
|
||||||
|
logger.debug("Applied dynamic auth config update")
|
||||||
|
|
||||||
# drain review updates every iteration, not just when birdseye is
|
# drain review updates every iteration, not just when birdseye is
|
||||||
# being consumed, so a dropped end never strands a camera
|
# being consumed, so a dropped end never strands a camera
|
||||||
if birdseye is not None:
|
if birdseye is not None:
|
||||||
@@ -330,6 +339,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
|
|
||||||
config_subscriber.stop()
|
config_subscriber.stop()
|
||||||
birdseye_config_subscriber.stop()
|
birdseye_config_subscriber.stop()
|
||||||
|
auth_config_subscriber.stop()
|
||||||
websocket_server.manager.close_all()
|
websocket_server.manager.close_all()
|
||||||
websocket_server.manager.stop()
|
websocket_server.manager.stop()
|
||||||
websocket_server.manager.join()
|
websocket_server.manager.join()
|
||||||
|
|||||||
@@ -101,6 +101,71 @@ class TestAuthInternalPort(BaseTestHttp):
|
|||||||
self.assertEqual(resp.headers["remote-user"], "anonymous")
|
self.assertEqual(resp.headers["remote-user"], "anonymous")
|
||||||
self.assertEqual(resp.headers["remote-role"], "admin")
|
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||||
|
|
||||||
|
def _internal_auth(self, original_url: str, **headers: str):
|
||||||
|
app = self._create_app()
|
||||||
|
|
||||||
|
with AuthTestClient(app) as client:
|
||||||
|
return client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={
|
||||||
|
"x-server-port": "5000",
|
||||||
|
"x-original-url": original_url,
|
||||||
|
"x-original-host": "frigate.lan:5000",
|
||||||
|
**{k.replace("_", "-"): v for k, v in headers.items()},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cross_origin_websocket_on_internal_port_denied(self):
|
||||||
|
resp = self._internal_auth(
|
||||||
|
"/ws", origin="https://evil.example", x_original_upgrade="websocket"
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
def test_cross_origin_go2rtc_stream_denied(self):
|
||||||
|
resp = self._internal_auth(
|
||||||
|
"/live/mse/api/ws?src=front_door",
|
||||||
|
origin="https://evil.example",
|
||||||
|
x_original_upgrade="websocket",
|
||||||
|
x_go2rtc_stream_proxy="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
def test_cross_origin_webrtc_post_denied(self):
|
||||||
|
# the offer POST is not an upgrade, so the stream marker alone gates it
|
||||||
|
resp = self._internal_auth(
|
||||||
|
"/api/go2rtc/webrtc?src=front_door",
|
||||||
|
origin="https://evil.example",
|
||||||
|
x_go2rtc_stream_proxy="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
def test_same_origin_websocket_allowed(self):
|
||||||
|
resp = self._internal_auth(
|
||||||
|
"/live/mse/api/ws?src=front_door",
|
||||||
|
origin="http://frigate.lan:5000",
|
||||||
|
x_original_upgrade="websocket",
|
||||||
|
x_go2rtc_stream_proxy="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
|
||||||
|
def test_websocket_without_origin_allowed(self):
|
||||||
|
resp = self._internal_auth("/ws", x_original_upgrade="websocket")
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
|
||||||
|
def test_forwarded_host_origin_allowed(self):
|
||||||
|
# Home Assistant integration proxy: Host is Frigate, Origin is HA
|
||||||
|
resp = self._internal_auth(
|
||||||
|
"/api/go2rtc/webrtc?src=front_door",
|
||||||
|
origin="https://ha.example.com:8123",
|
||||||
|
x_forwarded_host="ha.example.com:8123",
|
||||||
|
x_go2rtc_stream_proxy="1",
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
|
||||||
|
def test_cross_origin_plain_http_not_checked(self):
|
||||||
|
resp = self._internal_auth("/api/events", origin="https://evil.example")
|
||||||
|
self.assertEqual(resp.status_code, 202)
|
||||||
|
|
||||||
def test_external_port_requires_auth(self):
|
def test_external_port_requires_auth(self):
|
||||||
app = self._create_app()
|
app = self._create_app()
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,22 @@
|
|||||||
"""Tests that /auth takes the role and revocation state from the user, not the JWT."""
|
"""Tests that /auth only accepts a JWT whose role is still in the config."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock, Mock, patch
|
from unittest.mock import MagicMock, Mock, patch
|
||||||
|
|
||||||
from joserfc import jwt
|
from frigate.api.auth import create_encoded_jwt
|
||||||
|
|
||||||
from frigate.api.auth import create_encoded_jwt, hash_password
|
|
||||||
from frigate.api.fastapi_app import create_fastapi_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.app import FrigateApp
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
from frigate.config.camera.updater import CameraConfigUpdatePublisher
|
||||||
from frigate.const import JWT_SECRET_ENV_VAR
|
from frigate.const import JWT_SECRET_ENV_VAR
|
||||||
from frigate.models import Event, Recordings, ReviewSegment, User
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
PASSWORD = "correct-horse-battery"
|
|
||||||
|
|
||||||
|
|
||||||
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
@patch.dict(os.environ, {JWT_SECRET_ENV_VAR: "test-secret"})
|
||||||
class TestAuthJwtRole(BaseTestHttp):
|
class TestAuthJwtRole(BaseTestHttp):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp(models=[Event, Recordings, ReviewSegment, User])
|
super().setUp(models=[Event, Recordings, ReviewSegment])
|
||||||
self.minimal_config = {
|
self.minimal_config = {
|
||||||
"mqtt": {"host": "mqtt"},
|
"mqtt": {"host": "mqtt"},
|
||||||
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
"auth": {"enabled": True, "roles": {"garage": ["front_door"]}},
|
||||||
@@ -43,19 +36,6 @@ class TestAuthJwtRole(BaseTestHttp):
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
User.delete().execute()
|
|
||||||
self._insert_user("admin", "admin")
|
|
||||||
self._insert_user("bob", "garage")
|
|
||||||
|
|
||||||
def _insert_user(self, username: str, role: str):
|
|
||||||
User.insert(
|
|
||||||
{
|
|
||||||
User.username: username,
|
|
||||||
User.role: role,
|
|
||||||
User.password_hash: hash_password(PASSWORD, iterations=1),
|
|
||||||
User.notification_tokens: [],
|
|
||||||
}
|
|
||||||
).execute()
|
|
||||||
|
|
||||||
def _create_app(self):
|
def _create_app(self):
|
||||||
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
|
||||||
@@ -75,243 +55,27 @@ class TestAuthJwtRole(BaseTestHttp):
|
|||||||
enforce_default_admin=False,
|
enforce_default_admin=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _token(self, app, user: str, role: str, iat: int | None = None) -> str:
|
def _auth(self, app, role: str):
|
||||||
if iat is None:
|
token = create_encoded_jwt("bob", role, int(time.time()) + 3600, app.jwt_token)
|
||||||
return create_encoded_jwt(
|
|
||||||
user, role, int(time.time()) + 3600, app.jwt_token
|
with AuthTestClient(app) as client:
|
||||||
|
return client.get(
|
||||||
|
"/auth",
|
||||||
|
headers={
|
||||||
|
"x-server-port": "8971",
|
||||||
|
"authorization": f"Bearer {token}",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return jwt.encode(
|
|
||||||
{"alg": "HS256"},
|
|
||||||
{"sub": user, "role": role, "exp": int(time.time()) + 3600, "iat": iat},
|
|
||||||
app.jwt_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _auth(self, client, token: str, source: str = "bearer"):
|
|
||||||
headers = {"x-server-port": "8971"}
|
|
||||||
if source == "bearer":
|
|
||||||
headers["authorization"] = f"Bearer {token}"
|
|
||||||
else:
|
|
||||||
client.cookies.set("frigate_token", token)
|
|
||||||
|
|
||||||
resp = client.get("/auth", headers=headers)
|
|
||||||
client.cookies.clear()
|
|
||||||
return resp
|
|
||||||
|
|
||||||
def test_configured_role_is_accepted(self):
|
def test_configured_role_is_accepted(self):
|
||||||
app = self._create_app()
|
resp = self._auth(self._create_app(), "garage")
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
resp = self._auth(client, self._token(app, "bob", "garage"))
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
self.assertEqual(resp.status_code, 202)
|
||||||
self.assertEqual(resp.headers["remote-user"], "bob")
|
self.assertEqual(resp.headers["remote-user"], "bob")
|
||||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
self.assertEqual(resp.headers["remote-role"], "garage")
|
||||||
|
|
||||||
def test_role_claim_is_ignored(self):
|
|
||||||
app = self._create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
resp = self._auth(client, self._token(app, "bob", "admin"))
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
|
||||||
|
|
||||||
def test_role_removed_from_config_is_rejected(self):
|
def test_role_removed_from_config_is_rejected(self):
|
||||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
resp = self._auth(self._create_app(), "removed_role")
|
||||||
app = self._create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
resp = self._auth(client, self._token(app, "bob", "garage"), source)
|
|
||||||
self.assertEqual(resp.status_code, 401)
|
|
||||||
self.assertNotIn("remote-role", resp.headers)
|
|
||||||
|
|
||||||
def test_login_refused_for_role_removed_from_config(self):
|
|
||||||
User.update(role="removed_role").where(User.username == "bob").execute()
|
|
||||||
with AuthTestClient(self._create_app()) as client:
|
|
||||||
resp = client.post("/login", json={"user": "bob", "password": PASSWORD})
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 401)
|
self.assertEqual(resp.status_code, 401)
|
||||||
self.assertNotIn("set-cookie", resp.headers)
|
self.assertNotIn("remote-role", resp.headers)
|
||||||
|
|
||||||
def test_demoted_user_gets_new_role_on_next_request(self):
|
|
||||||
app = self._create_app()
|
|
||||||
token = self._token(app, "bob", "admin")
|
|
||||||
User.update(role="admin").where(User.username == "bob").execute()
|
|
||||||
app.user_sessions["bob"] = ("admin", None)
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(self._auth(client, token).headers["remote-role"], "admin")
|
|
||||||
resp = client.put("/users/bob/role", json={"role": "viewer"})
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
resp = self._auth(client, token, source)
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
|
||||||
|
|
||||||
def test_deleted_user_is_rejected(self):
|
|
||||||
app = self._create_app()
|
|
||||||
token = self._token(app, "bob", "garage")
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(self._auth(client, token).status_code, 202)
|
|
||||||
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
|
||||||
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
|
||||||
|
|
||||||
def test_recreated_user_rejects_token_from_deleted_account(self):
|
|
||||||
app = self._create_app()
|
|
||||||
token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(client.delete("/users/bob").status_code, 200)
|
|
||||||
resp = client.post(
|
|
||||||
"/users",
|
|
||||||
json={"username": "bob", "password": PASSWORD, "role": "viewer"},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
self.assertEqual(self._auth(client, token, source).status_code, 401)
|
|
||||||
|
|
||||||
def test_created_user_is_accepted(self):
|
|
||||||
app = self._create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
resp = client.post(
|
|
||||||
"/users",
|
|
||||||
json={"username": "carol", "password": PASSWORD, "role": "viewer"},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
resp = self._auth(client, self._token(app, "carol", "viewer"))
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertEqual(resp.headers["remote-role"], "viewer")
|
|
||||||
|
|
||||||
def test_password_change_revokes_older_tokens(self):
|
|
||||||
app = self._create_app()
|
|
||||||
old_token = self._token(app, "bob", "garage", iat=int(time.time()) - 10)
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(self._auth(client, old_token).status_code, 202)
|
|
||||||
resp = client.put(
|
|
||||||
"/users/bob/password",
|
|
||||||
json={"password": "a-brand-new-password"},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
|
||||||
|
|
||||||
changed_at = app.user_sessions["bob"][1]
|
|
||||||
resp = self._auth(client, self._token(app, "bob", "garage", changed_at))
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
|
|
||||||
def test_login_in_the_same_second_as_password_change_is_valid(self):
|
|
||||||
app = self._create_app()
|
|
||||||
changed_at = datetime.now().replace(microsecond=500000)
|
|
||||||
new_token = self._token(app, "bob", "garage", iat=int(changed_at.timestamp()))
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("frigate.api.auth.datetime") as mock_datetime,
|
|
||||||
AuthTestClient(app) as client,
|
|
||||||
):
|
|
||||||
mock_datetime.now.return_value = changed_at
|
|
||||||
resp = client.put(
|
|
||||||
"/users/bob/password",
|
|
||||||
json={"password": "a-brand-new-password"},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
self.assertEqual(self._auth(client, new_token, source).status_code, 202)
|
|
||||||
|
|
||||||
def test_own_password_change_cookie_stays_valid(self):
|
|
||||||
app = self._create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
resp = client.put(
|
|
||||||
"/users/bob/password",
|
|
||||||
json={"password": "a-brand-new-password", "old_password": PASSWORD},
|
|
||||||
headers={"remote-user": "bob", "remote-role": "garage"},
|
|
||||||
)
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
new_token = resp.cookies["frigate_token"]
|
|
||||||
|
|
||||||
resp = self._auth(client, new_token, "cookie")
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertEqual(resp.headers["remote-role"], "garage")
|
|
||||||
|
|
||||||
def test_admin_password_reset_revokes_older_tokens(self):
|
|
||||||
config = FrigateConfig(
|
|
||||||
**{**self.minimal_config, "auth": {"reset_admin_password": True}}
|
|
||||||
)
|
|
||||||
FrigateApp.init_auth(SimpleNamespace(config=config))
|
|
||||||
|
|
||||||
app = self._create_app()
|
|
||||||
old_token = self._token(app, "admin", "admin", iat=int(time.time()) - 10)
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
for source in ("bearer", "cookie"):
|
|
||||||
self.assertEqual(self._auth(client, old_token, source).status_code, 401)
|
|
||||||
|
|
||||||
def test_auth_does_not_query_users(self):
|
|
||||||
app = self._create_app()
|
|
||||||
|
|
||||||
# expires inside the refresh window so the cookie refresh runs too
|
|
||||||
token = jwt.encode(
|
|
||||||
{"alg": "HS256"},
|
|
||||||
{"sub": "bob", "role": "garage", "exp": int(time.time()) + 60, "iat": 0},
|
|
||||||
app.jwt_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.object(User, "select", side_effect=AssertionError("db query")),
|
|
||||||
AuthTestClient(app) as client,
|
|
||||||
):
|
|
||||||
client.cookies.set("frigate_token", token)
|
|
||||||
resp = client.get("/auth", headers={"x-server-port": "8971"})
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertIn("frigate_token=", resp.headers["set-cookie"])
|
|
||||||
|
|
||||||
def _login_cookie(self, headers: dict | None = None) -> str:
|
|
||||||
with AuthTestClient(self._create_app()) as client:
|
|
||||||
resp = client.post(
|
|
||||||
"/login",
|
|
||||||
json={"user": "bob", "password": PASSWORD},
|
|
||||||
headers=headers or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
return resp.headers["set-cookie"].lower()
|
|
||||||
|
|
||||||
def test_login_cookie_is_secure_over_tls(self):
|
|
||||||
cookie = self._login_cookie({"x-forwarded-proto": "https"})
|
|
||||||
self.assertIn("; secure", cookie)
|
|
||||||
|
|
||||||
def test_login_cookie_is_not_secure_over_http(self):
|
|
||||||
cookie = self._login_cookie({"x-forwarded-proto": "http"})
|
|
||||||
self.assertNotIn("; secure", cookie)
|
|
||||||
|
|
||||||
def test_cookie_secure_config_forces_secure(self):
|
|
||||||
self.minimal_config["auth"]["cookie_secure"] = True
|
|
||||||
cookie = self._login_cookie()
|
|
||||||
self.assertIn("; secure", cookie)
|
|
||||||
|
|
||||||
def test_refreshed_cookie_is_secure_over_tls(self):
|
|
||||||
app = self._create_app()
|
|
||||||
token = jwt.encode(
|
|
||||||
{"alg": "HS256"},
|
|
||||||
{"sub": "bob", "exp": int(time.time()) + 60, "iat": int(time.time())},
|
|
||||||
app.jwt_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
client.cookies.set("frigate_token", token)
|
|
||||||
resp = client.get(
|
|
||||||
"/auth",
|
|
||||||
headers={"x-server-port": "8971", "x-forwarded-proto": "https"},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(resp.status_code, 202)
|
|
||||||
self.assertIn("; secure", resp.headers["set-cookie"].lower())
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from frigate.config.camera.updater import (
|
|||||||
CameraConfigUpdateTopic,
|
CameraConfigUpdateTopic,
|
||||||
)
|
)
|
||||||
from frigate.config.holder import ConfigHolder
|
from frigate.config.holder import ConfigHolder
|
||||||
from frigate.const import REDACTED_CREDENTIAL_SENTINEL
|
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
from frigate.models import Event, Recordings, ReviewSegment
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||||
|
|
||||||
@@ -504,358 +503,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
|
|||||||
finally:
|
finally:
|
||||||
os.unlink(config_path)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ class TestGo2rtcProxyAuth(BaseTestHttp):
|
|||||||
self.minimal_config = _CONFIG
|
self.minimal_config = _CONFIG
|
||||||
self.app = super().create_app()
|
self.app = super().create_app()
|
||||||
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
self.app.jwt_token = OctKey.import_key(b"0" * 32)
|
||||||
self.app.user_sessions = {"admin": ("admin", None), "viewer": ("viewer", None)}
|
|
||||||
|
|
||||||
get_patch = patch(
|
get_patch = patch(
|
||||||
"frigate.api.auth.requests.get",
|
"frigate.api.auth.requests.get",
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Tests for motion search job limits."""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from frigate.jobs import motion_search
|
|
||||||
from frigate.models import Event, Recordings, ReviewSegment
|
|
||||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
|
||||||
|
|
||||||
URL = "/front_door/search/motion"
|
|
||||||
BODY = {
|
|
||||||
"start_time": 1000,
|
|
||||||
"end_time": 2000,
|
|
||||||
"polygon_points": [[0, 0], [1, 0], [1, 1]],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestHttpMotionSearch(BaseTestHttp):
|
|
||||||
def setUp(self):
|
|
||||||
super().setUp([Event, Recordings, ReviewSegment])
|
|
||||||
self.app = self.create_app()
|
|
||||||
|
|
||||||
motion_search._motion_search_jobs.clear()
|
|
||||||
self.addCleanup(motion_search._motion_search_jobs.clear)
|
|
||||||
|
|
||||||
runner_patch = patch("frigate.jobs.motion_search.MotionSearchRunner")
|
|
||||||
self.runner = runner_patch.start()
|
|
||||||
self.addCleanup(runner_patch.stop)
|
|
||||||
|
|
||||||
def start(self, client: AuthTestClient, user: str, body: dict = BODY):
|
|
||||||
return client.post(URL, json=body, headers={"remote-user": user})
|
|
||||||
|
|
||||||
def test_second_job_for_same_user_rejected(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 409)
|
|
||||||
self.assertEqual(self.start(client, "bob").status_code, 200)
|
|
||||||
|
|
||||||
def test_user_can_start_again_after_job_finishes(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
job_id = self.start(client, "alice").json()["job_id"]
|
|
||||||
job = motion_search.get_motion_search_job(job_id)
|
|
||||||
job.end_time = datetime.now().timestamp()
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
|
||||||
self.assertIsNotNone(motion_search.get_motion_search_job(job_id))
|
|
||||||
|
|
||||||
def test_cancelled_job_frees_user_but_holds_global_slot_until_grace(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
job_id = self.start(client, "alice").json()["job_id"]
|
|
||||||
with patch("frigate.jobs.motion_search.InterProcessRequestor"):
|
|
||||||
self.assertTrue(motion_search.cancel_motion_search_job(job_id))
|
|
||||||
job = motion_search.get_motion_search_job(job_id)
|
|
||||||
self.assertIsNotNone(job.cancelled_at)
|
|
||||||
self.assertIsNone(job.end_time)
|
|
||||||
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "bob").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "carol").status_code, 429)
|
|
||||||
|
|
||||||
job.cancelled_at -= motion_search.CANCEL_GRACE_SECONDS + 1
|
|
||||||
self.assertEqual(self.start(client, "carol").status_code, 200)
|
|
||||||
|
|
||||||
def test_start_failure_releases_slot(self):
|
|
||||||
self.runner.return_value.start.side_effect = RuntimeError("boom")
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
with self.assertRaises(RuntimeError):
|
|
||||||
self.start(client, "alice")
|
|
||||||
self.assertEqual(motion_search._motion_search_jobs, {})
|
|
||||||
self.runner.return_value.requestor.stop.assert_called_once()
|
|
||||||
|
|
||||||
self.runner.return_value.start.side_effect = None
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
|
||||||
|
|
||||||
def test_shared_identity_skips_per_user_limit(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "anonymous").status_code, 200)
|
|
||||||
|
|
||||||
motion_search._motion_search_jobs.clear()
|
|
||||||
self.minimal_config["auth"] = {"enabled": False}
|
|
||||||
app = self.create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "viewer").status_code, 200)
|
|
||||||
|
|
||||||
def test_proxy_user_header_keeps_per_user_limit_without_auth(self):
|
|
||||||
self.minimal_config["auth"] = {"enabled": False}
|
|
||||||
self.minimal_config["proxy"] = {"header_map": {"user": "x-forwarded-user"}}
|
|
||||||
app = self.create_app()
|
|
||||||
with AuthTestClient(app) as client:
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "alice").status_code, 409)
|
|
||||||
|
|
||||||
def test_global_cap_enforced(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
for i in range(motion_search.MAX_ACTIVE_JOBS):
|
|
||||||
self.assertEqual(self.start(client, f"user{i}").status_code, 200)
|
|
||||||
self.assertEqual(self.start(client, "late").status_code, 429)
|
|
||||||
|
|
||||||
def test_finished_jobs_evicted_after_ttl(self):
|
|
||||||
with AuthTestClient(self.app) as client:
|
|
||||||
old_id = self.start(client, "alice").json()["job_id"]
|
|
||||||
recent_id = self.start(client, "bob").json()["job_id"]
|
|
||||||
running_id = self.start(client, "carol").json()["job_id"]
|
|
||||||
|
|
||||||
now = 10_000.0
|
|
||||||
motion_search.get_motion_search_job(old_id).end_time = (
|
|
||||||
now - motion_search.FINISHED_JOB_TTL - 1
|
|
||||||
)
|
|
||||||
motion_search.get_motion_search_job(recent_id).end_time = now - 1
|
|
||||||
motion_search._evict_finished_jobs(now)
|
|
||||||
|
|
||||||
self.assertIsNone(motion_search.get_motion_search_job(old_id))
|
|
||||||
self.assertIsNotNone(motion_search.get_motion_search_job(recent_id))
|
|
||||||
self.assertIsNotNone(motion_search.get_motion_search_job(running_id))
|
|
||||||
|
|
||||||
def test_username_and_cancel_time_not_broadcast(self):
|
|
||||||
job = motion_search.MotionSearchJob(username="alice", cancelled_at=1.0)
|
|
||||||
payload = job.to_dict()
|
|
||||||
self.assertNotIn("username", payload)
|
|
||||||
self.assertNotIn("cancelled_at", payload)
|
|
||||||
@@ -1,13 +1,8 @@
|
|||||||
"""Test camera user and password cleanup."""
|
"""Test camera user and password cleanup."""
|
||||||
|
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from frigate.util.builtin import (
|
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||||
clean_camera_user_pass,
|
|
||||||
escape_special_characters,
|
|
||||||
is_masked_camera_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserPassCleanup(unittest.TestCase):
|
class TestUserPassCleanup(unittest.TestCase):
|
||||||
@@ -50,90 +45,3 @@ class TestUserPassMasking(unittest.TestCase):
|
|||||||
escaped = clean_camera_user_pass(self.rtsp_log_message)
|
escaped = clean_camera_user_pass(self.rtsp_log_message)
|
||||||
print(f"The escaped is {escaped}")
|
print(f"The escaped is {escaped}")
|
||||||
assert escaped == "Did you mean file:rtsp://*:*@192.168.1.3:554"
|
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,6 +1,5 @@
|
|||||||
"""Tests for the motion search hardware-accelerated decode helpers."""
|
"""Tests for the motion search hardware-accelerated decode helpers."""
|
||||||
|
|
||||||
import subprocess as sp
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -141,11 +140,6 @@ class TestVodDecodeCommand(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestProbeVodKeyframePts(unittest.TestCase):
|
class TestProbeVodKeyframePts(unittest.TestCase):
|
||||||
def _proc(self, stdout="", returncode=0):
|
|
||||||
proc = mock.Mock(returncode=returncode)
|
|
||||||
proc.communicate.return_value = (stdout, None)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
def test_parses_keyframe_packets(self):
|
def test_parses_keyframe_packets(self):
|
||||||
sample = (
|
sample = (
|
||||||
'{"packets":['
|
'{"packets":['
|
||||||
@@ -153,37 +147,19 @@ class TestProbeVodKeyframePts(unittest.TestCase):
|
|||||||
'{"pts_time":"1.000000","flags":"___"},'
|
'{"pts_time":"1.000000","flags":"___"},'
|
||||||
'{"pts_time":"2.000000","flags":"K__"}]}'
|
'{"pts_time":"2.000000","flags":"K__"}]}'
|
||||||
)
|
)
|
||||||
|
completed = mock.Mock(stdout=sample, returncode=0)
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"frigate.jobs.motion_search_decode.sp.Popen",
|
"frigate.jobs.motion_search_decode.sp.run", return_value=completed
|
||||||
return_value=self._proc(sample),
|
|
||||||
):
|
):
|
||||||
pts = probe_vod_keyframe_pts(
|
pts = probe_vod_keyframe_pts("ffprobe", "http://x/index.m3u8")
|
||||||
"ffprobe", "http://x/index.m3u8", lambda: False
|
|
||||||
)
|
|
||||||
self.assertEqual(pts, [0.0, 2.0])
|
self.assertEqual(pts, [0.0, 2.0])
|
||||||
|
|
||||||
def test_returns_empty_on_failure(self):
|
def test_returns_empty_on_failure(self):
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"frigate.jobs.motion_search_decode.sp.Popen",
|
"frigate.jobs.motion_search_decode.sp.run",
|
||||||
side_effect=OSError("boom"),
|
side_effect=OSError("boom"),
|
||||||
):
|
):
|
||||||
self.assertEqual(
|
self.assertEqual(probe_vod_keyframe_pts("ffprobe", "http://x"), [])
|
||||||
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: False), []
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_stop_request_kills_probe(self):
|
|
||||||
proc = self._proc()
|
|
||||||
proc.communicate.side_effect = [
|
|
||||||
sp.TimeoutExpired("ffprobe", 0.5),
|
|
||||||
("", None),
|
|
||||||
]
|
|
||||||
with mock.patch(
|
|
||||||
"frigate.jobs.motion_search_decode.sp.Popen", return_value=proc
|
|
||||||
):
|
|
||||||
self.assertEqual(
|
|
||||||
probe_vod_keyframe_pts("ffprobe", "http://x", lambda: True), []
|
|
||||||
)
|
|
||||||
proc.kill.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestProbeVideoDimensions(unittest.TestCase):
|
class TestProbeVideoDimensions(unittest.TestCase):
|
||||||
|
|||||||
@@ -36,73 +36,6 @@ class TestListenConfig(unittest.TestCase):
|
|||||||
self.assertEqual(listen.internal_port, 5001)
|
self.assertEqual(listen.internal_port, 5001)
|
||||||
self.assertEqual(listen.external_port, 8971)
|
self.assertEqual(listen.external_port, 8971)
|
||||||
|
|
||||||
def test_valid_listen_values_are_rebuilt(self):
|
|
||||||
cases = {
|
|
||||||
5000: 5000,
|
|
||||||
"5000": 5000,
|
|
||||||
"127.0.0.1:5000": "127.0.0.1:5000",
|
|
||||||
"0.0.0.0:5000": "0.0.0.0:5000",
|
|
||||||
"[::1]:5000": "[::1]:5000",
|
|
||||||
"[::]:5000": "[::]:5000",
|
|
||||||
"[2001:DB8:0:0::1]:5000": "[2001:db8::1]:5000",
|
|
||||||
" 5000 ": 5000,
|
|
||||||
"5000\n": 5000,
|
|
||||||
" 127.0.0.1:5000 ": "127.0.0.1:5000",
|
|
||||||
"localhost:5000": "localhost:5000",
|
|
||||||
"frigate-host.lan:5000": "frigate-host.lan:5000",
|
|
||||||
"*:5000": "*:5000",
|
|
||||||
}
|
|
||||||
|
|
||||||
for value, expected in cases.items():
|
|
||||||
with self.subTest(value=value):
|
|
||||||
listen = ListenConfig(internal=value)
|
|
||||||
|
|
||||||
self.assertEqual(listen.internal, expected)
|
|
||||||
self.assertEqual(listen.internal_port, 5000)
|
|
||||||
|
|
||||||
def test_invalid_listen_values_rejected(self):
|
|
||||||
cases = [
|
|
||||||
"5000; location /x/ { alias /; autoindex on; } #:5000",
|
|
||||||
"127.0.0.1:5000; include /etc/passwd",
|
|
||||||
"5000 ssl",
|
|
||||||
"5000;",
|
|
||||||
"::1:5000",
|
|
||||||
"[127.0.0.1]:5000",
|
|
||||||
"[fe80::1%eth0]:5000",
|
|
||||||
"local host:5000",
|
|
||||||
"localhost;:5000",
|
|
||||||
"local{host}:5000",
|
|
||||||
"host#:5000",
|
|
||||||
"'localhost':5000",
|
|
||||||
'"localhost":5000',
|
|
||||||
"-host:5000",
|
|
||||||
"host-:5000",
|
|
||||||
"host..lan:5000",
|
|
||||||
"**:5000",
|
|
||||||
"*.lan:5000",
|
|
||||||
"unix:/tmp/sock:5000",
|
|
||||||
"5000 5001",
|
|
||||||
":5000",
|
|
||||||
"127.0.0.1:",
|
|
||||||
"127.0.0.1:+5000",
|
|
||||||
0,
|
|
||||||
65536,
|
|
||||||
-1,
|
|
||||||
]
|
|
||||||
|
|
||||||
for value in cases:
|
|
||||||
with self.subTest(value=value):
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
ListenConfig(internal=value)
|
|
||||||
|
|
||||||
def test_bool_listen_value_rejected(self):
|
|
||||||
# get_nginx_settings.py sees the raw bool and falls back to the default,
|
|
||||||
# so accepting it as port 1 would split nginx and the auth port check
|
|
||||||
for value in (True, False):
|
|
||||||
with self.subTest(value=value):
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
ListenConfig(internal=value)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import AuthConfig, FrigateConfig
|
||||||
from frigate.output.ws_auth import ws_has_camera_access
|
from frigate.output.ws_auth import ws_has_camera_access
|
||||||
|
|
||||||
|
|
||||||
@@ -55,3 +55,11 @@ class TestWsHasCameraAccess(unittest.TestCase):
|
|||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
|
ws_has_camera_access(self._make_ws("limited_user"), "birdseye", self.config)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_replaced_auth_config_revokes_camera(self):
|
||||||
|
ws = self._make_ws("limited_user")
|
||||||
|
self.assertTrue(ws_has_camera_access(ws, "front_door", self.config))
|
||||||
|
|
||||||
|
self.config.auth = AuthConfig(roles={"limited_user": ["back_door"]})
|
||||||
|
self.assertFalse(ws_has_camera_access(ws, "front_door", self.config))
|
||||||
|
self.assertTrue(ws_has_camera_access(ws, "back_door", self.config))
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Tests for the device each model runner reports after loading."""
|
"""Tests for the device each model runner reports after loading."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import MagicMock, patch
|
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 (
|
from frigate.detectors.detection_runners import (
|
||||||
CudaGraphRunner,
|
CudaGraphRunner,
|
||||||
ONNXModelRunner,
|
ONNXModelRunner,
|
||||||
@@ -18,7 +17,6 @@ from frigate.detectors.detection_runners import (
|
|||||||
record_loaded_device,
|
record_loaded_device,
|
||||||
snapshot_loaded_devices,
|
snapshot_loaded_devices,
|
||||||
)
|
)
|
||||||
from frigate.util import model as model_util
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunnerDeviceName(unittest.TestCase):
|
class TestRunnerDeviceName(unittest.TestCase):
|
||||||
@@ -319,215 +317,3 @@ class TestLighterANE(unittest.TestCase):
|
|||||||
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
|
get_optimized_runner("/models/arcface.onnx", "CPU", "arcface")
|
||||||
|
|
||||||
ane.assert_not_called()
|
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
|
import unittest
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
|
|
||||||
from frigate.api.notification import _validate_subscription
|
from frigate.api.notification import _validate_push_endpoint, _validate_subscription
|
||||||
from frigate.comms.webpush import (
|
from frigate.comms.webpush import _build_web_pushers
|
||||||
_build_web_pushers,
|
|
||||||
_NoRedirectSession,
|
|
||||||
_vapid_audience,
|
|
||||||
validate_push_endpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
VALID_ENDPOINTS = [
|
VALID_ENDPOINTS = [
|
||||||
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
"https://fcm.googleapis.com/fcm/send/dGhpcy1pcy1hLXRva2Vu",
|
||||||
@@ -31,11 +26,11 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
def test_accepts_real_push_service_endpoints(self):
|
def test_accepts_real_push_service_endpoints(self):
|
||||||
for endpoint in VALID_ENDPOINTS:
|
for endpoint in VALID_ENDPOINTS:
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNone(validate_push_endpoint(endpoint))
|
self.assertIsNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_http(self):
|
def test_rejects_http(self):
|
||||||
self.assertIsNotNone(
|
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):
|
def test_rejects_non_http_schemes(self):
|
||||||
@@ -45,7 +40,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"//example.com/token",
|
"//example.com/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_localhost(self):
|
def test_rejects_localhost(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -55,7 +50,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://[::1]/token",
|
"https://[::1]/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_private_addresses(self):
|
def test_rejects_private_addresses(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -66,7 +61,7 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://0.0.0.0/token",
|
"https://0.0.0.0/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_internal_hostnames(self):
|
def test_rejects_internal_hostnames(self):
|
||||||
for endpoint in (
|
for endpoint in (
|
||||||
@@ -76,16 +71,16 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
"https://host.home.arpa/token",
|
"https://host.home.arpa/token",
|
||||||
):
|
):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_non_default_port(self):
|
def test_rejects_non_default_port(self):
|
||||||
self.assertIsNotNone(
|
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):
|
def test_rejects_embedded_credentials(self):
|
||||||
self.assertIsNotNone(
|
self.assertIsNotNone(
|
||||||
validate_push_endpoint(
|
_validate_push_endpoint(
|
||||||
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
"https://user:pass@fcm.googleapis.com/fcm/send/token"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -93,26 +88,26 @@ class TestValidatePushEndpoint(unittest.TestCase):
|
|||||||
def test_rejects_endpoint_without_path(self):
|
def test_rejects_endpoint_without_path(self):
|
||||||
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
for endpoint in ("https://fcm.googleapis.com", "https://fcm.googleapis.com/"):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_empty_hostname_labels(self):
|
def test_rejects_endpoint_that_breaks_audience_parsing(self):
|
||||||
for endpoint in (
|
# webpush.py locates the host by searching for a separator after index
|
||||||
"https://./x",
|
# 10, which raises ValueError when the url has no path at all
|
||||||
"https://fcm..googleapis.com/token",
|
endpoint = "https://fcm.googleapis.com"
|
||||||
"https://.fcm.googleapis.com/token",
|
|
||||||
"https://fcm.googleapis.com./token",
|
with self.assertRaises(ValueError):
|
||||||
):
|
endpoint.index("/", 10)
|
||||||
with self.subTest(endpoint=endpoint):
|
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_missing_or_non_string_endpoint(self):
|
def test_rejects_missing_or_non_string_endpoint(self):
|
||||||
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
for endpoint in (None, "", 5, {"url": "https://example.com/token"}):
|
||||||
with self.subTest(endpoint=endpoint):
|
with self.subTest(endpoint=endpoint):
|
||||||
self.assertIsNotNone(validate_push_endpoint(endpoint))
|
self.assertIsNotNone(_validate_push_endpoint(endpoint))
|
||||||
|
|
||||||
def test_rejects_overlong_endpoint(self):
|
def test_rejects_overlong_endpoint(self):
|
||||||
self.assertIsNotNone(
|
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))
|
self.assertIsNotNone(_validate_subscription(sub))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
class TestBuildWebPushers(unittest.TestCase):
|
class TestBuildWebPushers(unittest.TestCase):
|
||||||
def test_skips_subscriptions_with_malformed_keys(self):
|
def test_skips_subscriptions_with_malformed_keys(self):
|
||||||
bad = _subscription(VALID_ENDPOINTS[0])
|
bad = _subscription(VALID_ENDPOINTS[0])
|
||||||
@@ -164,52 +163,3 @@ class TestBuildWebPushers(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(pushers), 1)
|
self.assertEqual(len(pushers), 1)
|
||||||
self.assertEqual(pushers[0].receiver_key, b"\x04" + bytes(64))
|
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()
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Tests for the same-origin check on WebSocket handshakes."""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from frigate.comms.ws import OriginCheckedWebSocketApplication, WebSocket
|
||||||
|
from frigate.util.origin import origin_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def ws_origin_allowed(environ: dict[str, str]) -> bool:
|
||||||
|
return origin_allowed(
|
||||||
|
environ.get("HTTP_ORIGIN"),
|
||||||
|
environ.get("HTTP_HOST"),
|
||||||
|
environ.get("HTTP_X_FORWARDED_HOST"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOriginAllowed(unittest.TestCase):
|
||||||
|
def test_missing_origin_allowed(self):
|
||||||
|
self.assertTrue(ws_origin_allowed({"HTTP_HOST": "frigate.lan"}))
|
||||||
|
|
||||||
|
def test_direct_access_same_origin(self):
|
||||||
|
# nginx forwards Host without the port and X-Forwarded-Host with it
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "http://192.168.1.10:5000",
|
||||||
|
"HTTP_HOST": "192.168.1.10",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "192.168.1.10:5000",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_default_port_origin(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://frigate.example.com",
|
||||||
|
"HTTP_HOST": "frigate.example.com",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_host_comparison_is_case_insensitive(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://Frigate.Example.com",
|
||||||
|
"HTTP_HOST": "frigate.example.COM",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_ipv6_host(self):
|
||||||
|
environ = {"HTTP_ORIGIN": "http://[::1]:8971", "HTTP_HOST": "[::1]"}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_cross_origin_rejected(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://evil.example",
|
||||||
|
"HTTP_HOST": "frigate.lan",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "frigate.lan:5000",
|
||||||
|
}
|
||||||
|
self.assertFalse(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_null_origin_rejected(self):
|
||||||
|
self.assertFalse(
|
||||||
|
ws_origin_allowed({"HTTP_ORIGIN": "null", "HTTP_HOST": "frigate.lan"})
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_proxy_rewriting_host_with_forwarded_host(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://frigate.example.com",
|
||||||
|
"HTTP_HOST": "10.0.0.5",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "frigate.example.com",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_forwarded_host_list_uses_first_entry(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://frigate.example.com",
|
||||||
|
"HTTP_HOST": "10.0.0.5",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "frigate.example.com, proxy.internal",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
environ["HTTP_ORIGIN"] = "https://proxy.internal"
|
||||||
|
self.assertFalse(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_home_assistant_ingress(self):
|
||||||
|
# HA core copies Host and sets X-Forwarded-Host to its own host
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://ha.example.com:8123",
|
||||||
|
"HTTP_HOST": "ha.example.com",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
|
||||||
|
"HTTP_X_INGRESS_PATH": "/api/hassio_ingress/abc123",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_ingress_path_does_not_bypass_origin_check(self):
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://evil.example",
|
||||||
|
"HTTP_HOST": "frigate.example.com",
|
||||||
|
"HTTP_X_INGRESS_PATH": "/frigate",
|
||||||
|
}
|
||||||
|
self.assertFalse(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
def test_home_assistant_integration_proxy(self):
|
||||||
|
# hass-web-proxy-lib drops Host, so Host is the integration's Frigate URL
|
||||||
|
environ = {
|
||||||
|
"HTTP_ORIGIN": "https://ha.example.com:8123",
|
||||||
|
"HTTP_HOST": "frigate.lan",
|
||||||
|
"HTTP_X_FORWARDED_HOST": "ha.example.com:8123",
|
||||||
|
}
|
||||||
|
self.assertTrue(ws_origin_allowed(environ))
|
||||||
|
|
||||||
|
|
||||||
|
class TestOriginCheckedWebSocketApplication(unittest.TestCase):
|
||||||
|
def test_cross_origin_handshake_gets_403(self):
|
||||||
|
app = OriginCheckedWebSocketApplication(handler_cls=WebSocket)
|
||||||
|
start_response = MagicMock()
|
||||||
|
environ = {
|
||||||
|
"REQUEST_METHOD": "GET",
|
||||||
|
"HTTP_ORIGIN": "https://evil.example",
|
||||||
|
"HTTP_HOST": "frigate.lan",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(app(environ, start_response), [])
|
||||||
|
start_response.assert_called_once()
|
||||||
|
self.assertEqual(start_response.call_args.args[0], "403 Forbidden")
|
||||||
|
self.assertNotIn("ws4py.websocket", environ)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+4
-22
@@ -21,11 +21,8 @@ import numpy as np
|
|||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
REGEX_CREDENTIAL_QUERY_KEY,
|
REGEX_HTTP_CAMERA_USER_PASS,
|
||||||
REGEX_CREDENTIAL_QUERY_PARAM,
|
|
||||||
REGEX_MASKED_CREDENTIAL,
|
|
||||||
REGEX_RTSP_CAMERA_USER_PASS,
|
REGEX_RTSP_CAMERA_USER_PASS,
|
||||||
REGEX_URL_USERINFO,
|
|
||||||
STREAM_TYPE_MAIN,
|
STREAM_TYPE_MAIN,
|
||||||
STREAM_TYPE_SUB,
|
STREAM_TYPE_SUB,
|
||||||
)
|
)
|
||||||
@@ -120,24 +117,9 @@ def deep_merge(dct1: dict, dct2: dict, override=False, merge_lists=False) -> dic
|
|||||||
|
|
||||||
|
|
||||||
def clean_camera_user_pass(line: str) -> str:
|
def clean_camera_user_pass(line: str) -> str:
|
||||||
"""Mask URL userinfo and credential query parameters in line."""
|
"""Removes user and password from line."""
|
||||||
|
rtsp_cleaned = re.sub(REGEX_RTSP_CAMERA_USER_PASS, "://*:*@", line)
|
||||||
line = re.sub(REGEX_URL_USERINFO, _mask_userinfo, line)
|
return re.sub(REGEX_HTTP_CAMERA_USER_PASS, "user=*&password=*", rtsp_cleaned)
|
||||||
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:
|
def escape_special_characters(path: str) -> str:
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""configuration utils."""
|
"""configuration utils."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -128,49 +126,6 @@ def redact_credential(obj: dict[str, Any], key: str) -> None:
|
|||||||
obj.pop(key, None)
|
obj.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
LISTEN_HOSTNAME = re.compile(
|
|
||||||
r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
|
|
||||||
r"(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_listen_address(value: int | str) -> tuple[int | str, int]:
|
|
||||||
"""Validate a networking.listen value and rebuild it from its parsed parts.
|
|
||||||
|
|
||||||
Accepts a port, "host:port", "*:port", or "[IPv6]:port", where host is an
|
|
||||||
IPv4 address or an RFC 1123 hostname. nginx receives the rebuilt value
|
|
||||||
verbatim, so anything else raises ValueError.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The normalized value (an int for a bare port) and the port
|
|
||||||
"""
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int | str):
|
|
||||||
raise ValueError(f"invalid listen value: {value!r}")
|
|
||||||
|
|
||||||
host, sep, port_text = str(value).strip().rpartition(":")
|
|
||||||
|
|
||||||
if not re.fullmatch(r"[0-9]{1,5}", port_text) or not (1 <= int(port_text) <= 65535):
|
|
||||||
raise ValueError(f"invalid listen port: {value!r}")
|
|
||||||
|
|
||||||
port = int(port_text)
|
|
||||||
|
|
||||||
if not sep:
|
|
||||||
return port, port
|
|
||||||
|
|
||||||
if host.startswith("[") and host.endswith("]"):
|
|
||||||
address = ipaddress.IPv6Address(host[1:-1])
|
|
||||||
|
|
||||||
if address.scope_id:
|
|
||||||
raise ValueError(f"listen address must not have a scope ID: {value!r}")
|
|
||||||
|
|
||||||
return f"[{address}]:{port}", port
|
|
||||||
|
|
||||||
if host != "*" and not LISTEN_HOSTNAME.fullmatch(host):
|
|
||||||
raise ValueError(f"invalid listen address: {value!r}")
|
|
||||||
|
|
||||||
return f"{host}:{port}", port
|
|
||||||
|
|
||||||
|
|
||||||
def find_config_file() -> str:
|
def find_config_file() -> str:
|
||||||
"""Return the path of the config file to use.
|
"""Return the path of the config file to use.
|
||||||
|
|
||||||
|
|||||||
@@ -385,9 +385,15 @@ def get_ort_providers(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif provider == "MIGraphXExecutionProvider":
|
elif provider == "MIGraphXExecutionProvider":
|
||||||
# MIGraphX is a plugin provider, which ONNX Runtime can't create by
|
migraphx_cache_dir = os.path.join(MODEL_CACHE_DIR, "migraphx")
|
||||||
# name, so get_optimized_runner selects it through its devices
|
os.makedirs(migraphx_cache_dir, exist_ok=True)
|
||||||
continue
|
|
||||||
|
providers.append(provider)
|
||||||
|
options.append(
|
||||||
|
{
|
||||||
|
"migraphx_model_cache_dir": migraphx_cache_dir,
|
||||||
|
}
|
||||||
|
)
|
||||||
elif provider == "CPUExecutionProvider":
|
elif provider == "CPUExecutionProvider":
|
||||||
providers.append(provider)
|
providers.append(provider)
|
||||||
options.append(
|
options.append(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Same-origin check for browser WebSocket handshakes and go2rtc stream requests."""
|
||||||
|
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def _hostname(value: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return urlsplit(value).hostname
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def origin_allowed(
|
||||||
|
origin: str | None, host: str | None, forwarded_host: str | None
|
||||||
|
) -> bool:
|
||||||
|
"""Return True unless a browser sent the request from a foreign origin.
|
||||||
|
|
||||||
|
Browsers always send Origin on a WebSocket handshake, so a missing Origin
|
||||||
|
means a non-browser client. A page cannot add headers to a handshake, so
|
||||||
|
X-Forwarded-Host comes from a proxy. Ports are ignored because nginx
|
||||||
|
forwards Host without one.
|
||||||
|
"""
|
||||||
|
if origin is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
origin_host = _hostname(origin)
|
||||||
|
if not origin_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
hosts = ((forwarded_host or "").split(",")[0].strip(), host or "")
|
||||||
|
return any(origin_host == _hostname(f"//{h}") for h in hosts if h)
|
||||||
@@ -338,12 +338,7 @@ def process_frames(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if datetime.now().astimezone(UTC) > next_region_update:
|
if datetime.now().astimezone(UTC) > next_region_update:
|
||||||
updated_grid = requestor.send_data(REQUEST_REGION_GRID, camera_config.name)
|
region_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)
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -218,50 +218,6 @@ test.describe("Chat — streaming @medium", () => {
|
|||||||
frigateApp.page.getByText(/searching for people/i),
|
frigateApp.page.getByText(/searching for people/i),
|
||||||
).toBeVisible({ timeout: 5_000 });
|
).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assistant markdown renders only same-origin images", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
const reply =
|
|
||||||
" " +
|
|
||||||
" " +
|
|
||||||
"[offsite](https://evil.example/page)";
|
|
||||||
await installChatStreamOverride(frigateApp, [
|
|
||||||
{ type: "content", delta: reply },
|
|
||||||
{
|
|
||||||
type: "messages",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: "sys" },
|
|
||||||
{ role: "user", content: "show me" },
|
|
||||||
{ role: "assistant", content: reply },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ type: "done" },
|
|
||||||
]);
|
|
||||||
const offsiteRequests: string[] = [];
|
|
||||||
frigateApp.page.on("request", (req) => {
|
|
||||||
if (req.url().includes("evil.example")) offsiteRequests.push(req.url());
|
|
||||||
});
|
|
||||||
await frigateApp.goto("/chat");
|
|
||||||
const input = frigateApp.page.getByPlaceholder(/ask/i);
|
|
||||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
|
||||||
await input.fill("show me");
|
|
||||||
await input.press("Enter");
|
|
||||||
|
|
||||||
await expect(frigateApp.page.getByText("leaked alt")).toBeVisible({
|
|
||||||
timeout: 10_000,
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('img[src*="evil.example"]'),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.locator('img[src="/api/events/abc/thumbnail.webp"]'),
|
|
||||||
).toHaveCount(1);
|
|
||||||
const link = frigateApp.page.getByRole("link", { name: "offsite" });
|
|
||||||
await expect(link).toHaveAttribute("target", "_blank");
|
|
||||||
await expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
|
||||||
expect(offsiteRequests).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Chat — stop @medium", () => {
|
test.describe("Chat — stop @medium", () => {
|
||||||
|
|||||||
@@ -32,11 +32,7 @@ type CameraInput = {
|
|||||||
input_args?: string;
|
input_args?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function installRoutes(
|
async function installRoutes(page: Page, frontDoorInputs: CameraInput[]) {
|
||||||
page: Page,
|
|
||||||
frontDoorInputs: CameraInput[],
|
|
||||||
rawInputs: CameraInput[] = frontDoorInputs,
|
|
||||||
) {
|
|
||||||
const config = configFactory({
|
const config = configFactory({
|
||||||
go2rtc: { streams: GO2RTC_STREAMS },
|
go2rtc: { streams: GO2RTC_STREAMS },
|
||||||
cameras: {
|
cameras: {
|
||||||
@@ -60,7 +56,7 @@ async function installRoutes(
|
|||||||
await page.route("**/api/config/raw_paths", (route) =>
|
await page.route("**/api/config/raw_paths", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
cameras: { front_door: { ffmpeg: { inputs: rawInputs } } },
|
cameras: { front_door: { ffmpeg: { inputs: frontDoorInputs } } },
|
||||||
go2rtc: { streams: GO2RTC_STREAMS },
|
go2rtc: { streams: GO2RTC_STREAMS },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -204,64 +200,4 @@ test.describe("camera ffmpeg input source toggle @medium", () => {
|
|||||||
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
payload?.config_data?.cameras?.front_door?.ffmpeg?.inputs?.[0];
|
||||||
expect(input?.input_args).not.toBe("preset-rtsp-restream");
|
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
+426
-305
File diff suppressed because it is too large
Load Diff
+23
-22
@@ -23,28 +23,28 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
"@cycjimmy/jsmpeg-player": "^6.1.2",
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
"@radix-ui/react-alert-dialog": "^1.1.24",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
"@radix-ui/react-aspect-ratio": "^1.1.16",
|
||||||
"@radix-ui/react-checkbox": "^1.4.1",
|
"@radix-ui/react-checkbox": "^1.3.12",
|
||||||
"@radix-ui/react-collapsible": "^1.2.1",
|
"@radix-ui/react-collapsible": "^1.1.21",
|
||||||
"@radix-ui/react-context-menu": "^2.4.1",
|
"@radix-ui/react-context-menu": "^2.3.8",
|
||||||
"@radix-ui/react-dialog": "^1.2.0",
|
"@radix-ui/react-dialog": "^1.2.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.25",
|
||||||
"@radix-ui/react-hover-card": "^1.2.1",
|
"@radix-ui/react-hover-card": "^1.1.24",
|
||||||
"@radix-ui/react-label": "^2.1.17",
|
"@radix-ui/react-label": "^2.1.16",
|
||||||
"@radix-ui/react-popover": "^1.3.1",
|
"@radix-ui/react-popover": "^1.2.0",
|
||||||
"@radix-ui/react-progress": "^1.2.1",
|
"@radix-ui/react-progress": "^1.1.17",
|
||||||
"@radix-ui/react-radio-group": "^1.5.1",
|
"@radix-ui/react-radio-group": "^1.4.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.4.0",
|
"@radix-ui/react-scroll-area": "^1.3.0",
|
||||||
"@radix-ui/react-select": "^2.4.1",
|
"@radix-ui/react-select": "^2.3.8",
|
||||||
"@radix-ui/react-separator": "^1.2.0",
|
"@radix-ui/react-separator": "^1.1.16",
|
||||||
"@radix-ui/react-slider": "^1.6.1",
|
"@radix-ui/react-slider": "^1.5.0",
|
||||||
"@radix-ui/react-slot": "1.5.0",
|
"@radix-ui/react-slot": "1.4.0",
|
||||||
"@radix-ui/react-switch": "^1.4.1",
|
"@radix-ui/react-switch": "^1.3.8",
|
||||||
"@radix-ui/react-tabs": "^1.2.1",
|
"@radix-ui/react-tabs": "^1.1.22",
|
||||||
"@radix-ui/react-toggle": "^1.2.1",
|
"@radix-ui/react-toggle": "^1.1.19",
|
||||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
"@radix-ui/react-toggle-group": "^1.1.20",
|
||||||
"@radix-ui/react-tooltip": "^1.4.1",
|
"@radix-ui/react-tooltip": "^1.3.0",
|
||||||
"@rjsf/core": "^6.10.1",
|
"@rjsf/core": "^6.10.1",
|
||||||
"@rjsf/shadcn": "^6.10.1",
|
"@rjsf/shadcn": "^6.10.1",
|
||||||
"@rjsf/utils": "^6.10.1",
|
"@rjsf/utils": "^6.10.1",
|
||||||
@@ -127,6 +127,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@radix-ui/react-compose-refs": "1.1.2",
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
},
|
},
|
||||||
"cookie_secure": {
|
"cookie_secure": {
|
||||||
"label": "Secure cookie flag",
|
"label": "Secure cookie flag",
|
||||||
"description": "Always set the secure flag on the auth cookie. Frigate sets it automatically when serving TLS; enable this when a reverse proxy terminates TLS in front of Frigate. A proxy that connects to the TLS port must serve Frigate to browsers over HTTPS."
|
"description": "Set the secure flag on the auth cookie; should be true when using TLS."
|
||||||
},
|
},
|
||||||
"session_length": {
|
"session_length": {
|
||||||
"label": "Session length",
|
"label": "Session length",
|
||||||
|
|||||||
@@ -56,8 +56,6 @@
|
|||||||
"noROI": "Please draw a region of interest",
|
"noROI": "Please draw a region of interest",
|
||||||
"noTimeRange": "Please select a time range",
|
"noTimeRange": "Please select a time range",
|
||||||
"invalidTimeRange": "End time must be after start time",
|
"invalidTimeRange": "End time must be after start time",
|
||||||
"alreadyRunning": "You already have a motion search running. Wait for it to finish or cancel it.",
|
|
||||||
"tooManySearches": "Too many motion searches are running. Try again when one finishes.",
|
|
||||||
"searchFailed": "Search failed: {{message}}",
|
"searchFailed": "Search failed: {{message}}",
|
||||||
"polygonTooSmall": "Polygon must have at least 3 points",
|
"polygonTooSmall": "Polygon must have at least 3 points",
|
||||||
"unknown": "Unknown error"
|
"unknown": "Unknown error"
|
||||||
|
|||||||
@@ -18,18 +18,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||||
import { parseAttachedEvent } from "@/utils/chatUtil";
|
import { parseAttachedEvent } from "@/utils/chatUtil";
|
||||||
import type { ChatStats, ShowStatsMode } from "@/types/chat";
|
import type { ChatStats, ShowStatsMode } from "@/types/chat";
|
||||||
import { baseUrl } from "@/api/baseUrl";
|
|
||||||
|
|
||||||
// Assistant output can carry prompt-injected URLs, and the browser fetches
|
|
||||||
// images without a click, so only same-origin images render.
|
|
||||||
function isSameOrigin(url: string | undefined): boolean {
|
|
||||||
if (!url) return false;
|
|
||||||
try {
|
|
||||||
return new URL(url, baseUrl).origin === window.location.origin;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageBubbleProps = {
|
type MessageBubbleProps = {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
@@ -235,23 +223,6 @@ export function MessageBubble({
|
|||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
img: ({ node: _n, src, alt, ...props }) =>
|
|
||||||
typeof src === "string" && isSameOrigin(src) ? (
|
|
||||||
<img src={src} alt={alt} {...props} />
|
|
||||||
) : (
|
|
||||||
<>{alt}</>
|
|
||||||
),
|
|
||||||
a: ({ node: _n, href, ...props }) =>
|
|
||||||
isSameOrigin(href) ? (
|
|
||||||
<a href={href} {...props} />
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { WidgetProps } from "@rjsf/utils";
|
import type { WidgetProps } from "@rjsf/utils";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import get from "lodash/get";
|
|
||||||
import isEqual from "lodash/isEqual";
|
|
||||||
import { useMemo, useState, type FocusEvent } from "react";
|
import { useMemo, useState, type FocusEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||||
@@ -23,7 +21,6 @@ type RawPathsResponse = {
|
|||||||
ffmpeg?: {
|
ffmpeg?: {
|
||||||
inputs?: Array<{
|
inputs?: Array<{
|
||||||
path?: string;
|
path?: string;
|
||||||
roles?: string[];
|
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -69,35 +66,17 @@ export function CameraPathWidget(props: WidgetProps) {
|
|||||||
shouldFetchRawPaths ? "config/raw_paths" : null,
|
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(() => {
|
const rawPath = useMemo(() => {
|
||||||
if (!cameraName || !isMaskedPath(rawValue)) {
|
if (!cameraName || inputIndex === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let matches = (
|
const path =
|
||||||
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []
|
rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs?.[inputIndex]?.path;
|
||||||
).filter(
|
return typeof path === "string" ? path : undefined;
|
||||||
(input): input is { path: string; roles?: string[] } =>
|
}, [cameraName, inputIndex, rawPaths]);
|
||||||
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 =
|
const resolvedValue =
|
||||||
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");
|
||||||
const canReveal =
|
const canReveal =
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function VideoPreview({
|
|||||||
}, [playerRef]);
|
}, [playerRef]);
|
||||||
|
|
||||||
const onProgressHover = useCallback(
|
const onProgressHover = useCallback(
|
||||||
(event: React.MouseEvent<HTMLElement>) => {
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!sliderRef.current) {
|
if (!sliderRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ export function InProgressPreview({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onProgressHover = useCallback(
|
const onProgressHover = useCallback(
|
||||||
(event: React.MouseEvent<HTMLElement>) => {
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!sliderRef.current || !previewFrames) {
|
if (!sliderRef.current || !previewFrames) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,40 @@
|
|||||||
// Mirrors clean_camera_user_pass and REGEX_MASKED_CREDENTIAL in the backend.
|
const MASKED_AUTH_PATTERN = /:\/\/\*:\*@/i;
|
||||||
const CREDENTIAL_QUERY_KEYS =
|
const MASKED_QUERY_PATTERN = /(?:[?&])user=\*&password=\*/i;
|
||||||
"(?: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 =>
|
export const isMaskedPath = (value: string): boolean =>
|
||||||
MASKED_PATTERN.test(value);
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const maskCredentials = (value: string): string => {
|
export const maskCredentials = (value: string): string => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
const maskedAuth = value.replace(/:\/\/[^:@/\s]+:[^@/\s]*@/g, "://*:*@");
|
||||||
.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 =>
|
return maskedAuth
|
||||||
!!value && (isMaskedPath(value) || maskCredentials(value) !== value);
|
.replace(/([?&]user=)[^&]*/gi, "$1*")
|
||||||
|
.replace(/([?&]password=)[^&]*/gi, "$1*");
|
||||||
|
};
|
||||||
|
|||||||
@@ -892,20 +892,6 @@ export default function MotionSearchView({
|
|||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = axios.isAxiosError(error)
|
|
||||||
? error.response?.status
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (status === 409 || status === 429) {
|
|
||||||
toast.error(
|
|
||||||
t(
|
|
||||||
status === 409 ? "errors.alreadyRunning" : "errors.tooManySearches",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setIsSearching(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let errorMessage = t("errors.unknown");
|
let errorMessage = t("errors.unknown");
|
||||||
|
|
||||||
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
if (axios.isAxiosError<{ message?: string; detail?: string }>(error)) {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
"/live": {
|
"/live": {
|
||||||
target: `ws://${proxyHost}`,
|
target: `ws://${proxyHost}`,
|
||||||
changeOrigin: true,
|
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user