mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-15 18:00:52 +03:00
API access improvements (#23183)
* restrict viewer access to logs, labels, and go2rtc stream list * filter stats data for non admins * track creator on vlm watch jobs and scope view/cancel to admin or creator * add shortcut for admins in /stats
This commit is contained in:
parent
e20fc521b1
commit
bd1fc1cc72
@ -96,11 +96,46 @@ def version():
|
||||
|
||||
|
||||
@router.get("/stats", dependencies=[Depends(allow_any_authenticated())])
|
||||
def stats(request: Request):
|
||||
return JSONResponse(content=request.app.stats_emitter.get_latest_stats())
|
||||
def stats(
|
||||
request: Request,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
stats_data = request.app.stats_emitter.get_latest_stats()
|
||||
|
||||
# Admins see the full snapshot
|
||||
if request.headers.get("remote-role") == "admin":
|
||||
return JSONResponse(content=stats_data)
|
||||
|
||||
allowed_set = set(allowed_cameras)
|
||||
|
||||
# Shallow-copy so we don't mutate the cached stats history entry.
|
||||
filtered = {**stats_data}
|
||||
|
||||
cameras = stats_data.get("cameras")
|
||||
if cameras is not None:
|
||||
filtered["cameras"] = {
|
||||
name: data for name, data in cameras.items() if name in allowed_set
|
||||
}
|
||||
|
||||
bandwidth = stats_data.get("bandwidth_usages")
|
||||
if bandwidth is not None:
|
||||
filtered["bandwidth_usages"] = {
|
||||
name: data for name, data in bandwidth.items() if name in allowed_set
|
||||
}
|
||||
|
||||
# cmdline can leak camera URLs/paths; strip but keep cpu/mem so
|
||||
# client-side problem heuristics still work.
|
||||
cpu_usages = stats_data.get("cpu_usages")
|
||||
if cpu_usages is not None:
|
||||
filtered["cpu_usages"] = {
|
||||
pid: {k: v for k, v in usage.items() if k != "cmdline"}
|
||||
for pid, usage in cpu_usages.items()
|
||||
}
|
||||
|
||||
return JSONResponse(content=filtered)
|
||||
|
||||
|
||||
@router.get("/stats/history", dependencies=[Depends(allow_any_authenticated())])
|
||||
@router.get("/stats/history", dependencies=[Depends(require_role(["admin"]))])
|
||||
def stats_history(request: Request, keys: str = None):
|
||||
if keys:
|
||||
keys = keys.split(",")
|
||||
@ -835,7 +870,7 @@ def nvinfo():
|
||||
@router.get(
|
||||
"/logs/{service}",
|
||||
tags=[Tags.logs],
|
||||
dependencies=[Depends(allow_any_authenticated())],
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def logs(
|
||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||
@ -1040,12 +1075,27 @@ def get_media_sync_status(job_id: str):
|
||||
|
||||
|
||||
@router.get("/labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_labels(camera: str = ""):
|
||||
def get_labels(
|
||||
camera: str = "",
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
if camera:
|
||||
if camera not in allowed_cameras:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": f"Access denied to camera '{camera}'",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
events = Event.select(Event.label).where(Event.camera == camera).distinct()
|
||||
else:
|
||||
events = Event.select(Event.label).distinct()
|
||||
events = (
|
||||
Event.select(Event.label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
.distinct()
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return JSONResponse(
|
||||
@ -1058,9 +1108,16 @@ def get_labels(camera: str = ""):
|
||||
|
||||
|
||||
@router.get("/sub_labels", dependencies=[Depends(allow_any_authenticated())])
|
||||
def get_sub_labels(split_joined: Optional[int] = None):
|
||||
def get_sub_labels(
|
||||
split_joined: Optional[int] = None,
|
||||
allowed_cameras: List[str] = Depends(get_allowed_cameras_for_filter),
|
||||
):
|
||||
try:
|
||||
events = Event.select(Event.sub_label).distinct()
|
||||
events = (
|
||||
Event.select(Event.sub_label)
|
||||
.where(Event.camera << allowed_cameras)
|
||||
.distinct()
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
content=({"success": False, "message": "Failed to get sub_labels"}),
|
||||
|
||||
@ -19,7 +19,9 @@ from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import (
|
||||
_get_stream_owner_cameras,
|
||||
allow_any_authenticated,
|
||||
get_current_user,
|
||||
require_go2rtc_stream_access,
|
||||
require_role,
|
||||
)
|
||||
@ -31,6 +33,7 @@ from frigate.config.camera.updater import (
|
||||
CameraConfigUpdateTopic,
|
||||
)
|
||||
from frigate.config.env import substitute_frigate_vars
|
||||
from frigate.models import User
|
||||
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
|
||||
@ -66,7 +69,7 @@ def _is_valid_host(host: str) -> bool:
|
||||
|
||||
|
||||
@router.get("/go2rtc/streams", dependencies=[Depends(allow_any_authenticated())])
|
||||
def go2rtc_streams():
|
||||
async def go2rtc_streams(request: Request):
|
||||
r = requests.get("http://127.0.0.1:1984/api/streams")
|
||||
if not r.ok:
|
||||
logger.error("Failed to fetch streams from go2rtc")
|
||||
@ -75,6 +78,24 @@ def go2rtc_streams():
|
||||
status_code=500,
|
||||
)
|
||||
stream_data = r.json()
|
||||
|
||||
# Roles with an explicit camera list see only streams owned by an allowed
|
||||
# camera. Admin and full-access roles (no list / empty list) see all streams.
|
||||
current_user = await get_current_user(request)
|
||||
if not isinstance(current_user, JSONResponse):
|
||||
role = current_user["role"]
|
||||
roles_dict = request.app.frigate_config.auth.roles
|
||||
if role != "admin" and roles_dict.get(role):
|
||||
all_camera_names = set(request.app.frigate_config.cameras.keys())
|
||||
allowed_cameras = set(
|
||||
User.get_allowed_cameras(role, roles_dict, all_camera_names)
|
||||
)
|
||||
stream_data = {
|
||||
name: data
|
||||
for name, data in stream_data.items()
|
||||
if _get_stream_owner_cameras(request, name) & allowed_cameras
|
||||
}
|
||||
|
||||
for data in stream_data.values():
|
||||
for producer in data.get("producers") or []:
|
||||
producer["url"] = clean_camera_user_pass(producer.get("url", ""))
|
||||
@ -966,7 +987,6 @@ async def onvif_probe(
|
||||
probe = ffprobe_stream(
|
||||
request.app.frigate_config.ffmpeg, test_uri, detailed=False
|
||||
)
|
||||
print(probe)
|
||||
ok = probe is not None and getattr(probe, "returncode", 1) == 0
|
||||
tested_candidates.append(
|
||||
{
|
||||
|
||||
@ -10,7 +10,7 @@ from functools import reduce
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import cv2
|
||||
from fastapi import APIRouter, Body, Depends, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -1641,6 +1641,7 @@ async def start_vlm_monitor(
|
||||
dispatcher=request.app.dispatcher,
|
||||
labels=body.labels,
|
||||
zones=body.zones,
|
||||
username=request.headers.get("remote-user", ""),
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error("Failed to start VLM watch job: %s", e, exc_info=True)
|
||||
@ -1661,10 +1662,22 @@ async def start_vlm_monitor(
|
||||
summary="Get current VLM watch job",
|
||||
description="Returns the current (or most recently completed) VLM watch job.",
|
||||
)
|
||||
async def get_vlm_monitor() -> JSONResponse:
|
||||
async def get_vlm_monitor(request: Request) -> JSONResponse:
|
||||
job = get_vlm_watch_job()
|
||||
if job is None:
|
||||
return JSONResponse(content={"active": False}, status_code=200)
|
||||
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
|
||||
# Admin and the job's creator always see the job. Other users only see it
|
||||
# if they have access to the camera being watched; otherwise hide it.
|
||||
if role != "admin" and username != job.username:
|
||||
try:
|
||||
await require_camera_access(job.camera, request=request)
|
||||
except HTTPException:
|
||||
return JSONResponse(content={"active": False}, status_code=200)
|
||||
|
||||
return JSONResponse(content={"active": True, **job.to_dict()}, status_code=200)
|
||||
|
||||
|
||||
@ -1674,7 +1687,27 @@ async def get_vlm_monitor() -> JSONResponse:
|
||||
summary="Cancel the current VLM watch job",
|
||||
description="Cancels the running watch job if one exists.",
|
||||
)
|
||||
async def cancel_vlm_monitor() -> JSONResponse:
|
||||
async def cancel_vlm_monitor(request: Request) -> JSONResponse:
|
||||
job = get_vlm_watch_job()
|
||||
if job is None:
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "No active watch job to cancel."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
role = request.headers.get("remote-role", "viewer")
|
||||
username = request.headers.get("remote-user", "")
|
||||
|
||||
# Admin can cancel any job; other users can only cancel jobs they started.
|
||||
if role != "admin" and username != job.username:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Not authorized to cancel this watch job.",
|
||||
},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
cancelled = stop_vlm_watch_job()
|
||||
if not cancelled:
|
||||
return JSONResponse(
|
||||
|
||||
@ -45,6 +45,7 @@ class VLMWatchJob(Job):
|
||||
last_reasoning: str = ""
|
||||
notification_message: str = ""
|
||||
iteration_count: int = 0
|
||||
username: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
@ -374,6 +375,7 @@ def start_vlm_watch_job(
|
||||
dispatcher: Any,
|
||||
labels: list[str] | None = None,
|
||||
zones: list[str] | None = None,
|
||||
username: str = "",
|
||||
) -> str:
|
||||
"""Start a new VLM watch job. Returns the job ID.
|
||||
|
||||
@ -397,6 +399,7 @@ def start_vlm_watch_job(
|
||||
max_duration_minutes=max_duration_minutes,
|
||||
labels=labels or [],
|
||||
zones=zones or [],
|
||||
username=username,
|
||||
)
|
||||
cancel_ev = threading.Event()
|
||||
_current_job = job
|
||||
|
||||
Loading…
Reference in New Issue
Block a user