Files

166 lines
6.6 KiB
Markdown

# 🚪 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/<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`](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 <DOORMAN_WEBHOOK_URL>` 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) |