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 pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
import httpx
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Query, Request, Response
|
from fastapi import APIRouter, Depends, Query, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from onvif import ONVIFCamera, ONVIFError
|
from onvif import ONVIFCamera, ONVIFError
|
||||||
from zeep.exceptions import Fault, TransportError
|
from zeep.exceptions import Fault, TransportError
|
||||||
|
from zeep.transports import AsyncTransport
|
||||||
|
|
||||||
from frigate.api.auth import require_role
|
from frigate.api.auth import require_role
|
||||||
from frigate.api.defs.tags import Tags
|
from frigate.api.defs.tags import Tags
|
||||||
@ -464,7 +466,8 @@ def _extract_fps(r_frame_rate: str) -> float | None:
|
|||||||
summary="Probe ONVIF device",
|
summary="Probe ONVIF device",
|
||||||
description=(
|
description=(
|
||||||
"Probe an ONVIF device to determine capabilities and optionally test available stream URIs. "
|
"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(
|
async def onvif_probe(
|
||||||
@ -474,6 +477,7 @@ async def onvif_probe(
|
|||||||
username: str = Query(""),
|
username: str = Query(""),
|
||||||
password: str = Query(""),
|
password: str = Query(""),
|
||||||
test: bool = Query(False),
|
test: bool = Query(False),
|
||||||
|
auth_type: str = Query("basic"), # Add auth_type parameter
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Probe a single ONVIF device to determine capabilities.
|
Probe a single ONVIF device to determine capabilities.
|
||||||
@ -491,6 +495,7 @@ async def onvif_probe(
|
|||||||
username: ONVIF username (optional)
|
username: ONVIF username (optional)
|
||||||
password: ONVIF password (optional)
|
password: ONVIF password (optional)
|
||||||
test: run ffprobe on the stream (optional)
|
test: run ffprobe on the stream (optional)
|
||||||
|
auth_type: Authentication type - "basic" or "digest" (default "basic")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
JSON with device capabilities information
|
JSON with device capabilities information
|
||||||
@ -508,10 +513,20 @@ async def onvif_probe(
|
|||||||
status_code=400,
|
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
|
onvif_camera = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Probing ONVIF device at {host}:{port}")
|
logger.debug(f"Probing ONVIF device at {host}:{port} with {auth_type} auth")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wsdl_base = None
|
wsdl_base = None
|
||||||
@ -525,7 +540,29 @@ async def onvif_probe(
|
|||||||
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
host, port, username or "", password or "", wsdl_dir=wsdl_base
|
||||||
)
|
)
|
||||||
|
|
||||||
await onvif_camera.update_xaddrs()
|
# 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
|
# Get device information
|
||||||
device_info = {
|
device_info = {
|
||||||
@ -535,6 +572,14 @@ async def onvif_probe(
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
device_service = await onvif_camera.create_devicemgmt_service()
|
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()
|
device_info_resp = await device_service.GetDeviceInformation()
|
||||||
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
manufacturer = getattr(device_info_resp, "Manufacturer", None) or (
|
||||||
device_info_resp.get("Manufacturer")
|
device_info_resp.get("Manufacturer")
|
||||||
@ -558,8 +603,8 @@ async def onvif_probe(
|
|||||||
"firmware_version": firmware or "Unknown",
|
"firmware_version": firmware or "Unknown",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.debug("Failed to get device info")
|
logger.debug(f"Failed to get device info: {e}")
|
||||||
|
|
||||||
# Get media profiles
|
# Get media profiles
|
||||||
profiles = []
|
profiles = []
|
||||||
@ -568,6 +613,14 @@ async def onvif_probe(
|
|||||||
ptz_config_token = None
|
ptz_config_token = None
|
||||||
try:
|
try:
|
||||||
media_service = await onvif_camera.create_media_service()
|
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 = await media_service.GetProfiles()
|
||||||
profiles_count = len(profiles) if profiles else 0
|
profiles_count = len(profiles) if profiles else 0
|
||||||
if profiles and len(profiles) > 0:
|
if profiles and len(profiles) > 0:
|
||||||
@ -585,8 +638,8 @@ async def onvif_probe(
|
|||||||
if isinstance(ptz_configuration, dict)
|
if isinstance(ptz_configuration, dict)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
logger.debug("Failed to get media profiles")
|
logger.debug(f"Failed to get media profiles: {e}")
|
||||||
|
|
||||||
# Check PTZ support and capabilities
|
# Check PTZ support and capabilities
|
||||||
ptz_supported = False
|
ptz_supported = False
|
||||||
@ -596,6 +649,13 @@ async def onvif_probe(
|
|||||||
try:
|
try:
|
||||||
ptz_service = await onvif_camera.create_ptz_service()
|
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
|
# Check if PTZ service is available
|
||||||
try:
|
try:
|
||||||
await ptz_service.GetServiceCapabilities()
|
await ptz_service.GetServiceCapabilities()
|
||||||
@ -744,6 +804,14 @@ async def onvif_probe(
|
|||||||
rtsp_candidates: list[dict] = []
|
rtsp_candidates: list[dict] = []
|
||||||
try:
|
try:
|
||||||
media_service = await onvif_camera.create_media_service()
|
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:
|
if profiles_count and media_service:
|
||||||
for p in profiles or []:
|
for p in profiles or []:
|
||||||
token = getattr(p, "token", None) or (
|
token = getattr(p, "token", None) or (
|
||||||
|
|||||||
@ -196,6 +196,8 @@
|
|||||||
"manualMode": "Manual selection",
|
"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\".",
|
"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.",
|
"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": {
|
"errors": {
|
||||||
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
"brandOrCustomUrlRequired": "Either select a camera brand with host/IP or choose 'Other' with a custom URL",
|
||||||
"nameRequired": "Camera name is required",
|
"nameRequired": "Camera name is required",
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
} from "@/types/cameraWizard";
|
} from "@/types/cameraWizard";
|
||||||
import { FaCircleCheck } from "react-icons/fa6";
|
import { FaCircleCheck } from "react-icons/fa6";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { maskUri } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type OnvifProbeResultsProps = {
|
type OnvifProbeResultsProps = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@ -258,12 +259,6 @@ function CandidateItem({
|
|||||||
const { t } = useTranslation(["views/settings"]);
|
const { t } = useTranslation(["views/settings"]);
|
||||||
const [showFull, setShowFull] = useState(false);
|
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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@ -81,6 +82,7 @@ export default function Step1NameCamera({
|
|||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||||
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||||
|
useDigestAuth: z.boolean().optional(),
|
||||||
customUrl: z
|
customUrl: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@ -118,6 +120,7 @@ export default function Step1NameCamera({
|
|||||||
: "dahua",
|
: "dahua",
|
||||||
customUrl: wizardData.customUrl || "",
|
customUrl: wizardData.customUrl || "",
|
||||||
onvifPort: wizardData.onvifPort ?? 80,
|
onvifPort: wizardData.onvifPort ?? 80,
|
||||||
|
useDigestAuth: wizardData.useDigestAuth ?? false,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
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 && (
|
{!probeMode && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@ -191,6 +191,7 @@ export default function Step2ProbeOrSnapshot({
|
|||||||
username: wizardData.username || "",
|
username: wizardData.username || "",
|
||||||
password: wizardData.password || "",
|
password: wizardData.password || "",
|
||||||
test: false,
|
test: false,
|
||||||
|
auth_type: wizardData.useDigestAuth ? "digest" : "basic",
|
||||||
},
|
},
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { PlayerStatsType } from "@/types/live";
|
|||||||
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
|
||||||
import { LuX } from "react-icons/lu";
|
import { LuX } from "react-icons/lu";
|
||||||
import { Card, CardContent } from "../../ui/card";
|
import { Card, CardContent } from "../../ui/card";
|
||||||
|
import { maskUri } from "@/utils/cameraUtil";
|
||||||
|
|
||||||
type Step4ValidationProps = {
|
type Step4ValidationProps = {
|
||||||
wizardData: Partial<WizardFormData>;
|
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">
|
<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">
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
{stream.url}
|
{maskUri(stream.url)}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -114,6 +114,7 @@ export type WizardFormData = {
|
|||||||
streams?: StreamConfig[];
|
streams?: StreamConfig[];
|
||||||
probeMode?: boolean; // true for probe, false for manual
|
probeMode?: boolean; // true for probe, false for manual
|
||||||
onvifPort?: number;
|
onvifPort?: number;
|
||||||
|
useDigestAuth?: boolean;
|
||||||
probeResult?: OnvifProbeResponse;
|
probeResult?: OnvifProbeResponse;
|
||||||
probeCandidates?: string[]; // candidate URLs from probe
|
probeCandidates?: string[]; // candidate URLs from probe
|
||||||
candidateTests?: CandidateTestMap; // test results for candidates
|
candidateTests?: CandidateTestMap; // test results for candidates
|
||||||
|
|||||||
@ -71,3 +71,26 @@ export async function detectReolinkCamera(
|
|||||||
return null;
|
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