mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-24 10:18:53 -05:00
Compare commits
3 Commits
dev
...
fix-go2rtc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c10717103 | ||
|
|
07fd57209c | ||
|
|
8e35a6cd92 |
@@ -986,7 +986,16 @@ async def require_camera_access(
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
return current_user
|
||||
detail = "Authentication required"
|
||||
try:
|
||||
error_payload = json.loads(current_user.body)
|
||||
detail = (
|
||||
error_payload.get("message") or error_payload.get("detail") or detail
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(status_code=current_user.status_code, detail=detail)
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
@@ -1004,6 +1013,61 @@ async def require_camera_access(
|
||||
)
|
||||
|
||||
|
||||
def _get_stream_owner_cameras(request: Request, stream_name: str) -> set[str]:
|
||||
owner_cameras: set[str] = set()
|
||||
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
if stream_name == camera_name:
|
||||
owner_cameras.add(camera_name)
|
||||
continue
|
||||
|
||||
if stream_name in camera.live.streams.values():
|
||||
owner_cameras.add(camera_name)
|
||||
|
||||
return owner_cameras
|
||||
|
||||
|
||||
async def require_go2rtc_stream_access(
|
||||
stream_name: Optional[str] = None,
|
||||
request: Request = None,
|
||||
):
|
||||
"""Dependency to enforce go2rtc stream access based on owning camera access."""
|
||||
if stream_name is None:
|
||||
return
|
||||
|
||||
current_user = await get_current_user(request)
|
||||
if isinstance(current_user, JSONResponse):
|
||||
detail = "Authentication required"
|
||||
try:
|
||||
error_payload = json.loads(current_user.body)
|
||||
detail = (
|
||||
error_payload.get("message") or error_payload.get("detail") or detail
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(status_code=current_user.status_code, detail=detail)
|
||||
|
||||
role = current_user["role"]
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
allowed_cameras = User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
|
||||
# Admin or full access bypasses
|
||||
if role == "admin" or not roles_dict.get(role):
|
||||
return
|
||||
|
||||
owner_cameras = _get_stream_owner_cameras(request, stream_name)
|
||||
|
||||
if owner_cameras & set(allowed_cameras):
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Access denied to camera '{stream_name}'. Allowed: {allowed_cameras}",
|
||||
)
|
||||
|
||||
|
||||
async def get_allowed_cameras_for_filter(request: Request):
|
||||
"""Dependency to get allowed_cameras for filtering lists."""
|
||||
current_user = await get_current_user(request)
|
||||
|
||||
@@ -17,7 +17,7 @@ from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import (
|
||||
allow_any_authenticated,
|
||||
require_camera_access,
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
from frigate.api.defs.tags import Tags
|
||||
@@ -71,14 +71,27 @@ def go2rtc_streams():
|
||||
|
||||
|
||||
@router.get(
|
||||
"/go2rtc/streams/{camera_name}", dependencies=[Depends(require_camera_access)]
|
||||
"/go2rtc/streams/{stream_name}",
|
||||
dependencies=[Depends(require_go2rtc_stream_access)],
|
||||
)
|
||||
def go2rtc_camera_stream(request: Request, camera_name: str):
|
||||
def go2rtc_camera_stream(request: Request, stream_name: str):
|
||||
r = requests.get(
|
||||
f"http://127.0.0.1:1984/api/streams?src={camera_name}&video=all&audio=allµphone"
|
||||
"http://127.0.0.1:1984/api/streams",
|
||||
params={
|
||||
"src": stream_name,
|
||||
"video": "all",
|
||||
"audio": "all",
|
||||
"microphone": "",
|
||||
},
|
||||
)
|
||||
if not r.ok:
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
camera_config = request.app.frigate_config.cameras.get(stream_name)
|
||||
|
||||
if camera_config is None:
|
||||
for camera_name, camera in request.app.frigate_config.cameras.items():
|
||||
if stream_name in camera.live.streams.values():
|
||||
camera_config = request.app.frigate_config.cameras.get(camera_name)
|
||||
break
|
||||
|
||||
if camera_config and camera_config.enabled:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from frigate.api.auth import (
|
||||
get_allowed_cameras_for_filter,
|
||||
@@ -9,6 +10,33 @@ from frigate.api.auth import (
|
||||
from frigate.models import Event, Recordings, ReviewSegment
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
# Minimal multi-camera config used by go2rtc stream access tests.
|
||||
# front_door has a stream alias "front_door_main"; back_door uses its own name.
|
||||
# The "limited_user" role is restricted to front_door only.
|
||||
_MULTI_CAMERA_CONFIG = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {
|
||||
"roles": {
|
||||
"limited_user": ["front_door"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"live": {"streams": {"default": "front_door_main"}},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestCameraAccessEventReview(BaseTestHttp):
|
||||
def setUp(self):
|
||||
@@ -190,3 +218,179 @@ class TestCameraAccessEventReview(BaseTestHttp):
|
||||
resp = client.get("/events/summary")
|
||||
summary_list = resp.json()
|
||||
assert len(summary_list) == 2
|
||||
|
||||
|
||||
class TestGo2rtcStreamAccess(BaseTestHttp):
|
||||
"""Tests for require_go2rtc_stream_access — the auth dependency on
|
||||
GET /go2rtc/streams/{stream_name}.
|
||||
|
||||
go2rtc is not running in unit tests, so an authorized request returns
|
||||
500 (the proxy call fails), while an unauthorized request returns 401/403
|
||||
before the proxy is ever reached.
|
||||
"""
|
||||
|
||||
def _make_app(self, config_override: dict | None = None):
|
||||
"""Build a test app, optionally replacing self.minimal_config."""
|
||||
if config_override is not None:
|
||||
self.minimal_config = config_override
|
||||
app = super().create_app()
|
||||
|
||||
# Allow tests to control the current user via request headers.
|
||||
async def mock_get_current_user(request: Request):
|
||||
username = request.headers.get("remote-user")
|
||||
role = request.headers.get("remote-role")
|
||||
if not username or not role:
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
content={"message": "No authorization headers."},
|
||||
status_code=401,
|
||||
)
|
||||
return {"username": username, "role": role}
|
||||
|
||||
app.dependency_overrides[get_current_user] = mock_get_current_user
|
||||
return app
|
||||
|
||||
def setUp(self):
|
||||
super().setUp([Event, ReviewSegment, Recordings])
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_stream(
|
||||
self, app, stream_name: str, role: str = "admin", user: str = "test"
|
||||
):
|
||||
"""Issue GET /go2rtc/streams/{stream_name} with the given role."""
|
||||
with AuthTestClient(app) as client:
|
||||
return client.get(
|
||||
f"/go2rtc/streams/{stream_name}",
|
||||
headers={"remote-user": user, "remote-role": role},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_admin_can_access_any_stream(self):
|
||||
"""Admin role bypasses camera restrictions."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# front_door stream — go2rtc is not running so expect 500, not 401/403
|
||||
resp = self._get_stream(app, "front_door", role="admin")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"Admin should not be blocked; got {resp.status_code}"
|
||||
)
|
||||
|
||||
# back_door stream
|
||||
resp = self._get_stream(app, "back_door", role="admin")
|
||||
assert resp.status_code not in (401, 403)
|
||||
|
||||
def test_missing_auth_headers_returns_401(self):
|
||||
"""Requests without auth headers must be rejected with 401."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# Use plain TestClient (not AuthTestClient) so no headers are injected.
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/go2rtc/streams/front_door")
|
||||
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}"
|
||||
|
||||
def test_unconfigured_role_can_access_any_stream(self):
|
||||
"""When no camera restrictions are configured for a role the user
|
||||
should have access to all streams (no roles_dict entry ⇒ no restriction)."""
|
||||
no_roles_config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
},
|
||||
}
|
||||
app = self._make_app(no_roles_config)
|
||||
|
||||
# "myuser" role is not listed in roles_dict — should be allowed everywhere
|
||||
for stream in ("front_door", "back_door"):
|
||||
resp = self._get_stream(app, stream, role="myuser")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"Unconfigured role should not be blocked on '{stream}'; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_restricted_role_can_access_allowed_camera(self):
|
||||
"""limited_user role (restricted to front_door) can access front_door stream."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
resp = self._get_stream(app, "front_door", role="limited_user")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"limited_user should be allowed on front_door; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_restricted_role_blocked_from_disallowed_camera(self):
|
||||
"""limited_user role (restricted to front_door) cannot access back_door stream."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
resp = self._get_stream(app, "back_door", role="limited_user")
|
||||
assert resp.status_code == 403, (
|
||||
f"limited_user should be denied on back_door; got {resp.status_code}"
|
||||
)
|
||||
|
||||
def test_stream_alias_allowed_for_owning_camera(self):
|
||||
"""Stream alias 'front_door_main' is owned by front_door; limited_user (who
|
||||
is allowed front_door) should be permitted."""
|
||||
app = self._make_app(_MULTI_CAMERA_CONFIG)
|
||||
# front_door_main is the alias defined in live.streams for front_door
|
||||
resp = self._get_stream(app, "front_door_main", role="limited_user")
|
||||
assert resp.status_code not in (401, 403), (
|
||||
f"limited_user should be allowed on alias front_door_main; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
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."""
|
||||
# Give back_door a stream alias and restrict limited_user to front_door only
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"auth": {
|
||||
"roles": {
|
||||
"limited_user": ["front_door"],
|
||||
}
|
||||
},
|
||||
"cameras": {
|
||||
"front_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.1:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
},
|
||||
"back_door": {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}
|
||||
]
|
||||
},
|
||||
"detect": {"height": 1080, "width": 1920, "fps": 5},
|
||||
"live": {"streams": {"default": "back_door_main"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
app = self._make_app(config)
|
||||
resp = self._get_stream(app, "back_door_main", role="limited_user")
|
||||
assert resp.status_code == 403, (
|
||||
f"limited_user should be denied on alias back_door_main; "
|
||||
f"got {resp.status_code}"
|
||||
)
|
||||
|
||||
@@ -18,18 +18,25 @@ export default function useCameraLiveMode(
|
||||
|
||||
const streamNames = new Set<string>();
|
||||
cameras.forEach((camera) => {
|
||||
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
const selectedStreamName = activeStreams[camera.name];
|
||||
const isRestreamed = Object.keys(config.go2rtc.streams || {}).includes(
|
||||
selectedStreamName,
|
||||
);
|
||||
|
||||
if (isRestreamed) {
|
||||
if (activeStreams && activeStreams[camera.name]) {
|
||||
streamNames.add(activeStreams[camera.name]);
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
streamNames.add(streamName);
|
||||
});
|
||||
if (isRestreamed) {
|
||||
streamNames.add(selectedStreamName);
|
||||
}
|
||||
} else {
|
||||
Object.values(camera.live.streams).forEach((streamName) => {
|
||||
const isRestreamed = Object.keys(
|
||||
config.go2rtc.streams || {},
|
||||
).includes(streamName);
|
||||
|
||||
if (isRestreamed) {
|
||||
streamNames.add(streamName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,11 +73,11 @@ export default function useCameraLiveMode(
|
||||
} = {};
|
||||
|
||||
cameras.forEach((camera) => {
|
||||
const selectedStreamName =
|
||||
activeStreams?.[camera.name] ?? Object.values(camera.live.streams)[0];
|
||||
const isRestreamed =
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(
|
||||
Object.values(camera.live.streams)[0],
|
||||
);
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
|
||||
newIsRestreamedStates[camera.name] = isRestreamed ?? false;
|
||||
|
||||
@@ -101,14 +108,21 @@ export default function useCameraLiveMode(
|
||||
setPreferredLiveModes(newPreferredLiveModes);
|
||||
setIsRestreamedStates(newIsRestreamedStates);
|
||||
setSupportsAudioOutputStates(newSupportsAudioOutputStates);
|
||||
}, [cameras, config, windowVisible, streamMetadata]);
|
||||
}, [activeStreams, cameras, config, windowVisible, streamMetadata]);
|
||||
|
||||
const resetPreferredLiveMode = useCallback(
|
||||
(cameraName: string) => {
|
||||
const mseSupported =
|
||||
"MediaSource" in window || "ManagedMediaSource" in window;
|
||||
const cameraConfig = cameras.find((camera) => camera.name === cameraName);
|
||||
const selectedStreamName =
|
||||
activeStreams?.[cameraName] ??
|
||||
(cameraConfig
|
||||
? Object.values(cameraConfig.live.streams)[0]
|
||||
: cameraName);
|
||||
const isRestreamed =
|
||||
config && Object.keys(config.go2rtc.streams || {}).includes(cameraName);
|
||||
config &&
|
||||
Object.keys(config.go2rtc.streams || {}).includes(selectedStreamName);
|
||||
|
||||
setPreferredLiveModes((prevModes) => {
|
||||
const newModes = { ...prevModes };
|
||||
@@ -122,7 +136,7 @@ export default function useCameraLiveMode(
|
||||
return newModes;
|
||||
});
|
||||
},
|
||||
[config],
|
||||
[activeStreams, cameras, config],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user