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");
});
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({
+33 -8
View File
@@ -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,