feat(hik-alarm): record rejected pushes + a read endpoint to see arrivals

Debugging "is the camera event coming or not?" was painful: a rejected
push only logged a warning and recorded nothing, so "no event" was
ambiguous (never sent vs sent-and-refused), and the only durable record
was an unreadable device_events row.

- Record EVERY push, accepted or rejected: accepted -> kind:"alarm",
  rejected -> kind:"alarm-rejected" with the precise reason (unknown
  device / not-hikvision / push-disabled / source-IP mismatch / digest
  fail). The 404 body now also returns the reason.
- New GET /api/devices/hikvision/alarms (device:read): the recent pushes
  newest-first as JSON (accepted+rejected, with ip/reason/eventType/
  target/plate/rawHead) so you can SEE arrivals in the browser instead of
  grepping the dev log or querying SQLite.

Tests: hikvision-alarm.test.ts now 8 (rejection-recorded + read-endpoint
list + gating). server 111/111; build+lint green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 10:28:09 +02:00
parent 6133923094
commit 3db8f517d3
2 changed files with 149 additions and 60 deletions
+43 -1
View File
@@ -1,8 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing"; import { createTestDb } from "@parking/db/testing";
import { and, eq, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { and, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js"; import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle- // Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle-
// detection POST from the camera's configured IP is accepted, summarized (eventType / // detection POST from the camera's configured IP is accepted, summarized (eventType /
@@ -61,6 +62,16 @@ function alarmEvents(): { detail: Record<string, unknown> }[] {
.all() as { detail: Record<string, unknown> }[]; .all() as { detail: Record<string, unknown> }[];
} }
/** Every recorded push for a device — accepted (kind:"alarm") AND rejected
* (kind:"alarm-rejected"). */
function allRecorded(deviceId: string): { kind: string; detail: Record<string, unknown> }[] {
return db
.select()
.from(deviceEventsTable)
.where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"])))
.all() as { kind: string; detail: Record<string, unknown> }[];
}
describe("Hikvision Alarm Server push", () => { describe("Hikvision Alarm Server push", () => {
it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => { it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => {
seedHikCamera(); seedHikCamera();
@@ -122,7 +133,14 @@ describe("Hikvision Alarm Server push", () => {
remoteAddress: "10.0.10.200", // not the camera remoteAddress: "10.0.10.200", // not the camera
}); });
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
// No ACCEPTED alarm...
expect(alarmEvents()).toHaveLength(0); expect(alarmEvents()).toHaveLength(0);
// ...but the rejection IS recorded (with the reason), so "nothing arrived" is never
// ambiguous — you can see it came in and why it was refused.
const recorded = allRecorded(CAM_ID);
expect(recorded).toHaveLength(1);
expect(recorded[0]!.kind).toBe("alarm-rejected");
expect(String(recorded[0]!.detail.reason)).toMatch(/source IP/i);
}); });
it("rejects when alarm push is disabled on the device", async () => { it("rejects when alarm push is disabled on the device", async () => {
@@ -146,5 +164,29 @@ describe("Hikvision Alarm Server push", () => {
remoteAddress: CAM_IP, remoteAddress: CAM_IP,
}); });
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
expect(res.json().reason).toMatch(/unknown device/i);
});
it("GET /api/devices/hikvision/alarms lists accepted AND rejected pushes, newest first", async () => {
seedHikCamera();
// One accepted (right IP) + one rejected (wrong IP).
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: CAM_IP });
await app.inject({ method: "POST", url: `/api/devices/hikvision/${CAM_ID}/event`, headers: { "content-type": "application/xml" }, payload: VEHICLE_XML, remoteAddress: "10.0.10.200" });
const { username, password } = await seedUser(db, { username: "admin1", roleId: "admin" });
const { cookie } = await login(app, username, password);
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms", headers: { cookie } });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.count).toBe(2);
// Both accepted and rejected appear, with the accepted/reason flags.
expect(body.alarms.some((a: { accepted: boolean }) => a.accepted === true)).toBe(true);
const rejected = body.alarms.find((a: { accepted: boolean }) => a.accepted === false);
expect(rejected.reason).toMatch(/source IP/i);
});
it("the alarms read endpoint is gated (device:read) — 401 without a session", async () => {
const res = await app.inject({ method: "GET", url: "/api/devices/hikvision/alarms" });
expect(res.statusCode).toBe(401);
}); });
}); });
+106 -59
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db"; import { desc, eq, inArray, devices, deviceEvents as deviceEventsTable, type Db } from "@parking/db";
import { deviceEvents } from "../device-events.js"; import { deviceEvents } from "../device-events.js";
import { requirePermission } from "../auth.js";
import { verifyDigest } from "../digest-auth.js"; import { verifyDigest } from "../digest-auth.js";
// Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event → // Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event →
@@ -80,59 +81,35 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
done(null, body); done(null, body);
}); });
const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => { /** Record EVERY push (accepted or rejected) as a device_event so the read endpoint /
const { deviceId } = req.params; * DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); * warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
const cfg = row?.config as HikDeviceConfig | undefined; function record(args: {
const ip = clientIp(req); deviceId: string;
accepted: boolean;
// Guard: must be a known hikvision device with alarm-push enabled, posting from its reason?: string;
// configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian). ip: string;
if (!row || row.driverId !== "hikvision" || !cfg?.alarmPushEnabled || !cfg.host || ip !== cfg.host) { contentType: string;
app.log.warn(`rejected hik alarm push: device=${deviceId} ip=${ip} (unknown/disabled/ip-mismatch)`); raw: Buffer;
return reply.code(404).send({ error: "not found" }); summary: AlarmSummary;
} }): void {
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg.pushUser && cfg.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) {
return; // 401 challenge already sent
}
}
const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
// Decode as text for summary + storage. Multipart bodies have a binary image part;
// we keep the readable head (the XML part lives at the top) and note the full size.
const text = raw.toString("utf8");
const summary = summarize(text);
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
// Record verbatim as telemetry (unsigned, prunable). The whole point of this first
// cut: capture exactly what arrives so we can design the real handler. We cap the
// stored body so a giant multipart frame doesn't bloat the row (the head holds the
// XML); the summary carries the parsed fields.
try { try {
db.insert(deviceEventsTable) db.insert(deviceEventsTable)
.values({ .values({
id: randomUUID(), id: randomUUID(),
deviceId, deviceId: args.deviceId,
category: "camera", category: "camera",
kind: "alarm", kind: args.accepted ? "alarm" : "alarm-rejected",
detail: { detail: {
source: "hikvision-alarm-server", source: "hikvision-alarm-server",
ip, accepted: args.accepted,
contentType, ...(args.reason ? { reason: args.reason } : {}),
bytes: raw.length, ip: args.ip,
...summary, contentType: args.contentType,
// Store the readable head verbatim (XML part); truncate to keep the row small. bytes: args.raw.length,
rawHead: text.slice(0, 8000), ...args.summary,
// Readable head verbatim (the XML part); truncated to keep the row small.
rawHead: args.raw.toString("utf8").slice(0, 8000),
}, },
occurredAt: new Date().toISOString(), occurredAt: new Date().toISOString(),
}) })
@@ -140,19 +117,54 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
} catch (err) { } catch (err) {
app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`); app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`);
} }
}
// Also surface on the in-process bus as a generic input breadcrumb so any live const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
// listener (e.g. the booth feed) can show "camera saw a vehicle" during testing. const { deviceId } = req.params;
// NOTE: deliberately NOT emitted as a DeviceReadEvent yet — that (a plate identity const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
// driving entry/exit) is the next, separate step once we know the payload. const cfg = row?.config as HikDeviceConfig | undefined;
deviceEvents.emitInput({ const ip = clientIp(req);
driverId: "hikvision", const contentType = String(req.headers["content-type"] ?? "");
deviceId, const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
input: 0, const summary = summarize(raw.toString("utf8"));
edge: "on",
at: new Date().toISOString(), // Guard: must be a known hikvision device with alarm-push enabled, posting from its
source: "push", // configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian).
}); // On rejection we STILL record it (with the precise reason) so a push that reached us
// never silently disappears — that's what makes "is it coming?" answerable.
let reason: string | null = null;
if (!row) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`;
else if (!cfg?.alarmPushEnabled) reason = "alarm push not enabled on this device (tick it in Setup)";
else if (!cfg.host) reason = "device has no host IP configured";
else if (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`;
if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason });
}
// Optional Digest auth — only when the admin configured push creds (some firmware
// can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: cfg!.pushPassword })) {
record({ deviceId, accepted: false, reason: "digest auth failed/challenge", ip, contentType, raw, summary });
return; // 401 challenge already sent
}
}
// Loud log so the operator can SEE the payload during testing.
app.log.info(
`[hik-alarm:${deviceId}] ACCEPTED ${ip} ${contentType} ${raw.length}B ` +
`event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`,
);
record({ deviceId, accepted: true, ip, contentType, raw, summary });
// Surface on the in-process bus as a generic breadcrumb so a live listener can show
// "camera saw a vehicle". NOT a DeviceReadEvent yet — that (plate identity driving
// entry/exit) is the deliberate next step once we know the real payload.
deviceEvents.emitInput({ driverId: "hikvision", deviceId, input: 0, edge: "on", at: new Date().toISOString(), source: "push" });
// 200 so the camera considers the alarm delivered and doesn't retry-storm. // 200 so the camera considers the alarm delivered and doesn't retry-storm.
return reply.code(200).send({ ok: true }); return reply.code(200).send({ ok: true });
@@ -162,4 +174,39 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
for (const method of ["POST", "GET"] as const) { for (const method of ["POST", "GET"] as const) {
app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle }); app.route({ method, url: "/api/devices/hikvision/:deviceId/event", handler: handle });
} }
// Read endpoint: the recent alarm pushes (accepted AND rejected), newest first — so you
// can SEE in the browser whether events are arriving and why any were refused, instead
// of grepping the dev log or querying SQLite. Gated device:read (admin device view).
app.get<{ Querystring: { limit?: string } }>(
"/api/devices/hikvision/alarms",
{ preHandler: requirePermission("device:read") },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const rows = db
.select()
.from(deviceEventsTable)
.where(inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))
.orderBy(desc(deviceEventsTable.occurredAt))
.limit(limit)
.all();
const alarms = rows.map((r) => {
const d = (r.detail ?? {}) as Record<string, unknown>;
return {
at: r.occurredAt,
deviceId: r.deviceId,
accepted: d.accepted === true,
reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null,
bytes: (d.bytes as number) ?? 0,
eventType: (d.eventType as string) ?? null,
target: (d.target as string) ?? null,
plate: (d.plate as string) ?? null,
rawHead: (d.rawHead as string) ?? null,
};
});
return { count: alarms.length, alarms };
},
);
} }