diff --git a/apps/server/src/routes/hikvision-alarm.test.ts b/apps/server/src/routes/hikvision-alarm.test.ts index f9f92e1..746c6fc 100644 --- a/apps/server/src/routes/hikvision-alarm.test.ts +++ b/apps/server/src/routes/hikvision-alarm.test.ts @@ -138,6 +138,23 @@ describe("Hikvision Alarm Server push", () => { expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection"); }); + it("accepts a push from ANY source IP when skipSourceIpCheck is set (WSL rewrites it)", async () => { + // WSL mirrored mode rewrites the inbound source to the host's own IP, so the camera's + // real IP never survives and a strict check rejects every push. With the opt-out, a + // push from the 'wrong' IP is accepted. + seedHikCamera({ skipSourceIpCheck: true }); + const res = await app.inject({ + method: "POST", + url: `/api/devices/hikvision/${CAM_ID}/event`, + headers: { "content-type": "application/xml" }, + payload: VEHICLE_XML, + remoteAddress: "10.0.10.203", // the rewritten host IP, NOT the camera's + }); + expect(res.statusCode).toBe(200); + expect(alarmEvents()).toHaveLength(1); + expect(alarmEvents()[0]!.detail.target).toBe("vehicle"); + }); + it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => { seedHikCamera(); const res = await app.inject({ diff --git a/apps/server/src/routes/hikvision-alarm.ts b/apps/server/src/routes/hikvision-alarm.ts index 231d20e..1b1b898 100644 --- a/apps/server/src/routes/hikvision-alarm.ts +++ b/apps/server/src/routes/hikvision-alarm.ts @@ -28,6 +28,13 @@ interface HikDeviceConfig { 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 @@ -97,6 +104,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis * 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; @@ -114,6 +122,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis detail: { source: "hikvision-alarm-server", accepted: args.accepted, + method: args.method, ...(args.reason ? { reason: args.reason } : {}), ip: args.ip, contentType: args.contentType, @@ -132,27 +141,36 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis 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 (ip !== cfg.host) reason = `source IP ${ip} != device host ${cfg.host}`; + 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 from ${ip} (${contentType} ${raw.length}B): ${reason}`); - record({ deviceId, accepted: false, reason, ip, contentType, raw, summary }); + 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 }); } @@ -160,17 +178,17 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis // 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 }); + 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 ${ip} ${contentType} ${raw.length}B ` + + `[hik-alarm:${deviceId}] ACCEPTED ${method} ${ip} ${contentType} ${raw.length}B ` + `event=${summary.eventType ?? "?"} target=${summary.target ?? "?"} plate=${summary.plate ?? "-"}`, ); - record({ deviceId, accepted: true, ip, contentType, raw, summary }); + record({ deviceId, method, 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 @@ -181,8 +199,14 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis return reply.code(200).send({ ok: true }); }; - // Hik posts to a single configured URL; accept POST (and GET, for a quick manual probe). - for (const method of ["POST", "GET"] as const) { + // 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 }); } @@ -207,6 +231,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis 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, diff --git a/packages/devices/src/drivers/camera.ts b/packages/devices/src/drivers/camera.ts index 4c5d9b2..6370a71 100644 --- a/packages/devices/src/drivers/camera.ts +++ b/packages/devices/src/drivers/camera.ts @@ -124,6 +124,14 @@ const alarmPushFields: ConfigField[] = [ required: false, help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.", }, + { + key: "skipSourceIpCheck", + label: "Don't verify push source IP", + type: "boolean", + required: false, + default: false, + help: "Accept pushes regardless of the source IP. Needed when the network rewrites the inbound source address (e.g. WSL mirrored mode reports the host's own IP, not the camera's), which would otherwise reject every push. Leave OFF on a normal LAN.", + }, ]; export const hikvisionDriver: CameraDriver = {