cameras/src/lib/camera.ts
Michael Mainguy 33c12ea70c Stop camera routes echoing raw error messages
cameraErrorResponse now returns fixed text for each error class and
logs the original server-side. Upstream ONVIF/SOAP responses, socket
errors and credential-file paths no longer reach the browser (vrek
iss-tz5s098). CameraAuthError gains a missingLogin flag so the UI can
still tell "no login saved" apart from "login rejected".

Tests throw errors carrying a fake password and file path, and check
neither appears in any response. Closes the dashboard migration (vrek
iss-qbh3541). Refreshes the vrek export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 09:03:46 -05:00

282 lines
9.0 KiB
TypeScript

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<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", true));
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,
};
}