Fleet CI standard adoption (Discourse t/333); 16 tests existed but nothing enforced them mechanically. https://projects.knownelement.com/issues/784
🚪 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.
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 · Tracking: Redmine #345 → #355 (code) → #356 (deployment) · HA config: KNEL/pfv-bms · Fleet: KNEL/PFVCluster
🧭 Architecture — one scan, end to end
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/<token> (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-rosterrepo (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;
0600env 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)
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 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 <DOORMAN_WEBHOOK_URL> with 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/),
carried under legacy/. 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) |