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