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>
282 lines
9.0 KiB
TypeScript
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,
|
|
};
|
|
}
|