# πŸšͺ doorman β€” badge access control, modernized > **USB badge readers β†’ pure-bash listeners β†’ Home Assistant brains β†’ relay-actuated door strike.** > Fail-closed by construction: if the brain is unreachable, the door stays shut. ![bash](https://img.shields.io/badge/language-pure%20bash%20%2B%20coreutils-4EAA25?logo=gnu-bash&logoColor=white) ![systemd](https://img.shields.io/badge/daemon-systemd-3DA63E?logo=systemd&logoColor=white) ![tests](https://img.shields.io/badge/tests-16%2F16%20passing-brightgreen) ![lineage](https://img.shields.io/badge/lineage-2018%20Perl%20%2B%20HID%20readers-8A712C) ![UAT](https://img.shields.io/badge/UAT-verified%20end--to--end%202026--09--03-blue) Physical access control for the server-room door: a keyboard-emulating USB RFID reader listener on the reader host, dispatching scans to Home Assistant, which owns the whitelist, logging, alerts, and the unlock decision. **Dedicated docs:** [Discourse t/318](https://community.turnsys.com/t/318) Β· Tracking: [Redmine #345](https://projects.knownelement.com/issues/345) β†’ [#355](https://projects.knownelement.com/issues/355) (code) β†’ [#356](https://projects.knownelement.com/issues/356) (deployment) Β· HA config: [KNEL/pfv-bms](https://git.knownelement.com/KNEL/pfv-bms) Β· Fleet: [KNEL/PFVCluster](https://git.knownelement.com/KNEL/PFVCluster) --- ## 🧭 Architecture β€” one scan, end to end ```mermaid sequenceDiagram autonumber participant B as πŸͺͺ Badge participant R as πŸ“‘ USB reader (HID kbd) participant L as doorman.sh (reader host) participant H as 🏠 Home Assistant participant RO as πŸ”’ access-roster (private repo) participant D as ⚑ doorctl (pfvsvrpi) participant S as πŸšͺ Door strike B->>R: tap R->>L: HID scancodes (/dev/input/eventN) L->>L: od(1)+awk(1) decode β†’ badge_id L->>H: POST webhook {badge_id, reader, ts} H->>RO: REST roster poll (every 300 s) alt HA unreachable L-->>L: log + DROP (fail-closed) else known + enabled alt armed + not local_unlock H->>D: GET /unlock/ (Tailscale) D->>D: source-IP allowlist + token check D->>S: relay=1 … hold … =0 (EXIT-trap secured) H-->>H: logbook + push notification else break-glass scan L->>D: relay fired locally already L->>H: local_unlock:true β†’ HA logs, no re-dispatch end else unknown / disabled badge H-->>H: instant alert 🚨 + logbook end ``` ### The three decision lanes | Lane | Condition | Outcome | |------|-----------|---------| | βœ… **Unlock** | badge in roster **and** enabled **and** arm switch ON | relay fires for the hold window; logbook + notification | | 🚨 **Alert** | badge unknown **or** in-roster-but-disabled | instant push alert; nothing actuates | | 🧯 **Break-glass** | badge listed in `DOORMAN_BREAKGLASS_IDS` on the reader host | relay fires **locally** β€” works even with HA down; HA is told afterwards | The **arm switch** (`input_boolean.doorman_unlock_enabled` in HA) is a founder-level control: OFF = valid badges log and notify, but the door stays shut. --- ## πŸ›‘οΈ Security model - **HA is the single authentication brain** β€” the listener never decides. - **Fail-closed**: HA unreachable β†’ scan logged, door does not open. - **Badge IDs never live in this repo.** The whitelist is the private [`KNEL/access-roster`](https://git.knownelement.com/KNEL/access-roster) repo (two-human PR gate); HA fetches it live via API. All fixtures in this repo use synthetic IDs β€” real ones were redacted 2026-09-03. - **doorctl** (the unlock endpoint on the Pi): - systemd **socket activation**, bound to the Pi's Tailscale IP only - **token in the URL** (secret; `0600` env file on-box, never in git) - **per-connection source-IP allowlist** (HA + the Pi itself) - **EXIT-trap self-securing**: relay returns to 0 even if the hold is killed - every accept **and** every deny logged to journald (`-t doorctl`) - Secrets discipline: `/etc/default/doorman` (`0600`) holds webhook URL, unlock token, break-glass IDs β€” nothing sensitive is committable. --- ## πŸ“‘ Fleet | Host | Role | Reader device | Arch | |------|------|---------------|------| | `pfvsvrpi` | **prod** (server-room door) | `/dev/input/event0` | aarch64 | | `ultix-field` | dev / staging | `/dev/input/event15` | x86_64 | Deploy discipline: **ultix-field first, verify end-to-end, then pfvsvrpi** β€” never both at once. --- ## πŸš€ 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 journald (`-t doorman`); `DOORMAN_DEBUG=true` mirrors to stderr. ## πŸ§ͺ Testing (no hardware needed) ```bash bash tests/run-tests.sh # decoder suite (9 tests): synthetic HID event fixtures bash tests/test-breakglass.sh # break-glass list matching (7 tests) bash tests/test-doorctl.sh # doorctl endpoint bash tests/shellcheck.sh # lint gate (docker, zero-warning) ``` `--selftest` decodes a fixture and prints one badge ID per line β€” 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). ## πŸ”— Webhook contract (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. The token lives in the URL path β€” treat it as a secret. --- ## πŸ“œ Lineage Successor of the 2018 Perl doorman ([KNEL/LegacyTechops `doorman/`](https://git.knownelement.com/KNEL/LegacyTechops/src/branch/master/doorman/doorman.pl)), carried under [`legacy/`](legacy/doorman.pl). Same reader class (keyboard-emulating USB RFID), same decode semantics β€” the defunct plain-HTTP auth portal (`doors.pfv.turnsys.net`) is **not** replicated. ## πŸ—ΊοΈ Repo map | Path | What | |------|------| | `bin/doorman.sh` | The listener daemon | | `bin/doorctl.sh` | Socket-activated unlock endpoint | | `deploy/doorman.service` | systemd unit (`/etc/default/doorman` env) | | `tests/` | Offline suites β€” decoder, break-glass, doorctl, lint | | `legacy/` | Verbatim 2018 Perl snapshot (badge IDs redacted) | | `scripts/` | Rule engine + hooks (adopted from TSYSGroupAIOS) |