🚪 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 systemd tests lineage UAT

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-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)

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)
S
Description
Physical access control: badge-reader listener + tooling; modern successor of LegacyTechops doorman (2018 Perl). Scans -> Home Assistant webhook. Tickets #345/#355/#356.
Readme AGPL-3.0
124 KiB
Languages
Shell 85.9%
Perl 14.1%