diff --git a/docker/main/rootfs/usr/local/go2rtc/create_config.py b/docker/main/rootfs/usr/local/go2rtc/create_config.py index fb701a9b6..71897be0a 100644 --- a/docker/main/rootfs/usr/local/go2rtc/create_config.py +++ b/docker/main/rootfs/usr/local/go2rtc/create_config.py @@ -9,6 +9,7 @@ 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.const import ( BIRDSEYE_PIPE, DEFAULT_FFMPEG_VERSION, @@ -47,14 +48,6 @@ ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str( allow_arbitrary_exec ).lower() in ("true", "1", "yes") -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() @@ -103,13 +96,13 @@ if go2rtc_config["webrtc"].get("candidates") is None: go2rtc_config["webrtc"]["candidates"] = default_candidates if go2rtc_config.get("rtsp", {}).get("username") is not None: - go2rtc_config["rtsp"]["username"] = go2rtc_config["rtsp"]["username"].format( - **FRIGATE_ENV_VARS + go2rtc_config["rtsp"]["username"] = substitute_frigate_vars( + go2rtc_config["rtsp"]["username"] ) if go2rtc_config.get("rtsp", {}).get("password") is not None: - go2rtc_config["rtsp"]["password"] = go2rtc_config["rtsp"]["password"].format( - **FRIGATE_ENV_VARS + go2rtc_config["rtsp"]["password"] = substitute_frigate_vars( + go2rtc_config["rtsp"]["password"] ) # ensure ffmpeg path is set correctly @@ -145,7 +138,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 not ALLOW_ARBITRARY_EXEC and is_restricted_source(formatted_stream): print( f"[ERROR] Stream '{name}' uses a restricted source (echo/expr/exec) which is disabled by default for security. " @@ -164,7 +157,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 not ALLOW_ARBITRARY_EXEC and is_restricted_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. " diff --git a/frigate/api/camera.py b/frigate/api/camera.py index 1881bde6d..7a3b19439 100644 --- a/frigate/api/camera.py +++ b/frigate/api/camera.py @@ -30,7 +30,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateTopic, ) -from frigate.config.env import FRIGATE_ENV_VARS +from frigate.config.env import substitute_frigate_vars from frigate.util.builtin import clean_camera_user_pass from frigate.util.camera_cleanup import cleanup_camera_db, cleanup_camera_files from frigate.util.config import find_config_file @@ -126,7 +126,7 @@ def go2rtc_add_stream(request: Request, stream_name: str, src: str = ""): params = {"name": stream_name} if src: try: - params["src"] = src.format(**FRIGATE_ENV_VARS) + params["src"] = substitute_frigate_vars(src) except KeyError: params["src"] = src diff --git a/frigate/config/env.py b/frigate/config/env.py index db094a8af..209dda67b 100644 --- a/frigate/config/env.py +++ b/frigate/config/env.py @@ -1,4 +1,5 @@ import os +import re from pathlib import Path from typing import Annotated @@ -15,8 +16,77 @@ if os.path.isdir(secrets_dir) and os.access(secrets_dir, os.R_OK): ) +# Matches a FRIGATE_* identifier following an opening brace. +_FRIGATE_IDENT_RE = re.compile(r"FRIGATE_[A-Za-z0-9_]+") + + +def substitute_frigate_vars(value: str) -> str: + """Substitute `{FRIGATE_*}` placeholders in *value*. + + Reproduces the subset of `str.format()` brace semantics that Frigate's + config has historically supported, while leaving unrelated brace content + (e.g. ffmpeg `%{localtime\\:...}` expressions) untouched: + + * `{{` 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. + * 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. + * Any other `{` or `}` is treated as a literal and passed through. + """ + out: list[str] = [] + i = 0 + n = len(value) + while i < n: + ch = value[i] + if ch == "{": + # Escaped literal `{{`. + if i + 1 < n and value[i + 1] == "{": + out.append("{") + i += 2 + continue + # Possible `{FRIGATE_*}` placeholder. + if value.startswith("{FRIGATE_", i): + ident_match = _FRIGATE_IDENT_RE.match(value, i + 1) + if ( + ident_match is not None + and ident_match.end() < n + and value[ident_match.end()] == "}" + ): + key = ident_match.group(0) + if key not in FRIGATE_ENV_VARS: + raise KeyError(key) + out.append(FRIGATE_ENV_VARS[key]) + i = ident_match.end() + 1 + continue + # Looks like a FRIGATE placeholder but is malformed + # (no closing brace, illegal char, format spec, etc.). + raise ValueError( + f"Malformed FRIGATE_ placeholder near {value[i : i + 32]!r}" + ) + # Plain `{` — pass through (e.g. `%{localtime\:...}`). + out.append("{") + i += 1 + continue + if ch == "}": + # Escaped literal `}}`. + if i + 1 < n and value[i + 1] == "}": + out.append("}") + i += 2 + continue + out.append("}") + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + + def validate_env_string(v: str) -> str: - return v.format(**FRIGATE_ENV_VARS) + return substitute_frigate_vars(v) EnvString = Annotated[str, AfterValidator(validate_env_string)]