restore masked camera paths from the saved yaml

Masked paths were restored from the in-memory config, where {FRIGATE_*} placeholders are already resolved, so saving an untouched camera replaced the placeholder in config.yml with the real secret. Masked paths now restore from the YAML on disk, matched against each saved path with its placeholders resolved, so the path is written back as it was. In-memory saves still restore from the live config.
This commit is contained in:
Josh Hawkins
2026-10-18 14:41:28 -05:00
parent 7dc981e5be
commit 31a18d8771
2 changed files with 107 additions and 30 deletions
+69 -30
View File
@@ -45,6 +45,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdateEnum, CameraConfigUpdateEnum,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.env import substitute_frigate_vars
from frigate.const import REDACTED_CREDENTIAL_SENTINEL from frigate.const import REDACTED_CREDENTIAL_SENTINEL
from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector from frigate.ffmpeg_presets import FFMPEG_HWACCEL_VAAPI, _gpu_selector
from frigate.genai import PROVIDERS, load_providers from frigate.genai import PROVIDERS, load_providers
@@ -774,30 +775,44 @@ def config_save(save_option: str, body: Any = Body(media_type="text/plain")):
) )
def _restore_masked_camera_paths( def _restore_masked_camera_paths(config_data: dict, saved: dict) -> str | None:
config_data: dict, config: FrigateConfig """Substitute incoming masked camera paths with the saved ones.
) -> str | None:
"""Substitute incoming masked camera paths 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 existing input that masks to edited. Each masked path is matched to the saved input that masks to the
the same value, with roles breaking a tie, so deleting or reordering inputs same value, with roles breaking a tie, so deleting or reordering inputs
keeps each path's own credentials. Mutates `config_data` in place and 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. 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 None
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):
continue continue
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)
existing_inputs = existing.ffmpeg.inputs if existing else [] saved_camera = saved_cameras.get(camera_name)
saved_ffmpeg = (
saved_camera.get("ffmpeg") if isinstance(saved_camera, dict) else None
)
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):
@@ -807,14 +822,16 @@ def _restore_masked_camera_paths(
continue continue
candidates = [ candidates = [
inp inp
for inp in existing_inputs for inp in saved_inputs
if clean_camera_user_pass(inp.path) == path if clean_camera_user_pass(_resolve_frigate_vars(inp["path"])) == path
] ]
if len({inp.path for inp in candidates}) > 1: if len({inp["path"] for inp in candidates}) > 1:
candidates = [ candidates = [
inp for inp in candidates if inp.roles == input_obj.get("roles") inp
for inp in candidates
if inp.get("roles") == input_obj.get("roles")
] ]
real_paths = {inp.path for inp in candidates} real_paths = {inp["path"] for inp in candidates}
if len(real_paths) != 1: if len(real_paths) != 1:
return ( return (
f"Input {index} of camera {camera_name} has masked credentials " f"Input {index} of camera {camera_name} has masked credentials "
@@ -825,6 +842,32 @@ def _restore_masked_camera_paths(
return None return None
def _resolve_frigate_vars(value: str) -> str:
"""Resolve {FRIGATE_*} placeholders the way /config shows them."""
try:
return substitute_frigate_vars(value)
except (KeyError, ValueError):
return value
def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]:
"""Shape the in-memory camera inputs and go2rtc 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()
},
"go2rtc": config.go2rtc.model_dump(mode="json", warnings="none"),
}
def _restore_redacted_ice_servers( def _restore_redacted_ice_servers(
config_data: dict, saved_go2rtc: dict[str, Any] config_data: dict, saved_go2rtc: dict[str, Any]
) -> str | None: ) -> str | None:
@@ -876,16 +919,20 @@ def _restore_redacted_ice_servers(
def _config_data_to_updates( def _config_data_to_updates(
config_data: dict, config: FrigateConfig, saved_go2rtc: dict[str, Any] config_data: dict, saved: dict[str, Any]
) -> tuple[dict[str, Any], str | None]: ) -> tuple[dict[str, Any], str | None]:
"""Flatten a /config/set payload after restoring masked credentials. """Flatten a /config/set payload after restoring masked credentials.
Returns the flat updates, or an error message when a masked or redacted `saved` is the config the masked values were taken from. Returns the flat
value can't be restored. updates, or an error message when a masked or redacted value can't be
restored.
""" """
saved_go2rtc = saved.get("go2rtc")
error = _restore_masked_camera_paths( error = _restore_masked_camera_paths(
config_data, config config_data, saved
) or _restore_redacted_ice_servers(config_data, saved_go2rtc) ) or _restore_redacted_ice_servers(
config_data, saved_go2rtc if isinstance(saved_go2rtc, dict) else {}
)
if error: if error:
return {}, error return {}, error
@@ -913,11 +960,7 @@ def _config_set_in_memory(request: Request, body: AppConfigSetBody) -> JSONRespo
updates = {} updates = {}
if body.config_data: if body.config_data:
updates, error = _config_data_to_updates( updates, error = _config_data_to_updates(
body.config_data, body.config_data, _saved_config_from_memory(request.app.frigate_config)
request.app.frigate_config,
request.app.frigate_config.go2rtc.model_dump(
mode="json", warnings="none"
),
) )
if error: if error:
return JSONResponse( return JSONResponse(
@@ -1076,13 +1119,9 @@ def config_set(request: Request, body: AppConfigSetBody):
saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config) saved_config = ruamel.yaml.YAML(typ="safe").load(old_raw_config)
except ruamel.yaml.YAMLError: except ruamel.yaml.YAMLError:
saved_config = None saved_config = None
saved_go2rtc = (
saved_config.get("go2rtc") or {}
if isinstance(saved_config, dict)
else {}
)
updates, error = _config_data_to_updates( updates, error = _config_data_to_updates(
body.config_data, request.app.frigate_config, saved_go2rtc body.config_data,
saved_config if isinstance(saved_config, dict) else {},
) )
if error: if error:
return JSONResponse( return JSONResponse(
@@ -584,6 +584,44 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
{"path": path, "roles": [role]} for path, role in paths {"path": path, "roles": [role]} for path, role in paths
] ]
@patch.dict(
"frigate.config.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") @patch("frigate.api.app.find_config_file")
def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config): def test_go2rtc_credentials_redacted_and_round_trip(self, mock_find_config):
self._add_credentials() self._add_credentials()