diff --git a/README.md b/README.md index 4669774..ee7f94b 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,137 @@ -# doorman β€” badge access, modernized +# πŸšͺ doorman β€” badge access control, 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. +> **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. -**Dedicated docs topic: [Discourse t/318](https://community.turnsys.com/t/318)** -Β· Tracking: [Redmine #345](https://projects.knownelement.com/issues/345) -(umbrella) β†’ [#355](https://projects.knownelement.com/issues/355) -(code & testing) β†’ [#356](https://projects.knownelement.com/issues/356) -(deployment) Β· Ops status: [Discourse t/297](https://community.turnsys.com/t/297) -Β· Fleet context: [KNEL/PFVCluster](https://git.knownelement.com/KNEL/PFVCluster) +![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) -## Lineage +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. -Successor of the 2018 Perl doorman -([KNEL/LegacyTechops `doorman/`](https://git.knownelement.com/KNEL/LegacyTechops/src/branch/main/doorman/doorman.pl)), -carried verbatim under [`legacy/`](legacy/doorman.pl). 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. +**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 +--- -``` -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) +## 🧭 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 ``` -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. +### The three decision lanes -### Door control (unlock path β€” built 2026-09-03, founder-approved) +| 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 | -Decision: HA β†’ Pi authenticated callback over Tailscale. +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. -``` -HA automation (badge valid + input_boolean.doorman_unlock_enabled ON) - └─ rest_command.doorman_unlock - └─ GET http://pfvsvrpi.knel.net:8333/unlock/ - (token = DOORMAN_UNLOCK_TOKEN, on-box 0600; URL treated as secret) - └─ doorctl.socket β€” systemd socket activation, bound to the - Pi's Tailscale IP ONLY, per-connection doorctl@.service: - Β· source IP must be in DOORMAN_ALLOWED_SRC (HA + the Pi) - Β· relay =1 for DOORMAN_HOLD seconds, then =0 - Β· EXIT trap guarantees the relay returns to 0 (secure) - even if the hold is killed +--- + +## πŸ›‘οΈ 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 ``` -Everything is logged to syslog/journald (`-t doorctl`): every accept, -every deny with source. Disarm = flip `input_boolean.doorman_unlock_enabled` -off β€” valid badges then log and notify but the door stays shut. -Arming is a founder-level action; the strike side of the relay was -disconnected during build/test (founder confirmed) and gets wired onsite. +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. -### Webhook contract (for the HA side) +## πŸ§ͺ 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: @@ -74,49 +139,27 @@ disconnected during build/test (founder confirmed) and gets wired onsite. {"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. +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. -## 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 -``` +## πŸ“œ Lineage -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). +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. -## Testing (no hardware needed) - -```bash -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 +## πŸ—ΊοΈ 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) | +| `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) | -| `questions-v1.md` | Points to the active round (PFVCluster questions-v4.md) |