From f39d16a8c0c56c93c317ea58b808747ff4d33948 Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Sat, 19 Sep 2026 08:40:32 -0500 Subject: [PATCH] Add ONVIF camera app with a server-rendered camera dashboard Discovers ONVIF cameras (Hikvision/Annke) over WS-Discovery, keeps a server-side registry and an encrypted credential store, and serves info, snapshot and credentials routes for each camera. The home page is now a Server Component that lists known cameras from the registry on load, without a scan (vrek iss-a0hz0py). The snapshot refresh rate lives in the URL (?refresh=), and a scan refreshes the server-rendered list. Route input is validated with zod (iss-rjqy3hy), and /api/discover no longer returns raw error messages (iss-dbwgww8). Adds @tanstack/react-query and zod as dependencies, with a QueryClient provider in the root layout. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + next.config.ts | 3 +- package-lock.json | 82 +++- package.json | 6 +- scripts/camera-check.ts | 35 ++ scripts/probe-debug.mjs | 100 +++++ src/app/api/cameras/[id]/credentials/route.ts | 56 +++ src/app/api/cameras/[id]/info/route.ts | 13 + src/app/api/cameras/[id]/snapshot/route.ts | 17 + src/app/api/discover/route.ts | 22 ++ src/app/camera-card.tsx | 359 ++++++++++++++++++ src/app/camera-scanner.tsx | 77 ++++ src/app/layout.tsx | 9 +- src/app/page.tsx | 96 ++--- src/app/providers.tsx | 23 ++ src/app/refresh-rate-select.tsx | 27 ++ src/app/refresh-rate.ts | 11 + src/lib/camera-registry.ts | 125 ++++++ src/lib/camera-route.ts | 49 +++ src/lib/camera.ts | 274 +++++++++++++ src/lib/credential-store.ts | 167 ++++++++ src/lib/onvif.ts | 222 +++++++++++ src/types/onvif-utils.d.ts | 13 + 23 files changed, 1720 insertions(+), 70 deletions(-) create mode 100644 scripts/camera-check.ts create mode 100644 scripts/probe-debug.mjs create mode 100644 src/app/api/cameras/[id]/credentials/route.ts create mode 100644 src/app/api/cameras/[id]/info/route.ts create mode 100644 src/app/api/cameras/[id]/snapshot/route.ts create mode 100644 src/app/api/discover/route.ts create mode 100644 src/app/camera-card.tsx create mode 100644 src/app/camera-scanner.tsx create mode 100644 src/app/providers.tsx create mode 100644 src/app/refresh-rate-select.tsx create mode 100644 src/app/refresh-rate.ts create mode 100644 src/lib/camera-registry.ts create mode 100644 src/lib/camera-route.ts create mode 100644 src/lib/camera.ts create mode 100644 src/lib/credential-store.ts create mode 100644 src/lib/onvif.ts create mode 100644 src/types/onvif-utils.d.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..dac5660 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# camera credentials store (passwords) +/.data/ +/snapshot.jpg diff --git a/next.config.ts b/next.config.ts index e9ffa30..4e46ae0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // onvif uses Node's dgram/os modules; load it with native require instead of bundling. + serverExternalPackages: ["onvif"], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 6a7393c..c8526f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "cameras", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.103.1", "next": "16.3.5", + "onvif": "^0.8.3", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "server-only": "^0.0.1", + "zod": "^4.6.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1676,6 +1680,32 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz", + "integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz", + "integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.4", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", @@ -5504,6 +5534,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/onvif": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/onvif/-/onvif-0.8.3.tgz", + "integrity": "sha512-55gkQKRHVehU4tZWR/dy6ALLU9a6A3YfmX6da/joLFfNtH3YSLaw2yuioGLC8H5ZjHqY8I9UDSboPiQqEFL+tw==", + "license": "MIT", + "dependencies": { + "xml2js": "^0.6.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5930,6 +5972,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5946,6 +5997,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -6848,6 +6905,28 @@ "node": ">=0.10.0" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6872,7 +6951,6 @@ "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 66a500d..85a0244 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,13 @@ "lint": "eslint" }, "dependencies": { + "@tanstack/react-query": "^5.103.1", "next": "16.3.5", + "onvif": "^0.8.3", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "server-only": "^0.0.1", + "zod": "^4.6.5" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/scripts/camera-check.ts b/scripts/camera-check.ts new file mode 100644 index 0000000..0cbf794 --- /dev/null +++ b/scripts/camera-check.ts @@ -0,0 +1,35 @@ +// Checks ONVIF login, profiles and snapshot for one camera, outside of Next.js. +// The camera must be in .data/cameras.json (run a scan in the app first). +// Usage: npx tsx --env-file=.env.local --conditions=react-server scripts/camera-check.ts +import { readFileSync, writeFileSync } from "node:fs"; +import { getCameraInfo, getSnapshot } from "../src/lib/camera"; +import type { CameraRecord } from "../src/lib/camera-registry"; + +const [idOrHost] = process.argv.slice(2); +if (!idOrHost) { + console.error("Usage: scripts/camera-check.ts "); + process.exit(1); +} + +const registry: Record = JSON.parse( + readFileSync(".data/cameras.json", "utf8"), +); +const record = Object.values(registry).find((r) => r.id === idOrHost || r.host === idOrHost); +if (!record) { + console.error(`${idOrHost} is not in .data/cameras.json; scan the network first.`); + process.exit(1); +} +const target = { id: record.id, host: record.host, port: record.port }; +console.log(`Camera ${record.id} at ${record.host}:${record.port}`); + +(async () => { + try { + console.log(JSON.stringify(await getCameraInfo(target), null, 2)); + const snap = await getSnapshot(target); + writeFileSync("snapshot.jpg", snap.body); + console.log(`Snapshot: ${snap.contentType}, ${snap.body.length} bytes -> snapshot.jpg`); + } catch (err) { + console.error(`Error (${(err as Error).name}):`, err instanceof Error ? err.message : err); + } + process.exit(0); +})(); diff --git a/scripts/probe-debug.mjs b/scripts/probe-debug.mjs new file mode 100644 index 0000000..68729fa --- /dev/null +++ b/scripts/probe-debug.mjs @@ -0,0 +1,100 @@ +// WS-Discovery diagnostic: sends several Probe variants and listens both on the +// sending socket (unicast replies) and on 239.255.255.250:3702 (multicast replies). +// Usage: node scripts/probe-debug.mjs [interfaceName] [timeoutMs] [targetIp] +// With targetIp, probes are sent by unicast to targetIp:3702 instead of multicast. +import dgram from "node:dgram"; +import os from "node:os"; +import { randomUUID } from "node:crypto"; + +const ifaceName = process.argv[2]; +const timeoutMs = Number(process.argv[3]) || 5000; +const target = process.argv[4]; +const GROUP = "239.255.255.250"; +const PORT = 3702; + +const ifaces = os.networkInterfaces(); +const candidates = Object.entries(ifaces).flatMap(([name, addrs]) => + (addrs ?? []) + .filter((a) => a.family === "IPv4" && !a.internal) + .map((a) => ({ name, address: a.address })), +); +console.log("IPv4 interfaces:", candidates.map((c) => `${c.name}=${c.address}`).join(", ")); +const iface = ifaceName ? candidates.find((c) => c.name === ifaceName) : candidates[0]; +if (!iface) { + console.error(`No IPv4 interface ${ifaceName ?? ""} found`); + process.exit(1); +} +console.log(`Using ${iface.name} (${iface.address}), timeout ${timeoutMs}ms\n`); + +const probe = (types) => { + const id = `urn:uuid:${randomUUID()}`; + return { + id, + xml: + `` + + `` + + `${id}` + + `urn:schemas-xmlsoap-org:ws:2005:04:discovery` + + `http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe` + + `${types ? `${types}` : ""}` + + ``, + }; +}; +const variants = { + "NetworkVideoTransmitter": probe("dn:NetworkVideoTransmitter"), + "tds:Device": probe("tds:Device"), + "no Types": probe(null), +}; + +const seen = new Set(); +function report(via) { + return (msg, rinfo) => { + const text = msg.toString(); + if (/<[^>]*:?Probe>/.test(text) && !/ProbeMatch/.test(text)) return; // our own / others' probes + const relates = text.match(/RelatesTo[^>]*>([^<]+) v.id === relates)?.[0] ?? "unknown"; + const xaddrs = text.match(/XAddrs[^>]*>([^<]+) console.error("unicast socket error:", e)); + +const mcast = dgram.createSocket({ type: "udp4", reuseAddr: true }); +mcast.on("message", report("multicast 3702")); +mcast.on("error", (e) => console.error(`multicast listener error (port ${PORT} busy?):`, e.message)); +mcast.bind(PORT, () => { + try { + mcast.addMembership(GROUP, iface.address); + } catch (e) { + console.error("addMembership failed:", e.message); + } +}); + +unicast.bind(0, iface.address, () => { + unicast.setMulticastInterface(iface.address); + unicast.setMulticastTTL(4); + console.log(`Sending from ${iface.address}:${unicast.address().port}`); + for (const [name, { xml }] of Object.entries(variants)) { + unicast.send(xml, PORT, target ?? GROUP, (err) => + console.log(`sent "${name}" to ${target ?? GROUP}${err ? ` ERROR ${err.message}` : ""}`), + ); + } +}); + +setTimeout(() => { + console.log(`\nDone. ${seen.size} reply(ies).`); + unicast.close(); + mcast.close(); +}, timeoutMs); diff --git a/src/app/api/cameras/[id]/credentials/route.ts b/src/app/api/cameras/[id]/credentials/route.ts new file mode 100644 index 0000000..033f151 --- /dev/null +++ b/src/app/api/cameras/[id]/credentials/route.ts @@ -0,0 +1,56 @@ +import { resetConnection, testCredentials } from "@/lib/camera"; +import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; +import { + credentialsSchema, + deleteCredentials, + describeCredentials, + setCredentials, +} from "@/lib/credential-store"; + +// Passwords are write-only: GET reports whether one is set, never its value. + +export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) { + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + try { + return Response.json(await describeCredentials(target.id)); + } catch (err) { + return cameraErrorResponse(err); + } +} + +export async function PUT(request: Request, ctx: RouteContext<"/api/cameras/[id]/credentials">) { + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + + const parsed = credentialsSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) { + return Response.json({ error: "Username is required" }, { status: 400 }); + } + const { username, password } = parsed.data; + + // Only store a login the camera actually accepts. + try { + await testCredentials(target, { username, password }); + await setCredentials(target.id, { username, password }); + resetConnection(target); + return Response.json(await describeCredentials(target.id)); + } catch (err) { + return cameraErrorResponse(err); + } +} + +export async function DELETE( + _request: Request, + ctx: RouteContext<"/api/cameras/[id]/credentials">, +) { + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + try { + await deleteCredentials(target.id); + resetConnection(target); + return Response.json(await describeCredentials(target.id)); + } catch (err) { + return cameraErrorResponse(err); + } +} diff --git a/src/app/api/cameras/[id]/info/route.ts b/src/app/api/cameras/[id]/info/route.ts new file mode 100644 index 0000000..4a6fcae --- /dev/null +++ b/src/app/api/cameras/[id]/info/route.ts @@ -0,0 +1,13 @@ +import { getCameraInfo } from "@/lib/camera"; +import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; + +export async function GET(_request: Request, ctx: RouteContext<"/api/cameras/[id]/info">) { + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + + try { + return Response.json(await getCameraInfo(target)); + } catch (err) { + return cameraErrorResponse(err); + } +} diff --git a/src/app/api/cameras/[id]/snapshot/route.ts b/src/app/api/cameras/[id]/snapshot/route.ts new file mode 100644 index 0000000..80e28f1 --- /dev/null +++ b/src/app/api/cameras/[id]/snapshot/route.ts @@ -0,0 +1,17 @@ +import { getSnapshot } from "@/lib/camera"; +import { cameraErrorResponse, cameraTarget } from "@/lib/camera-route"; + +export async function GET(request: Request, ctx: RouteContext<"/api/cameras/[id]/snapshot">) { + const target = await cameraTarget(ctx.params); + if (target instanceof Response) return target; + const profile = new URL(request.url).searchParams.get("profile") ?? undefined; + + try { + const { contentType, body } = await getSnapshot(target, profile); + return new Response(new Uint8Array(body), { + headers: { "Content-Type": contentType, "Cache-Control": "no-store" }, + }); + } catch (err) { + return cameraErrorResponse(err); + } +} diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts new file mode 100644 index 0000000..6a4084c --- /dev/null +++ b/src/app/api/discover/route.ts @@ -0,0 +1,22 @@ +import { recordDiscovered } from "@/lib/camera-registry"; +import { discoverCameras, discoverRequestSchema } from "@/lib/onvif"; + +export async function POST(request: Request) { + // An empty body means "use the defaults". + const body = await request.json().catch(() => ({})); + const parsed = discoverRequestSchema.safeParse(body ?? {}); + if (!parsed.success) { + return Response.json({ error: "Invalid scan options" }, { status: 400 }); + } + const { timeout, unicastSweep } = parsed.data; + + try { + const cameras = await discoverCameras({ timeoutMs: timeout, unicastSweep }); + await recordDiscovered(cameras); + return Response.json({ cameras }); + } catch (err) { + // OS and socket errors can carry interface names and addresses; keep them in the server log. + console.error("Network scan failed", err); + return Response.json({ error: "Network scan failed" }, { status: 500 }); + } +} diff --git a/src/app/camera-card.tsx b/src/app/camera-card.tsx new file mode 100644 index 0000000..5b68329 --- /dev/null +++ b/src/app/camera-card.tsx @@ -0,0 +1,359 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { CameraInfo } from "@/lib/camera"; +import type { CameraSummary } from "@/lib/camera-registry"; + +type Problem = { kind: "inactive" | "auth" | "error"; message: string }; + +const DEFAULT_USERNAME = "camera"; + +async function problemFrom(res: Response): Promise { + const data = await res.json().catch(() => ({})); + const kind = + data.code === "inactive" ? "inactive" : res.status === 401 || data.code === "auth" ? "auth" : "error"; + return { kind, message: data.error ?? `HTTP ${res.status}` }; +} + +function apiUrl(cam: CameraSummary, path: string) { + return `/api/cameras/${cam.id}/${path}`; +} + +/** The camera's own web interface; it can't be framed (X-Frame-Options: SAMEORIGIN). */ +function webUiUrl(cam: CameraSummary) { + return `http://${cam.host}${cam.port === 80 ? "" : `:${cam.port}`}/`; +} + +function WebUiLink({ cam, children }: { cam: CameraSummary; children: React.ReactNode }) { + return ( + + {children} ↗ + + ); +} + +function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) { + return ( +
+
Setup required: camera not activated
+
    +
  1. + Open the camera's web page and activate it by + choosing an admin password. Save it in your password manager; this app never stores + it. +
  2. +
  3. + Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration + Protocol). +
  4. +
  5. + On the same page, add an ONVIF user named {DEFAULT_USERNAME} with the{" "} + Operator level. +
  6. +
  7. Come back here and click Check again, then enter that user's login.
  8. +
+
+ +
+
+ ); +} + +/** + * Polls the snapshot route, requesting the next frame only after the current one has + * arrived. Stops on the first failure and reports it; the parent restarts it by + * clearing `paused`. + */ +function Snapshot({ + cam, + intervalMs, + paused, + onProblem, +}: { + cam: CameraSummary; + intervalMs: number; + paused: boolean; + onProblem: (p: Problem) => void; +}) { + const [frame, setFrame] = useState(null); + const frameRef = useRef(null); + const onProblemRef = useRef(onProblem); + useEffect(() => { + onProblemRef.current = onProblem; + }); + + useEffect(() => () => { + if (frameRef.current) URL.revokeObjectURL(frameRef.current); + }, []); + + useEffect(() => { + if (paused) return; + let cancelled = false; + let timer: ReturnType | undefined; + + async function poll() { + try { + const res = await fetch(apiUrl(cam, "snapshot"), { cache: "no-store" }); + if (cancelled) return; + if (!res.ok) return onProblemRef.current(await problemFrom(res)); + const url = URL.createObjectURL(await res.blob()); + if (cancelled) return URL.revokeObjectURL(url); + if (frameRef.current) URL.revokeObjectURL(frameRef.current); + frameRef.current = url; + setFrame(url); + timer = setTimeout(poll, intervalMs); + } catch (err) { + if (!cancelled) { + onProblemRef.current({ + kind: "error", + message: err instanceof Error ? err.message : String(err), + }); + } + } + } + poll(); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [cam, intervalMs, paused]); + + return ( +
+ {frame && ( + // eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable + {cam.name + )} + {!frame && !paused && ( +
+ Loading… +
+ )} +
+ ); +} + +function CredentialsForm({ + cam, + reason, + onSaved, + onCancel, +}: { + cam: CameraSummary; + reason?: string; + onSaved: () => void; + onCancel?: () => void; +}) { + const [username, setUsername] = useState(DEFAULT_USERNAME); + const [password, setPassword] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Prefill the username only; the API never returns passwords. + useEffect(() => { + fetch(apiUrl(cam, "credentials")) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + // A username the user has already typed wins over the saved one. + if (data?.username) { + setUsername((u) => (u === DEFAULT_USERNAME ? data.username : u)); + } + }) + .catch(() => {}); + }, [cam]); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const res = await fetch(apiUrl(cam, "credentials"), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const problem = await problemFrom(res); + setError(problem.kind === "auth" ? "Camera rejected that login." : problem.message); + return; + } + setPassword(""); + onSaved(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + return ( +
+
Camera login
+ {reason &&
{reason}
} +
+ Use the ONVIF user you created on the camera ( + camera web page). +
+ setUsername(e.target.value)} + placeholder="Username" + autoComplete="off" + required + className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700" + /> + setPassword(e.target.value)} + placeholder="Password" + autoComplete="new-password" + className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700" + /> + {error &&
{error}
} +
+ + {onCancel && ( + + )} +
+
+ ); +} + +export default function CameraCard({ + cam, + intervalMs, +}: { + cam: CameraSummary; + intervalMs: number; +}) { + const [info, setInfo] = useState(null); + const [problem, setProblem] = useState(null); + const [editingLogin, setEditingLogin] = useState(false); + // Bumped to retry after a failure or a credentials change. + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + let cancelled = false; + fetch(apiUrl(cam, "info")) + .then(async (res) => { + if (cancelled) return; + if (!res.ok) return setProblem(await problemFrom(res)); + setInfo(await res.json()); + }) + .catch((err) => { + if (!cancelled) setProblem({ kind: "error", message: String(err) }); + }); + return () => { + cancelled = true; + }; + }, [cam, attempt]); + + function retry() { + setProblem(null); + setEditingLogin(false); + setAttempt((a) => a + 1); + } + + const showSetup = problem?.kind === "inactive"; + const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin); + + return ( +
  • + + +
    +
    {cam.name ?? info?.model ?? "Unnamed camera"}
    +
    + + {cam.host}:{cam.port} + + + Web page + {!showLogin && !showSetup && ( + + )} + +
    + {info?.manufacturer && ( +
    + {info.manufacturer} {info.model} · fw {info.firmwareVersion} +
    + )} + {cam.location && ( +
    {cam.location}
    + )} +
    + + {showSetup && } + + {showLogin && ( + setEditingLogin(false)} + /> + )} + + {problem?.kind === "error" && ( +
    + Stopped: {problem.message} + +
    + )} + + {info && info.profiles.length > 0 && ( + + + + + + + + + + + {info.profiles.map((p) => ( + + + + + + + ))} + +
    ProfileCodecResolutionFPS
    {p.name ?? p.token}{p.encoding ?? "?"}{p.width && p.height ? `${p.width}×${p.height}` : "?"}{p.fps ?? "?"}
    + )} +
  • + ); +} diff --git a/src/app/camera-scanner.tsx b/src/app/camera-scanner.tsx new file mode 100644 index 0000000..934b354 --- /dev/null +++ b/src/app/camera-scanner.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +/** + * Runs a network scan. The scan writes the server-side registry, so afterwards the + * page is refreshed and the server re-renders the camera list. + */ +export default function CameraScanner() { + const router = useRouter(); + const [scanning, setScanning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [timeout, setTimeoutSecs] = useState(5); + const [unicastSweep, setUnicastSweep] = useState(true); + + async function scan() { + setScanning(true); + setError(null); + setResult(null); + try { + const res = await fetch("/api/discover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + // The server rejects timeouts outside 1–30 s; keep a half-typed value in range. + body: JSON.stringify({ + timeout: Math.min(Math.max(Math.round(timeout) || 5, 1), 30) * 1000, + unicastSweep, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + const found = data.cameras.length; + setResult(`Scan found ${found} camera${found === 1 ? "" : "s"}`); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setScanning(false); + } + } + + return ( + <> + + + + {result &&

    {result}

    } + {error &&

    Error: {error}

    } + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9852c15..78f0f86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Providers from "./providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "ONVIF Cameras", + description: "Discover ONVIF cameras on the local network", }; export default function RootLayout({ children }: LayoutProps<"/">) { @@ -23,7 +24,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) { lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index c887311..34352fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,69 +1,39 @@ -import Image from "next/image"; +import { listCameras } from "@/lib/camera-registry"; +import CameraCard from "./camera-card"; +import CameraScanner from "./camera-scanner"; +import RefreshRateSelect from "./refresh-rate-select"; +import { refreshMsSchema } from "./refresh-rate"; + +export default async function Home({ searchParams }: PageProps<"/">) { + const [cameras, { refresh }] = await Promise.all([listCameras(), searchParams]); + const intervalMs = refreshMsSchema.parse(refresh); -export default function Home() { return ( -
    -
    - Next.js logo -
    -

    - To get started, edit the{" "} - - page.tsx - {" "} - file. -

    -

    - Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +

    +

    ONVIF Cameras

    +

    + Discover ONVIF cameras on the local network using WS-Discovery. +

    + +
    +
    + + +
    + +
    +

    + {cameras.length === 0 + ? "No cameras known yet. Scan the network to find them." + : `${cameras.length} camera${cameras.length === 1 ? "" : "s"}`}

    +
      + {cameras.map((cam) => ( + + ))} +
    - -
    -
    + +
    ); } diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..13fdf52 --- /dev/null +++ b/src/app/providers.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // Device state is never served from cache; always refetch. + staleTime: 0, + // One retry at most, so an offline camera isn't hammered. + retry: 1, + }, + }, + }); +} + +export default function Providers({ children }: { children: React.ReactNode }) { + // One client per browser session; useState keeps it stable across re-renders. + const [queryClient] = useState(makeQueryClient); + return {children}; +} diff --git a/src/app/refresh-rate-select.tsx b/src/app/refresh-rate-select.tsx new file mode 100644 index 0000000..a58468a --- /dev/null +++ b/src/app/refresh-rate-select.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { usePathname, useRouter } from "next/navigation"; +import { REFRESH_OPTIONS_MS } from "./refresh-rate"; + +/** Keeps the refresh rate in the URL so the server-rendered grid can read it. */ +export default function RefreshRateSelect({ value }: { value: number }) { + const router = useRouter(); + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/app/refresh-rate.ts b/src/app/refresh-rate.ts new file mode 100644 index 0000000..a086800 --- /dev/null +++ b/src/app/refresh-rate.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +/** Snapshot refresh intervals offered in the UI, in milliseconds. */ +export const REFRESH_OPTIONS_MS = [250, 500, 1000, 2000, 5000] as const; +export const DEFAULT_REFRESH_MS = 1000; + +/** The `?refresh=` search param; anything unexpected falls back to the default. */ +export const refreshMsSchema = z.coerce + .number() + .refine((ms) => (REFRESH_OPTIONS_MS as readonly number[]).includes(ms)) + .catch(DEFAULT_REFRESH_MS); diff --git a/src/lib/camera-registry.ts b/src/lib/camera-registry.ts new file mode 100644 index 0000000..a3fc5ec --- /dev/null +++ b/src/lib/camera-registry.ts @@ -0,0 +1,125 @@ +import "server-only"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { connection } from "next/server"; +import { z } from "zod"; +import { CredentialStoreError, moveCredentials } from "./credential-store"; + +/** + * Last known address of each camera, keyed by a stable ID derived from its + * WS-Discovery endpoint reference (usually a MAC-based UUID). Every scan refreshes it, + * so a camera that changes IP keeps its stored login. Not secret: no credentials here. + */ +export interface CameraRecord { + id: string; + urn: string; + host: string; + port: number; + name?: string; + location?: string; + lastSeen: string; +} + +/** What the dashboard is given for each known camera: no URN, nothing secret. */ +export interface CameraSummary { + id: string; + host: string; + port: number; + name?: string; + location?: string; + lastSeen: string; +} + +const REGISTRY_PATH = + process.env.CAMERA_REGISTRY_FILE ?? path.join(process.cwd(), ".data", "cameras.json"); + +const UUID_URN = /^urn:uuid:([0-9a-f-]{36})$/i; + +export function cameraIdFromUrn(urn: string): string { + const uuid = urn.match(UUID_URN)?.[1]; + return uuid + ? uuid.toLowerCase() + : createHash("sha256").update(urn).digest("hex").slice(0, 32); +} + +export const cameraIdSchema = z.string().regex(/^[0-9a-f-]{32,36}$/); + +export function isValidCameraId(id: string): boolean { + return cameraIdSchema.safeParse(id).success; +} + +let cache: Promise> | null = null; + +function load(): Promise> { + cache ??= readFile(REGISTRY_PATH, "utf8") + .then((text) => JSON.parse(text) as Record) + .catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return {}; + cache = null; + throw err; + }); + return cache; +} + +async function save(registry: Record) { + await mkdir(path.dirname(REGISTRY_PATH), { recursive: true, mode: 0o700 }); + const tmp = `${REGISTRY_PATH}.${process.pid}.tmp`; + await writeFile(tmp, JSON.stringify(registry, null, 2), { mode: 0o600 }); + await rename(tmp, REGISTRY_PATH); + cache = Promise.resolve(registry); +} + +export async function getCameraRecord(id: string): Promise { + return (await load())[id] ?? null; +} + +/** Every known camera, ordered by name then address. Read per request, never prerendered. */ +export async function listCameras(): Promise { + await connection(); + return Object.values(await load()) + .map(({ id, host, port, name, location, lastSeen }) => ({ + id, + host, + port, + name, + location, + lastSeen, + })) + .sort( + (a, b) => + (a.name ?? "").localeCompare(b.name ?? "") || + a.host.localeCompare(b.host, undefined, { numeric: true }), + ); +} + +export async function recordDiscovered( + cameras: { + id: string; + urn: string; + hostname: string; + port: number; + name?: string; + location?: string; + }[], +) { + if (cameras.length === 0) return; + const registry = { ...(await load()) }; + const now = new Date().toISOString(); + for (const cam of cameras) { + registry[cam.id] = { + id: cam.id, + urn: cam.urn, + host: cam.hostname, + port: cam.port, + name: cam.name, + location: cam.location, + lastSeen: now, + }; + // Logins saved before cameras were keyed by ID were stored under "host:port". + await moveCredentials(`${cam.hostname}:${cam.port}`, cam.id).catch((err) => { + if (!(err instanceof CredentialStoreError)) throw err; + }); + } + await save(registry); +} diff --git a/src/lib/camera-route.ts b/src/lib/camera-route.ts new file mode 100644 index 0000000..143bbe6 --- /dev/null +++ b/src/lib/camera-route.ts @@ -0,0 +1,49 @@ +import "server-only"; +import { + CameraAuthError, + CameraInactiveError, + isAllowedHost, + type CameraTarget, +} from "./camera"; +import { getCameraRecord, isValidCameraId } from "./camera-registry"; +import { CredentialStoreError } from "./credential-store"; + +/** + * Resolves the [id] route param to the camera's last known address. The address comes + * from the server-side registry, never from the client, so a stored login can only be + * sent to the camera it belongs to. + */ +export async function cameraTarget( + params: Promise<{ id: string }>, +): Promise { + const { id } = await params; + if (!isValidCameraId(id)) { + return Response.json({ error: "Invalid camera ID" }, { status: 400 }); + } + const record = await getCameraRecord(id); + if (!record) { + return Response.json({ error: "Unknown camera; scan the network again" }, { status: 404 }); + } + if (!isAllowedHost(record.host)) { + return Response.json({ error: "Camera address is not a private IPv4 address" }, { status: 400 }); + } + return { id, host: record.host, port: record.port }; +} + +/** + * Error codes the UI acts on: "inactive" shows the setup panel, "auth" stops polling + * and asks for a login, "store" is a problem with the encrypted credentials file. + */ +export function cameraErrorResponse(err: unknown): Response { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof CameraInactiveError) { + return Response.json({ error: message, code: "inactive" }, { status: 409 }); + } + if (err instanceof CameraAuthError) { + return Response.json({ error: message, code: "auth" }, { status: 401 }); + } + if (err instanceof CredentialStoreError) { + return Response.json({ error: message, code: "store" }, { status: 500 }); + } + return Response.json({ error: message }, { status: 502 }); +} diff --git a/src/lib/camera.ts b/src/lib/camera.ts new file mode 100644 index 0000000..8f295ac --- /dev/null +++ b/src/lib/camera.ts @@ -0,0 +1,274 @@ +import "server-only"; +import http from "node:http"; +import https from "node:https"; +import { Cam, type CamProfile } from "onvif"; +import { getDigestHeaders } from "onvif/lib/utils"; +import { getCredentials, type Credentials } from "./credential-store"; + +export interface CameraProfile { + token: string; + name?: string; + encoding?: string; + width?: number; + height?: number; + fps?: number; +} + +export interface CameraInfo { + manufacturer?: string; + model?: string; + firmwareVersion?: string; + serialNumber?: string; + profiles: CameraProfile[]; +} + +export interface Snapshot { + contentType: string; + body: Buffer; +} + +const REQUEST_TIMEOUT_MS = 10_000; + +/** A camera in the registry: stable ID plus its last known address. */ +export interface CameraTarget { + id: string; + host: string; + port: number; +} + +/** The camera rejected (or was never given) a username/password. */ +export class CameraAuthError extends Error { + name = "CameraAuthError"; +} + +/** + * The camera hasn't been activated (first admin password never set). Vendors such as + * Hikvision refuse every ONVIF request, even unauthenticated ones, until then. + */ +export class CameraInactiveError extends Error { + name = "CameraInactiveError"; +} + +const INACTIVE_RESPONSE = /device is inactive|not activated|inactive device/i; + +const AUTH_FAILURE = /\b401\b|not ?authori[sz]ed|authenticat|unauthori[sz]ed/i; + +function asAuthError(err: unknown): unknown { + const message = err instanceof Error ? err.message : String(err); + return AUTH_FAILURE.test(message) ? new CameraAuthError(message) : err; +} + +/** + * Only RFC 1918 IPv4 addresses may be targeted, so the snapshot/info routes can't be + * used to make this server request arbitrary hosts. + */ +export function isAllowedHost(host: string): boolean { + const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return false; + const [a, b, c, d] = m.slice(1).map(Number); + if ([a, b, c, d].some((n) => n > 255)) return false; + return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +// Connected Cam instances are reused across requests; connect() makes several SOAP +// calls, which is too slow to repeat for every snapshot. +const cams = new Map>(); +const snapshotUris = new Map>(); + +/** + * Connects and loads profiles. Rejects with CameraInactiveError for unactivated cameras + * and CameraAuthError if there is no login or it is refused. With no credentials it still + * contacts the camera (GetSystemDateAndTime needs no login) to tell those two apart. + */ +function openCam(host: string, port: number, creds: Credentials | null): Promise { + return new Promise((resolve, reject) => { + const cam = new Cam({ + hostname: host, + port, + ...(creds ?? {}), + timeout: REQUEST_TIMEOUT_MS, + // Use the discovered address even if the camera advertises another one. + preserveAddress: true, + autoconnect: false, + }); + let inactive = false; + cam.on("rawResponse", (body: string) => { + if (INACTIVE_RESPONSE.test(body)) inactive = true; + }); + // If GetProfiles fails (typically bad credentials), connect() still succeeds but + // only emits a "warning" and leaves profiles empty; surface that as an error. + let warning: string | undefined; + cam.on("warning", (w: unknown) => (warning = String(w))); + cam.connect((err) => { + if (inactive) { + return reject(new CameraInactiveError("Camera has not been activated yet")); + } + if (err) return reject(asAuthError(err)); + if (!cam.profiles?.length) { + if (!creds) return reject(new CameraAuthError("No login saved for this camera")); + return reject( + asAuthError(new Error(`Camera returned no media profiles${warning ? `: ${warning}` : ""}`)), + ); + } + resolve(cam); + }); + }); +} + +function connect(target: CameraTarget): Promise { + const key = `${target.host}:${target.port}`; + let pending = cams.get(key); + if (!pending) { + pending = getCredentials(target.id).then((creds) => openCam(target.host, target.port, creds)); + pending.catch(() => cams.delete(key)); + cams.set(key, pending); + } + return pending; +} + +/** Verifies a login against the camera without touching cached connections. */ +export async function testCredentials(target: CameraTarget, creds: Credentials) { + await openCam(target.host, target.port, creds); +} + +/** Drops cached connections so the next request logs in again. */ +export function resetConnection({ host, port }: CameraTarget) { + const prefix = `${host}:${port}`; + cams.delete(prefix); + for (const key of snapshotUris.keys()) { + if (key.startsWith(`${prefix}/`)) snapshotUris.delete(key); + } +} + +function toProfile(p: CamProfile): CameraProfile { + // Media1 uses videoEncoderConfiguration; Media2 profiles use configurations.videoEncoder. + const enc = p.videoEncoderConfiguration ?? p.configurations?.videoEncoder; + const resolution = enc?.resolution; + return { + token: p.token ?? p.$?.token, + name: p.name, + encoding: enc?.encoding, + width: resolution?.width, + height: resolution?.height, + fps: enc?.rateControl?.frameRateLimit ?? enc?.rateControl?.$?.FrameRateLimit, + }; +} + +export async function getCameraInfo(target: CameraTarget): Promise { + const cam = await connect(target); + const device = await new Promise>((resolve, reject) => + cam.getDeviceInformation((err, info) => (err ? reject(err) : resolve(info ?? {}))), + ).catch(() => ({}) as Record); + + return { + manufacturer: device.manufacturer, + model: device.model, + firmwareVersion: device.firmwareVersion, + serialNumber: device.serialNumber, + profiles: (cam.profiles ?? []).map(toProfile), + }; +} + +function snapshotUri(target: CameraTarget, profileToken?: string): Promise { + const key = `${target.host}:${target.port}/${profileToken ?? ""}`; + let pending = snapshotUris.get(key); + if (!pending) { + pending = connect(target).then( + (cam) => + new Promise((resolve, reject) => + cam.getSnapshotUri(profileToken ? { profileToken } : {}, (err, res) => + err || !res?.uri + ? reject(err ?? new Error("Camera returned no snapshot URI")) + : resolve(res.uri), + ), + ), + ); + pending.catch(() => snapshotUris.delete(key)); + snapshotUris.set(key, pending); + } + return pending; +} + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + rawHeaders: string[]; + body: Buffer; +} + +function httpGet(url: URL, authorization?: string): Promise { + const lib = url.protocol === "https:" ? https : http; + return new Promise((resolve, reject) => { + const req = lib.request( + url, + { + method: "GET", + headers: authorization ? { Authorization: authorization } : {}, + // Cameras commonly use self-signed certificates. + rejectUnauthorized: false, + timeout: REQUEST_TIMEOUT_MS, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + rawHeaders: res.rawHeaders, + body: Buffer.concat(chunks), + }), + ); + res.on("error", reject); + }, + ); + req.on("timeout", () => req.destroy(new Error("Snapshot request timed out"))); + req.on("error", reject); + req.end(); + }); +} + +/** + * Fetches a JPEG from the camera's snapshot URI, answering a Digest or Basic + * challenge with the ONVIF credentials. + */ +export async function getSnapshot(target: CameraTarget, profileToken?: string): Promise { + const { host } = target; + let uri: string; + try { + uri = await snapshotUri(target, profileToken); + } catch (err) { + resetConnection(target); + throw err; + } + + const url = new URL(uri); + // Some cameras advertise an unreachable or internal hostname in the snapshot URI. + url.hostname = host; + let res = await httpGet(url); + + if (res.status === 401) { + const cam = await connect(target); + const { username, password } = (await getCredentials(target.id)) ?? { + username: "", + password: "", + }; + const digest = getDigestHeaders(res.rawHeaders); + const authorization = digest.length + ? cam.digestAuth(digest, { method: "GET", path: url.pathname + url.search }) + : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + res = await httpGet(url, authorization); + } + + if (res.status === 401) { + resetConnection(target); + throw new CameraAuthError("Snapshot login rejected (HTTP 401)"); + } + if (res.status !== 200) { + throw new Error(`Snapshot request failed with HTTP ${res.status}`); + } + return { + contentType: String(res.headers["content-type"] ?? "image/jpeg"), + body: res.body, + }; +} diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts new file mode 100644 index 0000000..9df624c --- /dev/null +++ b/src/lib/credential-store.ts @@ -0,0 +1,167 @@ +import "server-only"; +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +export interface Credentials { + username: string; + password: string; +} + +/** A login submitted from the web UI. Password may be empty (some cameras allow it). */ +export const credentialsSchema = z.object({ + username: z.string().min(1).max(64), + password: z.string().max(256), +}) satisfies z.ZodType; + +// Stored on the server's disk only, encrypted with CAMERA_CREDENTIALS_KEY. The API never +// returns passwords; the web UI can only set, replace, or clear them. +const STORE_PATH = + process.env.CAMERA_CREDENTIALS_FILE ?? path.join(process.cwd(), ".data", "credentials.json"); + +type Store = Record; + +/** On-disk format: the whole store encrypted with AES-256-GCM. */ +interface EncryptedFile { + version: 1; + kdf: "scrypt"; + salt: string; + iv: string; + tag: string; + data: string; +} + +export class CredentialStoreError extends Error { + name = "CredentialStoreError"; +} + +// scrypt is deliberately slow, so derive once per salt. +const derivedKeys = new Map(); +let fileSalt: Buffer | null = null; + +function encryptionKey(salt: Buffer): Buffer { + const secret = process.env.CAMERA_CREDENTIALS_KEY; + if (!secret) { + throw new CredentialStoreError( + "CAMERA_CREDENTIALS_KEY is not set in .env.local; stored camera logins can't be read or saved.", + ); + } + const id = `${salt.toString("base64")}:${secret}`; + let key = derivedKeys.get(id); + if (!key) { + key = scryptSync(secret, salt, 32); + derivedKeys.set(id, key); + } + return key; +} + +function encrypt(store: Store): EncryptedFile { + fileSalt ??= randomBytes(16); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", encryptionKey(fileSalt), iv); + const data = Buffer.concat([cipher.update(JSON.stringify(store), "utf8"), cipher.final()]); + return { + version: 1, + kdf: "scrypt", + salt: fileSalt.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + data: data.toString("base64"), + }; +} + +function decrypt(file: EncryptedFile): Store { + const salt = Buffer.from(file.salt, "base64"); + const decipher = createDecipheriv( + "aes-256-gcm", + encryptionKey(salt), + Buffer.from(file.iv, "base64"), + ); + decipher.setAuthTag(Buffer.from(file.tag, "base64")); + try { + const text = Buffer.concat([ + decipher.update(Buffer.from(file.data, "base64")), + decipher.final(), + ]).toString("utf8"); + fileSalt = salt; + return JSON.parse(text) as Store; + } catch { + throw new CredentialStoreError( + `Can't decrypt ${STORE_PATH}: CAMERA_CREDENTIALS_KEY is wrong or the file is corrupt.`, + ); + } +} + +let cache: Promise | null = null; + +function load(): Promise { + cache ??= readFile(STORE_PATH, "utf8") + .then(async (text) => { + const parsed = JSON.parse(text); + if (parsed?.version === 1 && parsed.kdf === "scrypt") return decrypt(parsed); + // Plaintext file from before encryption was added: re-save it encrypted. + await save(parsed as Store); + return parsed as Store; + }) + .catch((err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT") return {}; + cache = null; + throw err; + }); + return cache; +} + +async function save(store: Store) { + const contents = JSON.stringify(encrypt(store), null, 2); + await mkdir(path.dirname(STORE_PATH), { recursive: true, mode: 0o700 }); + const tmp = `${STORE_PATH}.${process.pid}.tmp`; + await writeFile(tmp, contents, { mode: 0o600 }); + await rename(tmp, STORE_PATH); + cache = Promise.resolve(store); +} + +// Entries are keyed by camera ID (see camera-registry.ts), so they survive IP changes. + +/** Stored credentials for the camera, falling back to ONVIF_USERNAME / ONVIF_PASSWORD. */ +export async function getCredentials(cameraId: string): Promise { + const stored = (await load())[cameraId]; + if (stored) return stored; + if (process.env.ONVIF_USERNAME || process.env.ONVIF_PASSWORD) { + return { + username: process.env.ONVIF_USERNAME ?? "", + password: process.env.ONVIF_PASSWORD ?? "", + }; + } + return null; +} + +export async function setCredentials(cameraId: string, creds: Credentials) { + await save({ ...(await load()), [cameraId]: creds }); +} + +export async function deleteCredentials(cameraId: string) { + const store = { ...(await load()) }; + delete store[cameraId]; + await save(store); +} + +/** Re-keys an entry (used to migrate old "host:port" keys to camera IDs). */ +export async function moveCredentials(fromKey: string, toKey: string) { + const store = await load(); + if (!(fromKey in store) || toKey in store) return; + const next = { ...store, [toKey]: store[fromKey] }; + delete next[fromKey]; + await save(next); +} + +/** What the web UI may see: never the password itself. */ +export async function describeCredentials(cameraId: string) { + const stored = (await load())[cameraId]; + const effective = await getCredentials(cameraId); + return { + source: stored ? "stored" : effective ? "env" : "none", + username: effective?.username ?? null, + hasPassword: Boolean(effective?.password), + } as const; +} diff --git a/src/lib/onvif.ts b/src/lib/onvif.ts new file mode 100644 index 0000000..93b5ebc --- /dev/null +++ b/src/lib/onvif.ts @@ -0,0 +1,222 @@ +import "server-only"; +import dgram from "node:dgram"; +import os from "node:os"; +import { Discovery } from "onvif"; +import { z } from "zod"; +import { guid, linerase, parseSOAPString } from "onvif/lib/utils"; +import { cameraIdFromUrn } from "./camera-registry"; + +export type DiscoverySource = "multicast" | "unicast"; + +export interface DiscoveredCamera { + /** Stable ID derived from the WS-Discovery endpoint reference; survives IP changes. */ + id: string; + urn: string; + hostname: string; + port: number; + xaddrs: string[]; + name?: string; + hardware?: string; + location?: string; + scopes: string[]; + foundBy: DiscoverySource[]; +} + +export interface DiscoverOptions { + timeoutMs?: number; + /** Also send the probe by unicast to every host on the local IPv4 subnets. */ + unicastSweep?: boolean; +} + +/** Scan options accepted from the web UI; omitted fields take their defaults. */ +export const discoverRequestSchema = z.object({ + timeout: z.number().int().min(1000).max(30_000).default(5000), + unicastSweep: z.boolean().default(true), +}); + +interface ProbeMatch { + endpointReference?: { address?: string }; + XAddrs?: string; + scopes?: string | { _?: string }; +} + +const WS_DISCOVERY_PORT = 3702; +// Largest subnet we will sweep: a /20 is 4094 hosts. +const MIN_SWEEP_PREFIX = 20; +const SWEEP_BATCH_SIZE = 64; +const SWEEP_BATCH_DELAY_MS = 15; + +// Discovery is an EventEmitter singleton that emits "error" for malformed replies; +// without a listener Node would throw and take down the server process. +let errorListenerAttached = false; +function ensureErrorListener() { + if (errorListenerAttached) return; + Discovery.on("error", (err: unknown) => { + console.warn("[onvif] discovery error:", err); + }); + errorListenerAttached = true; +} + +function scopeValue(scopes: string[], key: string): string | undefined { + const prefix = `onvif://www.onvif.org/${key}/`; + const match = scopes.find((s) => s.startsWith(prefix)); + return match ? decodeURIComponent(match.slice(prefix.length)) : undefined; +} + +function toCamera(raw: unknown, source: DiscoverySource): DiscoveredCamera | null { + const match = (raw as { probeMatches?: { probeMatch?: ProbeMatch } }) + ?.probeMatches?.probeMatch; + if (!match?.XAddrs) return null; + + const xaddrs = match.XAddrs.split(/\s+/).filter(Boolean); + // Prefer an IPv4 XAddr; cameras often also advertise IPv6 addresses. + const primary = new URL(xaddrs.find((x) => !x.includes("[")) ?? xaddrs[0]); + const scopeString = + typeof match.scopes === "string" ? match.scopes : match.scopes?._ ?? ""; + const scopes = scopeString.split(/\s+/).filter(Boolean); + + const urn = match.endpointReference?.address ?? xaddrs[0]; + return { + id: cameraIdFromUrn(urn), + urn, + hostname: primary.hostname, + port: Number(primary.port) || 80, + xaddrs, + name: scopeValue(scopes, "name"), + hardware: scopeValue(scopes, "hardware"), + location: scopeValue(scopes, "location"), + scopes, + foundBy: [source], + }; +} + +function multicastProbe(timeoutMs: number): Promise { + ensureErrorListener(); + return new Promise((resolve, reject) => { + Discovery.probe({ timeout: timeoutMs, resolve: false }, (err, devices) => { + // Parse errors from individual devices come back as an array; only fail on + // socket-level errors. + if (err && !Array.isArray(err)) return reject(err); + resolve( + (devices ?? []) + .map((d) => toCamera(d, "multicast")) + .filter((c) => c !== null), + ); + }); + }); +} + +function ipToInt(ip: string): number { + return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0; +} + +function intToIp(n: number): string { + return [24, 16, 8, 0].map((shift) => (n >>> shift) & 255).join("."); +} + +/** Every host address on the machine's non-internal IPv4 subnets, excluding itself. */ +function localSubnetHosts(): string[] { + const hosts = new Set(); + for (const addrs of Object.values(os.networkInterfaces())) { + for (const addr of addrs ?? []) { + if (addr.family !== "IPv4" || addr.internal || !addr.cidr) continue; + if (addr.address.startsWith("169.254.")) continue; + const prefix = Number(addr.cidr.split("/")[1]); + if (prefix < MIN_SWEEP_PREFIX || prefix > 30) { + console.warn(`[onvif] skipping unicast sweep of ${addr.cidr}`); + continue; + } + const mask = (0xffffffff << (32 - prefix)) >>> 0; + const network = (ipToInt(addr.address) & mask) >>> 0; + const broadcast = (network | ~mask) >>> 0; + for (let n = network + 1; n < broadcast; n++) { + const ip = intToIp(n); + if (ip !== addr.address) hosts.add(ip); + } + } + } + return [...hosts]; +} + +function probeMessage(messageId: string): Buffer { + return Buffer.from( + '' + + '' + + `${messageId}` + + "urn:schemas-xmlsoap-org:ws:2005:04:discovery" + + "http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe" + + "dn:NetworkVideoTransmitter" + + "", + ); +} + +/** + * Sends the WS-Discovery Probe directly to each host on the local subnets. Works on + * networks that drop multicast (e.g. Wi-Fi access points that don't forward it upstream). + */ +function unicastSweep(timeoutMs: number): Promise { + const hosts = localSubnetHosts(); + if (hosts.length === 0) return Promise.resolve([]); + + return new Promise((resolve, reject) => { + const cameras = new Map(); + const message = probeMessage(`urn:uuid:${guid()}`); + const socket = dgram.createSocket("udp4"); + let closed = false; + + socket.on("message", (msg) => { + parseSOAPString(msg.toString(), (err, data) => { + if (err || !data) return; + const camera = toCamera(linerase(data), "unicast"); + if (camera && !cameras.has(camera.urn)) cameras.set(camera.urn, camera); + }); + }); + socket.on("error", (err) => { + if (closed) return; + closed = true; + socket.close(); + reject(err); + }); + + socket.bind(0, async () => { + for (let i = 0; i < hosts.length && !closed; i += SWEEP_BATCH_SIZE) { + for (const host of hosts.slice(i, i + SWEEP_BATCH_SIZE)) { + // Send errors (e.g. EHOSTDOWN for absent hosts) are expected; ignore them. + socket.send(message, WS_DISCOVERY_PORT, host, () => {}); + } + await new Promise((r) => setTimeout(r, SWEEP_BATCH_DELAY_MS)); + } + }); + + setTimeout(() => { + if (closed) return; + closed = true; + socket.close(); + resolve([...cameras.values()]); + }, timeoutMs); + }); +} + +export async function discoverCameras({ + timeoutMs = 5000, + unicastSweep: sweep = true, +}: DiscoverOptions = {}): Promise { + const results = await Promise.all([ + multicastProbe(timeoutMs), + sweep ? unicastSweep(timeoutMs) : Promise.resolve([]), + ]); + + const merged = new Map(); + for (const camera of results.flat()) { + const existing = merged.get(camera.urn); + if (existing) { + existing.foundBy = [...new Set([...existing.foundBy, ...camera.foundBy])]; + } else { + merged.set(camera.urn, { ...camera }); + } + } + return [...merged.values()]; +} diff --git a/src/types/onvif-utils.d.ts b/src/types/onvif-utils.d.ts new file mode 100644 index 0000000..a10c6d2 --- /dev/null +++ b/src/types/onvif-utils.d.ts @@ -0,0 +1,13 @@ +// onvif ships no typings for its internal helpers; these are the ones we reuse so +// unicast replies parse into exactly the same shape as Discovery.probe results. +declare module "onvif/lib/utils" { + export function parseSOAPString( + xml: string, + callback: (err: Error | null, data: unknown[] | null, xml: string, statusCode?: number) => void, + statusCode?: number, + ): void; + export function linerase(xml: unknown): unknown; + export function guid(): string; + /** Extracts Digest WWW-Authenticate challenges from Node's res.rawHeaders. */ + export function getDigestHeaders(rawHeaders: string[]): string[]; +}