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"; constructor( message: string, /** True when no login was saved at all, as opposed to the camera rejecting one. */ readonly missingLogin = false, ) { super(message); } } /** * 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", true)); return reject( asAuthError(new Error(`Camera returned no media profiles${warning ? `: ${warning}` : ""}`)), ); } resolve(cam); }); }); } /** The cached, logged-in connection for a camera (shared with camera-settings.ts). */ export 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); } } /** A profile's video encoder: Media1 uses videoEncoderConfiguration, Media2 configurations.videoEncoder. */ export function encoderConfig(p: CamProfile) { return p.videoEncoderConfiguration ?? p.configurations?.videoEncoder; } /** A profile's token: an element in Media1, an attribute in Media2. */ export function profileToken(p: CamProfile): string { return p.token ?? p.$?.token; } function toProfile(p: CamProfile): CameraProfile { const enc = encoderConfig(p); const resolution = enc?.resolution; return { token: profileToken(p), 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, }; } /** * The camera's RTSP address for its main (first) or sub (second) stream, with the stored * login embedded, for the local video bridge only (vrek iss-nk6zrzv). Contains the camera * password: never return it to a client or log it. */ export async function rtspSourceWithLogin(target: CameraTarget, stream: "main" | "sub"): Promise { const cam = await connect(target); const profile = (cam.profiles ?? [])[stream === "main" ? 0 : 1]; if (!profile) throw new Error(`Camera has no ${stream} stream`); const uri = await new Promise((resolve, reject) => cam.getStreamUri({ protocol: "RTSP", profileToken: profileToken(profile) }, (err, res) => err || !res?.uri ? reject(err ?? new Error("Camera returned no stream URI")) : resolve(res.uri), ), ); const url = new URL(uri); // Some cameras advertise an unreachable or internal hostname in the stream URI. url.hostname = target.host; const creds = await getCredentials(target.id); if (creds) { url.username = encodeURIComponent(creds.username); url.password = encodeURIComponent(creds.password); } return url.toString(); } /** The camera's own name and location, as discovery sees them (vrek iss-h1qwke5). */ export interface CameraScopes { name?: string; location?: string; } const SCOPE_PREFIX = "onvif://www.onvif.org/"; const scopeUri = (key: string, value: string) => `${SCOPE_PREFIX}${key}/${encodeURIComponent(value)}`; function scopeValue(uris: string[], key: string): string | undefined { const prefix = `${SCOPE_PREFIX}${key}/`; const match = uris.find((uri) => uri.startsWith(prefix)); return match ? decodeURIComponent(match.slice(prefix.length)) : undefined; } /** A scope as the library reports it: Media1 nests the parts, Media2 uses attributes. */ type Scope = { ScopeDef?: string; ScopeItem?: string; scopeDef?: string; scopeItem?: string }; const asUri = (scope: Scope) => scope.ScopeItem ?? scope.scopeItem ?? ""; const isConfigurable = (scope: Scope) => (scope.ScopeDef ?? scope.scopeDef ?? "").toLowerCase() === "configurable"; function readScopes(cam: Cam): Promise { return new Promise((resolve, reject) => (cam as unknown as { getScopes(cb: (err: Error | null, scopes?: Scope[]) => void): void }).getScopes( (err, scopes) => (err ? reject(err) : resolve(scopes ?? [])), ), ); } export async function getCameraScopes(target: CameraTarget): Promise { const scopes = await readScopes(await connect(target)); const uris = scopes.map(asUri); return { name: scopeValue(uris, "name"), location: scopeValue(uris, "location") }; } /** * Renames the camera itself. SetScopes replaces every configurable scope, so the others * are read first and sent back untouched; fixed scopes are the camera's own and are left * out. Returns what the camera reports afterwards, which is what actually applied. */ export async function setCameraScopes(target: CameraTarget, patch: CameraScopes): Promise { const cam = await connect(target); const existing = await readScopes(cam); const keep = existing .filter(isConfigurable) .map(asUri) .filter((uri) => uri && !uri.startsWith(`${SCOPE_PREFIX}name/`) && !uri.startsWith(`${SCOPE_PREFIX}location/`)); const wanted = [...keep]; const current = await getCameraScopes(target); const name = patch.name ?? current.name; const location = patch.location ?? current.location; if (name) wanted.push(scopeUri("name", name)); if (location) wanted.push(scopeUri("location", location)); await new Promise((resolve, reject) => (cam as unknown as { setScopes(uris: string[], cb: (err: Error | null) => void): void }).setScopes( wanted, (err) => (err ? reject(err) : resolve()), ), ); return getCameraScopes(target); }