From 3db8f517d32d5d1ef7ce3ee9bf4f59439b526fd1 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 22 Jun 2026 10:28:09 +0200 Subject: [PATCH] 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 --- .../server/src/routes/hikvision-alarm.test.ts | 44 ++++- apps/server/src/routes/hikvision-alarm.ts | 165 +++++++++++------- 2 files changed, 149 insertions(+), 60 deletions(-) diff --git a/apps/server/src/routes/hikvision-alarm.test.ts b/apps/server/src/routes/hikvision-alarm.test.ts index 972a344..342d020 100644 --- a/apps/server/src/routes/hikvision-alarm.test.ts +++ b/apps/server/src/routes/hikvision-alarm.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; 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 { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; // Hikvision Alarm Server push ingress. Verifies the discovery endpoint: a vehicle- // detection POST from the camera's configured IP is accepted, summarized (eventType / @@ -61,6 +62,16 @@ function alarmEvents(): { detail: Record }[] { .all() as { detail: Record }[]; } +/** Every recorded push for a device — accepted (kind:"alarm") AND rejected + * (kind:"alarm-rejected"). */ +function allRecorded(deviceId: string): { kind: string; detail: Record }[] { + return db + .select() + .from(deviceEventsTable) + .where(and(eq(deviceEventsTable.deviceId, deviceId), inArray(deviceEventsTable.kind, ["alarm", "alarm-rejected"]))) + .all() as { kind: string; detail: Record }[]; +} + describe("Hikvision Alarm Server push", () => { it("accepts a vehicle event from the camera IP and records it with a parsed summary", async () => { seedHikCamera(); @@ -122,7 +133,14 @@ describe("Hikvision Alarm Server push", () => { remoteAddress: "10.0.10.200", // not the camera }); expect(res.statusCode).toBe(404); + // No ACCEPTED alarm... 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 () => { @@ -146,5 +164,29 @@ describe("Hikvision Alarm Server push", () => { remoteAddress: CAM_IP, }); 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); }); }); diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts index 68bfe61..8b9c407 100644 --- a/apps/server/src/routes/hikvision-alarm.ts +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; 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 { requirePermission } from "../auth.js"; import { verifyDigest } from "../digest-auth.js"; // 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); }); - const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => { - const { deviceId } = req.params; - const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); - const cfg = row?.config as HikDeviceConfig | undefined; - const ip = clientIp(req); - - // Guard: must be a known hikvision device with alarm-push enabled, posting from its - // configured host IP. Source-IP is the primary guard on the LAN (like the Dingtian). - if (!row || row.driverId !== "hikvision" || !cfg?.alarmPushEnabled || !cfg.host || ip !== cfg.host) { - app.log.warn(`rejected hik alarm push: device=${deviceId} ip=${ip} (unknown/disabled/ip-mismatch)`); - return reply.code(404).send({ error: "not found" }); - } - - // 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. + /** Record EVERY push (accepted or rejected) as a device_event so the read endpoint / + * DB always shows that SOMETHING arrived — the key fix: a rejected push used to log a + * warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */ + function record(args: { + deviceId: string; + accepted: boolean; + reason?: string; + ip: string; + contentType: string; + raw: Buffer; + summary: AlarmSummary; + }): void { try { db.insert(deviceEventsTable) .values({ id: randomUUID(), - deviceId, + deviceId: args.deviceId, category: "camera", - kind: "alarm", + kind: args.accepted ? "alarm" : "alarm-rejected", detail: { source: "hikvision-alarm-server", - ip, - contentType, - bytes: raw.length, - ...summary, - // Store the readable head verbatim (XML part); truncate to keep the row small. - rawHead: text.slice(0, 8000), + accepted: args.accepted, + ...(args.reason ? { reason: args.reason } : {}), + ip: args.ip, + contentType: args.contentType, + bytes: args.raw.length, + ...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(), }) @@ -140,19 +117,54 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis } catch (err) { 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 - // listener (e.g. the booth feed) can show "camera saw a vehicle" during testing. - // NOTE: deliberately NOT emitted as a DeviceReadEvent yet — that (a plate identity - // driving entry/exit) is the next, separate step once we know the payload. - deviceEvents.emitInput({ - driverId: "hikvision", - deviceId, - input: 0, - edge: "on", - at: new Date().toISOString(), - source: "push", - }); + const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => { + const { deviceId } = req.params; + const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); + const cfg = row?.config as HikDeviceConfig | undefined; + const ip = clientIp(req); + const contentType = String(req.headers["content-type"] ?? ""); + const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from(""); + const summary = summarize(raw.toString("utf8")); + + // Guard: must be a known hikvision device with alarm-push enabled, posting from its + // 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. 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) { 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; + 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 }; + }, + ); }