# doorman — badge access, modernized Physical access control for the server-room door: badge-reader listener for the reader host, dispatching scans to Home Assistant, which owns the whitelist, logging, alerts, and unlock decision. **Dedicated docs topic: [Discourse t/318](https://community.turnsys.com/t/318)** · Tracking: [Redmine #345](https://projects.knownelement.com/issues/345) (umbrella) → [#355](https://projects.knownelement.com/issues/355) (code & testing) → [#356](https://projects.knownelement.com/issues/356) (deployment) · Ops status: [Discourse t/297](https://community.turnsys.com/t/297) · Fleet context: [KNEL/PFVCluster](https://git.knownelement.com/KNEL/PFVCluster) ## Lineage Successor of the 2018 Perl doorman ([KNEL/LegacyTechops `doorman/`](https://git.knownelement.com/KNEL/LegacyTechops/src/branch/main/doorman/doorman.pl)), carried verbatim under [`legacy/`](legacy/doorman.pl). Same reader class (keyboard-emulating USB RFID), same decode semantics — but the defunct plain-HTTP auth portal (`doors.pfv.turnsys.net`) is NOT replicated: HA is the single authentication brain. ## Architecture ``` USB RFID reader (HID keyboard) └─> /dev/input/by-id/*-event-kbd (reader host: pfvsvrpi prod, | ultix-field dev) v bin/doorman.sh od(1) + awk(1) decode — bash and | coreutils ONLY, no package installs v POST {badge_id, reader, ts} -----> Home Assistant webhook |– whitelist decision |– logbook/recorder history |– unknown badge -> instant alert '- unlock (e-lock or relay) ``` Fail-closed by construction: if HA is unreachable, the scan is logged and dropped — the door does not open. The optional local relay bridge (`usbrelay`, 2018 lineage) exists behind `DOORMAN_UNLOCK_ON_2XX` (default **off**) for the transition period; native HA webhooks always answer 200, so that flag must stay off until HA returns real accept/deny codes. ### Door control (unlock path — built 2026-09-03, founder-approved) Decision: HA → Pi authenticated callback over Tailscale. ``` HA automation (badge valid + input_boolean.doorman_unlock_enabled ON) └─ rest_command.doorman_unlock └─ GET http://pfvsvrpi.knel.net:8333/unlock/ (token = DOORMAN_UNLOCK_TOKEN, on-box 0600; URL treated as secret) └─ doorctl.socket — systemd socket activation, bound to the Pi's Tailscale IP ONLY, per-connection doorctl@.service: · source IP must be in DOORMAN_ALLOWED_SRC (HA + the Pi) · relay =1 for DOORMAN_HOLD seconds, then =0 · EXIT trap guarantees the relay returns to 0 (secure) even if the hold is killed ``` Everything is logged to syslog/journald (`-t doorctl`): every accept, every deny with source. Disarm = flip `input_boolean.doorman_unlock_enabled` off — valid badges then log and notify but the door stays shut. Arming is a founder-level action; the strike side of the relay was disconnected during build/test (founder confirmed) and gets wired onsite. ### Webhook contract (for the HA side) `POST ` with JSON: ```json {"badge_id": "0000000001", "reader": "pfvsvrpi", "ts": "2026-09-02T19:12:44-0500"} ``` - Values are digits + env-provided strings; the JSON cannot carry user-controlled quotes/backslashes. - Any 2xx satisfies the listener; HA automations do the real work. - Configure the HA webhook trigger to this URL; the token lives in the URL path — treat it as a secret, keep it in `/etc/default/doorman` (0600) on the reader host, never in git. ## Deploy (reader host) ```bash sudo mkdir -p /opt/doorman sudo cp bin/doorman.sh /opt/doorman/bin/ # + this repo, or rsync sudo cp deploy/doorman.service /etc/systemd/system/ sudo cp .env.example /etc/default/doorman # fill in, chown root:root sudo chmod 600 /etc/default/doorman sudo systemctl daemon-reload && sudo systemctl enable --now doorman ``` Record width auto-detects (24 B on 64-bit kernels, 16 B on 32-bit Pis). Logs go to syslog/journald (`-t doorman`); `DOORMAN_DEBUG=true` mirrors to stderr. Deployment discipline: **ultix-field (dev) first, verify end-to-end, then pfvsvrpi (prod)** — never both at once (#356). ## Testing (no hardware needed) ```bash bash scripts/test.sh # decoder suite: synthetic event fixtures bash tests/shellcheck.sh # lint gate (docker, zero-warning) bash scripts/check-rules.sh # full rule audit ``` `--selftest` mode decodes a fixture file and prints one badge ID per line — that is the entire test surface of the decoder, including the 2018 quirks we intentionally preserve (usage passthrough 0-9, bad-usage parity-anchor behavior, empty-scan suppression). ## Repo map | Path | What | |------|------| | `bin/doorman.sh` | The listener daemon | | `deploy/doorman.service` | systemd unit (`/etc/default/doorman` env file) | | `tests/run-tests.sh` | Offline decoder suite (9 tests) | | `legacy/` | Verbatim 2018 Perl snapshot (not maintained) | | `scripts/` | Rule engine + hooks (adopted from TSYSGroupAIOS) | | `questions-v1.md` | Points to the active round (PFVCluster questions-v4.md) |