2 Commits

Author SHA1 Message Date
julian 2ab5a39a57 Permanent WSL2 dev fix for multi-subnet source-address trap
Mirrored mode re-clones the Windows NIC's addresses each boot, so the kernel
keeps picking the wrong source for stacked device subnets (10.0.10.x sourced
from 192.168.1.123) — ARP resolves but ping/TCP dies, and every runtime
ip-route fix is wiped by wsl --shutdown.

deploy/wsl-fix-route-source.sh pins each scope-link route's src to this
host's own address in that subnet (no hardcoded IPs, idempotent, preserves
metric, non-fatal per route, waits for the route at boot). deploy/parking-net
.service reapplies it on every boot.

Dev-box only; the appliance is bare-metal Linux with static networkd config.
Verified: camera pings with no -I flag; driver pulls a snapshot with no
localAddress set.
2026-06-15 16:17:59 +02:00
julian fa65b2df86 Real Hikvision/Dahua camera driver; gate Backend-push-IP on capability
Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI
snapshots over client-side HTTP Digest (new drivers/http-digest.ts).
healthCheck() now pulls a real frame instead of returning ready/stub.
Snapshot carries bytes (driver fetches); storage/imageRef is the caller's
job, keeping the adapter free of storage deps.

Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver
(only Dingtian sets it), expose as pushCapable in the catalog, and gate the
wizard's backend-IP fetch + field on it so pull-only devices hide it.

Verified on hardware (Hikvision 10.0.10.121): healthCheck ready,
captureSnapshot returns a valid JPEG.
2026-06-15 16:17:49 +02:00
13 changed files with 530 additions and 34 deletions
+4 -2
View File
@@ -62,11 +62,13 @@ export async function setupRoutes(
const adminGuard = requireRole("admin"); const adminGuard = requireRole("admin");
// Catalog of selectable drivers per category (no secrets — schema only). // Catalog of selectable drivers per category (no secrets — schema only).
// `discoverable` flags drivers that can scan the LAN. // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags
// drivers that push to the backend (and thus need a backend IP at assign time).
app.get("/api/setup/catalog", async () => { app.get("/api/setup/catalog", async () => {
const catalog = registry.catalog(); const catalog = registry.catalog();
const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id);
return { ...catalog, discoverable }; const pushCapable = registry.pushCapable();
return { ...catalog, discoverable, pushCapable };
}); });
// Scan the LAN for devices a driver can discover (UDP broadcast, etc). // Scan the LAN for devices a driver can discover (UDP broadcast, etc).
+15 -4
View File
@@ -78,6 +78,7 @@ export function SetupWizard() {
noun={noun} noun={noun}
entries={catalog[key]} entries={catalog[key]}
discoverableIds={catalog.discoverable} discoverableIds={catalog.discoverable}
pushCapableIds={catalog.pushCapable}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)} assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
onChanged={reloadState} onChanged={reloadState}
/> />
@@ -93,6 +94,7 @@ function CategorySection({
noun, noun,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds,
assignments, assignments,
onChanged, onChanged,
}: { }: {
@@ -102,6 +104,7 @@ function CategorySection({
noun: string; noun: string;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[];
assignments: Assignment[]; assignments: Assignment[];
onChanged: () => Promise<void> | void; onChanged: () => Promise<void> | void;
}) { }) {
@@ -155,6 +158,7 @@ function CategorySection({
category={category} category={category}
entries={entries} entries={entries}
discoverableIds={discoverableIds} discoverableIds={discoverableIds}
pushCapableIds={pushCapableIds}
onSaved={async (w) => { onSaved={async (w) => {
setWarnings(w); setWarnings(w);
await onChanged(); await onChanged();
@@ -227,6 +231,7 @@ function DeviceForm({
category, category,
entries, entries,
discoverableIds, discoverableIds,
pushCapableIds,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
@@ -234,12 +239,17 @@ function DeviceForm({
category: DeviceCategory; category: DeviceCategory;
entries: CatalogEntry[]; entries: CatalogEntry[];
discoverableIds: string[]; discoverableIds: string[];
pushCapableIds: string[];
onSaved: (warnings: string[]) => Promise<void> | void; onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void; onCancel?: () => void;
}) { }) {
const [selectedId, setSelectedId] = useState<string>(""); const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId); const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id); const canDiscover = selected != null && discoverableIds.includes(selected.id);
// Only push-capable drivers (e.g. the Dingtian relay) call back to the
// backend and need a backend IP. Pull-only devices (cameras, commanded relays)
// must NOT show the field. See wiki/concepts/device-input-flow.md.
const pushesToBackend = selected != null && pushCapableIds.includes(selected.id);
// Config values (auto-filled by discovery, editable by hand). // Config values (auto-filled by discovery, editable by hand).
const [config, setConfig] = useState<Record<string, string | number>>({}); const [config, setConfig] = useState<Record<string, string | number>>({});
@@ -255,15 +265,16 @@ function DeviceForm({
// Backend push IP: which of OUR addresses the device should call back on. We // Backend push IP: which of OUR addresses the device should call back on. We
// auto-pick the NIC on the device's subnet, but surface it editable here so a // auto-pick the NIC on the device's subnet, but surface it editable here so a
// multi-NIC host can be corrected (the chosen IP is baked into the device on // multi-NIC host can be corrected (the chosen IP is baked into the device on
// save). Only relevant for drivers that push (the field hides if no candidates). // save). Only relevant for drivers that push back to us (pushesToBackend).
const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null); const [backendIps, setBackendIps] = useState<BackendIpCandidate[] | null>(null);
const [backendIp, setBackendIp] = useState<string>(""); const [backendIp, setBackendIp] = useState<string>("");
// (Re)load backend-IP candidates whenever the device host changes after a // (Re)load backend-IP candidates whenever the device host changes after a
// successful test (the test confirms the host is real + reachable). // successful test (the test confirms the host is real + reachable) — but only
// for push-capable drivers; a pull-only device never calls back.
const testedHost = tested ? String(mergedConfig().host ?? "") : ""; const testedHost = tested ? String(mergedConfig().host ?? "") : "";
useEffect(() => { useEffect(() => {
if (!testedHost) { if (!testedHost || !pushesToBackend) {
setBackendIps(null); setBackendIps(null);
return; return;
} }
@@ -281,7 +292,7 @@ function DeviceForm({
live = false; live = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost]); }, [testedHost, pushesToBackend]);
function selectDriver(id: string) { function selectDriver(id: string) {
setSelectedId(id); setSelectedId(id);
+2
View File
@@ -96,6 +96,8 @@ export type DeviceCategory = "access" | "reader" | "camera" | "printer";
export type Catalog = Record<DeviceCategory, CatalogEntry[]> & { export type Catalog = Record<DeviceCategory, CatalogEntry[]> & {
/** Driver ids that support LAN discovery. */ /** Driver ids that support LAN discovery. */
discoverable: string[]; discoverable: string[];
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable: string[];
}; };
export function fetchCatalog(): Promise<Catalog> { export function fetchCatalog(): Promise<Catalog> {
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Parking dev: pin route source addresses (WSL2 mirrored-mode fix)
# Run after WSL has populated the mirrored interfaces/addresses.
After=network.target wsl-pro.service
Wants=network.target
[Service]
Type=oneshot
RemainAfterExit=yes
# Idempotent; safe to re-run. Path is the repo checkout on this dev box.
ExecStart=/home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1
# Mirrored-mode addresses can land slightly after boot; one retry covers the race.
ExecStartPost=/bin/sh -c 'sleep 3; /home/julian/projects/JS/parking-system/deploy/wsl-fix-route-source.sh eth1 || true'
[Install]
WantedBy=multi-user.target
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# WSL2 mirrored-mode source-address fix (dev box only).
#
# Problem: in WSL2 mirrored networking the Windows host's interfaces — and ALL
# their IPs — are cloned into Linux on every boot. When two device subnets land
# on one NIC (e.g. 192.168.1.x AND 10.0.10.x on eth1), the kernel's connected
# routes come up `scope link` with NO preferred source, and source selection can
# pick the WRONG address (sourcing 10.0.10.x traffic from 192.168.1.123). ARP
# still resolves (L2), so the device looks REACHABLE while every ping/TCP times
# out. See wiki/concepts/wsl-dev-networking.md.
#
# Fix: for each connected `scope link` route, pin its preferred `src` to THIS
# host's own address in that same subnet. No hardcoded IPs — derived at runtime,
# so it also covers future device subnets. Idempotent; a no-op when nothing needs
# fixing. Runs at boot via parking-net.service.
#
# Production note: the real appliance is bare-metal Linux, not WSL — there this
# is just static networkd/netplan config. This script exists only for the dev box.
# NB: intentionally NOT `set -e`. This is a best-effort boot fixer; an individual
# `ip` call failing (e.g. a route not up yet) must not abort the rest.
set -uo pipefail
fix_iface() {
local iface="$1"
# Each connected /N route on this iface that the kernel manages (proto kernel,
# scope link) — i.e. the directly-attached subnets. Capture the full line so we
# can preserve attributes (notably `metric`) when we replace the route.
ip -4 route show dev "$iface" proto kernel scope link | while read -r line; do
local subnet="${line%% *}" # e.g. "10.0.10.0/24"
local prefix="${subnet%/*}"
# Preserve a metric if the route has one (mirrored-mode routes carry e.g. 281);
# replacing without it would change the route's priority.
local metric=""
case "$line" in *" metric "*) metric="metric ${line##* metric }";; esac
# Find THIS host's own address inside the same subnet — the correct src.
local hostip=""
local cidr
for cidr in $(ip -4 -o addr show dev "$iface" | awk '{print $4}'); do
if ipcalc_net "$cidr" "$subnet"; then hostip="${cidr%/*}"; break; fi
done
[ -n "$hostip" ] || continue
local current
current=$(ip -4 route get "$prefix" 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' | head -1)
[ "$current" = "$hostip" ] && continue # already correct — no-op
# `replace` creates-or-updates, so it works whether or not the route is
# present yet (avoids the boot-race RTNETLINK "No such file" that `change` hits).
# Non-fatal: a single failure must not abort the whole boot fixer.
if ip route replace "$subnet" dev "$iface" proto kernel scope link src "$hostip" $metric; then
echo "pinned $subnet -> src $hostip (was ${current:-none})"
else
echo "warn: could not pin $subnet -> src $hostip" >&2
fi
done
return 0
}
# True if address $1 (a.b.c.d/p) is inside subnet $2 (n.n.n.0/p), same prefix len.
ipcalc_net() {
local addr="${1%/*}" alen="${1#*/}"
local net="${2%/*}" nlen="${2#*/}"
[ "$alen" = "$nlen" ] || return 1
# Compare the network part by masking both to /nlen.
local a n
a=$(mask_to_net "$addr" "$nlen")
n=$(mask_to_net "$net" "$nlen")
[ "$a" = "$n" ]
}
# Mask an IPv4 dotted-quad to its /len network address.
mask_to_net() {
local ip="$1" len="$2"
local IFS=. ; read -r o1 o2 o3 o4 <<<"$ip"
local int=$(( (o1<<24) + (o2<<16) + (o3<<8) + o4 ))
local mask=$(( len == 0 ? 0 : (0xFFFFFFFF << (32 - len)) & 0xFFFFFFFF ))
local net=$(( int & mask ))
echo "$(( (net>>24)&255 )).$(( (net>>16)&255 )).$(( (net>>8)&255 )).$(( net&255 ))"
}
main() {
# Default to eth1 (the mirrored LAN NIC here); accept overrides as args.
local ifaces=("${@:-eth1}")
# Boot race: WSL mirrored mode can populate the interface's addresses/routes a
# beat after the unit starts. Wait (bounded) for at least one connected route
# to appear on the first interface before pinning.
local i tries=0
for i in "${ifaces[@]}"; do
while [ "$tries" -lt 15 ] \
&& [ -z "$(ip -4 route show dev "$i" proto kernel scope link 2>/dev/null)" ]; do
sleep 1; tries=$((tries + 1))
done
break
done
for i in "${ifaces[@]}"; do
ip link show "$i" >/dev/null 2>&1 && fix_iface "$i"
done
}
main "$@"
@@ -721,6 +721,7 @@ export const dingtianDriver: AccessDriver = {
description: description:
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.", "Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
transports: ["udp"], transports: ["udp"],
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
configFields: [ configFields: [
hostField, hostField,
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." }, { ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
+88 -25
View File
@@ -1,57 +1,120 @@
import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js"; import type { CameraDevice, DeviceHealth, Snapshot, SnapshotContext } from "../interfaces.js";
import type { CameraDriver, DeviceConfig } from "../registry.js"; import type { CameraDriver, ConfigField, DeviceConfig } from "../registry.js";
import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js"; import { hostField, passwordField, portField, usernameField, stubLog } from "./common.js";
import { digestGet } from "./http-digest.js";
// Camera drivers — entry/exit snapshot-on-event. The image is stored and // Camera drivers — entry/exit snapshot-on-event. The host pulls a still over
// referenced from the signed event as an independent fraud-control record. // HTTP when an event fires; the bytes are stored and referenced from the signed
// Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL. STUBS only. // event as an independent fraud-control record (the camera PULLS, it never pushes
// to us). Hikvision (ISAPI) and Dahua (CGI) differ only in the snapshot URL and
// channel encoding. Both use HTTP Digest auth (see ./http-digest.ts).
//
// VERIFIED on hardware (2026-06-15): a Hikvision unit at 10.0.10.121 returns a
// 2688×1520 JPEG from /ISAPI/Streaming/channels/101/picture with Digest auth.
// See wiki/entities/lpr-camera.md.
const DEFAULT_TIMEOUT_MS = 8000;
class HttpCamera implements CameraDevice {
readonly #host: string;
readonly #port: number;
readonly #user: string;
readonly #password: string;
readonly #channel: number;
readonly #timeout: number;
// Source outbound from the device-facing NIC on a multi-homed host (the
// multi-subnet source-address trap — see wiki/concepts/wsl-dev-networking.md).
readonly #localAddress: string | undefined;
class StubCamera implements CameraDevice {
constructor( constructor(
readonly driverId: string, readonly driverId: string,
protected readonly config: DeviceConfig, config: DeviceConfig,
protected readonly snapshotPath: string, /** Builds the snapshot path from the configured channel. */
) {} private readonly snapshotPath: (channel: number) => string,
async connect(): Promise<void> { ) {
stubLog(this.driverId, `connect ${this.config.host} (${this.snapshotPath})`); this.#host = String(config.host);
} this.#port = Number(config.port ?? 80);
async disconnect(): Promise<void> { this.#user = String(config.username ?? "");
stubLog(this.driverId, "disconnect"); this.#password = String(config.password ?? "");
this.#channel = Number(config.channel ?? 1);
this.#timeout = Number(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
} }
async connect(): Promise<void> {}
async disconnect(): Promise<void> {}
async healthCheck(): Promise<DeviceHealth> { async healthCheck(): Promise<DeviceHealth> {
return { status: "ready", detail: "stub" }; // The only honest liveness probe for a snapshot camera is to actually pull a
// frame: it exercises reachability + auth + the path/channel in one shot.
try {
const res = await this.#get();
if (res.status === 200) return { status: "ready", detail: `${res.body.length} bytes` };
if (res.status === 401) return { status: "degraded", detail: "auth rejected (check username/password)" };
return { status: "degraded", detail: `HTTP ${res.status}` };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
} }
async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> { async captureSnapshot(ctx: SnapshotContext): Promise<Snapshot> {
// Real driver: GET http(s)://host{snapshotPath}, store bytes, return ref. const res = await this.#get();
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction}`); if (res.status !== 200) {
throw new Error(
`${this.driverId} snapshot failed (lane=${ctx.lane} ${ctx.direction}): HTTP ${res.status}`,
);
}
stubLog(this.driverId, `captureSnapshot lane=${ctx.lane} ${ctx.direction} (${res.body.length} bytes)`);
return { return {
imageRef: `stub://${this.driverId}/lane${ctx.lane}/${ctx.direction}/${Date.now()}`, bytes: res.body,
contentType: "image/jpeg", contentType: res.contentType || "image/jpeg",
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
}; };
} }
#get() {
return digestGet({
host: this.#host,
port: this.#port,
path: this.snapshotPath(this.#channel),
user: this.#user,
password: this.#password,
timeoutMs: this.#timeout,
localAddress: this.#localAddress,
});
}
} }
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, { key: "channel", label: "Channel", type: "number" as const, required: false, default: 1 }]; const channelField: ConfigField = {
key: "channel",
label: "Channel",
type: "number",
required: false,
default: 1,
};
const cameraConfigFields = [hostField, portField(80), usernameField, passwordField, channelField];
export const hikvisionDriver: CameraDriver = { export const hikvisionDriver: CameraDriver = {
id: "hikvision", id: "hikvision",
category: "camera", category: "camera",
label: "Hikvision camera", label: "Hikvision camera",
description: "Hikvision snapshot via ISAPI.", description: "Hikvision snapshot via ISAPI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// /ISAPI/Streaming/channels/<id>/picture // ISAPI channel id: <channel><stream>, e.g. ch1 main = 101, ch2 main = 201.
create: (c) => new StubCamera("hikvision", c, "/ISAPI/Streaming/channels/101/picture"), create: (c) =>
new HttpCamera("hikvision", c, (ch) => `/ISAPI/Streaming/channels/${ch}01/picture`),
}; };
export const dahuaDriver: CameraDriver = { export const dahuaDriver: CameraDriver = {
id: "dahua", id: "dahua",
category: "camera", category: "camera",
label: "Dahua camera", label: "Dahua camera",
description: "Dahua snapshot via CGI.", description: "Dahua snapshot via CGI (HTTP Digest).",
transports: ["tcp-ip"], transports: ["tcp-ip"],
configFields: cameraConfigFields, configFields: cameraConfigFields,
// /cgi-bin/snapshot.cgi?channel=<n> // Dahua channels are 0-based on the CGI; the admin enters 1-based.
create: (c) => new StubCamera("dahua", c, "/cgi-bin/snapshot.cgi"), create: (c) =>
new HttpCamera("dahua", c, (ch) => `/cgi-bin/snapshot.cgi?channel=${Math.max(0, ch - 1)}`),
}; };
+139
View File
@@ -0,0 +1,139 @@
import { createHash, randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import type { IncomingMessage } from "node:http";
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
function parseChallenge(header: string): Record<string, string> {
const out: Record<string, string> = {};
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
let m: RegExpExecArray | null;
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
return out;
}
/** Build the `Authorization: Digest …` response value for a challenge. */
function buildAuthHeader(
c: Record<string, string>,
user: string,
password: string,
method: string,
uri: string,
): string {
const realm = c.realm ?? "";
const nonce = c.nonce ?? "";
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
const ha1 = md5(`${user}:${realm}:${password}`);
const ha2 = md5(`${method}:${uri}`);
const parts: string[] = [
`username="${user}"`,
`realm="${realm}"`,
`nonce="${nonce}"`,
`uri="${uri}"`,
];
let response: string;
if (qop === "auth") {
const cnonce = randomBytes(8).toString("hex");
const nc = "00000001";
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
} else {
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
response = md5(`${ha1}:${nonce}:${ha2}`);
}
parts.push(`response="${response}"`);
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
return `Digest ${parts.join(", ")}`;
}
export interface DigestGetResult {
readonly status: number;
readonly contentType: string;
readonly body: Buffer;
}
export interface DigestGetOptions {
readonly host: string;
readonly port: number;
readonly path: string;
readonly user: string;
readonly password: string;
readonly timeoutMs: number;
/** Bind outbound to the device-facing NIC on a multi-homed host. */
readonly localAddress?: string;
}
function getOnce(
o: DigestGetOptions,
authHeader?: string,
): Promise<{ res: IncomingMessage; body: Buffer }> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (authHeader) headers["authorization"] = authHeader;
const req = httpRequest(
{
host: o.host,
port: o.port,
path: o.path,
method: "GET",
timeout: o.timeoutMs,
localAddress: o.localAddress,
headers,
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (c) => chunks.push(c as Buffer));
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("digest GET timeout")));
req.end();
});
}
/**
* GET a resource with HTTP Digest auth. Does the standard two-shot handshake:
* the first request (no Authorization) draws a 401 + challenge, the second
* carries the computed response. If the server doesn't challenge (200 straight
* away, or no auth required), the first response is returned as-is.
*/
export async function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
const first = await getOnce(o);
if (first.res.statusCode !== 401) {
return {
status: first.res.statusCode ?? 0,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
if (!/^digest/i.test(challengeHeader)) {
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
return {
status: 401,
contentType: String(first.res.headers["content-type"] ?? ""),
body: first.body,
};
}
const challenge = parseChallenge(challengeHeader);
const auth = buildAuthHeader(challenge, o.user, o.password, "GET", o.path);
const second = await getOnce(o, auth);
return {
status: second.res.statusCode ?? 0,
contentType: String(second.res.headers["content-type"] ?? ""),
body: second.body,
};
}
+7 -2
View File
@@ -179,10 +179,15 @@ export interface SnapshotContext {
} }
export interface Snapshot { export interface Snapshot {
/** Storage reference for the captured image (file path / blob id). */ /** The captured image bytes. The DRIVER fetches them over the network; the
readonly imageRef: string; * CALLER (entry/exit flow) owns storage and minting a durable reference —
* keeping the device adapter free of any filesystem/blob-store dependency. */
readonly bytes: Buffer;
readonly contentType: string; readonly contentType: string;
readonly capturedAt: string; // ISO-8601 readonly capturedAt: string; // ISO-8601
/** Storage reference (file path / blob id), set once the caller has stored
* the bytes. Absent on the value the driver returns. */
readonly imageRef?: string;
} }
// --- Printers (ticket dispenser / booth printer) ------------------------- // --- Printers (ticket dispenser / booth printer) -------------------------
+12
View File
@@ -42,6 +42,13 @@ export interface DeviceDriver<T extends Device = Device> {
/** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */ /** Transports/notes surfaced in the UI, e.g. ["tcp-ip"], ["wiegand"]. */
readonly transports: readonly string[]; readonly transports: readonly string[];
readonly configFields: readonly ConfigField[]; readonly configFields: readonly ConfigField[];
/**
* True if the device calls BACK to our backend (HTTP push) and therefore needs
* a backend IP configured at assign time. Pull-only devices (cameras poll a
* snapshot, the relay is commanded) leave this false so the setup wizard hides
* the "Backend push IP" field. See wiki/concepts/device-input-flow.md.
*/
readonly pushesToBackend?: boolean;
/** Build a live adapter instance from validated config. */ /** Build a live adapter instance from validated config. */
create(config: DeviceConfig): T; create(config: DeviceConfig): T;
} }
@@ -130,6 +137,11 @@ class DeviceRegistry {
} }
return byCategory; return byCategory;
} }
/** Driver ids that push to the backend (need a backend IP at assign time). */
pushCapable(): string[] {
return [...this.#drivers.values()].filter((d) => d.pushesToBackend).map((d) => d.id);
}
} }
export interface CatalogEntry { export interface CatalogEntry {
+53
View File
@@ -56,6 +56,59 @@ Mirrored networking is necessary but **not sufficient** — these still bit us:
(`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the (`127.0.0.1`). Node's Vite proxy can stall on the v6 attempt before falling back — point the
proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].) proxy at `127.0.0.1` explicitly. (See [[local-dev-workflow]].)
## Multi-subnet source-address trap (the "ARP works but ping/TCP dies" bug)
Field devices arrive **statically configured on assorted `/24`s** by whoever installed them last
(e.g. a camera on `10.0.10.121`, a printer on `10.0.10.6`, others on `192.168.1.x`). The host
copes by carrying **one IP per device subnet on a single NIC** (this is correct — you do **not**
need a NIC per subnet). But stacking subnets on one interface exposes a Linux source-selection
trap:
- Connected routes come up as `proto kernel scope link` **with no preferred source**. With two
such subnets on one NIC, the kernel may pick the **wrong source address** — e.g. sourcing
traffic to `10.0.10.121` from `192.168.1.123`.
- Symptom is baffling: **ARP resolves and the neighbor shows `REACHABLE`** (L2 is fine, source
address is irrelevant to ARP) while **every ping and TCP connect times out** (replies have a
wrong/unroutable source → dropped, possibly by uRPF). Looks like "the device is down / the whole
subnet is unreachable" when nothing is actually broken.
- **Diagnose:** `ip route get <device-ip>` shows the chosen `src` — if it's an address on a
*different* subnet, that's the bug. Confirm by forcing the right source:
`ping -I <correct-src> <device-ip>` (or `curl --interface <correct-src> …`) — instant replies.
- **Fix (runtime):** pin the preferred source on the connected route, per subnet:
`sudo ip route replace <subnet>/24 dev <nic> proto kernel scope link src <correct-host-ip> metric <m>`
(use `replace`, not `change` — `change` errors `RTNETLINK: No such file` if the route isn't up
yet). Do **not** delete the other subnet's address unless it's genuinely unwanted — you need all
of them to reach all the devices.
- **Fix (permanent, this box):** `deploy/wsl-fix-route-source.sh` + `deploy/parking-net.service`.
The script walks each `proto kernel scope link` route on the NIC and pins `src` to THIS host's own
address in that same subnet — **no hardcoded IPs**, so it also covers future device subnets; it's
idempotent, preserves the route metric, and tolerates a missing route. The systemd unit (oneshot,
`enabled`) reapplies it on every WSL boot — which is the point, since `wsl --shutdown` otherwise
wipes the runtime fix (mirrored mode re-clones the Windows addresses fresh each boot, see below).
Install once: copy the unit to `/etc/systemd/system/`, `systemctl enable --now parking-net`.
Gotchas hit while building it: `network.target` is too early for mirrored-mode addresses (the
script waits up to 15s for a route to appear); and it must NOT `set -e` or one failed `ip` call
aborts the whole boot fixer.
> **Root cause is on the Windows side.** Mirrored mode clones the Windows host NIC's addresses into
> Linux at every boot, so the stray `192.168.1.x` lives on Windows — the truly permanent fix is to
> remove/reconfigure it there (or set `SkipAsSource`/interface metric). The systemd hook is the
> self-contained Linux-side answer that needs no Windows changes.
Verified on hardware (2026-06-15): after the hook, `10.0.10.121` pings and the real [[lpr-camera]]
Hikvision driver pulls a snapshot with **no** source-forcing (`localAddress` becomes optional).
## On the real appliance: multi-subnet is a deployment config, not a WSL hack
Production is a **dedicated hardened Linux appliance** ([[disk-os-hardening]]), so the WSL story
above is dev-only. The device-subnet problem persists, though, and is solved the same way at the
OS level: the appliance NIC carries **one address per device subnet**, each connected route with a
pinned `src`, made persistent (systemd-networkd / netplan). Per the threat model this still rides
on **[[network-isolation]]** — device subnets are isolated segments reachable only by the host.
The long-term clean answer is to **re-IP the devices onto one planned parking-system subnet** at
install so the host needs only one address; the multi-subnet config is what you run until then.
## Alternative if you can't use mirrored mode ## Alternative if you can't use mirrored mode
Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows** Windows 10 / old WSL can't do mirrored mode. Options: run the **backend natively on Windows**
+34 -1
View File
@@ -2,7 +2,7 @@
type: entity type: entity
tags: [parking, hardware, readers, offline-first] tags: [parking, hardware, readers, offline-first]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-06-14 updated: 2026-06-15
--- ---
# LPR Camera # LPR Camera
@@ -20,3 +20,36 @@ License-plate-recognition camera (recommended: **Milesight edge-AI LPR**). For
host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that host's signed [[append-only-event-chain]] entry + the controller's remote-open event) that
should reconcile one-to-one; any mismatch is an anomaly. should reconcile one-to-one; any mismatch is an anomaly.
- Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads. - Mounting: within ~15° of vehicle travel at a controlled chokepoint for best reads.
## Snapshot driver (entry/exit fraud-control record)
Separate from edge-AI LPR: the camera driver (`packages/devices/src/drivers/camera.ts`) does
**snapshot-on-event** — the host pulls a still over HTTP when an entry/exit fires and stores it,
referenced from the signed [[append-only-event-chain]] entry as an independent record. The camera
**pulls, it does not push** — so it is NOT `pushesToBackend` and the setup wizard correctly hides
the "Backend push IP" field for it (gated on the driver's `pushesToBackend` flag; only
[[dingtian-relay]] sets it).
- **Hikvision** uses **ISAPI**: `GET /ISAPI/Streaming/channels/<id>/picture` (`101` = ch1 main
stream) with **HTTP Digest** auth. The "Enable Hikvision-CGI" toggle (Network → Advanced →
Integration Protocol) is a *different* legacy CGI surface — **not** needed for ISAPI.
- **Dahua** uses CGI: `GET /cgi-bin/snapshot.cgi?channel=<n>` (0-based channel; the wizard's
1-based channel is decremented).
**Driver / storage boundary:** the driver FETCHES the image bytes (client-side HTTP Digest in
`drivers/http-digest.ts`) and returns them on `Snapshot.bytes`; **storage is the caller's job**
(the future entry/exit flow stores the bytes + mints a durable `imageRef`). This keeps the device
adapter free of any filesystem/blob-store dependency. `healthCheck()` is honest — it actually pulls
a frame (exercising reachability + auth + path/channel in one shot), not a fake `ready/stub`.
### Verified on hardware (2026-06-15)
A **Hikvision** unit ("Camera 20", MAC `94:e1:ac:…`, Hikvision OUI) at `10.0.10.121`, creds
`admin` / `admin123` (Digest), TCP 80:
- Initial `curl` test confirmed the ISAPI path returns a 2688×1520 JPEG (~306 KB).
- The **real driver** (no longer a stub) was then run end to end against it:
`healthCheck()` → `ready` (pulled a frame), `captureSnapshot()` → valid `image/jpeg`, ~322 KB,
correct JPEG magic. Digest handshake works through `HttpCamera`.
- Reaching it from the WSL dev box required forcing the source address (`config.localAddress`,
threaded into the driver) — see [[wsl-dev-networking]] (multi-subnet source-selection trap).
+58
View File
@@ -297,3 +297,61 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a - Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
device field); device provenance is in `identity`. device field); device provenance is in `identity`.
- Updated [[append-only-event-chain]]. - Updated [[append-only-event-chain]].
## [2026-06-15] test+lesson | Hikvision camera verified; multi-subnet source-address trap
- Pulled a real snapshot from a Hikvision camera on the bench: `GET
http://10.0.10.121/ISAPI/Streaming/channels/101/picture`, Digest auth, admin/admin123 → HTTP 200,
2688×1520 JPEG. Path + auth + creds confirmed. ISAPI is the right surface; the device's
"Enable Hikvision-CGI" toggle is a *different* legacy CGI API and is NOT needed.
- Caveat recorded: the camera driver is still a STUB — the wizard's "● ready — stub / ●
preconditions OK" contacts nothing; cameras have no preconditions (only [[dingtian-relay]]
implements checkPreconditions). Noted the cosmetic "Backend push IP" bug (camera pulls, doesn't
push; field should gate on a `pushesToBackend` capability).
- LESSON (cost an hour of "why can't we ping the subnet"): with two device subnets stacked on one
NIC (`192.168.1.123` + `10.0.10.203` on eth1), Linux picked the WRONG source address for
`10.0.10.x` → ARP shows REACHABLE but all ping/TCP times out. Fix: pin `src` on the connected
route (`ip route change <subnet>/24 dev <nic> proto kernel scope link src <host-ip>`), or force
source per-call (`ping -I` / `curl --interface`). Devices arrive on assorted static `/24`s; the
host carries one IP per subnet — this trap is the recurring cost of that.
- Decision context: production is a dedicated hardened **Linux appliance** (this WSL2 box is a dev
stand-in). Multi-subnet config + `src` pinning is an appliance deployment concern (made
persistent via networkd/netplan), riding on [[network-isolation]]; long-term answer is to re-IP
devices onto one planned parking subnet at install.
- Updated [[lpr-camera]] (snapshot driver + verified-on-hardware section), [[wsl-dev-networking]]
(multi-subnet source-address trap + appliance pattern).
## [2026-06-15] driver+fix | Real Hikvision/Dahua camera driver; push-IP field gated
- Replaced the camera STUB with a real `HttpCamera` (`packages/devices/src/drivers/camera.ts`):
Hikvision ISAPI (`/ISAPI/Streaming/channels/<ch>01/picture`) + Dahua CGI (0-based channel), both
over client-side HTTP Digest (new `drivers/http-digest.ts`, two-shot 401→challenge→response,
qop=auth MD5 — the client counterpart to the server's digest-auth.ts). `healthCheck()` now
actually pulls a frame instead of returning `ready/stub`. Added `localAddress` + `timeoutMs` +
`channel` config; threads the device-facing NIC for the multi-subnet trap.
- Snapshot interface: `Snapshot` now carries `bytes: Buffer` (driver fetches); `imageRef` is
optional and set by the CALLER once stored — keeps the adapter free of storage deps. Nothing
consumed captureSnapshot yet, so no migration needed.
- Cosmetic bug fixed: "Backend push IP" showed for any reachable host. Added a `pushesToBackend`
flag to `DeviceDriver` (only [[dingtian-relay]] sets it), exposed as `pushCapable` in the catalog
(mirrors `discoverable`), and gated both the wizard's backend-IP fetch and the field on it.
Cameras/printers/readers no longer show it.
- VERIFIED on hardware: built clean (5/5 packages); ran the real driver against the Hikvision at
10.0.10.121 → healthCheck ready, captureSnapshot returned a valid 322 KB JPEG (correct magic).
- Updated [[lpr-camera]].
## [2026-06-15] fix | Permanent WSL2 source-address fix (systemd hook)
- The multi-subnet source-address trap kept recurring (every `wsl --shutdown` wipes the runtime
`ip route` pin — mirrored mode re-clones the Windows NIC's addresses fresh each boot, and NOTHING
inside Linux owns them: networkd/NM/netplan all inactive). Made it permanent on the dev box.
- `deploy/wsl-fix-route-source.sh`: walks each `proto kernel scope link` route on the NIC and pins
`src` to the host's own address in that same subnet — no hardcoded IPs (covers future device
subnets), idempotent, preserves route metric, non-fatal per route. `deploy/parking-net.service`:
oneshot, enabled, reapplies on every boot.
- BUGS hit + fixed while building it: (1) `ip route change` errors `RTNETLINK: No such file` when
the route isn't up yet at boot → use `replace`; (2) `set -e` made one failed `ip` abort the whole
unit → dropped it, per-route warnings instead; (3) `network.target` fires before mirrored-mode
addresses land → script waits up to 15s for a route.
- VERIFIED: service enabled+active, journal shows `pinned 10.0.10.0/24 -> src 10.0.10.203`, camera
pings with NO -I flag (0% loss), and the real Hikvision driver pulls a snapshot with NO
`localAddress` set. Root cause noted as Windows-side (stray 192.168.1.x); this is the
self-contained Linux answer.
- Updated [[wsl-dev-networking]].