import { randomUUID } from "node:crypto"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; 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"; import type { LaneStatus } from "../lane-status.js"; import type { AnprBridge } from "../anpr-entry.js"; // Hikvision "Alarm Server" event PUSH ingress. The newer-firmware cameras (Event → // Smart/VCA with "Detection Target: Human/Vehicle", Notify Surveillance Center, Alarm // Settings → Alarm Server) HTTP-POST an EventNotificationAlert to a URL we host every // time the chosen target is detected. This is the same machine-call pattern as the // Dingtian Input Link push (routes/devices.ts): source-IP guarded, NOT behind the SPA // cookie/CSRF. // // DISCOVERY-FIRST. Hik's push format varies by model/firmware (event XML, or multipart // with an attached JPEG, or — on some ANPR units — an / block). So // this endpoint is deliberately PERMISSIVE: it accepts ANY content-type as raw bytes, // records the verbatim body as a `kind:"alarm"` device_event, and best-effort extracts a // summary (eventType / target / plate). The goal of this first cut is to SEE exactly what // a given camera sends — inspect via GET /api/events or the logs — before we wire it into // the read bus / a snapshot trigger. It never opens a barrier (a plate read is advisory, // never the sole reason; see wiki/concepts/append-only-event-chain.md). // // See wiki/entities/lpr-camera.md, wiki/concepts/device-input-flow.md. interface HikDeviceConfig { host?: string; alarmPushEnabled?: boolean | string | number; pushUser?: string; pushPassword?: string; /** Skip the source-IP guard for this device's pushes. The source IP is the primary * LAN guard, but it's UNRELIABLE in some environments — notably WSL mirrored mode, * which rewrites an inbound packet's source to the host's OWN address, so the camera's * real IP never survives and a strict check rejects every push. When pushUser/ * pushPassword (Digest) are set, that auth is the real guard and source-IP adds little; * this flag lets a deployment opt out. The signed ledger remains the anti-fraud truth. */ skipSourceIpCheck?: boolean | string | number; } /** Coerce a device-config flag to a boolean. The config is loosely-typed JSON from the * setup form, which has historically stored a checkbox as the STRING "true" (a form- * serialization quirk) — so accept true / "true" / 1 / "1" / "yes" / "on", reject the * rest. Being lenient here means a stray "true" never silently disables a real feature. */ function isOn(v: unknown): boolean { if (v === true) return true; if (typeof v === "number") return v === 1; if (typeof v === "string") return /^(1|true|yes|on)$/i.test(v.trim()); return false; } /** A best-effort summary pulled out of the raw push body (XML or JSON), for the device * event detail + the log line. Absent fields just mean "not found in this firmware's * payload" — the raw body is always stored so nothing is lost. */ interface AlarmSummary { eventType?: string; /** `active` (target entered the region) | `inactive` (target left). The edge that * drives lane busy/free — see [[lpr-camera]] / hikvision-alarm.ts. */ eventState?: string; target?: string; plate?: string; dateTime?: string; channelId?: string; } function clientIp(req: FastifyRequest): string { return req.ip.replace(/^::ffff:/, ""); } /** First capture group of `re` in `s`, trimmed, or undefined. */ function pick(s: string, re: RegExp): string | undefined { const m = re.exec(s); return m?.[1]?.trim() || undefined; } /** * Best-effort summary extraction. Hikvision event XML uses tags like , * , ; smart/ANPR events add target/plate tags whose exact names * vary by firmware (, , , ). * We probe several spellings; whatever doesn't match is simply absent. JSON bodies are * scanned for the same keys. */ function summarize(body: string): AlarmSummary { return { eventType: pick(body, /([^<]+)<\/eventType>/i) ?? pick(body, /"eventType"\s*:\s*"([^"]+)"/i), eventState: pick(body, /([^<]+)<\/eventState>/i) ?? pick(body, /"eventState"\s*:\s*"([^"]+)"/i), target: pick(body, /<(?:detectionTarget|targetType|objectType)>([^<]+)<\//i) ?? pick(body, /"(?:detectionTarget|targetType|objectType)"\s*:\s*"([^"]+)"/i), plate: pick(body, /<(?:plateNumber|licensePlate|plateNo)>([^<]+)<\//i) ?? pick(body, /"(?:plateNumber|licensePlate|plateNo)"\s*:\s*"([^"]+)"/i), dateTime: pick(body, /([^<]+)<\/dateTime>/i), channelId: pick(body, /([^<]+)<\/channelID>/i) ?? pick(body, /([^<]+)<\/channelId>/i), }; } export async function hikvisionAlarmRoutes( app: FastifyInstance, db: Db, laneStatus?: LaneStatus, anprBridge?: AnprBridge, ): Promise { // Accept ANY content-type as a raw Buffer (the camera may POST application/xml, // multipart/form-data with a JPEG, or text). Fastify's default JSON parser would 415 // or empty these — we want the bytes verbatim. Scoped to THIS app instance via a // wildcard parser; a 10 MB cap covers an event + an attached frame. app.addContentTypeParser("*", { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, (_req, body, done) => { done(null, body); }); /** 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; method: string; accepted: boolean; reason?: string; ip: string; contentType: string; raw: Buffer; summary: AlarmSummary; }): void { try { db.insert(deviceEventsTable) .values({ id: randomUUID(), deviceId: args.deviceId, category: "camera", kind: args.accepted ? "alarm" : "alarm-rejected", detail: { source: "hikvision-alarm-server", accepted: args.accepted, method: args.method, ...(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(), }) .run(); } catch (err) { app.log.error(`hik-alarm device-event insert failed: ${(err as Error).message}`); } } const handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => { const { deviceId } = req.params; const method = req.method; 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")); // Log EVERY hit immediately (method + ip + size), before any guard — so even a probe // that gets rejected is visible in the dev log the instant it arrives. app.log.info(`[hik-alarm:${deviceId}] HIT ${method} from ${ip} (${contentType || "no-ct"} ${raw.length}B)`); // 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. // The source-IP check is skipped when the device opts out (skipSourceIpCheck) — needed // where the network rewrites the inbound source IP (e.g. WSL mirrored mode rewrites it // to the host's own address), so a strict match can never pass. Digest auth (when set) // and the signed ledger remain the real guards. See HikDeviceConfig.skipSourceIpCheck. const skipIp = isOn(cfg?.skipSourceIpCheck); let reason: string | null = null; if (!row || !cfg) reason = "unknown device id"; else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`; else if (!isOn(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 (!skipIp && ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host} (set skipSourceIpCheck if the network rewrites it, e.g. WSL)`; if (reason) { app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`); record({ deviceId, method, 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, method, 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 ${method} ${ip} ${contentType} ${raw.length}B ` + `event=${summary.eventType ?? "?"}/${summary.eventState ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`, ); record({ deviceId, method, accepted: true, ip, contentType, raw, summary }); // Lane busy/free: a VEHICLE detection marks the camera's bound lane busy (advisory, // for the booth barrier lights). Only on a vehicle target that's `active` — an // `inactive` (leave) isn't sent by this camera class, so the lane auto-clears on a // timeout in LaneStatus. We filter to vehicle per the booth's "vehicle only" intent. const isVehicleActive = (summary.target ?? "").toLowerCase() === "vehicle" && (summary.eventState ?? "active").toLowerCase() !== "inactive"; if (laneStatus && isVehicleActive) { laneStatus.vehicleDetected(deviceId); } // ANPR BRIDGE: on a vehicle detection, if this camera opts into ANPR (config.anpr), // pull a snapshot → read the plate → if it matches a SUBSCRIBER, emit a plate read // onto the bus, which the existing gated SubscriptionFlow turns into an entry/exit + // barrier open. Fire-and-forget — NEVER awaited on the 200 path (the camera must get // a prompt ack or it retry-storms), and fail-soft inside the bridge. See anpr-entry.ts. if (anprBridge && isVehicleActive) { void anprBridge.onVehicleDetected(deviceId); } // 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 }); }; // Listen for EVERY method on the event path. The camera (and its "Test" button) may // probe with GET/HEAD/OPTIONS/PUT, not just POST — and a method we don't register gets // Fastify's generic 404, which the camera reads as "service available" while our // handler never runs (so nothing is recorded). Registering all methods means ANYTHING // that hits this URL reaches `handle` and is captured (the method is logged + stored), // so we can finally SEE exactly what the camera sends. See wiki/entities/lpr-camera.md. // (HEAD is auto-added by Fastify alongside GET — don't register it explicitly.) for (const method of ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"] 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, method: (d.method as string) ?? null, 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, eventState: (d.eventState 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 }; }, ); }