mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 13:28:58 +03:00
Refactor Notices and System Health pane (#24243)
* refactor notices * show startup message for enrichments in health pane * tweaks
This commit is contained in:
committed by
Nicolas Mowen
parent
5cef6823a6
commit
7bc32fd4c9
@@ -8,6 +8,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -34,6 +35,7 @@ from frigate.api.media_auth import (
|
||||
from frigate.config import AuthConfig, ProxyConfig
|
||||
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
|
||||
from frigate.models import User
|
||||
from frigate.notices import raise_notice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -253,6 +255,58 @@ class RateLimiter:
|
||||
|
||||
rateLimiter = RateLimiter()
|
||||
|
||||
# a failed login this long after the user's previous one opens a new burst
|
||||
FAILED_LOGIN_BURST_GAP_S = 300
|
||||
|
||||
# the username comes from the request, so it is cut before it reaches a notice
|
||||
MAX_NOTICE_USERNAME = 64
|
||||
|
||||
# unknown usernames are unbounded, so past this many open bursts a new one only
|
||||
# reaches the log
|
||||
MAX_OPEN_BURSTS = 100
|
||||
|
||||
|
||||
class FailedLoginTracker:
|
||||
"""Groups each user's failed logins into bursts, one notice per burst."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# user -> (burst start, last attempt), stalest attempt first
|
||||
self._bursts: dict[str, tuple[int, float]] = {}
|
||||
|
||||
def record(self, user: str, now: float, *, known: bool = False) -> None:
|
||||
"""Count a failed login toward the user's open burst, or open a new one.
|
||||
|
||||
Once MAX_OPEN_BURSTS are open, only a known user opens another.
|
||||
"""
|
||||
user = user[:MAX_NOTICE_USERNAME]
|
||||
|
||||
with self._lock:
|
||||
# bursts that went quiet are over
|
||||
while self._bursts:
|
||||
stalest = next(iter(self._bursts))
|
||||
|
||||
if now - self._bursts[stalest][1] < FAILED_LOGIN_BURST_GAP_S:
|
||||
break
|
||||
|
||||
del self._bursts[stalest]
|
||||
|
||||
if (
|
||||
not known
|
||||
and user not in self._bursts
|
||||
and len(self._bursts) >= MAX_OPEN_BURSTS
|
||||
):
|
||||
return
|
||||
|
||||
start, _ = self._bursts.pop(user, (int(now), now))
|
||||
self._bursts[user] = (start, now)
|
||||
|
||||
raise_notice("failed_login", scope=f"{user}:{start}", params={"user": user})
|
||||
|
||||
|
||||
failed_logins = FailedLoginTracker()
|
||||
|
||||
|
||||
def get_remote_addr(request: Request):
|
||||
# fall back to the direct TCP peer when no proxy chain is present
|
||||
@@ -880,6 +934,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
db_user: User = User.get_by_id(user)
|
||||
except DoesNotExist:
|
||||
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
|
||||
failed_logins.record(user, time.time())
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
password_hash = db_user.password_hash
|
||||
@@ -911,6 +966,7 @@ def login(request: Request, body: AppPostLoginBody):
|
||||
logger.warning(
|
||||
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
|
||||
)
|
||||
failed_logins.record(user, time.time(), known=True)
|
||||
return JSONResponse(content={"message": "Login failed"}, status_code=401)
|
||||
|
||||
|
||||
|
||||
+27
-17
@@ -7,7 +7,6 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
from frigate.notices.registry import DismissResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,13 +15,13 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
"""Get the active notices, most severe first.
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return event notices the user has dismissed
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
|
||||
Returns:
|
||||
The active notices
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
@@ -35,26 +34,37 @@ def get_notice_stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def get_dismissed_checks(request: Request) -> JSONResponse:
|
||||
"""Get the dismissed config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
|
||||
|
||||
|
||||
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id}/dismiss", dependencies=[Depends(require_role(["admin"]))]
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide an event notice until it is raised again."""
|
||||
result = request.app.notice_registry.dismiss(notice_id)
|
||||
"""Hide a notice or a config or stream check row.
|
||||
|
||||
if result == DismissResult.not_found:
|
||||
It stays hidden if the same problem happens again.
|
||||
"""
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
if result == DismissResult.not_dismissable:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "This notice clears itself when the problem is fixed",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
|
||||
Reference in New Issue
Block a user