mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-19 13:12:48 +03:00
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:
+69
-30
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user