Files
parking_solution/wiki/log.md
T
julian f5fd61984a Dingtian web password: set the admin's chosen password, verified
Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
2026-06-15 12:26:34 +02:00

20 KiB
Raw Blame History

Wiki Log

Append-only chronological record. Each entry: ## [YYYY-MM-DD] <op> | <subject>. Query with grep "^## \[" log.md | tail -5.

[2026-06-14] ingest | Parking System — Architecture & Design Notes

First source ingested. Bootstrapped wiki scaffolding (CLAUDE.md schema, index.md, overview.md, log.md). Created source summary, 14 entity pages, 9 concept pages, and decision records (settled decisions + 6 open questions). Source is a dense design doc covering stack, threat model, device architecture, UHPPOTE access control, the custom ESP32 controller alternative, readers, and a reference BOM.

[2026-06-15] decision | JWT key choice + ESP32 deferred

From app work, not a new source. Added open-questions #7 (symmetric vs. asymmetric JWT signing key — raised by the commit security review; prefer RS256/EdDSA so verifying hosts hold only a public key, mirroring the ATECC608 / challenge-response property). Marked esp32-custom-controller status: deferred per decision not to implement device-level auth for now (access control stays on UHPPOTE + network isolation); noted in open-questions #6. Updated local-jwt-auth (hardened secret handling + 8h expiry, asymmetric-key pointer) and the index.

[2026-06-15] decision | Device-agnostic registry + first-run setup

From app work. Made the device-adapter-pattern selectable: added a device-registry (catalog of drivers per category) and a first-run-setup flow so the admin picks a device per lane at install. Categories: access (ZKTeco / ESP32 relay), reader (Wiegand / TCP-IP), camera (Hikvision / Dahua, snapshot-on-event), printer. Added a CameraDevice interface; new lane_devices

  • setup_state tables (migration 0001); admin-only setup endpoints. Stub drivers for now (no real vendor protocols yet). Verified catalog + assign + validation + auth end to end.

[2026-06-15] decision | UHPPOTE library chosen + real driver

Researched Node options for the UHPPOTE controller. Chose the official uhppoted npm package (MIT, actively maintained, full API incl. openDoor, get-event(s)/event-index, set-listener, restore-default — covers the whole event-log-ingestion design). Rejected: raw-dgram DIY (reinvents the lib), node-red-contrib-uhppoted (wrong model), Go REST sidecar (extra runtime). Added it to @parking/devices and implemented a real uhppote uhppote-controller access driver (pulseOpen→openDoor, healthCheck→getStatus), registered in the catalog. CJS interop: default-import + destructure. Verified it builds, appears in the catalog, and degrades to "offline" gracefully without hardware. Real on-VLAN test still pending.

[2026-06-15] feature | Device discovery (UHPPOTE scan in setup)

The frontend had no way to find a UHPPOTE — but the controllers self-announce via UDP broadcast. Added a generic device-discovery capability: optional DiscoverableDriver.discover() on the registry, implemented by the uhppote driver via getDevices. New admin-only GET /api/setup/discover/:driverId (health-checks each found device); catalog now returns a discoverable list. SetupWizard gains a "Scan for controllers" button that lists found devices with health badges and auto-fills serial + host on selection. Verified: catalog flags uhppote; discover runs and fails gracefully without hardware (broadcast EACCES); non-discoverable driver → 400; no token → 401. Modeled generically so cameras (ONVIF) can add discovery later.

[2026-06-15] test+blocker | UHPPOTE hardware bring-up + entry-flow blocker

Brought up the real UHPPOTE (serial 225088491, fw 09120) end to end. Fixed the networking path: WSL2 mirrored mode, then driver bugs — subnet-directed broadcast (the lib doesn't enable SO_BROADCAST for global 255.255.255.255), broadcast must match the target's subnet for unicast reply routing (health-check timeout fix), multi-subnet discovery, and serialized I/O (concurrent calls collided on :60001). Added .env loading (Node --env-file), env-gated+fail-closed SETUP_AUTH_BYPASS, and an authBypass flag so the wizard drops the token field. Test scripts in apps/server/scripts/ (uhppote-listen, uhppote-relay).

VERIFIED on hardware: discovery; host-commanded openDoor doors 1&2 (physical + reason="remote open door"); button presses live (reason="push button ok").

BLOCKER FOUND: the controller push-button input auto-opens the relay in firmware — no command to report-without-opening — so ticket-first entry (button→print→open) is impossible as wired. UHPPOTE can't do it on that input; ZKTeco might via a programmable aux input + PULL SDK but that's unverified and needs a new driver. Recorded in access-controller-button-flow + zkteco-controller. Entry-lane hardware decision paused to focus on the business side.

Built real authentication: bcrypt login → JWT in an HttpOnly+SameSite=Strict cookie, readable CSRF cookie + X-CSRF-Token header (double-submit) on mutations, role-guarded routes. Routes: /api/auth/{login,logout,me}. First admin seeded via pnpm --filter @parking/server seed-admin. Removed the SETUP_AUTH_BYPASS shim and the wizard token field; the SPA gates on /api/auth/me and only shows setup to admins. Same-origin via the Vite dev proxy and a new prod nginx config (deploy/nginx.conf). Verified end to end (curl + browser): wrong pass→401, login→cookies set, me→admin, assign without CSRF→403 / with→201, no cookie→401, session persists across reload. Updated local-jwt-auth.

[2026-06-15] lint+docs | Dev-environment pages (WSL networking, workflow)

Captured hard-won dev knowledge that was only in commit messages: new wsl-dev-networking (WSL2 NAT blocks UDP broadcast → mirrored mode + the multi-interface / subnet-broadcast / IPv6-localhost gotchas that remained) and local-dev-workflow (setup, seed:admin, the dev-server-hang from the broken strip-types script → tsx, the 127.0.0.1 proxy fix, .env loading). Corrected the earlier "broadcast permission (EACCES)" note in device-discovery — the real cause was the lib not enabling SO_BROADCAST for the global 255.255.255.255; documented the three verified broadcast gotchas + serialization. Added a reference page type to the schema; new "Dev environment" index section.

[2026-06-15] decision | Dingtian relay chosen; HTTP over MQTT; unmanned direction

New relay+input controller on hand (Dingtian 4ch). Its inputs are decoupled from relays (configurable via input_link_relay) — solves the access-controller-button-flow blocker the UHPPOTE couldn't. Transport decision dingtian-vs-mqtt: direct HTTP/UDP now (UDP string for relay control on :60001; device input_link_url HTTP push for button events), MQTT skipped (broker = infra + failure mode + overkill at this scale) but kept for later multi-lane scale. Recorded the stated roadmap to fully unmanned, no-booth operation in autonomous-direction and its threat-model shift (operator-fraud → unattended-machine threats). New stub dingtian-relay with the full protocol from the SDK. Driver + on-hardware test still to build.

[2026-06-15] driver+test | Dingtian driver built; button blocker RESOLVED

Built the dingtian access driver (AccessControlDevice relay control + InputDevice poll-based button events + new PreconditionDevice capability). Verified end to end on real hardware (DT-R004 @ 10.0.10.172, HTTP config on :8080, UDP control :60001): status read, relay pulse, input press/release. Disabled input_link_relay via the driver's fixPreconditions (GET config → flag 0 + clear maps → POST config_set), then confirmed: pressing inputs now fires NO relay (0000 status) — host-in-the-loop entry works. The access-controller-button-flow blocker is RESOLVED. Gotcha recorded in dingtian-relay: config_set requires injecting "command":"setconfig" after "status" (GET omits it) or the write silently no-ops. Added httpPort config field (port 8080 ≠ default 80). Test script apps/server/scripts/dingtian-test.mjs. Next: input HTTP-push endpoint + wiring input→ticket→pulseOpen.

[2026-06-15] cleanup | Remove UHPPOTE/ZKTeco code; wiki → rejected/historical

Neither UHPPOTE nor ZKTeco is used (Dingtian chosen). Removed their code: deleted access-uhppote.ts, uhppoted.d.ts, access.ts (zkteco/esp32 stubs), the three uhppote-*.mjs scripts; dropped the uhppoted npm dep from both packages; unregistered uhppote/zkteco/esp32-relay from the driver registry; updated example comments. Catalog access drivers now = dingtian only. Build green. Wiki: kept the pages but marked uhppote-controller + zkteco-controller rejected/historical, uhppote-vs-esp32 historical; re-pointed all "current device" framing (standing-decisions, bom, overview, open-questions) to dingtian-relay; noted no current driver uses device-discovery. Transferable concepts (network-isolation, event-log-ingestion, barrier-not-a-door, threat-model) kept as-is. Links lint clean; raw source untouched (immutable).

[2026-06-15] feature | Dingtian input HTTP-push → backend (no polling)

Wired the device's "Input Link URL" feature so it HTTP-pushes button events to our backend — no polling. Driver configureInputPush() writes input_link_url (per-input server/port/path, en=1, active-LOW, plain HTTP) via the config API (reusing the #writeConfig + command:setconfig helper). New backend route routes/devices.ts: public GET/POST /api/devices/dingtian/:deviceId/input/:n/{on,off} → emits onto an internal device-events bus (device-events.ts, EventEmitter) for the entry flow to consume. VERIFIED on hardware: configured device, real presses on all 4 inputs pushed to the backend (input N on+off, source = device IP). Trust model recorded in device-input-flow: flat network / no VLAN → backend is source of truth, every open is a signed event (out-of-band open = anomaly); push endpoint not behind cookie auth (machine call), shared-secret available as defence-in-depth. Next: wire signed event + ticket print + pulseOpen.

[2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)

Secured the device→backend input push. Empirically tested auth options on the device: HTTPS-to-self-signed FAILS, Basic works, Digest works → chose Digest (MD5, qop=auth): password never on the wire, single-use nonces. Backend digest-auth.ts (challenge/verify) + source-IP allowlist on the push route; per-device pushUser/pushPassword generated on assign, written to the device and stored in lane_devices (admin never types a URL/secret). Driver configureInputPush now sets auth=2 + creds; the assign flow auto-configures the device and persists the creds (net.ts derives the backend IP on the device's subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged). TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length — the device silently ignores chunked bodies (Node's default), which masqueraded as "writes don't apply" all session; (2) the pass field caps at 31 chars → use a 24-char password. Driver #writeConfig now polls-until-verified (device reboots on apply). VERIFIED on hardware: assign auto-configures the device, then all 4 inputs push with Digest auth, zero failures. Recorded in device-input-flow.

[2026-06-15] feature | Setup wizard: Test connection + Save & configure

Two-step device setup UX. New admin-only POST /api/setup/test (healthCheck + checkPreconditions, no save / no device change). The assign (Save) step now also fixes preconditions (disables input_link_relay) before configuring push — closing a gap where assigned devices could still auto-fire relays; fails the save with no DB row if device config fails (no orphan rows). SetupWizard wires the config fields → Test button (health badge + precondition warnings) → Save & configure button. Verified in-browser against the real device: Test shows ● ready + preconditions OK; Save persists the row AND writes the device's Input Link URL (push path matches the saved device id). Admin never logs into the device web UI. Updated first-run-setup.

[2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels

Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched pulseOpen from the unauthenticated string protocol (:60001) to the binary protocol (:60000) with a relay password — the only authenticated relay option (frame verified on hardware: FF AA 03 ). New HardenableDevice capability: harden() sets a random relay_pw + disables unused channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the assign/Save flow (preconditions → harden → push); relayPassword stored in lane_devices. Verified end to end: assign configures + hardens the device, config API stays reachable, pulseOpen with the stored password fires the relay, without it is rejected.

⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY RESET to recover. harden() deliberately does NOT touch session_en. The open CGI API is accepted as flat-network reality; the signed log is the real guarantee. Recorded in device-input-flow + dingtian-relay.

[2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated

While addressing "change the device's default admin/admin", traced the device web UI JS (system.js) → the change-login endpoint is GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>& (response &0&/& = success, &2&/& = wrong old pw). Added a best-effort setWebLogin/#rotateWebLogin step to harden() (new pw stored back as config webPassword, stripped from API responses). KEY FINDING: the device CGI API needs NO authentication — config dump, config write, relay fire, and userset.cgi itself all return 200 unauthenticated (verified on 10.0.10.5). admin/admin gates only the browser UI; there is no inbound-auth setting (only session_en, which bricks the read API). So rotating the login is COSMETIC, not a boundary — the signed event log remains the real guarantee. Recorded in dingtian-relay (new Hardening section).

[2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover

  • Added rongta PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry.
  • Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside); entry ticket fails over outside→booth (asymmetric — receipts never print outside).
  • Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
  • One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
  • New pages: rongta-printer, printer-roles-failover. Updated bom, index.
  • Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.

[2026-06-14] ingest | Live printer status monitoring

  • Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
  • Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/ Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
  • Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s), caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
  • Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw); bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
  • New page: printer-status-monitoring. Updated rongta-printer, index.
  • Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.

[2026-06-15] ingest | Multi-instance device setup (add/remove per category)

  • Confirmed the data model was already multi-instance (lane_devices = one row per instance, assign always inserts); the limitation was UI-only (one slot per category).
  • Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets (pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
  • Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add another" form; select-type config fields now render as dropdowns (fixes printer role input).
  • Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak, delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
  • Updated first-run-setup.

[2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)

  • Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
  • Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events; integrity: GET /api/events/verify (admin).
  • Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY) shipped now since chip wiring is open-question #6. Caveat documented: software signer is tamper-evident but NOT unforgeable-by-owner.
  • Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
  • Added ParkingEventType 'input_received'.
  • Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok; direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent appends -> indices 1..5 intact. Full repo typechecks.
  • Updated append-only-event-chain, dingtian-relay.

[2026-06-15] ingest | Event log + Dingtian string-protocol security fix

  • Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received. Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
  • SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays ("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses, but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack is dead and binary control/status still work.
  • GAP (user-identified): event log captures host-originated actions only; out-of-band relay actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware. Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
  • Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area); pushPassword/relayPassword stay machine-only.
  • harden() warnings surfaced via the assign response.
  • localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
  • INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults). Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
  • Updated append-only-event-chain, dingtian-relay.

[2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings

  • BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning never reached the admin (frontend discarded the assign response).
  • FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and only returns secrets.webPassword on success (else warning, no save). assign strips typed webPassword/webPasswordCurrent and persists only verified secrets.
  • SetupWizard now shows assign-response warnings (amber banner, per category) — closes the feedback loop for the UDP2-can't-disable case.
  • Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
  • Updated dingtian-relay.