fix(hik-alarm): listen on all methods + skippable source-IP guard (WSL)

Diagnosed why no camera push ever landed: (1) the route only registered
POST/GET, so a probe with another method got a generic 404 the camera
reads as "service available" while our handler never ran; (2) more
fundamentally, WSL mirrored mode REWRITES the inbound source IP to the
host's own address (10.0.10.203), so the camera's real IP (10.0.10.12)
never survives and the source-IP guard rejected every push as a mismatch.

- Register the event route on POST/GET/PUT/PATCH/DELETE/OPTIONS (HEAD comes
  with GET) so ANYTHING hitting the path reaches the handler and is recorded.
- Log + store the HTTP method of each hit; log every hit on arrival, before
  any guard, so even a rejected probe is visible immediately.
- Add per-device skipSourceIpCheck (a Setup checkbox) to bypass the
  source-IP guard where the network rewrites the source (WSL). Digest auth +
  the signed ledger remain the real guards.

Tests: hik-alarm 10 (skip-IP accept + method capture). server green;
web build green (new checkbox renderer + this field).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 11:02:17 +02:00
parent 461275521d
commit b7300ec080
3 changed files with 58 additions and 8 deletions
@@ -138,6 +138,23 @@ describe("Hikvision Alarm Server push", () => {
expect(alarmEvents()[0]!.detail.eventType).toBe("vehicleDetection"); 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 () => { it("rejects a push from a DIFFERENT source IP (404, nothing recorded)", async () => {
seedHikCamera(); seedHikCamera();
const res = await app.inject({ const res = await app.inject({
+33 -8
View File
@@ -28,6 +28,13 @@ interface HikDeviceConfig {
alarmPushEnabled?: boolean | string | number; alarmPushEnabled?: boolean | string | number;
pushUser?: string; pushUser?: string;
pushPassword?: 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 /** 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?). */ * warning and vanish, so "no event" was ambiguous (never sent? or sent + rejected?). */
function record(args: { function record(args: {
deviceId: string; deviceId: string;
method: string;
accepted: boolean; accepted: boolean;
reason?: string; reason?: string;
ip: string; ip: string;
@@ -114,6 +122,7 @@ export async function hikvisionAlarmRoutes(app: FastifyInstance, db: Db): Promis
detail: { detail: {
source: "hikvision-alarm-server", source: "hikvision-alarm-server",
accepted: args.accepted, accepted: args.accepted,
method: args.method,
...(args.reason ? { reason: args.reason } : {}), ...(args.reason ? { reason: args.reason } : {}),
ip: args.ip, ip: args.ip,
contentType: args.contentType, 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 handle = async (req: FastifyRequest<{ Params: { deviceId: string } }>, reply: FastifyReply) => {
const { deviceId } = req.params; const { deviceId } = req.params;
const method = req.method;
const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get(); const row = await db.select().from(devices).where(eq(devices.id, deviceId)).get();
const cfg = row?.config as HikDeviceConfig | undefined; const cfg = row?.config as HikDeviceConfig | undefined;
const ip = clientIp(req); const ip = clientIp(req);
const contentType = String(req.headers["content-type"] ?? ""); const contentType = String(req.headers["content-type"] ?? "");
const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from(""); const raw: Buffer = Buffer.isBuffer(req.body) ? (req.body as Buffer) : Buffer.from("");
const summary = summarize(raw.toString("utf8")); 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 // 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). // 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 // 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. // 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; let reason: string | null = null;
if (!row || !cfg) reason = "unknown device id"; if (!row || !cfg) reason = "unknown device id";
else if (row.driverId !== "hikvision") reason = `device is ${row.driverId}, not hikvision`; 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 (!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 (!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) { if (reason) {
app.log.warn(`[hik-alarm:${deviceId}] REJECTED from ${ip} (${contentType} ${raw.length}B): ${reason}`); app.log.warn(`[hik-alarm:${deviceId}] REJECTED ${method} from ${ip} (${contentType} ${raw.length}B): ${reason}`);
record({ deviceId, accepted: false, reason, ip, contentType, raw, summary }); record({ deviceId, method, accepted: false, reason, ip, contentType, raw, summary });
return reply.code(404).send({ error: "not found", reason }); 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). // can't authenticate the Alarm Server call; then we rely on source-IP alone).
if (cfg!.pushUser && cfg!.pushPassword) { if (cfg!.pushUser && cfg!.pushPassword) {
if (!verifyDigest(req, reply, { user: cfg!.pushUser, password: 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 return; // 401 challenge already sent
} }
} }
// Loud log so the operator can SEE the payload during testing. // Loud log so the operator can SEE the payload during testing.
app.log.info( 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 ?? "-"}`, `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 // 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 // "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 }); return reply.code(200).send({ ok: true });
}; };
// Hik posts to a single configured URL; accept POST (and GET, for a quick manual probe). // Listen for EVERY method on the event path. The camera (and its "Test" button) may
for (const method of ["POST", "GET"] as const) { // 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 }); 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, at: r.occurredAt,
deviceId: r.deviceId, deviceId: r.deviceId,
accepted: d.accepted === true, accepted: d.accepted === true,
method: (d.method as string) ?? null,
reason: (d.reason as string) ?? null, reason: (d.reason as string) ?? null,
ip: (d.ip as string) ?? null, ip: (d.ip as string) ?? null,
contentType: (d.contentType as string) ?? null, contentType: (d.contentType as string) ?? null,
+8
View File
@@ -124,6 +124,14 @@ const alarmPushFields: ConfigField[] = [
required: false, required: false,
help: "Paired with the username above for Digest auth on the push. Leave blank for source-IP-only.", 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 = { export const hikvisionDriver: CameraDriver = {