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) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 08:40:32 -05:00
parent 5e73d3ffe0
commit f39d16a8c0
23 changed files with 1720 additions and 70 deletions

4
.gitignore vendored
View File

@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# camera credentials store (passwords)
/.data/
/snapshot.jpg

View File

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

82
package-lock.json generated
View File

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

View File

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

35
scripts/camera-check.ts Normal file
View File

@ -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 <id-or-ip>
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 <id-or-ip>");
process.exit(1);
}
const registry: Record<string, CameraRecord> = 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);
})();

100
scripts/probe-debug.mjs Normal file
View File

@ -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:
`<?xml version="1.0" encoding="UTF-8"?>` +
`<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ` +
`xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" ` +
`xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" ` +
`xmlns:dn="http://www.onvif.org/ver10/network/wsdl" ` +
`xmlns:tds="http://www.onvif.org/ver10/device/wsdl">` +
`<s:Header><a:MessageID>${id}</a:MessageID>` +
`<a:To s:mustUnderstand="1">urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>` +
`<a:Action s:mustUnderstand="1">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>` +
`</s:Header><s:Body><d:Probe>${types ? `<d:Types>${types}</d:Types>` : ""}</d:Probe></s:Body>` +
`</s:Envelope>`,
};
};
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[^>]*>([^<]+)</)?.[1];
const variant = Object.entries(variants).find(([, v]) => v.id === relates)?.[0] ?? "unknown";
const xaddrs = text.match(/XAddrs[^>]*>([^<]+)</)?.[1];
const key = `${via}|${rinfo.address}|${variant}`;
if (seen.has(key)) return;
seen.add(key);
console.log(`REPLY via ${via} from ${rinfo.address}:${rinfo.port} to probe "${variant}"`);
console.log(` XAddrs: ${xaddrs ?? "(none)"}`);
if (!xaddrs) console.log(` raw: ${text.slice(0, 500)}`);
};
}
const unicast = dgram.createSocket({ type: "udp4", reuseAddr: true });
unicast.on("message", report("unicast socket"));
unicast.on("error", (e) => 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);

View File

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

View File

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

View File

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

View File

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

359
src/app/camera-card.tsx Normal file
View File

@ -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<Problem> {
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 (
<a href={webUiUrl(cam)} target="_blank" rel="noopener noreferrer" className="underline">
{children} ↗
</a>
);
}
function SetupPanel({ cam, onCheckAgain }: { cam: CameraSummary; onCheckAgain: () => void }) {
return (
<div className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Setup required: camera not activated</div>
<ol className="list-decimal space-y-1 pl-5 text-zinc-700 dark:text-zinc-300">
<li>
<WebUiLink cam={cam}>Open the camera&apos;s web page</WebUiLink> and activate it by
choosing an admin password. Save it in your password manager; this app never stores
it.
</li>
<li>
Enable ONVIF (usually Configuration → Network → Advanced Settings → Integration
Protocol).
</li>
<li>
On the same page, add an ONVIF user named <code>{DEFAULT_USERNAME}</code> with the{" "}
<strong>Operator</strong> level.
</li>
<li>Come back here and click Check again, then enter that user&apos;s login.</li>
</ol>
<div>
<button
onClick={onCheckAgain}
className="rounded bg-black px-3 py-1 text-white dark:bg-white dark:text-black"
>
Check again
</button>
</div>
</div>
);
}
/**
* 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<string | null>(null);
const frameRef = useRef<string | null>(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<typeof setTimeout> | 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 (
<div className="relative aspect-video overflow-hidden rounded bg-zinc-100 dark:bg-zinc-900">
{frame && (
// eslint-disable-next-line @next/next/no-img-element -- live frames, not optimizable
<img
src={frame}
alt={cam.name ?? cam.host}
className={`h-full w-full object-contain ${paused ? "opacity-40" : ""}`}
/>
)}
{!frame && !paused && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-zinc-500">
Loading…
</div>
)}
</div>
);
}
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<string | null>(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 (
<form onSubmit={submit} className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
<div className="font-medium">Camera login</div>
{reason && <div className="text-xs text-zinc-500">{reason}</div>}
<div className="text-xs text-zinc-500">
Use the ONVIF user you created on the camera (
<WebUiLink cam={cam}>camera web page</WebUiLink>).
</div>
<input
value={username}
onChange={(e) => 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"
/>
<input
type="password"
value={password}
onChange={(e) => 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 && <div className="text-red-600">{error}</div>}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{saving ? "Checking…" : "Save"}
</button>
{onCancel && (
<button type="button" onClick={onCancel} className="rounded px-3 py-1 underline">
Cancel
</button>
)}
</div>
</form>
);
}
export default function CameraCard({
cam,
intervalMs,
}: {
cam: CameraSummary;
intervalMs: number;
}) {
const [info, setInfo] = useState<CameraInfo | null>(null);
const [problem, setProblem] = useState<Problem | null>(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 (
<li className="flex flex-col gap-3 rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<Snapshot
key={attempt}
cam={cam}
intervalMs={intervalMs}
paused={problem !== null || !info}
onProblem={setProblem}
/>
<div>
<div className="font-medium">{cam.name ?? info?.model ?? "Unnamed camera"}</div>
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-sm">
{cam.host}:{cam.port}
</span>
<span className="flex gap-3 text-xs">
<WebUiLink cam={cam}>Web page</WebUiLink>
{!showLogin && !showSetup && (
<button onClick={() => setEditingLogin(true)} className="underline">
Change login
</button>
)}
</span>
</div>
{info?.manufacturer && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{info.manufacturer} {info.model} · fw {info.firmwareVersion}
</div>
)}
{cam.location && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">{cam.location}</div>
)}
</div>
{showSetup && <SetupPanel cam={cam} onCheckAgain={retry} />}
{showLogin && (
<CredentialsForm
cam={cam}
reason={problem?.kind === "auth" ? problem.message : undefined}
onSaved={retry}
onCancel={problem?.kind === "auth" ? undefined : () => setEditingLogin(false)}
/>
)}
{problem?.kind === "error" && (
<div className="flex items-center justify-between gap-2 text-sm text-red-600">
<span>Stopped: {problem.message}</span>
<button onClick={retry} className="underline">
Retry
</button>
</div>
)}
{info && info.profiles.length > 0 && (
<table className="text-xs">
<thead className="text-left text-zinc-500">
<tr>
<th className="font-normal">Profile</th>
<th className="font-normal">Codec</th>
<th className="font-normal">Resolution</th>
<th className="font-normal">FPS</th>
</tr>
</thead>
<tbody className="font-mono">
{info.profiles.map((p) => (
<tr key={p.token}>
<td>{p.name ?? p.token}</td>
<td>{p.encoding ?? "?"}</td>
<td>{p.width && p.height ? `${p.width}×${p.height}` : "?"}</td>
<td>{p.fps ?? "?"}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
}

View File

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<>
<button
onClick={scan}
disabled={scanning}
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-50 dark:bg-white dark:text-black"
>
{scanning ? "Scanning…" : "Scan network"}
</button>
<label className="flex items-center gap-2 text-sm">
Timeout
<input
type="number"
min={1}
max={30}
value={timeout}
onChange={(e) => setTimeoutSecs(Number(e.target.value))}
className="w-16 rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
/>
s
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={unicastSweep}
onChange={(e) => setUnicastSweep(e.target.checked)}
/>
Unicast sweep of local subnet
</label>
{result && <p className="w-full text-sm text-zinc-600 dark:text-zinc-400">{result}</p>}
{error && <p className="w-full text-sm text-red-600">Error: {error}</p>}
</>
);
}

View File

@ -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`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -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 (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-12 font-sans">
<h1 className="text-3xl font-semibold tracking-tight">ONVIF Cameras</h1>
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
Discover ONVIF cameras on the local network using WS-Discovery.
</p>
<section className="mt-8">
<div className="flex flex-wrap items-center gap-4">
<CameraScanner />
<RefreshRateSelect value={intervalMs} />
</div>
<div className="mt-6">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{cameras.length === 0
? "No cameras known yet. Scan the network to find them."
: `${cameras.length} camera${cameras.length === 1 ? "" : "s"}`}
</p>
<ul className="mt-3 grid gap-3 sm:grid-cols-2">
{cameras.map((cam) => (
<CameraCard key={cam.id} cam={cam} intervalMs={intervalMs} />
))}
</ul>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
</section>
</main>
);
}

23
src/app/providers.tsx Normal file
View File

@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@ -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 (
<label className="flex items-center gap-2 text-sm">
Snapshot refresh
<select
value={value}
onChange={(e) => router.replace(`${pathname}?refresh=${e.target.value}`, { scroll: false })}
className="rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700"
>
{REFRESH_OPTIONS_MS.map((ms) => (
<option key={ms} value={ms}>
{ms / 1000} s
</option>
))}
</select>
</label>
);
}

11
src/app/refresh-rate.ts Normal file
View File

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

125
src/lib/camera-registry.ts Normal file
View File

@ -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<Record<string, CameraRecord>> | null = null;
function load(): Promise<Record<string, CameraRecord>> {
cache ??= readFile(REGISTRY_PATH, "utf8")
.then((text) => JSON.parse(text) as Record<string, CameraRecord>)
.catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return {};
cache = null;
throw err;
});
return cache;
}
async function save(registry: Record<string, CameraRecord>) {
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<CameraRecord | null> {
return (await load())[id] ?? null;
}
/** Every known camera, ordered by name then address. Read per request, never prerendered. */
export async function listCameras(): Promise<CameraSummary[]> {
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);
}

49
src/lib/camera-route.ts Normal file
View File

@ -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<CameraTarget | Response> {
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 });
}

274
src/lib/camera.ts Normal file
View File

@ -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<string, Promise<Cam>>();
const snapshotUris = new Map<string, Promise<string>>();
/**
* 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<Cam> {
return new Promise<Cam>((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<Cam> {
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<CameraInfo> {
const cam = await connect(target);
const device = await new Promise<Record<string, string>>((resolve, reject) =>
cam.getDeviceInformation((err, info) => (err ? reject(err) : resolve(info ?? {}))),
).catch(() => ({}) as Record<string, string>);
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<string> {
const key = `${target.host}:${target.port}/${profileToken ?? ""}`;
let pending = snapshotUris.get(key);
if (!pending) {
pending = connect(target).then(
(cam) =>
new Promise<string>((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<HttpResult> {
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<Snapshot> {
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,
};
}

167
src/lib/credential-store.ts Normal file
View File

@ -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<Credentials>;
// 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<string, Credentials>;
/** 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<string, Buffer>();
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<Store> | null = null;
function load(): Promise<Store> {
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<Credentials | null> {
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;
}

222
src/lib/onvif.ts Normal file
View File

@ -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<DiscoveredCamera[]> {
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<string>();
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(
'<?xml version="1.0" encoding="UTF-8"?>' +
'<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" ' +
'xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing" ' +
'xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" ' +
'xmlns:dn="http://www.onvif.org/ver10/network/wsdl">' +
`<s:Header><a:MessageID>${messageId}</a:MessageID>` +
"<a:To>urn:schemas-xmlsoap-org:ws:2005:04:discovery</a:To>" +
"<a:Action>http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</a:Action>" +
"</s:Header><s:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></s:Body>" +
"</s:Envelope>",
);
}
/**
* 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<DiscoveredCamera[]> {
const hosts = localSubnetHosts();
if (hosts.length === 0) return Promise.resolve([]);
return new Promise((resolve, reject) => {
const cameras = new Map<string, DiscoveredCamera>();
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<DiscoveredCamera[]> {
const results = await Promise.all([
multicastProbe(timeoutMs),
sweep ? unicastSweep(timeoutMs) : Promise.resolve([]),
]);
const merged = new Map<string, DiscoveredCamera>();
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()];
}

13
src/types/onvif-utils.d.ts vendored Normal file
View File

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