From 3ce3217db2ba53c3158db1fb57e04af6e805d330 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Sun, 23 Aug 2026 11:08:32 -0500
Subject: [PATCH] Add secrets.yaml and unify variable substitution sources
(#24044)
* add secrets.yaml and merge substitution sources by precedence
FRIGATE_ENV_VARS was built once at import from container env and /run/secrets, and the environment_vars validator overwrote it unconditionally, so the block beat the deployment and nothing could be re-read. Sources are now separate dicts merged lowest to highest (environment_vars, secrets.yaml, container env, credentials directory), re-read at the top of every parse, and a collision warns once naming the winner. An undefined {FRIGATE_*} raises a ValueError subclass so pydantic reports the field instead of a KeyError traceback.
* use the shared substitution namespace in go2rtc config
The generator rebuilt the namespace itself from os.environ and a hardcoded /run/secrets, so it never saw environment_vars or CREDENTIALS_DIRECTORY, and str.format made any stray brace fatal. It now installs the FRIGATE_ names from environment_vars and substitutes streams the same way every other field does.
* read the exec override from an import time snapshot
environment_vars is exported into os.environ, and is_go2rtc_arbitrary_exec_allowed read os.environ live, so the config file could enable exec sources. Snapshot the variable at import, which runs before any config is loaded.
* docs
* clarify docs
---
.../rootfs/usr/local/go2rtc/create_config.py | 34 +-
docs/docs/configuration/advanced/system.md | 67 +++-
docs/docs/configuration/config.md | 2 +-
docs/docs/configuration/restream.md | 2 +-
docs/docs/frigate/installation.md | 2 +
frigate/api/camera.py | 4 +-
frigate/config/config.py | 5 +-
frigate/config/env.py | 210 +++++++++++-
.../test/http_api/test_http_camera_access.py | 16 +-
frigate/test/test_env.py | 313 +++++++++++++++++-
frigate/util/services.py | 9 +-
11 files changed, 604 insertions(+), 60 deletions(-)
diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py
index 70cb744f13..0dee057ff5 100644
--- a/docker/main/rootfs/usr/local/go2rtc/create_config.py
+++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py
@@ -3,13 +3,12 @@
import json
import os
import sys
-from pathlib import Path
from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
-from frigate.config.env import substitute_frigate_vars
+from frigate.config.env import apply_config_env_vars, substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
LIBAVFORMAT_VERSION_MAJOR,
@@ -25,15 +24,6 @@ sys.path.remove("/opt/frigate")
yaml = YAML()
-FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
-# read docker secret files as env vars too
-if os.path.isdir("/run/secrets"):
- for secret_file in os.listdir("/run/secrets"):
- if secret_file.startswith("FRIGATE_"):
- FRIGATE_ENV_VARS[secret_file] = (
- Path(os.path.join("/run/secrets", secret_file)).read_text().strip()
- )
-
config_file = find_config_file()
try:
@@ -47,6 +37,20 @@ 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.
+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_")
+ }
+ if isinstance(config_env_vars, dict)
+ else {}
+)
+
go2rtc_config: dict[str, Any] = config.get("go2rtc", {})
# Need to enable CORS for go2rtc so the frigate integration / card work automatically
@@ -113,7 +117,7 @@ for name in list(go2rtc_config.get("streams", {})):
if isinstance(stream, str):
try:
- formatted_stream = stream.format(**FRIGATE_ENV_VARS)
+ formatted_stream = substitute_frigate_vars(stream)
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@@ -122,7 +126,7 @@ for name in list(go2rtc_config.get("streams", {})):
del go2rtc_config["streams"][name]
continue
go2rtc_config["streams"][name] = formatted_stream
- except KeyError as e:
+ except ValueError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
@@ -132,7 +136,7 @@ for name in list(go2rtc_config.get("streams", {})):
filtered_streams = []
for i, stream_item in enumerate(stream):
try:
- formatted_stream = stream_item.format(**FRIGATE_ENV_VARS)
+ formatted_stream = substitute_frigate_vars(stream_item)
if is_restricted_go2rtc_source(formatted_stream):
print(
f"[ERROR] Stream '{name}' item {i + 1} uses a restricted source (echo/expr/exec) which is disabled by default for security. "
@@ -141,7 +145,7 @@ for name in list(go2rtc_config.get("streams", {})):
continue
filtered_streams.append(formatted_stream)
- except KeyError as e:
+ except ValueError as e:
print(
"[ERROR] Invalid substitution found, see https://docs.frigate.video/configuration/restream#advanced-restream-configurations for more info."
)
diff --git a/docs/docs/configuration/advanced/system.md b/docs/docs/configuration/advanced/system.md
index d891e0f172..a1d097437e 100644
--- a/docs/docs/configuration/advanced/system.md
+++ b/docs/docs/configuration/advanced/system.md
@@ -63,15 +63,9 @@ go2rtc:
### `environment_vars`
-This section can be used to set environment variables for those unable to modify the environment of the container, like within Home Assistant OS. Docker users should set environment variables in their `docker run` command (`-e FRIGATE_MQTT_PASSWORD=secret`) or `docker-compose.yml` file (`environment:` section) instead. Note that values set here are stored in plain text in your config file, so if the goal is to keep credentials out of your configuration, use Docker environment variables or Docker secrets instead.
+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.
-Variables prefixed with `FRIGATE_` can be referenced in config fields that support environment variable substitution (such as MQTT host and credentials, camera stream URLs, and ONVIF host and credentials) using the `{FRIGATE_VARIABLE_NAME}` syntax.
-
-:::note
-
-The `go2rtc` section is an exception. go2rtc runs as a separate process, so its stream definitions can only be substituted with variables that exist in the container's environment (set via Docker `-e`, the `environment:` section of `docker-compose.yml`, or Docker secrets). Variables defined in the `environment_vars` block above are not available to go2rtc streams. Home Assistant app users, who cannot set container environment variables, must instead put credentials directly in their go2rtc stream URLs.
-
-:::
+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.
@@ -80,23 +74,17 @@ Navigate to to add
| Field | Description |
| ----------------- | --------------------------------------------------------- |
-| **Variable name** | The environment variable name (e.g., `FRIGATE_MQTT_USER`) |
+| **Variable name** | The environment variable name (e.g., `LIBVA_DRIVER_NAME`) |
| **Value** | The value for the variable |
-Variables defined here can be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
+Names prefixed with `FRIGATE_` can also be referenced elsewhere in your configuration using the `{FRIGATE_VARIABLE_NAME}` syntax.
```yaml
environment_vars:
- FRIGATE_MQTT_USER: my_mqtt_user
- FRIGATE_MQTT_PASSWORD: my_mqtt_password
-
-mqtt:
- host: "{FRIGATE_MQTT_HOST}"
- user: "{FRIGATE_MQTT_USER}"
- password: "{FRIGATE_MQTT_PASSWORD}"
+ LIBVA_DRIVER_NAME: i965
```
@@ -130,6 +118,51 @@ environment_vars:
+### `secrets.yaml`
+
+A `secrets.yaml` file next to your `config.yml` is an additional source of `FRIGATE_` variables, for installs that can't set container environment variables or mount Docker secrets. It's a flat map of names to values, and it is never read or written by the Frigate UI:
+
+```yaml
+FRIGATE_CAM_USER: viewer
+FRIGATE_CAM_PASS: "p@ss w0rd"
+FRIGATE_MQTT_HOST: mqtt.internal.example
+```
+
+For Docker this is `/config/secrets.yaml` inside the container, so it lives in whatever host directory you mounted at `/config`. For the Home Assistant App it's `/addon_configs//secrets.yaml`, in the same folder as your `config.yml`; see [the App config directory](../config.md#accessing-app-config-dir) for the directory name for your variant.
+
+Names must start with `FRIGATE_`, and nesting is not supported. `secrets.yaml` feeds `{FRIGATE_VARIABLE_NAME}` substitution, so the handful of variables Frigate reads straight from the process environment, such as `FRIGATE_JWT_SECRET`, still need a container environment variable or a Docker secret.
+
+### Substitution sources and precedence
+
+The same `{FRIGATE_VARIABLE_NAME}` placeholder resolves from four sources. When a name is defined in more than one, the higher one wins and a warning at startup names which source was used.
+
+| Priority | Source | Where it's set | Who can use it |
+| ----------- | --------------------- | -------------------------------------------------------------------------- | ------------------------------ |
+| 1 (highest) | Docker secrets | Files in `/run/secrets`, or the directory named by `CREDENTIALS_DIRECTORY` | Docker, systemd |
+| 2 | Container environment | `docker run -e`, the `environment:` section of `docker-compose.yml` | Docker |
+| 3 | `secrets.yaml` | Next to `config.yml`, see above | Everyone, including the HA App |
+| 4 (lowest) | `environment_vars` | The block in `config.yml` described above | Everyone, including the HA App |
+
+For example, with this `secrets.yaml`:
+
+```yaml
+FRIGATE_MQTT_PASSWORD: from_secrets
+```
+
+and this `config.yml`:
+
+```yaml
+environment_vars:
+ FRIGATE_MQTT_PASSWORD: from_config
+
+mqtt:
+ password: "{FRIGATE_MQTT_PASSWORD}"
+```
+
+the password resolves to `from_secrets`, and the log shows `FRIGATE_MQTT_PASSWORD is defined in more than one place, using the value from secrets.yaml`. Add `-e FRIGATE_MQTT_PASSWORD=from_env` to the container and it resolves to `from_env` instead.
+
+Referencing a name that no source defines is a config validation error naming the field.
+
### `database`
Tracked object and recording information is managed in a sqlite database at `/config/frigate.db`. If that database is deleted, recordings will be orphaned and will need to be cleaned up manually. They also won't show up in the Media Browser within Home Assistant.
diff --git a/docs/docs/configuration/config.md b/docs/docs/configuration/config.md
index 5d9eb7ef6b..e8add4208f 100644
--- a/docs/docs/configuration/config.md
+++ b/docs/docs/configuration/config.md
@@ -100,7 +100,7 @@ VS Code supports JSON schemas for automatically validating configuration files.
## Environment Variable Substitution
-Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). For example, the following values can be replaced at runtime by using environment variables:
+Frigate supports the use of environment variables starting with `FRIGATE_` **only** where specifically indicated in the [reference config](./advanced/reference.md). See [substitution sources and precedence](./advanced/system.md#substitution-sources-and-precedence) for where those values can come from, including `secrets.yaml`. For example, the following values can be replaced at runtime by using environment variables:
```yaml
mqtt:
diff --git a/docs/docs/configuration/restream.md b/docs/docs/configuration/restream.md
index 1ae96b64cc..76d7411352 100644
--- a/docs/docs/configuration/restream.md
+++ b/docs/docs/configuration/restream.md
@@ -221,7 +221,7 @@ For security reasons, the `echo:`, `expr:`, and `exec:` stream sources are disab
If you attempt to use these sources in your configuration, the streams will be removed and an error message will be printed in the logs.
-To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment:
+To enable these sources, you must set the environment variable `GO2RTC_ALLOW_ARBITRARY_EXEC=true`. This can be done in your Docker Compose file or container environment, or for Home Assistant App users with the `go2rtc_allow_arbitrary_exec` option in the App's configuration. The `environment_vars` section of the Frigate config can't enable it:
```yaml
environment:
diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md
index 14c85ec4f3..3e1b4869c2 100644
--- a/docs/docs/frigate/installation.md
+++ b/docs/docs/frigate/installation.md
@@ -612,6 +612,8 @@ Home Assistant OS users can install via the App repository.
5. Start the App
6. Use the _Open Web UI_ button to access the Frigate UI, then click in the _cog icon_ > _Configuration editor_ and configure Frigate to your liking
+App users who can't set container environment variables can put `FRIGATE_` values in a `secrets.yaml` next to `config.yml` in `/addon_configs/` instead. See [`secrets.yaml`](../configuration/advanced/system.md#secretsyaml).
+
There are several variants of the App available:
| App Variant | Description |
diff --git a/frigate/api/camera.py b/frigate/api/camera.py
index f4a844164c..6425d1a992 100644
--- a/frigate/api/camera.py
+++ b/frigate/api/camera.py
@@ -33,7 +33,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
-from frigate.config.env import substitute_frigate_vars
+from frigate.config.env import UnknownVariableError, substitute_frigate_vars
from frigate.models import User
from frigate.util.builtin import clean_camera_user_pass, get_record_segment_time
from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files
@@ -166,7 +166,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""):
if src:
try:
resolved_src = substitute_frigate_vars(src)
- except KeyError:
+ except UnknownVariableError:
resolved_src = src
if is_restricted_go2rtc_source(resolved_src):
diff --git a/frigate/config/config.py b/frigate/config/config.py
index a003e138cc..e0c3af9993 100644
--- a/frigate/config/config.py
+++ b/frigate/config/config.py
@@ -63,7 +63,7 @@ from .classification import (
SemanticSearchModelEnum,
)
from .database import DatabaseConfig
-from .env import EnvVars
+from .env import EnvVars, reload_sources
from .logger import LoggerConfig
from .mqtt import MqttConfig
from .network import NetworkingConfig
@@ -1307,6 +1307,9 @@ class FrigateConfig(FrigateBaseModel):
@classmethod
def parse(cls, config, *, is_json=None, safe_load=False, **context):
+ # Pick up secrets.yaml edits without a restart.
+ reload_sources()
+
# If config is a file, read its contents.
if hasattr(config, "read"):
fname = getattr(config, "name", None)
diff --git a/frigate/config/env.py b/frigate/config/env.py
index 209dda67bf..8ad2baf646 100644
--- a/frigate/config/env.py
+++ b/frigate/config/env.py
@@ -1,20 +1,193 @@
+"""Environment variable and secrets handling for the Frigate config."""
+
+import logging
import os
import re
+from collections.abc import Mapping
from pathlib import Path
-from typing import Annotated
+from typing import Annotated, Any
from pydantic import AfterValidator, ValidationInfo
+from ruamel.yaml import YAML, YAMLError
-FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
-secrets_dir = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
-# read secret files as env vars too
-if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK):
- for secret_file in os.listdir(secrets_dir):
- if secret_file.startswith("FRIGATE_"):
- FRIGATE_ENV_VARS[secret_file] = (
- Path(os.path.join(secrets_dir, secret_file)).read_text().strip()
+from frigate.const import CONFIG_DIR
+
+logger = logging.getLogger(__name__)
+
+
+class UnknownVariableError(ValueError):
+ """Undefined {FRIGATE_*} placeholder. ValueError so pydantic names the field."""
+
+
+# Substitution sources, lowest precedence first.
+_CONFIG_ENV_VARS: dict[str, str] = {}
+_SECRETS_FILE: dict[str, str] = {}
+# Snapshot: apply_config_env_vars() writes os.environ after import.
+_CONTAINER_ENV: dict[str, str] = {
+ k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")
+}
+_CREDENTIALS_DIR: dict[str, str] = {}
+
+_SOURCES: tuple[tuple[str, dict[str, str]], ...] = (
+ ("environment_vars config block", _CONFIG_ENV_VARS),
+ ("secrets.yaml", _SECRETS_FILE),
+ ("container environment", _CONTAINER_ENV),
+ ("credentials directory", _CREDENTIALS_DIR),
+)
+
+FRIGATE_ENV_VARS: dict[str, str] = {}
+
+_WARNED_COLLISIONS: set[str] = set()
+
+
+def _rebuild(warn: bool = True) -> None:
+ """Merge the sources into FRIGATE_ENV_VARS.
+
+ warn=False is for the import-time call, before logging is configured.
+ """
+ merged: dict[str, str] = {}
+ origin: dict[str, str] = {}
+ duplicated: set[str] = set()
+
+ for label, source in _SOURCES:
+ for key, value in source.items():
+ if key in merged and merged[key] != value:
+ duplicated.add(key)
+
+ merged[key] = value
+ origin[key] = label
+
+ if warn:
+ for key in sorted(duplicated - _WARNED_COLLISIONS):
+ _WARNED_COLLISIONS.add(key)
+ logger.warning(
+ "%s is defined in more than one place, using the value from %s",
+ key,
+ origin[key],
)
+ # In place: tests hold a reference to this dict.
+ FRIGATE_ENV_VARS.clear()
+ FRIGATE_ENV_VARS.update(merged)
+
+
+def _load_credentials_dir() -> dict[str, str]:
+ """Read FRIGATE_* files from the Docker or systemd credentials directory."""
+ directory = os.environ.get("CREDENTIALS_DIRECTORY", "/run/secrets")
+ values: dict[str, str] = {}
+
+ if not (os.path.isdir(directory) and os.access(directory, os.R_OK)):
+ return values
+
+ for name in os.listdir(directory):
+ if not name.startswith("FRIGATE_"):
+ continue
+
+ try:
+ values[name] = Path(os.path.join(directory, name)).read_text().strip()
+ except (OSError, UnicodeDecodeError):
+ logger.warning("Unable to read %s in %s, skipping", name, directory)
+
+ return values
+
+
+def _secrets_file_path() -> str | None:
+ """Locate secrets.yaml next to the config file."""
+ config_file = os.environ.get("CONFIG_FILE")
+ config_dir = os.path.dirname(config_file) if config_file else CONFIG_DIR
+
+ for name in ("secrets.yaml", "secrets.yml"):
+ path = os.path.join(config_dir, name)
+
+ if os.path.isfile(path):
+ return path
+
+ return None
+
+
+def _load_secrets_file() -> dict[str, str]:
+ """Read the flat FRIGATE_* map from secrets.yaml, if it exists."""
+ path = _secrets_file_path()
+
+ if path is None:
+ return {}
+
+ try:
+ with open(path) as f:
+ raw: Any = YAML(typ="safe").load(f)
+ except OSError as err:
+ raise ValueError(f"Unable to read {path}: {err.strerror}") from err
+ except YAMLError as err:
+ # The parser message can quote values, so only name a position.
+ mark = getattr(err, "problem_mark", None)
+ where = f" near line {mark.line + 1}" if mark is not None else ""
+ raise ValueError(f"{path} is not valid YAML{where}") from err
+
+ if raw is None:
+ return {}
+
+ if not isinstance(raw, dict):
+ raise ValueError(f"{path} must be a flat map of names to values")
+
+ values: dict[str, str] = {}
+
+ for key, value in raw.items():
+ name = str(key)
+
+ if isinstance(value, (dict, list)):
+ raise ValueError(f"{path} value for {name} must be a single value")
+
+ if not name.startswith("FRIGATE_"):
+ logger.warning(
+ "Ignoring %s in %s, names must start with FRIGATE_", name, path
+ )
+ continue
+
+ values[name] = "" if value is None else str(value)
+
+ return values
+
+
+def reload_sources(warn: bool = True) -> None:
+ """Re-read the file backed sources and rebuild the namespace."""
+ _CREDENTIALS_DIR.clear()
+ _CREDENTIALS_DIR.update(_load_credentials_dir())
+
+ try:
+ secrets = _load_secrets_file()
+ except ValueError as err:
+ # Keep the last good values; this runs at import and on every parse.
+ logger.error("Ignoring secrets file, %s", err)
+ else:
+ _SECRETS_FILE.clear()
+ _SECRETS_FILE.update(secrets)
+
+ _rebuild(warn)
+
+
+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.
+ """
+ for key, value in values.items():
+ resolved = str(value)
+
+ if key.startswith("FRIGATE_"):
+ _CONFIG_ENV_VARS[key] = resolved
+ else:
+ os.environ[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]
+
+
+reload_sources(warn=False)
+
# Matches a FRIGATE_* identifier following an opening brace.
_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+")
@@ -29,12 +202,13 @@ def substitute_frigate_vars(value: str) -> str:
* `{{` and `}}` collapse to literal `{` / `}` (the documented escape).
* `{FRIGATE_NAME}` is replaced from `FRIGATE_ENV_VARS`; an unknown name
- raises `KeyError` to preserve the existing "Invalid substitution"
- error path.
+ raises `UnknownVariableError` to preserve the existing "Invalid
+ substitution" error path.
* A `{` that begins `{FRIGATE_` but is not a well-formed
`{FRIGATE_NAME}` placeholder raises `ValueError` (malformed
- placeholder). Callers that catch `KeyError` to allow unknown-var
- passthrough will still surface malformed syntax as an error.
+ placeholder). Callers that catch `UnknownVariableError` to allow
+ unknown-var passthrough will still surface malformed syntax as an
+ error.
* Any other `{` or `}` is treated as a literal and passed through.
"""
out: list[str] = []
@@ -58,7 +232,10 @@ def substitute_frigate_vars(value: str) -> str:
):
key = ident_match.group(0)
if key not in FRIGATE_ENV_VARS:
- raise KeyError(key)
+ raise UnknownVariableError(
+ f"{key} is not defined in the environment, "
+ "secrets.yaml, or the environment_vars config"
+ )
out.append(FRIGATE_ENV_VARS[key])
i = ident_match.end() + 1
continue
@@ -94,10 +271,7 @@ EnvString = Annotated[str, AfterValidator(validate_env_string)]
def validate_env_vars(v: dict[str, str], info: ValidationInfo) -> dict[str, str]:
if isinstance(info.context, dict) and info.context.get("install", False):
- for k, val in v.items():
- os.environ[k] = val
- if k.startswith("FRIGATE_"):
- FRIGATE_ENV_VARS[k] = val
+ apply_config_env_vars(v)
return v
diff --git a/frigate/test/http_api/test_http_camera_access.py b/frigate/test/http_api/test_http_camera_access.py
index 4c74792c14..4c8b6f5582 100644
--- a/frigate/test/http_api/test_http_camera_access.py
+++ b/frigate/test/http_api/test_http_camera_access.py
@@ -390,7 +390,7 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
intent and forward the request to go2rtc instead of short-circuiting with 400."""
app = self._make_app(_MULTI_CAMERA_CONFIG)
mock_response = type("R", (), {"ok": True, "status_code": 200, "text": "ok"})()
- with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
+ with patch("frigate.util.services._GO2RTC_ARBITRARY_EXEC_ENV", "true"):
with patch(
"frigate.api.camera.requests.put", return_value=mock_response
) as mock_put:
@@ -403,6 +403,20 @@ class TestGo2rtcStreamAccess(BaseTestHttp):
forwarded_src = mock_put.call_args.kwargs["params"]["src"]
assert forwarded_src == "exec:/tmp/something"
+ def test_add_stream_ignores_override_written_after_import(self):
+ """The override is read once at import. A value written into os.environ
+ later, which is what the config's environment_vars block does, must not
+ unlock restricted sources."""
+ app = self._make_app(_MULTI_CAMERA_CONFIG)
+ with patch.dict(os.environ, {"GO2RTC_ALLOW_ARBITRARY_EXEC": "true"}):
+ with patch("frigate.api.camera.requests.put") as mock_put:
+ with AuthTestClient(app) as client:
+ resp = client.put("/go2rtc/streams/legit?src=exec:/tmp/something")
+ # A live go2rtc would also answer 400, so assert on the forward.
+ mock_put.assert_not_called()
+ assert resp.status_code == 400
+ assert resp.json().get("success") is False
+
def test_stream_alias_blocked_when_owning_camera_disallowed(self):
"""limited_user cannot access a stream alias that belongs to a camera they
are not allowed to see."""
diff --git a/frigate/test/test_env.py b/frigate/test/test_env.py
index 37b81a6564..66143c03ee 100644
--- a/frigate/test/test_env.py
+++ b/frigate/test/test_env.py
@@ -1,9 +1,13 @@
"""Tests for environment variable handling."""
import os
+import tempfile
import unittest
from unittest.mock import MagicMock, patch
+from pydantic import ValidationError
+
+from frigate.config import FrigateConfig, env
from frigate.config.env import (
FRIGATE_ENV_VARS,
validate_env_string,
@@ -105,9 +109,10 @@ class TestEnvString(unittest.TestCase):
self.assertEqual(result, "192.168.1.1")
def test_unknown_var_raises(self):
- """Referencing an unknown var raises KeyError."""
- with self.assertRaises(KeyError):
+ """Referencing an unknown var raises UnknownVariableError."""
+ with self.assertRaises(env.UnknownVariableError) as ctx:
validate_env_string("{FRIGATE_NONEXISTENT_VAR}")
+ self.assertIn("FRIGATE_NONEXISTENT_VAR", str(ctx.exception))
def test_non_frigate_braces_passthrough(self):
"""Braces that are not {FRIGATE_*} placeholders pass through untouched.
@@ -176,6 +181,18 @@ class TestEnvString(unittest.TestCase):
validate_env_string("{FRIGATE_FOO!r}")
+class TestUnknownVariableSurfacing(unittest.TestCase):
+ """An undefined variable must reach the user as a config validation error."""
+
+ def test_unknown_var_is_a_validation_error(self):
+ """Pydantic reports the field path instead of raising KeyError."""
+ with self.assertRaises(ValidationError) as ctx:
+ FrigateConfig.parse_object(
+ {"mqtt": {"host": "{FRIGATE_NOT_SET_ANYWHERE}"}, "cameras": {}}
+ )
+ self.assertIn("FRIGATE_NOT_SET_ANYWHERE", str(ctx.exception))
+
+
class TestEnvVars(unittest.TestCase):
def setUp(self):
self._original_env_vars = dict(FRIGATE_ENV_VARS)
@@ -184,6 +201,7 @@ class TestEnvVars(unittest.TestCase):
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:
@@ -233,5 +251,296 @@ class TestEnvVars(unittest.TestCase):
self.assertEqual(result, "mqtt.local")
+class TestVariableSources(unittest.TestCase):
+ """Precedence between the sources that feed FRIGATE_ENV_VARS."""
+
+ def setUp(self):
+ self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
+ self._original_os_environ = dict(os.environ)
+
+ def tearDown(self):
+ env.FRIGATE_ENV_VARS.clear()
+ env.FRIGATE_ENV_VARS.update(self._original_env_vars)
+ env._CONFIG_ENV_VARS.clear()
+ env._WARNED_COLLISIONS.clear()
+ os.environ.clear()
+ os.environ.update(self._original_os_environ)
+
+ def test_container_env_beats_config_env_vars(self):
+ """A container env var wins over the same key in environment_vars."""
+ with patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}):
+ env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_MQTT_HOST"], "from_env")
+
+ def test_credentials_dir_beats_container_env(self):
+ """A credentials directory file wins over a container env var."""
+ with (
+ patch.dict(env._CONTAINER_ENV, {"FRIGATE_MQTT_HOST": "from_env"}),
+ patch.dict(env._CREDENTIALS_DIR, {"FRIGATE_MQTT_HOST": "from_creds"}),
+ ):
+ env._rebuild()
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_MQTT_HOST"], "from_creds")
+
+ def test_config_env_vars_used_when_no_other_source(self):
+ """environment_vars still resolves when nothing else defines the key."""
+ env.apply_config_env_vars({"FRIGATE_CAM_PASS": "hunter2"})
+ 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."""
+ env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
+ self.assertEqual(os.environ["FRIGATE_CAM_PASS"], "from_config")
+ 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"})
+ self.assertEqual(os.environ["LIBVA_DRIVER_NAME"], "i965")
+ self.assertNotIn("LIBVA_DRIVER_NAME", env.FRIGATE_ENV_VARS)
+
+ 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"}):
+ with self.assertLogs("frigate.config.env", level="WARNING") as logs:
+ env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
+ self.assertEqual(len(logs.output), 1)
+ self.assertIn("FRIGATE_MQTT_HOST", logs.output[0])
+ self.assertIn("using the value from container environment", logs.output[0])
+ self.assertNotIn("environment_vars", logs.output[0])
+
+ with self.assertNoLogs("frigate.config.env", level="WARNING"):
+ env._rebuild()
+
+
+class TestSecretsFile(unittest.TestCase):
+ """Reading /secrets.yaml."""
+
+ def setUp(self):
+ self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
+ self._original_os_environ = dict(os.environ)
+ self._dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self._dir.cleanup)
+ os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
+
+ def tearDown(self):
+ env.FRIGATE_ENV_VARS.clear()
+ env.FRIGATE_ENV_VARS.update(self._original_env_vars)
+ env._SECRETS_FILE.clear()
+ env._CONFIG_ENV_VARS.clear()
+ env._WARNED_COLLISIONS.clear()
+ os.environ.clear()
+ os.environ.update(self._original_os_environ)
+
+ def _write(self, contents: str, name: str = "secrets.yaml") -> None:
+ with open(os.path.join(self._dir.name, name), "w") as f:
+ f.write(contents)
+
+ def test_missing_file_is_not_an_error(self):
+ """No secrets.yaml means no values and no exception."""
+ self.assertEqual(env._load_secrets_file(), {})
+
+ def test_flat_map_is_read(self):
+ """A flat FRIGATE_* map loads."""
+ self._write("FRIGATE_CAM_USER: viewer\nFRIGATE_CAM_PASS: 'p@ss w0rd'\n")
+ self.assertEqual(
+ env._load_secrets_file(),
+ {"FRIGATE_CAM_USER": "viewer", "FRIGATE_CAM_PASS": "p@ss w0rd"},
+ )
+
+ def test_yml_extension_is_read(self):
+ """secrets.yml works the same as secrets.yaml."""
+ self._write("FRIGATE_CAM_USER: viewer\n", name="secrets.yml")
+ self.assertEqual(env._load_secrets_file(), {"FRIGATE_CAM_USER": "viewer"})
+
+ def test_numeric_value_is_coerced_to_string(self):
+ """Unquoted numbers become strings so they can be substituted."""
+ self._write("FRIGATE_MQTT_PORT: 1883\n")
+ self.assertEqual(env._load_secrets_file(), {"FRIGATE_MQTT_PORT": "1883"})
+
+ def test_unprefixed_key_is_ignored_with_a_warning(self):
+ """Names must start with FRIGATE_, matching the credentials directory."""
+ self._write("cam_pass: hunter2\nFRIGATE_CAM_PASS: hunter2\n")
+ with self.assertLogs("frigate.config.env", level="WARNING") as logs:
+ values = env._load_secrets_file()
+ self.assertEqual(values, {"FRIGATE_CAM_PASS": "hunter2"})
+ self.assertIn("cam_pass", logs.output[0])
+
+ def test_non_mapping_document_raises(self):
+ """A list or scalar document is a config error."""
+ self._write("- FRIGATE_CAM_PASS\n")
+ with self.assertRaises(ValueError):
+ env._load_secrets_file()
+
+ def test_nested_value_raises_naming_the_key(self):
+ """Nesting is not supported and the error names the key."""
+ self._write("FRIGATE_CAMS:\n alley: hunter2\n")
+ with self.assertRaises(ValueError) as ctx:
+ env._load_secrets_file()
+ self.assertIn("FRIGATE_CAMS", str(ctx.exception))
+
+ def test_secrets_file_beats_config_env_vars(self):
+ """secrets.yaml outranks the environment_vars block."""
+ self._write("FRIGATE_CAM_PASS: from_secrets\n")
+ env.apply_config_env_vars({"FRIGATE_CAM_PASS": "from_config"})
+ env._SECRETS_FILE.update(env._load_secrets_file())
+ env._rebuild()
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "from_secrets")
+
+ def test_container_env_beats_secrets_file(self):
+ """The container environment outranks secrets.yaml."""
+ self._write("FRIGATE_CAM_PASS: from_secrets\n")
+ env._SECRETS_FILE.update(env._load_secrets_file())
+ with patch.dict(env._CONTAINER_ENV, {"FRIGATE_CAM_PASS": "from_env"}):
+ env._rebuild()
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "from_env")
+
+
+class TestSecretsReload(unittest.TestCase):
+ """secrets.yaml is re-read when a config is parsed."""
+
+ def setUp(self):
+ self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
+ self._original_os_environ = dict(os.environ)
+ self._dir = tempfile.TemporaryDirectory()
+ self.addCleanup(self._dir.cleanup)
+ os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
+
+ def tearDown(self):
+ env.FRIGATE_ENV_VARS.clear()
+ env.FRIGATE_ENV_VARS.update(self._original_env_vars)
+ env._SECRETS_FILE.clear()
+ env._CONFIG_ENV_VARS.clear()
+ env._WARNED_COLLISIONS.clear()
+ os.environ.clear()
+ os.environ.update(self._original_os_environ)
+ env.reload_sources()
+
+ def test_new_secret_resolves_without_restart(self):
+ """A key written after import is picked up by the next parse."""
+ with open(os.path.join(self._dir.name, "secrets.yaml"), "w") as f:
+ f.write("FRIGATE_MQTT_HOST: mqtt.internal\n")
+
+ config = FrigateConfig.parse_yaml(
+ 'mqtt:\n host: "{FRIGATE_MQTT_HOST}"\ncameras: {}\n'
+ )
+ self.assertEqual(config.mqtt.host, "mqtt.internal")
+
+ def test_config_env_vars_survive_the_reload(self):
+ """environment_vars is only installed when install=True, so it has to
+ outlive the reload that a later non-install parse triggers. This is
+ the /config/save path: a config that starts fine must still validate.
+ """
+ env.apply_config_env_vars({"FRIGATE_MQTT_HOST": "from_config"})
+ config = FrigateConfig.parse_yaml(
+ 'mqtt:\n host: "{FRIGATE_MQTT_HOST}"\ncameras: {}\n'
+ )
+ self.assertEqual(config.mqtt.host, "from_config")
+ self.assertEqual(env._CONFIG_ENV_VARS["FRIGATE_MQTT_HOST"], "from_config")
+
+
+class TestSourceRobustness(unittest.TestCase):
+ """Reload behavior, bad input, and the os.environ export."""
+
+ def setUp(self):
+ self._original_env_vars = dict(env.FRIGATE_ENV_VARS)
+ self._original_os_environ = dict(os.environ)
+ self._dir = tempfile.TemporaryDirectory()
+ self._creds = tempfile.TemporaryDirectory()
+ self.addCleanup(self._dir.cleanup)
+ self.addCleanup(self._creds.cleanup)
+ os.environ["CONFIG_FILE"] = os.path.join(self._dir.name, "config.yml")
+ os.environ["CREDENTIALS_DIRECTORY"] = self._creds.name
+
+ def tearDown(self):
+ env.FRIGATE_ENV_VARS.clear()
+ env.FRIGATE_ENV_VARS.update(self._original_env_vars)
+ env._SECRETS_FILE.clear()
+ env._CONFIG_ENV_VARS.clear()
+ env._CREDENTIALS_DIR.clear()
+ env._WARNED_COLLISIONS.clear()
+ os.environ.clear()
+ os.environ.update(self._original_os_environ)
+ env.reload_sources()
+
+ def _write_secrets(self, contents: str) -> None:
+ with open(os.path.join(self._dir.name, "secrets.yaml"), "w") as f:
+ f.write(contents)
+
+ def test_os_environ_gets_the_winning_value(self):
+ """FRIGATE_JWT_SECRET and friends are read straight from os.environ."""
+ self._write_secrets("FRIGATE_JWT_SECRET: from_secrets\n")
+ env.reload_sources()
+ env.apply_config_env_vars({"FRIGATE_JWT_SECRET": "from_config"})
+ self.assertEqual(os.environ["FRIGATE_JWT_SECRET"], "from_secrets")
+ self.assertEqual(
+ os.environ["FRIGATE_JWT_SECRET"],
+ env.FRIGATE_ENV_VARS["FRIGATE_JWT_SECRET"],
+ )
+
+ def test_rebuild_without_warn_stays_quiet_then_warns_later(self):
+ """The import-time rebuild must not consume the one-shot warning."""
+ self._write_secrets("FRIGATE_DUPE: from_secrets\n")
+ env.reload_sources()
+ env._CONFIG_ENV_VARS["FRIGATE_DUPE"] = "from_config"
+
+ with self.assertNoLogs("frigate.config.env", level="WARNING"):
+ env._rebuild(warn=False)
+
+ with self.assertLogs("frigate.config.env", level="WARNING") as logs:
+ env._rebuild()
+ self.assertIn("FRIGATE_DUPE", logs.output[0])
+
+ def test_malformed_secrets_file_keeps_last_good_values(self):
+ """A typo must not raise, since this runs at import and on every parse."""
+ self._write_secrets("FRIGATE_CAM_PASS: hunter2\n")
+ env.reload_sources()
+
+ self._write_secrets("FRIGATE_CAMS:\n alley: hunter2\n")
+ with self.assertLogs("frigate.config.env", level="ERROR") as logs:
+ env.reload_sources()
+
+ self.assertIn("FRIGATE_CAMS", logs.output[0])
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CAM_PASS"], "hunter2")
+
+ def test_duplicate_key_error_does_not_log_the_values(self):
+ """ruamel's duplicate key message quotes both values; the log must not."""
+ self._write_secrets("FRIGATE_CAM_PASS: hunter2\nFRIGATE_CAM_PASS: hunter3\n")
+ with self.assertLogs("frigate.config.env", level="ERROR") as logs:
+ env.reload_sources()
+
+ self.assertNotIn("hunter2", logs.output[0])
+ self.assertNotIn("hunter3", logs.output[0])
+ self.assertIn("secrets.yaml", logs.output[0])
+
+ def test_deleted_secret_stops_resolving(self):
+ """Removing a name takes effect on the next parse, not on restart."""
+ self._write_secrets("FRIGATE_GONE: hunter2\n")
+ env.reload_sources()
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_GONE"], "hunter2")
+
+ os.remove(os.path.join(self._dir.name, "secrets.yaml"))
+ env.reload_sources()
+ self.assertNotIn("FRIGATE_GONE", env.FRIGATE_ENV_VARS)
+
+ def test_reload_rereads_the_credentials_directory(self):
+ """The credentials directory is refreshed, not just secrets.yaml."""
+ with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
+ f.write("from_creds\n")
+ env.reload_sources()
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CRED"], "from_creds")
+
+ def test_unreadable_credentials_entry_is_skipped(self):
+ """A subdirectory must not take down validation on every parse."""
+ os.mkdir(os.path.join(self._creds.name, "FRIGATE_NOT_A_FILE"))
+ with open(os.path.join(self._creds.name, "FRIGATE_CRED"), "w") as f:
+ f.write("from_creds\n")
+
+ with self.assertLogs("frigate.config.env", level="WARNING") as logs:
+ env.reload_sources()
+
+ self.assertIn("FRIGATE_NOT_A_FILE", logs.output[0])
+ self.assertEqual(env.FRIGATE_ENV_VARS["FRIGATE_CRED"], "from_creds")
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/frigate/util/services.py b/frigate/util/services.py
index aa5427eb5a..ef5cd15d17 100644
--- a/frigate/util/services.py
+++ b/frigate/util/services.py
@@ -965,12 +965,17 @@ def get_hailo_temps() -> dict[str, float]:
return temps
+# Snapshot: environment_vars lands in os.environ after import and must not
+# be able to enable this.
+_GO2RTC_ARBITRARY_EXEC_ENV = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
+
+
def is_go2rtc_arbitrary_exec_allowed() -> bool:
"""Read the GO2RTC_ALLOW_ARBITRARY_EXEC override from env, docker
secrets, or the Home Assistant add-on options file."""
raw: str | None = None
- if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
- raw = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
+ if _GO2RTC_ARBITRARY_EXEC_ENV is not None:
+ raw = _GO2RTC_ARBITRARY_EXEC_ENV
elif (
os.path.isdir("/run/secrets")
and os.access("/run/secrets", os.R_OK)