7fd407ac82
Lock down the relay device for the flat (no-VLAN) network. Relay control: - pulseOpen/setRelay now use the Dingtian BINARY protocol (:60000) with a relay password — the only relay option with auth (string :60001 has none, and is kept only for the read-only status query). Frame verified on hardware. HardenableDevice capability (driver harden()): - set a random relay_pw (1-9999); disable unused channels (rs485/can/tcp x2/mqtt -> p:255), keeping UDP1 binary (control) + UDP2 string (status). - write-verified (device reboots on apply). Assign/Save flow now does: fix preconditions -> harden -> set up input push; the relay password is stored in lane_devices so the runtime device can command the relay. DELIBERATELY NOT touching the device's HTTP CGI session check (session_en): enabling it on this firmware breaks the config-READ API (ECONNRESET) and locked the backend out — required a factory reset to recover. The open CGI API is accepted as flat-network reality; the signed event log is the real guarantee. Verified end to end on hardware: assign hardens + configures the device, config API stays reachable, pulseOpen with the stored password fires the relay, without it is rejected. wiki: device-input-flow + dingtian-relay updated.
107 lines
5.9 KiB
Markdown
107 lines
5.9 KiB
Markdown
---
|
||
type: concept
|
||
tags: [parking, architecture, devices, entry-flow]
|
||
sources: []
|
||
updated: 2026-06-15
|
||
---
|
||
|
||
# Device Input Flow (button → backend → relay)
|
||
|
||
How a physical button press drives the entry lane. The **backend is the source of truth**: the
|
||
device only *reports* the press; the host decides and commands the relay. This is the host-in-the-
|
||
loop flow the [[dingtian-relay]] makes possible (and the [[uhppote-controller]] could not).
|
||
|
||
## The path (no polling)
|
||
|
||
```
|
||
car arrives → driver presses button (input I_N, dry contact to GND)
|
||
→ device HTTP-pushes GET …/api/devices/dingtian/<deviceId>/input/<N>/on
|
||
→ backend: emit internal device event (device-events bus)
|
||
→ backend entry flow: create + sign an entry event, print the ticket
|
||
→ backend: pulseOpen(N) over UDP → barrier opens
|
||
→ (on release) device pushes …/input/<N>/off
|
||
```
|
||
|
||
- **Push, not poll.** The device's `input_link_url` feature is configured (by the driver's
|
||
`configureInputPush()`) to call the backend on each input edge — see [[dingtian-relay]]. The
|
||
driver's poll path remains only as a dev/fallback aid.
|
||
- **Per-input path** carries the input number in the URL (`…/input/3/on`), so routing needs no
|
||
body parsing. Both edges (`on`/`off`) are sent.
|
||
- **Internal event bus** (`device-events.ts`, a Node `EventEmitter`) decouples the HTTP/transport
|
||
layer from business logic — drivers/pushes emit; the entry flow subscribes. Keeps the app
|
||
[[device-adapter-pattern|device-agnostic]].
|
||
|
||
## Trust model (important — flat network, no VLAN)
|
||
|
||
The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
|
||
so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
|
||
neither is the real boundary:
|
||
|
||
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
|
||
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
|
||
device + stored in `lane_devices` by the harden step (below).
|
||
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
|
||
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
|
||
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
|
||
flat network allows) has **no matching signed event → a detectable anomaly**. Device/network
|
||
auth is just speed bumps; both are plaintext over a sniffable network.
|
||
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
|
||
detection via the signed log matters more than perimeter auth.
|
||
|
||
## Device hardening (on assign)
|
||
|
||
The assign/Save step configures the device end-to-end (admin never touches the device web UI):
|
||
fix preconditions (disable `input_link_relay`) → **harden** → set up input push. The `harden`
|
||
capability ([[device-registry|HardenableDevice]]):
|
||
|
||
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
|
||
`lane_devices` so the backend can keep commanding the relay.
|
||
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
|
||
binary (relay control) + UDP2 string (status read) — fewer open doors.
|
||
|
||
> **⚠️ Lesson (the hard way):** do **NOT** enable the device's HTTP CGI session check
|
||
> (`session_en`). On this firmware (DT-R004) it makes the config-**read** API drop connections
|
||
> (`ECONNRESET`), locking the backend out of the very API it depends on — it required a **factory
|
||
> reset** to recover. The harden step deliberately leaves `session_en` off. The CGI config API
|
||
> being open is accepted as part of the flat-network reality (the signed log is the guarantee);
|
||
> the proper fix is network isolation, not this fragile device feature.
|
||
|
||
## Push authentication — Digest (decided by hardware testing)
|
||
|
||
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
|
||
the clear. We **empirically tested the device** to pick the strongest achievable option:
|
||
|
||
| Option | Device result |
|
||
| --- | --- |
|
||
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
|
||
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
|
||
| Basic auth | ✅ works (but password base64 on the wire) |
|
||
| URL token | rejected by design (visible in URL/logs) |
|
||
|
||
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
||
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
||
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
||
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
||
See `apps/server/src/digest-auth.ts`.
|
||
|
||
## Dingtian config-write gotchas (cost a lot of debugging)
|
||
|
||
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
|
||
handled in the driver:
|
||
|
||
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
|
||
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
|
||
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
|
||
nothing changes. Always set `Content-Length`.
|
||
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
|
||
generated push password is 24 hex chars (96 bits).
|
||
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
|
||
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
|
||
|
||
## Status
|
||
|
||
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
|
||
failures). The entry
|
||
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
|