Cross-links: Discourse t/318 (docs SoR), Redmine #345/#355/#356, PFVCluster, LegacyTechops lineage. https://community.turnsys.com/t/318
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 · Tracking: Redmine #345 (umbrella) → #355 (code & testing) → #356 (deployment) · Ops status: Discourse t/297 · Fleet context: KNEL/PFVCluster
Lineage
Successor of the 2018 Perl doorman
(KNEL/LegacyTechops doorman/),
carried verbatim under legacy/. 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.
Webhook contract (for the 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; 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)
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 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) |