harden() now rotates the device's default admin/admin web-UI login via GET /userset.cgi?<old>&<old>&<new>&<new>& (best-effort: a failure logs and doesn't fail the assign). The new password is stored back in config (webUser/webPassword) so a re-run can rotate again, and is stripped from the assign response like the push secret. Documented the load-bearing caveat: this device's CGI API is fully UNAUTHENTICATED — config read/write, relay fire, and userset.cgi itself all return 200 with no credentials (verified on hardware). admin/admin gates only the browser UI, and there's no inbound-auth setting (only session_en, which bricks the read API). So the rotation is defence-in- depth for the UI, NOT a boundary; the signed event log remains the real anti-fraud guarantee. Verified rotation end-to-end on 10.0.10.5 (success &0&, wrong-old-pw &2&); device left at admin/admin.
7.6 KiB
type, tags, sources, updated
| type | tags | sources | updated | ||||
|---|---|---|---|---|---|---|---|
| entity |
|
2026-06-14 |
Dingtian Relay Controller
A network relay + input board (the unit on hand is the 4-channel variant: 4 relays + 4 inputs). Chosen to drive the entry/exit lane because — unlike the uhppote-controller — its inputs are independent of its relays, which solves the access-controller-button-flow blocker (a button on an input does not auto-open a relay; the host decides).
SDK: dingtian/4ch/sdk_v2_0_0/ (programming manual, examples). MIT-compatible use; no vendor
runtime needed.
⚠️ The one gotcha: input_link_relay
By default the device links each input to auto-fire its matching relay (input_link_relay: 1,
on_action_on: [[0],[1],…] in the config) — i.e. the same auto-open problem as the UHPPOTE.
The difference: it is configurable. Set input_link_relay: 0 (or clear the action mappings)
so an input only reports and the host commands the relay. This config step is mandatory for
the ticket-first entry flow. See autonomous-direction.
Protocol (Dingtian string — what we use)
Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use HTTP + UDP — see dingtian-vs-mqtt.
- Relay control — UDP binary, port 60000 (authenticated): the driver's
pulseOpensends a binary "write relay with jogging" frame carrying therelay_pw(the only relay option with a password). Frame (verified on hardware):FF AA <session> 03 <pwLo> <pwHi> <relayByte> <jogLo> <jogHi>— relayByte bit0=on, bits1-7= channel-1; jog is 100 ms units, LSB-first; password 16-bit LSB-first (0 = none). The relay jogs ON then auto-releases, so we never time a close (barrier-not-a-door). (The simpler string protocol —1+ch on,2+ch off,11*jog — works too but has no auth; we use it only for the read-only status query.) - Status / inputs — send
00→「relays」:「inputs」:「count」, e.g.0000:1111:4(4ch: relays off, inputs high).0= OFF/Low,1= ON/High. Poll-based. - Input push —
input_link_url: device HTTP POSTs to a host URL on input change — the push path for button events without a broker. - Discovery: UDP multicast
224.0.2.11:60000, send\x05\xAA(devices reply). Defaults: IP192.168.1.100, UDP60000(binary) /60001(string). - Binary protocol (port 60000) adds optional password + multicast; bitmask relay/input maps.
Driver & config API
The dingtian driver (device-registry) implements three capabilities:
AccessControlDevice (relay pulse/latch over UDP), InputDevice (read inputs + poll-based
press/release events ~50 ms), and PreconditionDevice (below). Config fields include a separate
httpPort — the device's web/config API is on a configurable HTTP port (default 80),
distinct from the UDP control port 60001.
Precondition: input_link_relay must be OFF
The driver reads the device's JSON config (GET /api/v2/config.cgi) and checks
input_link_relay; if enabled it reports a fixable issue, and fixPreconditions() writes the
correction (POST /api/v2/config_set.cgi) — setting the flag to 0 and clearing on_action_on,
preserving everything else (network, etc.). This is the generic device-registry
capability: the app doesn't own full device config (that's the vendor web UI), only the few
settings our flow depends on.
Write gotcha (cost real debugging): the GET config payload omits a
"command"field, but the set endpoint requires"command":"setconfig"injected right after"status". Without it the POST returns/looks like success but silently does nothing (and the device may reset). With it, POST returns{"status":0}and the change sticks. JSON node order must be preserved.
Input push (no polling) — the chosen architecture
The device pushes button events to the backend; the backend decides. No polling. The
driver's configureInputPush() writes the device's input_link_url config to point each input at
the backend: input N HTTP-GETs …/api/devices/dingtian/<deviceId>/input/<N>/on (and /off) on
press/release. The backend (fastify route routes/devices.ts) translates each push into an
internal device event (device-input-flow); the entry flow then prints a ticket and commands
the relay via UDP. See device-input-flow for the full path + trust model.
The input-poll path in the driver (
onInput) remains as a dev/fallback aid, but push is the real path — lower latency, and it can be authenticated (the device supports Basic/Digest + HTTPS on the push), unlike the open UDP control direction.
Hardening (harden()) — and why HTTP auth is not a boundary here
On assign the driver runs harden() (the device-registry capability):
relay_pw— set a random relay password so binary relay commands (UDP 60000) need it.- Disable unused channels — set
p:255on rs485/can/tcp×2/mqtt; keep only UDP1 binary (relay control) + UDP2 string (status read). - Rotate the
admin/adminweb login —GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&(response&0&…&= success, verified on hardware). The new password is stored back in config (webUser/webPassword) so a re-run can rotate again (the device checks the old creds). This step is best-effort — a failure logs and does not fail the assign.
⚠️ The device CGI API is UNAUTHENTICATED. Verified on hardware:
GET /api/v2/config.cgi,/, and even/userset.cgiall return 200 with no credentials. Theadmin/adminlogin gates only the interactive browser UI — the CGI control plane (read/write full config, fire relays, change the password) bypasses it entirely. Thehttpconfig block has no setting to require Basic/Digest on inbound requests; the only inbound gate issession_en, which bricks the config-read API on this firmware (the factory-reset incident — do not enable it). So rotating the login is cosmetic (stops a casual browser reaching settings); it is not a boundary. On this flat, no-VLAN network the device control plane is effectively open — the signed event log is the real anti-fraud guarantee. See device-input-flow.
⚠️
session_enmust stay OFF. Enabling the HTTP CGI session check makes the config-read API drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by factory reset.harden()deliberately never touches it.
Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172)
- ✅ status read (
0000:1111:4), relay pulse, input press/release events (active-LOW, idle HIGH). - ✅
input_link_relaydisabled via the driver → pressing an input reports the event and fires NO relay (0000after presses). The access-controller-button-flow blocker is solved. - ✅ Input HTTP-push end to end — configured the device via
configureInputPush(), then real button presses (all 4 inputs) pushed to the backend (/input/N/on+/offper press, source = the device IP). No polling. Host-in-the-loop entry (button → backend → ticket → backend opens relay) is real. - ✅ Web-login rotation —
userset.cgirotatesadmin/admin(response&0&/&; wrong old password →&2&/&). Confirmed the device validates the old creds. Also confirmed the CGI API needs NO auth (config dump +userset.cgireturn 200 unauthenticated) → rotation is cosmetic. - ⬜ Next: wire the actual entry flow (input event → signed event + print ticket →
pulseOpen).