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:
@@ -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<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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user