mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 13:34:13 +03:00
Add Camera Wizard tweaks (#20889)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* digest auth backend * frontend * i18n * update field description language to include note about onvif specific credentials * mask util helper function * language * mask passwords in http-flv and others where a url param is password
This commit is contained in:
parent
e4eac4ac81
commit
a623150811
@ -7,11 +7,13 @@ from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from onvif import ONVIFCamera, ONVIFError
|
||||
from zeep.exceptions import Fault, TransportError
|
||||
from zeep.transports import AsyncTransport
|
||||
|
||||
from frigate.api.auth import require_role
|
||||
from frigate.api.defs.tags import Tags
|
||||
@ -464,7 +466,8 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
||||
summary="Probe ONVIF device",
|
||||
description=(
|
||||
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
|
||||
"Query params: host (required), port (default 80), username, password, test (boolean)."
|
||||
"Query params: host (required), port (default 80), username, password, test (boolean), "
|
||||
"auth_type (basic or digest, default basic)."
|
||||
),
|
||||
)
|
||||
async def onvif_probe(
|
||||
@ -474,6 +477,7 @@ async def onvif_probe(
|
||||
username: str = Query(""),
|
||||
password: str = Query(""),
|
||||
test: bool = Query(False),
|
||||
auth_type: str = Query("basic"), # Add auth_type parameter
|
||||
):
|
||||
"""
|
||||
Probe a single ONVIF device to determine capabilities.
|
||||
@ -491,6 +495,7 @@ async def onvif_probe(
|
||||
username: ONVIF username (optional)
|
||||
password: ONVIF password (optional)
|
||||
test: run ffprobe on the stream (optional)
|
||||
auth_type: Authentication type - "basic" or "digest" (default "basic")
|
||||
|
||||
Returns:
|
||||
JSON with device capabilities information
|
||||
@ -508,10 +513,20 @@ async def onvif_probe(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate auth_type
|
||||
if auth_type not in ["basic", "digest"]:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"success": False,
|
||||
"message": "auth_type must be 'basic' or 'digest'",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
onvif_camera = None
|
||||
|
||||
try:
|
||||
logger.debug(f"Probing ONVIF device at {host}:{port}")
|
||||
logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
|
||||
|
||||
try:
|
||||
wsdl_base = None
|
||||
@ -525,6 +540,28 @@ async def onvif_probe(
|
||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||
)
|
||||
|
||||
# Configure digest authentication if requested
|
||||
if auth_type == "digest" and username and password:
|
||||
# Create httpx client with digest auth
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
|
||||
# Replace the transport in the zeep client
|
||||
transport = AsyncTransport(client=client)
|
||||
|
||||
# Update the xaddr before setting transport
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Replace transport in all services
|
||||
if hasattr(onvif_camera, "devicemgmt"):
|
||||
onvif_camera.devicemgmt.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "media"):
|
||||
onvif_camera.media.zeep_client.transport = transport
|
||||
if hasattr(onvif_camera, "ptz"):
|
||||
onvif_camera.ptz.zeep_client.transport = transport
|
||||
|
||||
logger.debug("Configured digest authentication")
|
||||
else:
|
||||
await onvif_camera.update_xaddrs()
|
||||
|
||||
# Get device information
|
||||
@ -535,6 +572,14 @@ async def onvif_probe(
|
||||
}
|
||||
try:
|
||||
device_service = await onvif_camera.create_devicemgmt_service()
|
||||
|
||||
# Update transport for device service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
device_service.zeep_client.transport = transport
|
||||
|
||||
device_info_resp = await device_service.GetDeviceInformation()
|
||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||
device_info_resp.get("Manufacturer")
|
||||
@ -558,8 +603,8 @@ async def onvif_probe(
|
||||
"firmware_version": firmware or "Unknown",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to get device info")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get device info: {e}")
|
||||
|
||||
# Get media profiles
|
||||
profiles = []
|
||||
@ -568,6 +613,14 @@ async def onvif_probe(
|
||||
ptz_config_token = None
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
|
||||
profiles = await media_service.GetProfiles()
|
||||
profiles_count = len(profiles) if profiles else 0
|
||||
if profiles and len(profiles) > 0:
|
||||
@ -585,8 +638,8 @@ async def onvif_probe(
|
||||
if isinstance(ptz_configuration, dict)
|
||||
else None
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to get media profiles")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get media profiles: {e}")
|
||||
|
||||
# Check PTZ support and capabilities
|
||||
ptz_supported = False
|
||||
@ -596,6 +649,13 @@ async def onvif_probe(
|
||||
try:
|
||||
ptz_service = await onvif_camera.create_ptz_service()
|
||||
|
||||
# Update transport for PTZ service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
ptz_service.zeep_client.transport = transport
|
||||
|
||||
# Check if PTZ service is available
|
||||
try:
|
||||
await ptz_service.GetServiceCapabilities()
|
||||
@ -744,6 +804,14 @@ async def onvif_probe(
|
||||
rtsp_candidates: list[dict] = []
|
||||
try:
|
||||
media_service = await onvif_camera.create_media_service()
|
||||
|
||||
# Update transport for media service if digest auth
|
||||
if auth_type == "digest" and username and password:
|
||||
auth = httpx.DigestAuth(username, password)
|
||||
client = httpx.AsyncClient(auth=auth, timeout=10.0)
|
||||
transport = AsyncTransport(client=client)
|
||||
media_service.zeep_client.transport = transport
|
||||
|
||||
if profiles_count and media_service:
|
||||
for p in profiles or []:
|
||||
token = getattr(p, "token", None) or (
|
||||
|
||||
@ -196,6 +196,8 @@
|
||||
"manualMode": "Manual selection",
|
||||
"detectionMethodDescription": "Probe the camera with ONVIF (if supported) to find camera stream URLs, or manually select the camera brand to use pre-defined URLs. To enter a custom RTSP URL, choose the manual method and select \"Other\".",
|
||||
"onvifPortDescription": "For cameras that support ONVIF, this is usually 80 or 8080.",
|
||||
"useDigestAuth": "Use digest authentication",
|
||||
"useDigestAuthDescription": "Use HTTP digest authentication for ONVIF. Some cameras may require a dedicated ONVIF username/password instead of the standard admin user.",
|
||||
"errors": {
|
||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||
"nameRequired": "Camera name is required",
|
||||
|
||||
@ -16,6 +16,7 @@ import type {
|
||||
} from "@/types/cameraWizard";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { maskUri } from "@/utils/cameraUtil";
|
||||
|
||||
type OnvifProbeResultsProps = {
|
||||
isLoading: boolean;
|
||||
@ -258,12 +259,6 @@ function CandidateItem({
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
|
||||
const maskUri = (uri: string) => {
|
||||
const match = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||
if (match) return `rtsp://${match[1]}:••••@${match[3]}`;
|
||||
return uri;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@ -81,6 +82,7 @@ export default function Step1NameCamera({
|
||||
password: z.string().optional(),
|
||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
useDigestAuth: z.boolean().optional(),
|
||||
customUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
@ -118,6 +120,7 @@ export default function Step1NameCamera({
|
||||
: "dahua",
|
||||
customUrl: wizardData.customUrl || "",
|
||||
onvifPort: wizardData.onvifPort ?? 80,
|
||||
useDigestAuth: wizardData.useDigestAuth ?? false,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
@ -330,6 +333,32 @@ export default function Step1NameCamera({
|
||||
/>
|
||||
)}
|
||||
|
||||
{probeMode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useDigestAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-start space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(val) => field.onChange(!!val)}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex flex-1 flex-col space-y-1">
|
||||
<FormLabel className="mb-0 text-primary-variant">
|
||||
{t("cameraWizard.step1.useDigestAuth")}
|
||||
</FormLabel>
|
||||
<FormDescription className="mt-0">
|
||||
{t("cameraWizard.step1.useDigestAuthDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!probeMode && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
|
||||
@ -191,6 +191,7 @@ export default function Step2ProbeOrSnapshot({
|
||||
username: wizardData.username || "",
|
||||
password: wizardData.password || "",
|
||||
test: false,
|
||||
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ import { PlayerStatsType } from "@/types/live";
|
||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Card, CardContent } from "../../ui/card";
|
||||
import { maskUri } from "@/utils/cameraUtil";
|
||||
|
||||
type Step4ValidationProps = {
|
||||
wizardData: Partial<WizardFormData>;
|
||||
@ -374,7 +375,7 @@ export default function Step4Validation({
|
||||
|
||||
<div className="mb-2 flex flex-col justify-between gap-1 md:flex-row md:items-center">
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{stream.url}
|
||||
{maskUri(stream.url)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@ -114,6 +114,7 @@ export type WizardFormData = {
|
||||
streams?: StreamConfig[];
|
||||
probeMode?: boolean; // true for probe, false for manual
|
||||
onvifPort?: number;
|
||||
useDigestAuth?: boolean;
|
||||
probeResult?: OnvifProbeResponse;
|
||||
probeCandidates?: string[]; // candidate URLs from probe
|
||||
candidateTests?: CandidateTestMap; // test results for candidates
|
||||
|
||||
@ -71,3 +71,26 @@ export async function detectReolinkCamera(
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask credentials in RTSP URIs for display
|
||||
*/
|
||||
export function maskUri(uri: string): string {
|
||||
try {
|
||||
// Handle RTSP URLs with user:pass@host format
|
||||
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
||||
if (rtspMatch) {
|
||||
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
|
||||
}
|
||||
|
||||
// Handle HTTP/HTTPS URLs with password query parameter
|
||||
const urlObj = new URL(uri);
|
||||
if (urlObj.searchParams.has("password")) {
|
||||
urlObj.searchParams.set("password", "*".repeat(4));
|
||||
return urlObj.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user