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

* 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:
Josh Hawkins 2025-11-11 07:46:23 -06:00 committed by GitHub
parent e4eac4ac81
commit a623150811
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 134 additions and 14 deletions

View File

@ -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 (

View File

@ -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",

View File

@ -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(

View File

@ -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

View File

@ -191,6 +191,7 @@ export default function Step2ProbeOrSnapshot({
username: wizardData.username || "",
password: wizardData.password || "",
test: false,
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
},
timeout: 30000,
});

View File

@ -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={() => {

View File

@ -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

View File

@ -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;
}