diff --git a/frigate/api/app.py b/frigate/api/app.py index 0f7d375d95..00a7b8b5ef 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -73,6 +73,7 @@ from frigate.util.config import ( ) from frigate.util.schema import get_config_schema from frigate.util.services import ( + Go2rtcStreamIssue, find_restricted_go2rtc_stream_changes, get_nvidia_driver_info, process_logs, @@ -556,19 +557,26 @@ def _restricted_go2rtc_streams_response( # with no readable saved config, every source is treated as new old_streams = None - restricted = find_restricted_go2rtc_stream_changes(old_streams, new_streams) - if not restricted: + issues = find_restricted_go2rtc_stream_changes(old_streams, new_streams) + if not issues: return None - logger.warning( - "Rejected config change with restricted go2rtc stream sources: %s", - ", ".join(restricted), - ) + messages = [] + for issue, label in ( + (Go2rtcStreamIssue.restricted, "Restricted go2rtc stream source in"), + (Go2rtcStreamIssue.unknown_variable, "Unknown variable in go2rtc stream"), + ( + Go2rtcStreamIssue.invalid_placeholder, + "Invalid variable placeholder in go2rtc stream", + ), + ): + names = [name for name, found in issues.items() if found == issue] + if names: + messages.append(f"{label}: {', '.join(names)}") + + logger.warning("Rejected config change: %s", "; ".join(messages)) return JSONResponse( - content={ - "success": False, - "message": f"Restricted go2rtc stream source in: {', '.join(restricted)}", - }, + content={"success": False, "message": "; ".join(messages)}, status_code=400, ) diff --git a/frigate/test/http_api/test_http_config_go2rtc_streams.py b/frigate/test/http_api/test_http_config_go2rtc_streams.py index 823bd8e460..c7d95bad82 100644 --- a/frigate/test/http_api/test_http_config_go2rtc_streams.py +++ b/frigate/test/http_api/test_http_config_go2rtc_streams.py @@ -177,15 +177,38 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp): def test_set_rejects_unresolvable_placeholder(self): before = self._read_config() with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): - for src in ( - "{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn", - "{FRIGATE_RTSP_USER:>5}", - "rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video", + for src, message in ( + ( + "{FRIGATE_RTSP_USER!r}fmpeg:front_door#raw=-vn", + "Invalid variable placeholder in go2rtc stream: new_stream", + ), + ( + "{FRIGATE_RTSP_USER:>5}", + "Invalid variable placeholder in go2rtc stream: new_stream", + ), + ( + "rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video", + "Unknown variable in go2rtc stream: new_stream", + ), ): resp = self._set_streams({"new_stream": [src]}) assert resp.status_code == 400, src + assert resp.json()["message"] == message, src assert self._read_config() == before + def test_set_reports_each_issue(self): + resp = self._set_streams( + { + "typo_stream": ["rtsp://{FRIGATE_UNDEFINED}@10.0.0.2:554/video"], + "injected_stream": [_INJECTED_SRC], + } + ) + assert resp.status_code == 400 + assert resp.json()["message"] == ( + "Restricted go2rtc stream source in: injected_stream; " + "Unknown variable in go2rtc stream: typo_stream" + ) + def test_set_allows_resolvable_placeholder(self): src = "rtsp://{FRIGATE_RTSP_USER}@10.0.0.2:554/video" with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_RTSP_USER": "user"}): diff --git a/frigate/util/services.py b/frigate/util/services.py index d46c6257d9..6ae83aa639 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -12,6 +12,7 @@ import subprocess as sp import time import traceback from datetime import datetime +from enum import Enum from typing import Any import cv2 @@ -1043,22 +1044,30 @@ def _go2rtc_stream_sources(stream: Any) -> list[Any]: return stream if isinstance(stream, list) else [stream] +class Go2rtcStreamIssue(str, Enum): + """Why a go2rtc stream source was rejected.""" + + restricted = "restricted" + unknown_variable = "unknown_variable" + invalid_placeholder = "invalid_placeholder" + + def find_restricted_go2rtc_stream_changes( old_streams: dict[str, Any] | None, new_streams: dict[str, Any] | None -) -> list[str]: - """Return the names of go2rtc streams with new restricted sources. +) -> dict[str, Go2rtcStreamIssue]: + """Return the go2rtc streams with new sources that must be rejected. Used when the config is changed through the API. A source that is already in the saved config for the same stream is left alone, so streams the - operator configured on disk keep working; any other source must pass - `is_restricted_go2rtc_api_source`. + operator configured on disk keep working; any other source must resolve + its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`. """ # imported here since frigate.config imports this module from frigate.config.env import substitute_frigate_vars old_streams = old_streams if isinstance(old_streams, dict) else {} new_streams = new_streams if isinstance(new_streams, dict) else {} - restricted = [] + issues: dict[str, Go2rtcStreamIssue] = {} for name, stream in new_streams.items(): existing = _go2rtc_stream_sources(old_streams.get(name)) @@ -1071,24 +1080,27 @@ def find_restricted_go2rtc_stream_changes( # the map form is only usable with arbitrary exec enabled if is_go2rtc_arbitrary_exec_allowed(): continue - restricted.append(name) + issues[name] = Go2rtcStreamIssue.restricted break - # go2rtc startup resolves sources with the same substitution, and - # refuses to start on one it cannot resolve + # only sources whose placeholders resolve here are accepted, so the + # resolved value checked below is what go2rtc loads at startup try: resolved_source = substitute_frigate_vars(source) - except (KeyError, ValueError): - restricted.append(name) + except KeyError: + issues[name] = Go2rtcStreamIssue.unknown_variable + break + except ValueError: + issues[name] = Go2rtcStreamIssue.invalid_placeholder break if is_restricted_go2rtc_api_source( source ) or is_restricted_go2rtc_api_source(resolved_source): - restricted.append(name) + issues[name] = Go2rtcStreamIssue.restricted break - return restricted + return issues def ffprobe_stream(ffmpeg, path: str, detailed: bool = False) -> sp.CompletedProcess: