mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-07 22:23:00 +03:00
Compare commits
4
Commits
dev
...
config-exec-gate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e0a025c6 | ||
|
|
40c27fb04f | ||
|
|
97696d0cbe | ||
|
|
aa37641d7b |
@@ -40,16 +40,10 @@ try:
|
||||
except FileNotFoundError:
|
||||
config: dict[str, Any] = {}
|
||||
|
||||
# No validator runs here, so install environment_vars ourselves. FRIGATE_
|
||||
# names only: anything else lands in os.environ, where the exec gate reads
|
||||
# GO2RTC_ALLOW_ARBITRARY_EXEC.
|
||||
# No validator runs here, so install environment_vars ourselves.
|
||||
config_env_vars = config.get("environment_vars")
|
||||
apply_config_env_vars(
|
||||
{
|
||||
key: value
|
||||
for key, value in config_env_vars.items()
|
||||
if str(key).startswith("FRIGATE_")
|
||||
}
|
||||
{str(key): value for key, value in config_env_vars.items()}
|
||||
if isinstance(config_env_vars, dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ logger:
|
||||
|
||||
# Optional: set environment variables
|
||||
environment_vars:
|
||||
EXAMPLE_VAR: value
|
||||
LIBVA_DRIVER_NAME: i965
|
||||
|
||||
# Optional: birdseye configuration
|
||||
# NOTE: Can (enabled, mode) be overridden at the camera level
|
||||
|
||||
@@ -65,7 +65,16 @@ go2rtc:
|
||||
|
||||
This section sets environment variables in the Frigate process for those unable to modify the environment of the container, like within Home Assistant OS. It's meant for process settings such as `LIBVA_DRIVER_NAME` or the TensorFlow thread counts below. Docker users should set environment variables in their `docker run` command (`-e LIBVA_DRIVER_NAME=i965`) or `docker-compose.yml` file (`environment:` section) instead. Values set here are stored in plain text in your config file, so credentials belong in `secrets.yaml`, Docker environment variables, or Docker secrets instead.
|
||||
|
||||
Names prefixed with `FRIGATE_` set here also take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them.
|
||||
Besides `FRIGATE_` names, only these are accepted, and a variable already set in the container environment keeps its container value:
|
||||
|
||||
- `LIBVA_DRIVER_NAME` and `HSA_OVERRIDE_GFX_VERSION`
|
||||
- `TF_INTRA_OP_PARALLELISM_THREADS`, `TF_INTER_OP_PARALLELISM_THREADS`, `TF_DATASET_THREAD_POOL_SIZE`, and `SHM_MAX_FRAMES`
|
||||
- `USE_FP16`, `HF_HUB_OFFLINE`, and `TRANSFORMERS_OFFLINE`
|
||||
- `HF_ENDPOINT`, `GITHUB_ENDPOINT`, `GITHUB_RAW_ENDPOINT`, and `TF_KERAS_MOBILENET_V2_WEIGHTS_URL` (an `http://` or `https://` URL)
|
||||
|
||||
Any other name is ignored with a warning and must be set in the container environment.
|
||||
|
||||
Names prefixed with `FRIGATE_` set here take part in `{FRIGATE_VARIABLE_NAME}` substitution (see [below](#substitution-sources-and-precedence)), but `secrets.yaml` is the better home for them. Of these, only `FRIGATE_JWT_SECRET` is also set in the process environment.
|
||||
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
@@ -627,6 +627,7 @@ def _restricted_go2rtc_response(
|
||||
Go2rtcStreamIssue.invalid_placeholder,
|
||||
"Invalid variable placeholder in go2rtc stream",
|
||||
),
|
||||
(Go2rtcStreamIssue.invalid_name, "Invalid go2rtc stream name"),
|
||||
):
|
||||
names = [name for name, found in issues.items() if found == issue]
|
||||
if names:
|
||||
|
||||
+14
-1
@@ -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.models import User
|
||||
from frigate.notices import raise_notice
|
||||
from frigate.util.live_streams import is_transcode_stream_name
|
||||
from frigate.util.origin import origin_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -739,6 +740,13 @@ def auth(request: Request):
|
||||
int(request.headers.get("x-server-port", default=0))
|
||||
== request.app.auth_internal_port
|
||||
):
|
||||
# go2rtc runs the producer an unknown src describes for any caller
|
||||
deny_status = deny_response_for_go2rtc_stream(
|
||||
request.headers.get("x-original-url"), None, request
|
||||
)
|
||||
if deny_status is not None:
|
||||
return Response("", status_code=deny_status)
|
||||
|
||||
success_response.headers["remote-user"] = "anonymous"
|
||||
success_response.headers["remote-role"] = "admin"
|
||||
return success_response
|
||||
@@ -1275,7 +1283,12 @@ def _is_existing_go2rtc_stream(request: Request, stream_name: str) -> bool:
|
||||
"""
|
||||
frigate_config = request.app.frigate_config
|
||||
|
||||
if _get_stream_owner_cameras(request, stream_name):
|
||||
# live.streams values are free-form config, so only names Frigate controls
|
||||
# skip the lookup
|
||||
if any(
|
||||
stream_name == camera or is_transcode_stream_name(camera, stream_name)
|
||||
for camera in frigate_config.cameras
|
||||
):
|
||||
return True
|
||||
|
||||
config_streams = getattr(frigate_config.go2rtc, "streams", None) or {}
|
||||
|
||||
@@ -39,6 +39,7 @@ from frigate.util.image import create_mask
|
||||
from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
is_transcode_stream_name,
|
||||
is_valid_go2rtc_stream_name,
|
||||
transcode_stream_name,
|
||||
transcode_streams,
|
||||
)
|
||||
@@ -314,6 +315,11 @@ def apply_live_transcode_streams(
|
||||
f"Camera {camera_config.name} has transcoded streams enabled, but its source {transcode.source} is not a go2rtc stream."
|
||||
)
|
||||
|
||||
if not is_valid_go2rtc_stream_name(transcode.source):
|
||||
raise ValueError(
|
||||
f"Camera {camera_config.name} transcodes go2rtc stream {transcode.source}, whose name contains '#', whitespace or quotes."
|
||||
)
|
||||
|
||||
generated = transcode_streams(
|
||||
camera_config.name,
|
||||
transcode.source,
|
||||
|
||||
@@ -5,6 +5,7 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import requests
|
||||
import ruamel.yaml
|
||||
from fastapi import Request
|
||||
|
||||
@@ -234,6 +235,72 @@ class TestAuthInternalPort(BaseTestHttp):
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
@patch(
|
||||
"frigate.api.auth.requests.get",
|
||||
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||
)
|
||||
def test_internal_port_rejects_unknown_go2rtc_src(self, _mock_get):
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for url in (
|
||||
"/live/mse/api/ws?src=exec:id",
|
||||
"/live/webrtc/api/ws?src=ffmpeg:x%23raw=-f",
|
||||
"/api/go2rtc/webrtc?src=front_door&src=unknown",
|
||||
):
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={"x-server-port": "5000", "x-original-url": url},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403, url)
|
||||
|
||||
def test_internal_port_allows_existing_go2rtc_stream(self):
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "5000",
|
||||
"x-original-url": "/live/mse/api/ws?src=front_door",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 202)
|
||||
self.assertEqual(resp.headers["remote-role"], "admin")
|
||||
|
||||
def test_internal_port_skips_go2rtc_check_for_other_paths(self):
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
for url in ("/api/stats", "/api/go2rtc/streams?src=unknown"):
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={"x-server-port": "5000", "x-original-url": url},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 202, url)
|
||||
|
||||
@patch(
|
||||
"frigate.api.auth.requests.get",
|
||||
side_effect=requests.ConnectionError("go2rtc unavailable"),
|
||||
)
|
||||
def test_live_stream_value_is_not_trusted_as_a_go2rtc_stream(self, _mock_get):
|
||||
src = "ffmpeg:x#raw=-f"
|
||||
self.minimal_config["cameras"]["front_door"]["live"] = {
|
||||
"streams": {"Main": src}
|
||||
}
|
||||
app = self._create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
resp = client.get(
|
||||
"/auth",
|
||||
headers={
|
||||
"x-server-port": "5000",
|
||||
"x-original-url": "/live/mse/api/ws?src=ffmpeg:x%23raw=-f",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -151,6 +151,19 @@ class TestConfigGo2rtcStreamRestrictions(BaseTestHttp):
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _EXISTING_SRC in self._read_config()
|
||||
|
||||
def test_set_rejects_stream_name_with_ffmpeg_params(self):
|
||||
before = self._read_config()
|
||||
resp = self._set_streams(
|
||||
{"x#raw=-f#raw=mpegts#raw=/tmp/x": "rtsp://10.0.0.2:554/video"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid go2rtc stream name" in resp.json()["message"]
|
||||
assert self._read_config() == before
|
||||
|
||||
def test_set_allows_plain_stream_names(self):
|
||||
resp = self._set_streams({"back-yard.main_2": "rtsp://10.0.0.2:554/video"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
def test_set_allows_injected_source_when_override_set(self):
|
||||
with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
|
||||
resp = self._set_streams({"new_stream": [_INJECTED_SRC]})
|
||||
|
||||
+81
-15
@@ -194,15 +194,16 @@ class TestEnvVars(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._original_env_vars = dict(FRIGATE_ENV_VARS)
|
||||
self._original_environ = os.environ.copy()
|
||||
names_patch = patch.object(env, "_CONTAINER_ENV_NAMES", frozenset())
|
||||
names_patch.start()
|
||||
self.addCleanup(names_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
FRIGATE_ENV_VARS.clear()
|
||||
FRIGATE_ENV_VARS.update(self._original_env_vars)
|
||||
env._CONFIG_ENV_VARS.clear()
|
||||
# Clean up any env vars we set
|
||||
for key in list(os.environ.keys()):
|
||||
if key not in self._original_environ:
|
||||
del os.environ[key]
|
||||
os.environ.clear()
|
||||
os.environ.update(self._original_environ)
|
||||
|
||||
def _make_context(self, install: bool):
|
||||
"""Create a mock ValidationInfo with the given install flag."""
|
||||
@@ -215,10 +216,10 @@ class TestEnvVars(unittest.TestCase):
|
||||
return mock
|
||||
|
||||
def test_install_sets_os_environ(self):
|
||||
"""validate_env_vars with install=True sets os.environ."""
|
||||
"""validate_env_vars with install=True sets allowed names in os.environ."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"MY_CUSTOM_VAR": "value123"}, ctx)
|
||||
self.assertEqual(os.environ.get("MY_CUSTOM_VAR"), "value123")
|
||||
validate_env_vars({"SHM_MAX_FRAMES": "75"}, ctx)
|
||||
self.assertEqual(os.environ.get("SHM_MAX_FRAMES"), "75")
|
||||
|
||||
def test_install_updates_frigate_env_vars(self):
|
||||
"""validate_env_vars with install=True updates FRIGATE_ENV_VARS for FRIGATE_ keys."""
|
||||
@@ -229,9 +230,9 @@ class TestEnvVars(unittest.TestCase):
|
||||
def test_install_skips_non_frigate_in_env_vars_dict(self):
|
||||
"""Non-FRIGATE_ keys are set in os.environ but not in FRIGATE_ENV_VARS."""
|
||||
ctx = self._make_context(install=True)
|
||||
validate_env_vars({"OTHER_VAR": "value"}, ctx)
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
self.assertNotIn("OTHER_VAR", FRIGATE_ENV_VARS)
|
||||
validate_env_vars({"USE_FP16": "False"}, ctx)
|
||||
self.assertEqual(os.environ.get("USE_FP16"), "False")
|
||||
self.assertNotIn("USE_FP16", FRIGATE_ENV_VARS)
|
||||
|
||||
def test_no_install_does_not_set(self):
|
||||
"""validate_env_vars without install=True does not modify state."""
|
||||
@@ -258,10 +259,10 @@ class TestEnvVars(unittest.TestCase):
|
||||
ctx = self._make_context(install=True)
|
||||
with patch("frigate.util.services.os.path.isfile", return_value=False):
|
||||
for key in ("GO2RTC_ALLOW_ARBITRARY_EXEC", "go2rtc_allow_arbitrary_exec"):
|
||||
validate_env_vars({key: "true", "OTHER_VAR": "value"}, ctx)
|
||||
validate_env_vars({key: "true", "USE_FP16": "False"}, ctx)
|
||||
self.assertNotIn(key, os.environ)
|
||||
self.assertFalse(is_go2rtc_arbitrary_exec_allowed())
|
||||
self.assertEqual(os.environ.get("OTHER_VAR"), "value")
|
||||
self.assertEqual(os.environ.get("USE_FP16"), "False")
|
||||
|
||||
def test_install_keeps_container_go2rtc_exec_override(self):
|
||||
"""An override set in the container environment is left untouched."""
|
||||
@@ -307,17 +308,82 @@ class TestVariableSources(unittest.TestCase):
|
||||
self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
|
||||
|
||||
def test_config_env_vars_do_not_become_container_env(self):
|
||||
"""environment_vars writes os.environ but must not gain env precedence."""
|
||||
"""environment_vars FRIGATE_ keys stay out of os.environ and env precedence."""
|
||||
env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
|
||||
self.assertEqual(os.environ["FRIGATE_CAM_PASS"], "from_config")
|
||||
self.assertNotIn("FRIGATE_CAM_PASS", os.environ)
|
||||
self.assertNotIn("FRIGATE_CAM_PASS", env._CONTAINER_ENV)
|
||||
|
||||
def test_non_frigate_config_env_vars_only_set_os_environ(self):
|
||||
"""Unprefixed environment_vars keys reach os.environ but not substitution."""
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
|
||||
self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
|
||||
|
||||
def test_rejected_names_warn_and_are_not_exported(self):
|
||||
"""Names off the allowlist log a warning without the value."""
|
||||
for key in ("MY_CUSTOM_VAR", "LD_PRELOAD", "LIGHTER_ANE_EP", "PATH"):
|
||||
with self.subTest(key=key):
|
||||
original = os.environ.get(key)
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
env.apply_config_env_vars({key: "/config/evil.so"})
|
||||
self.assertEqual(os.environ.get(key), original)
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn(key, logs.output[0])
|
||||
self.assertIn("container environment", logs.output[0])
|
||||
self.assertNotIn("evil", logs.output[0])
|
||||
|
||||
def test_allowed_name_with_bad_value_is_rejected(self):
|
||||
"""LIBVA_DRIVER_NAME cannot carry a path."""
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
with self.assertLogs("frigate.util.env", level="WARNING"):
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "../x"})
|
||||
self.assertNotEqual(os.environ.get("LIBVA_DRIVER_NAME"), "../x")
|
||||
|
||||
def test_unsupported_values_are_rejected_without_crashing(self):
|
||||
"""Non-ASCII digits and NUL bytes never reach os.environ."""
|
||||
os.environ.pop("SHM_MAX_FRAMES", None)
|
||||
os.environ.pop("HF_ENDPOINT", None)
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
with self.assertLogs("frigate.util.env", level="WARNING") as logs:
|
||||
env.apply_config_env_vars(
|
||||
{"SHM_MAX_FRAMES": "\u0663\u0664", "HF_ENDPOINT": "https://x\x00"}
|
||||
)
|
||||
self.assertEqual(len(logs.output), 2)
|
||||
self.assertNotIn("SHM_MAX_FRAMES", os.environ)
|
||||
self.assertNotIn("HF_ENDPOINT", os.environ)
|
||||
|
||||
def test_container_env_wins_over_allowed_name(self):
|
||||
"""A name the container set is left as the container set it."""
|
||||
os.environ["LIBVA_DRIVER_NAME"] = "iHD"
|
||||
with patch.object(
|
||||
env, "_CONTAINER_ENV_NAMES", frozenset({"LIBVA_DRIVER_NAME"})
|
||||
):
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "iHD")
|
||||
|
||||
def test_second_apply_is_not_blocked_by_first(self):
|
||||
"""A safe-mode reload can change a value the first load exported."""
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "i965"})
|
||||
env.apply_config_env_vars({"LIBVA_DRIVER_NAME": "radeonsi"})
|
||||
self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "radeonsi")
|
||||
|
||||
def test_run_as_root_is_substitutable_but_not_exported(self):
|
||||
"""FRIGATE_RUN_AS_ROOT from the config never reaches os.environ."""
|
||||
os.environ.pop("FRIGATE_RUN_AS_ROOT", None)
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
env.apply_config_env_vars({"FRIGATE_RUN_AS_ROOT": "true"})
|
||||
self.assertNotIn("FRIGATE_RUN_AS_ROOT", os.environ)
|
||||
self.assertEqual(env.substitute_frigate_vars("{FRIGATE_RUN_AS_ROOT}"), "true")
|
||||
|
||||
def test_jwt_secret_is_exported(self):
|
||||
"""Auth reads FRIGATE_JWT_SECRET from os.environ."""
|
||||
os.environ.pop("FRIGATE_JWT_SECRET", None)
|
||||
with patch.object(env, "_CONTAINER_ENV_NAMES", frozenset()):
|
||||
env.apply_config_env_vars({"FRIGATE_JWT_SECRET": "s3cret"})
|
||||
self.assertEqual(os.environ["FRIGATE_JWT_SECRET"], "s3cret")
|
||||
|
||||
def test_collision_warns_once_naming_the_winner(self):
|
||||
"""A key from two sources logs one warning naming the source that won."""
|
||||
with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
|
||||
|
||||
@@ -23,7 +23,12 @@ _CONFIG = {
|
||||
"dual_user": ["front_door", "back_door"],
|
||||
}
|
||||
},
|
||||
"go2rtc": {"streams": {"doorbell_audio": ["rtsp://10.0.0.4:554/audio"]}},
|
||||
"go2rtc": {
|
||||
"streams": {
|
||||
"doorbell_audio": ["rtsp://10.0.0.4:554/audio"],
|
||||
"front_door_sub": ["rtsp://10.0.0.1:554/sub"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
|
||||
@@ -12,6 +12,7 @@ from frigate.util.live_streams import (
|
||||
default_transcode_source,
|
||||
generated_transcode_streams,
|
||||
is_transcode_stream_name,
|
||||
is_valid_go2rtc_stream_name,
|
||||
measure_stream_bitrate,
|
||||
raw_transcode_streams,
|
||||
sync_transcode_streams,
|
||||
@@ -37,6 +38,13 @@ class TestTranscodeNaming(unittest.TestCase):
|
||||
"#raw=-b:v#raw=500k#raw=-maxrate#raw=500k#raw=-bufsize#raw=1000k",
|
||||
)
|
||||
|
||||
def test_valid_go2rtc_stream_names(self):
|
||||
for name in ("front", "front_sub", "back-yard.main_2", "Haustür"):
|
||||
self.assertTrue(is_valid_go2rtc_stream_name(name), name)
|
||||
|
||||
for name in ("x#raw=-f", "front door", "front\tdoor", "a'b", 'a"b', "a${X}"):
|
||||
self.assertFalse(is_valid_go2rtc_stream_name(name), name)
|
||||
|
||||
def test_default_source_skips_transcoded_streams(self):
|
||||
streams = {"Low": "front_transcode_360p", "Main": "front_main"}
|
||||
self.assertEqual(default_transcode_source("front", streams), "front_main")
|
||||
@@ -121,6 +129,22 @@ class TestRawTranscodeStreams(unittest.TestCase):
|
||||
|
||||
self.assertEqual(list(raw_transcode_streams(config)), ["front_transcode_360p"])
|
||||
|
||||
def test_unsafe_source_name_is_skipped(self):
|
||||
config = {
|
||||
"cameras": {
|
||||
"front": {
|
||||
"live": {
|
||||
"transcode": {"enabled": True, "source": "x#raw=-f#raw=null"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(raw_transcode_streams(config), {})
|
||||
|
||||
config["cameras"]["front"]["live"]["transcode"]["source"] = "front_main"
|
||||
self.assertEqual(len(raw_transcode_streams(config)), 3)
|
||||
|
||||
def test_disabled_or_missing_generates_nothing(self):
|
||||
self.assertEqual(raw_transcode_streams({}), {})
|
||||
self.assertEqual(
|
||||
@@ -286,6 +310,17 @@ class TestLiveTranscodeConfig(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams({"transcode": {"enabled": True, "source": "missing"}})
|
||||
|
||||
def test_source_name_with_ffmpeg_params_fails(self):
|
||||
name = "x#raw=-f#raw=null"
|
||||
with self.assertRaisesRegex(ValidationError, "contains '#'"):
|
||||
self.streams(
|
||||
{"transcode": {"enabled": True, "source": name}},
|
||||
go2rtc={
|
||||
"front": ["rtsp://10.0.0.1:554/main"],
|
||||
name: ["rtsp://10.0.0.1:554/main"],
|
||||
},
|
||||
)
|
||||
|
||||
def test_duplicate_heights_fail(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.streams(
|
||||
|
||||
+59
-15
@@ -26,6 +26,8 @@ _SECRETS_FILE: dict[str, str] = {}
|
||||
_CONTAINER_ENV: dict[str, str] = {
|
||||
k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
|
||||
}
|
||||
# Empty image defaults, like ROCm's HSA_OVERRIDE_GFX_VERSION, don't count as set.
|
||||
_CONTAINER_ENV_NAMES = frozenset(k for k, v in os.environ.items() if v)
|
||||
_CREDENTIALS_DIR: dict[str, str] = {}
|
||||
|
||||
_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
|
||||
@@ -165,38 +167,80 @@ def reload_sources(warn: bool = True) -> None:
|
||||
_rebuild(warn)
|
||||
|
||||
|
||||
# Security overrides that must come from the container environment, which is
|
||||
# controlled by the operator, rather than from the config, which admins can edit
|
||||
_PROTECTED_ENV_VARS = frozenset({"GO2RTC_ALLOW_ARBITRARY_EXEC"})
|
||||
_INT = re.compile(r"\d+", re.ASCII)
|
||||
_WORD = re.compile(r"\w+", re.ASCII)
|
||||
_URL = re.compile(r"https?://\S+")
|
||||
|
||||
# The only unprefixed names the config may export, with the values each accepts.
|
||||
# Anything else, such as LD_PRELOAD, can load code into Frigate or its children.
|
||||
_ALLOWED_ENV_VARS: dict[str, re.Pattern[str]] = {
|
||||
"LIBVA_DRIVER_NAME": re.compile(r"[A-Za-z0-9_]+"),
|
||||
"TF_INTRA_OP_PARALLELISM_THREADS": _INT,
|
||||
"TF_INTER_OP_PARALLELISM_THREADS": _INT,
|
||||
"TF_DATASET_THREAD_POOL_SIZE": _INT,
|
||||
"SHM_MAX_FRAMES": _INT,
|
||||
"USE_FP16": _WORD,
|
||||
"HF_HUB_OFFLINE": _WORD,
|
||||
"TRANSFORMERS_OFFLINE": _WORD,
|
||||
"HSA_OVERRIDE_GFX_VERSION": re.compile(r"[0-9.]+"),
|
||||
"HF_ENDPOINT": _URL,
|
||||
"GITHUB_ENDPOINT": _URL,
|
||||
"GITHUB_RAW_ENDPOINT": _URL,
|
||||
"TF_KERAS_MOBILENET_V2_WEIGHTS_URL": _URL,
|
||||
}
|
||||
|
||||
# Auth reads it from os.environ; every other FRIGATE_ name only feeds substitution.
|
||||
_EXPORTED_FRIGATE_VARS = frozenset({"FRIGATE_JWT_SECRET"})
|
||||
|
||||
|
||||
def apply_config_env_vars(values: Mapping[str, object]) -> None:
|
||||
"""Install the environment_vars block as the lowest priority source.
|
||||
|
||||
Unprefixed keys only set os.environ. Protected security overrides are
|
||||
skipped.
|
||||
FRIGATE_ keys feed substitution. Allowlisted unprefixed keys with valid
|
||||
values are exported to os.environ. A name the container already set is
|
||||
never overwritten.
|
||||
"""
|
||||
exports: dict[str, str] = {}
|
||||
|
||||
for key, value in values.items():
|
||||
if key.upper() in _PROTECTED_ENV_VARS:
|
||||
resolved = str(value)
|
||||
|
||||
if key.startswith("FRIGATE_"):
|
||||
_CONFIG_ENV_VARS[key] = resolved
|
||||
continue
|
||||
|
||||
pattern = _ALLOWED_ENV_VARS.get(key)
|
||||
|
||||
if pattern is None:
|
||||
logger.warning(
|
||||
"Ignoring %s in environment_vars, it must be set in the container environment",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved = str(value)
|
||||
if not pattern.fullmatch(resolved):
|
||||
logger.warning("Ignoring %s in environment_vars, unsupported value", key)
|
||||
continue
|
||||
|
||||
if key.startswith("FRIGATE_"):
|
||||
_CONFIG_ENV_VARS[key] = resolved
|
||||
else:
|
||||
os.environ[key] = resolved
|
||||
exports[key] = resolved
|
||||
|
||||
_rebuild()
|
||||
|
||||
# Export the winning value; auth reads FRIGATE_JWT_SECRET from os.environ.
|
||||
for key in values:
|
||||
if key.startswith("FRIGATE_"):
|
||||
os.environ[key] = FRIGATE_ENV_VARS[key]
|
||||
for key in _EXPORTED_FRIGATE_VARS & values.keys():
|
||||
exports[key] = FRIGATE_ENV_VARS[key]
|
||||
|
||||
for key, value in exports.items():
|
||||
if key in _CONTAINER_ENV_NAMES:
|
||||
logger.info(
|
||||
"Ignoring %s in environment_vars, the container environment sets it",
|
||||
key,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
os.environ[key] = value
|
||||
except ValueError:
|
||||
logger.warning("Ignoring %s in environment_vars, unsupported value", key)
|
||||
|
||||
|
||||
reload_sources(warn=False)
|
||||
|
||||
@@ -31,6 +31,16 @@ def is_transcode_stream_name(camera: str, name: str) -> bool:
|
||||
return re.fullmatch(rf"{re.escape(camera)}_transcode_\d+p", name) is not None
|
||||
|
||||
|
||||
def is_valid_go2rtc_stream_name(name: str) -> bool:
|
||||
"""Return whether a stream name is safe to splice into a go2rtc source.
|
||||
|
||||
go2rtc splits sources on `#` and ffmpeg commands on whitespace, and expands
|
||||
`${VAR}` at load, so a name with any of these, or with quotes, could add its
|
||||
own parameters.
|
||||
"""
|
||||
return re.search(r"[\s#$'\"]", name) is None
|
||||
|
||||
|
||||
def transcode_stream_source(source: str, height: int, bitrate: int) -> str:
|
||||
"""Build the go2rtc ffmpeg source that transcodes a stream to H.264.
|
||||
|
||||
@@ -101,7 +111,9 @@ def raw_transcode_streams(config: dict[str, Any]) -> dict[str, str]:
|
||||
camera, live_streams or {camera: camera}
|
||||
)
|
||||
|
||||
if source is None:
|
||||
# an unsafe name fails Frigate's validation; skipping it here keeps
|
||||
# it out of go2rtc
|
||||
if source is None or not is_valid_go2rtc_stream_name(str(source)):
|
||||
continue
|
||||
|
||||
qualities = transcode.get("qualities")
|
||||
|
||||
@@ -31,6 +31,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.util.builtin import clean_camera_user_pass, escape_special_characters
|
||||
from frigate.util.env import UnknownVariableError, substitute_frigate_vars
|
||||
from frigate.util.live_streams import is_valid_go2rtc_stream_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1177,6 +1178,7 @@ class Go2rtcStreamIssue(str, Enum):
|
||||
restricted = "restricted"
|
||||
unknown_variable = "unknown_variable"
|
||||
invalid_placeholder = "invalid_placeholder"
|
||||
invalid_name = "invalid_name"
|
||||
|
||||
|
||||
def find_restricted_go2rtc_stream_changes(
|
||||
@@ -1188,12 +1190,17 @@ def find_restricted_go2rtc_stream_changes(
|
||||
in the saved config for the same stream is left alone, so streams the
|
||||
operator configured on disk keep working; any other source must resolve
|
||||
its {FRIGATE_*} placeholders and pass `is_restricted_go2rtc_api_source`.
|
||||
New stream names must pass `is_valid_go2rtc_stream_name`.
|
||||
"""
|
||||
old_streams = old_streams if isinstance(old_streams, dict) else {}
|
||||
new_streams = new_streams if isinstance(new_streams, dict) else {}
|
||||
issues: dict[str, Go2rtcStreamIssue] = {}
|
||||
|
||||
for name, stream in new_streams.items():
|
||||
if name not in old_streams and not is_valid_go2rtc_stream_name(str(name)):
|
||||
issues[name] = Go2rtcStreamIssue.invalid_name
|
||||
continue
|
||||
|
||||
existing = _go2rtc_stream_sources(old_streams.get(name))
|
||||
|
||||
for source in _go2rtc_stream_sources(stream):
|
||||
|
||||
Reference in New Issue
Block a user