diff --git a/frigate/api/app.py b/frigate/api/app.py index f18abecf5c..8f0fcb5f06 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -453,13 +453,6 @@ def _redact_go2rtc_credentials(go2rtc: dict[str, Any]) -> None: for name in list(go2rtc["publish"]): redact_credential(go2rtc["publish"], name) - webrtc = go2rtc.get("webrtc") - if isinstance(webrtc, dict): - for ice_server in webrtc.get("ice_servers") or []: - if isinstance(ice_server, dict): - redact_credential(ice_server, "username") - redact_credential(ice_server, "credential") - @router.get("/profiles", dependencies=[Depends(allow_any_authenticated())]) def get_profiles(request: Request): @@ -851,7 +844,7 @@ def _resolve_frigate_vars(value: str) -> str: def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: - """Shape the in-memory camera inputs and go2rtc like the saved YAML.""" + """Shape the in-memory camera inputs like the saved YAML.""" return { "cameras": { name: { @@ -864,84 +857,18 @@ def _saved_config_from_memory(config: FrigateConfig) -> dict[str, Any]: } for name, camera in config.cameras.items() }, - "go2rtc": config.go2rtc.model_dump(mode="json", warnings="none"), } -def _restore_redacted_ice_servers( - config_data: dict, saved_go2rtc: dict[str, Any] -) -> str | None: - """Fill redacted ICE server fields from the saved server with the same urls. - - Lists are written whole, so the sentinel can't be dropped per field like - other redacted credentials. Mutates `config_data` in place and returns an - error message when a redacted server has no saved match. - """ - go2rtc = config_data.get("go2rtc") - webrtc = go2rtc.get("webrtc") if isinstance(go2rtc, dict) else None - ice_servers = webrtc.get("ice_servers") if isinstance(webrtc, dict) else None - if not isinstance(ice_servers, list): - return None - - saved_webrtc = saved_go2rtc.get("webrtc") - saved_servers = ( - saved_webrtc.get("ice_servers") or [] if isinstance(saved_webrtc, dict) else [] - ) - - for server in ice_servers: - if ( - not isinstance(server, dict) - or REDACTED_CREDENTIAL_SENTINEL not in server.values() - ): - continue - redacted = [ - key - for key, value in server.items() - if value == REDACTED_CREDENTIAL_SENTINEL - ] - matches = [ - saved - for saved in saved_servers - if isinstance(saved, dict) and saved.get("urls") == server.get("urls") - ] - - # Servers sharing urls but holding different accounts can't be told apart - if not matches or any( - other.get(key) != matches[0].get(key) - for other in matches[1:] - for key in redacted - ): - return ( - "An ICE server contains saved credentials that could not be matched " - "by urls; resend it with real values" - ) - match = matches[0] - for key, value in list(server.items()): - if value != REDACTED_CREDENTIAL_SENTINEL: - continue - if key in match: - server[key] = match[key] - else: - del server[key] - - return None - - def _config_data_to_updates( config_data: dict, saved: dict[str, Any] ) -> tuple[dict[str, Any], str | None]: """Flatten a /config/set payload after restoring masked credentials. `saved` is the config the masked values were taken from. Returns the flat - updates, or an error message when a masked or redacted value can't be - restored. + updates, or an error message when a masked value can't be restored. """ - saved_go2rtc = saved.get("go2rtc") - error = _restore_masked_camera_paths( - config_data, saved - ) or _restore_redacted_ice_servers( - config_data, saved_go2rtc if isinstance(saved_go2rtc, dict) else {} - ) + error = _restore_masked_camera_paths(config_data, saved) if error: return {}, error diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index 549b74e500..009e72573a 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -737,15 +737,14 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): "ngroksecret", "19550224", "streamkey", - "turnpass", ): self.assertNotIn(secret, body) go2rtc = response["go2rtc"] self.assertEqual(go2rtc["webrtc"]["candidates"], ["10.0.0.5:8555"]) + # browsers need the TURN account to relay WebRTC self.assertEqual( - go2rtc["webrtc"]["ice_servers"][0]["credential"], - REDACTED_CREDENTIAL_SENTINEL, + go2rtc["webrtc"]["ice_servers"][0]["credential"], "turnpass" ) go2rtc.pop("ngrok") @@ -769,70 +768,6 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): finally: os.unlink(config_path) - @patch("frigate.api.app.find_config_file") - def test_redacted_ice_server_with_changed_urls_is_rejected(self, mock_find_config): - self._add_credentials() - 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: - ice_servers = client.get("/config").json()["go2rtc"]["webrtc"][ - "ice_servers" - ] - ice_servers[0]["urls"] = ["turn:new.example.com:3478"] - webrtc = {"go2rtc": {"webrtc": {"ice_servers": ice_servers}}} - resp = self._save(client, webrtc) - self.assertEqual(resp.status_code, 400) - self.assertIn("saved credentials", resp.json()["message"]) - - ice_servers[0]["username"] = "newuser" - ice_servers[0]["credential"] = "newpass" - self.assertEqual(self._save(client, webrtc).status_code, 200) - - raw, saved = self._read_config_file(config_path) - self.assertNotIn(REDACTED_CREDENTIAL_SENTINEL, raw) - self.assertEqual( - saved["go2rtc"]["webrtc"]["ice_servers"][0]["credential"], "newpass" - ) - finally: - os.unlink(config_path) - - @patch("frigate.api.app.find_config_file") - def test_redacted_ice_servers_sharing_urls_are_rejected(self, mock_find_config): - self._add_credentials() - self.minimal_config["go2rtc"]["webrtc"]["ice_servers"].append( - { - "urls": ["turn:turn.example.com:3478"], - "username": "otheruser", - "credential": "otherpass", - } - ) - 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: - ice_servers = client.get("/config").json()["go2rtc"]["webrtc"][ - "ice_servers" - ] - resp = self._save( - client, {"go2rtc": {"webrtc": {"ice_servers": ice_servers}}} - ) - self.assertEqual(resp.status_code, 400) - - _, saved = self._read_config_file(config_path) - self.assertEqual( - [s["credential"] for s in saved["go2rtc"]["webrtc"]["ice_servers"]], - ["turnpass", "otherpass"], - ) - finally: - os.unlink(config_path) - @patch("frigate.api.app.find_config_file") def test_masked_inputs_restore_by_value(self, mock_find_config): main = "rtsp://u:MAIN@10.0.0.1:554/main" diff --git a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts index d343a3b3ee..845c4d1a5a 100644 --- a/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts +++ b/web/e2e/specs/settings/camera-ffmpeg-streams.spec.ts @@ -235,4 +235,33 @@ test.describe("camera ffmpeg input source toggle @medium", () => { await paths.focus(); await expect(paths).toHaveValue(sub); }); + + test("inputs with the same masked path each reveal their own path by role", async ({ + frigateApp, + }) => { + const record = "rtsp://admin:one@10.0.0.1:554/live"; + const detect = "rtsp://viewer:two@10.0.0.1:554/live"; + const masked = "rtsp://*:*@10.0.0.1:554/live"; + await installRoutes( + frigateApp.page, + [ + { path: masked, roles: ["record"] }, + { path: masked, roles: ["detect"] }, + ], + [ + { path: record, roles: ["record"] }, + { path: detect, roles: ["detect"] }, + ], + ); + await frigateApp.goto("/settings?page=cameraFfmpeg&camera=front_door"); + + const paths = frigateApp.page.getByRole("textbox", { name: "Input path" }); + await expect(paths).toHaveCount(2); + + await paths.nth(0).focus(); + await expect(paths.nth(0)).toHaveValue(record); + + await paths.nth(1).focus(); + await expect(paths.nth(1)).toHaveValue(detect); + }); }); diff --git a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx index 4e446e8595..1adec37fed 100644 --- a/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx +++ b/web/src/components/config-form/theme/widgets/CameraPathWidget.tsx @@ -1,5 +1,7 @@ import type { WidgetProps } from "@rjsf/utils"; import useSWR from "swr"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; import { useMemo, useState, type FocusEvent } from "react"; import { useTranslation } from "react-i18next"; import { LuEye, LuEyeOff } from "react-icons/lu"; @@ -21,6 +23,7 @@ type RawPathsResponse = { ffmpeg?: { inputs?: Array<{ path?: string; + roles?: string[]; }>; }; } @@ -68,23 +71,32 @@ export function CameraPathWidget(props: WidgetProps) { const rawValue = typeof value === "string" ? value : ""; - // Match by masked value, as the backend restores on save, so deleting or - // reordering inputs never reveals another input's path + const roles = + inputIndex === undefined + ? undefined + : get(formContext?.formData, ["inputs", inputIndex, "roles"]); + + // Match by masked value with roles breaking a tie, as the backend restores + // on save, so deleting or reordering inputs never reveals another input's path const rawPath = useMemo(() => { if (!cameraName || !isMaskedPath(rawValue)) { return undefined; } - const matches = new Set( - (rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? []) - .map((input) => input.path) - .filter( - (path): path is string => - typeof path === "string" && maskCredentials(path) === rawValue, - ), + let matches = ( + rawPaths?.cameras?.[cameraName]?.ffmpeg?.inputs ?? [] + ).filter( + (input): input is { path: string; roles?: string[] } => + typeof input.path === "string" && + maskCredentials(input.path) === rawValue, ); - return matches.size === 1 ? [...matches][0] : undefined; - }, [cameraName, rawPaths, rawValue]); + if (new Set(matches.map((input) => input.path)).size > 1) { + matches = matches.filter((input) => isEqual(input.roles, roles)); + } + + const paths = new Set(matches.map((input) => input.path)); + return paths.size === 1 ? [...paths][0] : undefined; + }, [cameraName, rawPaths, rawValue, roles]); const resolvedValue = isMaskedPath(rawValue) && rawPath ? rawPath : (rawValue ?? "");