mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-10 07:33:00 +03:00
Add on-demand Frigate+ API key validation (#24918)
* Add on-demand Frigate+ API key validation The Frigate+ settings page reported the API key as validated whenever it matched the expected format, without ever checking it with the server. - Clarify the status text to say the key matches the expected format - Add PlusApi.validate_key(), which requests a token from Frigate+ and reports the key as valid, invalid, unreachable, or not set; server errors and timeouts are not reported as an invalid key - Add an admin-only POST /plus/validate endpoint and regenerate the API spec - Add a "Validate with Frigate+" button that shows the result inline Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde * Validate Frigate+ key asynchronously and keep cached token on bad replies - Use aiohttp for the validation request so the endpoint awaits it instead of blocking a worker thread - Only replace the cached token when the reply has a string accessToken and numeric expires; otherwise report unreachable so an unexpected response can't break later Frigate+ requests - Test against a local aiohttp server, covering timeouts, connection failures, non-JSON replies and unusable token shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde * Honor proxy settings when validating the Frigate+ key aiohttp ignores HTTP(S)_PROXY unless trust_env is set, so validation reported Frigate+ as unreachable on installs that need a proxy while other Frigate+ requests still worked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4iDiZE2b3tVFVYyZrfZde --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
52d469ed20
commit
81cf7be89a
Vendored
+19
@@ -3148,6 +3148,25 @@ paths:
|
||||
- frigateUserAuth: []
|
||||
x-required-role: any
|
||||
description: '**Access:** Any authenticated user.'
|
||||
/plus/validate:
|
||||
post:
|
||||
tags:
|
||||
- App
|
||||
summary: Validate Plus Key
|
||||
description: |-
|
||||
**Access:** Admin role required.
|
||||
|
||||
Validate the configured Frigate+ API key with the Frigate+ server.
|
||||
operationId: validate_plus_key_plus_validate_post
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
security:
|
||||
- frigateAdminAuth: []
|
||||
x-required-role: admin
|
||||
/recognized_license_plates:
|
||||
get:
|
||||
tags:
|
||||
|
||||
+11
-1
@@ -54,7 +54,7 @@ from frigate.jobs.media_sync import (
|
||||
start_media_sync_job,
|
||||
)
|
||||
from frigate.models import Event, Timeline
|
||||
from frigate.plus import load_plus_model_info
|
||||
from frigate.plus import PlusKeyStatus, load_plus_model_info
|
||||
from frigate.stats.prometheus import get_metrics, update_metrics
|
||||
from frigate.types import JobStatusTypesEnum
|
||||
from frigate.util.builtin import (
|
||||
@@ -1675,6 +1675,16 @@ def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
|
||||
return JSONResponse(content=validModels)
|
||||
|
||||
|
||||
@router.post("/plus/validate", dependencies=[Depends(require_role(["admin"]))])
|
||||
async def validate_plus_key(request: Request):
|
||||
"""Validate the configured Frigate+ API key with the Frigate+ server."""
|
||||
status = await request.app.frigate_config.plus_api.validate_key()
|
||||
|
||||
return JSONResponse(
|
||||
content={"success": status == PlusKeyStatus.valid, "status": status.value}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recognized_license_plates", dependencies=[Depends(allow_any_authenticated())]
|
||||
)
|
||||
|
||||
+81
-1
@@ -3,9 +3,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
import aiohttp
|
||||
import cv2
|
||||
import requests
|
||||
from numpy import ndarray
|
||||
@@ -17,6 +19,17 @@ from frigate.const import MODEL_CACHE_DIR, PLUS_API_HOST, PLUS_ENV_VAR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLUS_VALIDATE_TIMEOUT = 10
|
||||
|
||||
|
||||
class PlusKeyStatus(str, Enum):
|
||||
"""Result of validating a Frigate+ API key with the server."""
|
||||
|
||||
valid = "valid"
|
||||
invalid = "invalid"
|
||||
unreachable = "unreachable"
|
||||
not_set = "not_set"
|
||||
|
||||
|
||||
def add_hailo_alias(model_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Name the hailo detector by its current key as well as its old one.
|
||||
@@ -72,6 +85,19 @@ def get_jpg_bytes(image: ndarray, max_dim: int, quality: int) -> bytes:
|
||||
return jpg_bytes if isinstance(jpg_bytes, bytes) else b""
|
||||
|
||||
|
||||
def _is_usable_token(token_data: Any) -> TypeGuard[dict[str, Any]]:
|
||||
"""Whether a Frigate+ token response has a usable token and expiry."""
|
||||
if not isinstance(token_data, dict):
|
||||
return False
|
||||
|
||||
expires = token_data.get("expires")
|
||||
return (
|
||||
isinstance(token_data.get("accessToken"), str)
|
||||
and isinstance(expires, (int, float))
|
||||
and not isinstance(expires, bool)
|
||||
)
|
||||
|
||||
|
||||
class PlusApi:
|
||||
def __init__(self) -> None:
|
||||
self.host = PLUS_API_HOST
|
||||
@@ -153,6 +179,60 @@ class PlusApi:
|
||||
def is_active(self) -> bool:
|
||||
return self._is_active
|
||||
|
||||
async def validate_key(self) -> PlusKeyStatus:
|
||||
"""Check the configured API key against the Frigate+ server.
|
||||
|
||||
The key format is checked at startup, but only the server can tell
|
||||
whether the key is actually valid. This requests a fresh token so the
|
||||
result reflects the key's current state rather than a cached token.
|
||||
|
||||
Returns:
|
||||
The key's status as reported by the Frigate+ server
|
||||
"""
|
||||
if self.key is None:
|
||||
return PlusKeyStatus.not_set
|
||||
|
||||
key_id, _, secret = self.key.partition(":")
|
||||
|
||||
# Use a single request with a timeout instead of the retrying session,
|
||||
# since this is user initiated and should fail fast when unreachable.
|
||||
# trust_env honors proxy settings, matching the other Frigate+ requests.
|
||||
try:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=PLUS_VALIDATE_TIMEOUT),
|
||||
trust_env=True,
|
||||
) as session:
|
||||
async with session.get(
|
||||
f"{self.host}/v1/auth/token",
|
||||
auth=aiohttp.BasicAuth(key_id, secret),
|
||||
) as r:
|
||||
status = r.status
|
||||
token_data = await r.json(content_type=None) if r.ok else None
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
logger.warning("Unable to reach Frigate+ to validate API key: %s", err)
|
||||
return PlusKeyStatus.unreachable
|
||||
except ValueError:
|
||||
logger.warning("Invalid response validating Frigate+ API key")
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
if status in (401, 403):
|
||||
return PlusKeyStatus.invalid
|
||||
|
||||
if status >= 400:
|
||||
logger.warning(
|
||||
"Unexpected response validating Frigate+ API key: %s", status
|
||||
)
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
# Keep the cached token unless the reply is a usable one, so an
|
||||
# unexpected response can't break later Frigate+ requests
|
||||
if not _is_usable_token(token_data):
|
||||
logger.warning("Invalid response validating Frigate+ API key")
|
||||
return PlusKeyStatus.unreachable
|
||||
|
||||
self._token_data = token_data
|
||||
return PlusKeyStatus.valid
|
||||
|
||||
def upload_image(self, image: ndarray, camera: str) -> str:
|
||||
r = self._get("image/signed_urls")
|
||||
if not r.ok:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for the Frigate+ API endpoints."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from frigate.models import Event
|
||||
from frigate.plus import PlusKeyStatus
|
||||
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
|
||||
|
||||
|
||||
class TestHttpPlusValidate(BaseTestHttp):
|
||||
def setUp(self):
|
||||
super().setUp([Event])
|
||||
|
||||
def _validate(self, status: PlusKeyStatus) -> dict:
|
||||
app = self.create_app()
|
||||
|
||||
with patch.object(
|
||||
app.frigate_config.plus_api, "validate_key", return_value=status
|
||||
):
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post("/plus/validate")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.json()
|
||||
|
||||
def test_a_valid_key(self):
|
||||
self.assertEqual(
|
||||
self._validate(PlusKeyStatus.valid), {"success": True, "status": "valid"}
|
||||
)
|
||||
|
||||
def test_an_invalid_key(self):
|
||||
self.assertEqual(
|
||||
self._validate(PlusKeyStatus.invalid),
|
||||
{"success": False, "status": "invalid"},
|
||||
)
|
||||
|
||||
def test_viewers_cannot_validate(self):
|
||||
app = self.create_app()
|
||||
|
||||
with AuthTestClient(app) as client:
|
||||
response = client.post(
|
||||
"/plus/validate",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
+138
-1
@@ -1,12 +1,17 @@
|
||||
"""Tests for Frigate+ model metadata."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from frigate.plus import PlusApi, load_plus_model_info
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestServer
|
||||
|
||||
from frigate.plus import PlusApi, PlusKeyStatus, load_plus_model_info
|
||||
|
||||
|
||||
class TestHailoAlias(unittest.TestCase):
|
||||
@@ -75,5 +80,137 @@ class TestLoadPlusModelInfo(unittest.TestCase):
|
||||
self.assertIsNone(load_plus_model_info("bad"))
|
||||
|
||||
|
||||
class TestValidateKey(unittest.IsolatedAsyncioTestCase):
|
||||
"""The key format is checked locally, but only the server knows if it is valid."""
|
||||
|
||||
CACHED_TOKEN = {"accessToken": "cached", "expires": 2000000000}
|
||||
|
||||
async def _serve(self, handler) -> str:
|
||||
"""Serve the token endpoint locally and return its base url."""
|
||||
app = web.Application()
|
||||
app.router.add_get("/v1/auth/token", handler)
|
||||
server = TestServer(app)
|
||||
await server.start_server()
|
||||
self.addAsyncCleanup(server.close)
|
||||
return str(server.make_url("")).rstrip("/")
|
||||
|
||||
def _api(self, host: str, key: str | None = "id:secret") -> PlusApi:
|
||||
api = PlusApi.__new__(PlusApi)
|
||||
api.host = host
|
||||
api.key = key
|
||||
api._token_data = dict(self.CACHED_TOKEN)
|
||||
return api
|
||||
|
||||
async def _validate(self, handler) -> tuple[PlusKeyStatus, PlusApi]:
|
||||
api = self._api(await self._serve(handler))
|
||||
return await api.validate_key(), api
|
||||
|
||||
def _replies(self, status: int = 200, body: object = None, text: str | None = None):
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
self.auth = request.headers.get("Authorization")
|
||||
if text is not None:
|
||||
return web.Response(status=status, text=text)
|
||||
return web.json_response(body, status=status)
|
||||
|
||||
return handler
|
||||
|
||||
async def test_an_accepted_key_is_valid(self):
|
||||
token = {"accessToken": "fresh", "expires": 2000000500.5}
|
||||
status, api = await self._validate(self._replies(body=token))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.valid)
|
||||
self.assertEqual(api._token_data, token)
|
||||
self.assertEqual(self.auth, aiohttp.BasicAuth("id", "secret").encode())
|
||||
|
||||
async def test_a_rejected_key_is_invalid(self):
|
||||
for code in (401, 403):
|
||||
status, api = await self._validate(self._replies(code, {"error": "no"}))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.invalid)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_server_error_is_not_reported_as_invalid(self):
|
||||
status, _ = await self._validate(self._replies(500, {"error": "down"}))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_unusable_replies_keep_the_cached_token(self):
|
||||
# a successful reply without a usable token must not replace one that
|
||||
# later Frigate+ requests depend on
|
||||
for body in (
|
||||
None,
|
||||
[],
|
||||
{},
|
||||
{"accessToken": "t"},
|
||||
{"expires": 2000000000},
|
||||
{"accessToken": 1, "expires": 2000000000},
|
||||
{"accessToken": "t", "expires": "soon"},
|
||||
{"accessToken": "t", "expires": True},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
status, api = await self._validate(self._replies(body=body))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_non_json_reply_keeps_the_cached_token(self):
|
||||
status, api = await self._validate(self._replies(text="<html>"))
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
self.assertEqual(api._token_data, self.CACHED_TOKEN)
|
||||
|
||||
async def test_a_connection_failure_is_unreachable(self):
|
||||
# serve and stop, so the port refuses connections
|
||||
server = TestServer(web.Application())
|
||||
await server.start_server()
|
||||
host = str(server.make_url("")).rstrip("/")
|
||||
await server.close()
|
||||
api = self._api(host)
|
||||
|
||||
self.assertEqual(await api.validate_key(), PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_a_slow_server_times_out_as_unreachable(self):
|
||||
async def slow(request: web.Request) -> web.Response:
|
||||
await asyncio.sleep(5)
|
||||
return web.json_response(self.CACHED_TOKEN)
|
||||
|
||||
with patch("frigate.plus.PLUS_VALIDATE_TIMEOUT", 0.1):
|
||||
status, _ = await self._validate(slow)
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.unreachable)
|
||||
|
||||
async def test_the_request_goes_through_a_configured_proxy(self):
|
||||
# installs that reach Frigate+ through a proxy must validate the same way
|
||||
token = {"accessToken": "proxied", "expires": 2000000000}
|
||||
proxied = []
|
||||
|
||||
async def proxy(request: web.Request) -> web.Response:
|
||||
proxied.append(str(request.url))
|
||||
return web.json_response(token)
|
||||
|
||||
proxy_url = await self._serve(proxy)
|
||||
api = self._api("http://plus.invalid")
|
||||
|
||||
with patch.dict(
|
||||
os.environ, {"HTTP_PROXY": proxy_url, "NO_PROXY": ""}, clear=False
|
||||
):
|
||||
status = await api.validate_key()
|
||||
|
||||
self.assertEqual(status, PlusKeyStatus.valid)
|
||||
self.assertEqual(proxied, ["http://plus.invalid/v1/auth/token"])
|
||||
|
||||
async def test_no_key_is_not_sent_to_the_server(self):
|
||||
requests_seen = []
|
||||
|
||||
async def handler(request: web.Request) -> web.Response:
|
||||
requests_seen.append(request)
|
||||
return web.json_response(self.CACHED_TOKEN)
|
||||
|
||||
api = self._api(await self._serve(handler), key=None)
|
||||
|
||||
self.assertEqual(await api.validate_key(), PlusKeyStatus.not_set)
|
||||
self.assertEqual(requests_seen, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -1285,8 +1285,13 @@
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Frigate+ API Key",
|
||||
"validated": "Frigate+ API key is detected and validated",
|
||||
"notValidated": "Frigate+ API key is not detected or not validated",
|
||||
"formatValid": "Frigate+ API key is detected and matches the expected format",
|
||||
"formatInvalid": "Frigate+ API key is not detected or does not match the expected format",
|
||||
"validate": "Validate with Frigate+",
|
||||
"validating": "Validating…",
|
||||
"serverValid": "Frigate+ accepted this API key",
|
||||
"serverInvalid": "Frigate+ rejected this API key",
|
||||
"serverUnreachable": "Unable to reach Frigate+ to validate this API key",
|
||||
"desc": "The Frigate+ API key enables integration with the Frigate+ service.",
|
||||
"plusLink": "Read more about Frigate+"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import useSWR from "swr";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { AlertTriangle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { LuExternalLink } from "react-icons/lu";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -18,12 +19,34 @@ import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import type { SettingsPageProps } from "@/views/settings/SingleSectionPage";
|
||||
import { getPrimaryModel } from "@/utils/modelUtil";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
|
||||
type PlusKeyStatus = "valid" | "invalid" | "unreachable" | "not_set";
|
||||
|
||||
type PlusValidateResponse = { success: boolean; status: PlusKeyStatus };
|
||||
|
||||
export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
const { t } = useTranslation("views/settings");
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [keyStatus, setKeyStatus] = useState<PlusKeyStatus>();
|
||||
|
||||
const validateKey = useCallback(async () => {
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post<PlusValidateResponse>("plus/validate");
|
||||
setKeyStatus(response.data.status);
|
||||
} catch {
|
||||
setKeyStatus("unreachable");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("documentTitle.frigatePlus");
|
||||
@@ -68,17 +91,55 @@ export default function FrigatePlusSettingsView(_props: SettingsPageProps) {
|
||||
</>
|
||||
}
|
||||
content={
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{config?.plus?.enabled ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.formatValid")
|
||||
: t("frigatePlus.apiKey.formatInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
{config?.plus?.enabled && isAdmin && (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isValidating}
|
||||
onClick={validateKey}
|
||||
aria-label={t("frigatePlus.apiKey.validate")}
|
||||
>
|
||||
{isValidating && (
|
||||
<ActivityIndicator className="mr-2" size={16} />
|
||||
)}
|
||||
{isValidating
|
||||
? t("frigatePlus.apiKey.validating")
|
||||
: t("frigatePlus.apiKey.validate")}
|
||||
</Button>
|
||||
{keyStatus && !isValidating && (
|
||||
<div className="flex items-center gap-2">
|
||||
{keyStatus === "valid" ? (
|
||||
<CheckCircle2 className="size-5 shrink-0 text-green-500" />
|
||||
) : keyStatus === "unreachable" ? (
|
||||
<AlertTriangle className="size-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<XCircle className="size-5 shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{keyStatus === "valid"
|
||||
? t("frigatePlus.apiKey.serverValid")
|
||||
: keyStatus === "unreachable"
|
||||
? t("frigatePlus.apiKey.serverUnreachable")
|
||||
: t("frigatePlus.apiKey.serverInvalid")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{config?.plus?.enabled
|
||||
? t("frigatePlus.apiKey.validated")
|
||||
: t("frigatePlus.apiKey.notValidated")}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user