docs(doorman): showpiece README — mermaid scan lifecycle, badges, security model [#345]
Detail: https://projects.knownelement.com/issues/345
This commit is contained in:
@@ -1,72 +1,137 @@
|
|||||||
# doorman — badge access, modernized
|
# 🚪 doorman — badge access control, modernized
|
||||||
|
|
||||||
Physical access control for the server-room door: badge-reader listener
|
> **USB badge readers → pure-bash listeners → Home Assistant brains → relay-actuated door strike.**
|
||||||
for the reader host, dispatching scans to Home Assistant, which owns the
|
> Fail-closed by construction: if the brain is unreachable, the door stays shut.
|
||||||
whitelist, logging, alerts, and unlock decision.
|
|
||||||
|
|
||||||
**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)
|
|
||||||
|
|
||||||
## 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
|
**Dedicated docs:** [Discourse t/318](https://community.turnsys.com/t/318)
|
||||||
([KNEL/LegacyTechops `doorman/`](https://git.knownelement.com/KNEL/LegacyTechops/src/branch/main/doorman/doorman.pl)),
|
· Tracking: [Redmine #345](https://projects.knownelement.com/issues/345) →
|
||||||
carried verbatim under [`legacy/`](legacy/doorman.pl). Same reader class
|
[#355](https://projects.knownelement.com/issues/355) (code) →
|
||||||
(keyboard-emulating USB RFID), same decode semantics — but the defunct
|
[#356](https://projects.knownelement.com/issues/356) (deployment) ·
|
||||||
plain-HTTP auth portal (`doors.pfv.turnsys.net`) is NOT replicated: HA is
|
HA config: [KNEL/pfv-bms](https://git.knownelement.com/KNEL/pfv-bms) ·
|
||||||
the single authentication brain.
|
Fleet: [KNEL/PFVCluster](https://git.knownelement.com/KNEL/PFVCluster)
|
||||||
|
|
||||||
## Architecture
|
---
|
||||||
|
|
||||||
```
|
## 🧭 Architecture — one scan, end to end
|
||||||
USB RFID reader (HID keyboard)
|
|
||||||
└─> /dev/input/by-id/*-event-kbd (reader host: pfvsvrpi prod,
|
```mermaid
|
||||||
| ultix-field dev)
|
sequenceDiagram
|
||||||
v
|
autonumber
|
||||||
bin/doorman.sh od(1) + awk(1) decode — bash and
|
participant B as 🪪 Badge
|
||||||
| coreutils ONLY, no package installs
|
participant R as 📡 USB reader (HID kbd)
|
||||||
v
|
participant L as doorman.sh (reader host)
|
||||||
POST {badge_id, reader, ts} -----> Home Assistant webhook
|
participant H as 🏠 Home Assistant
|
||||||
|– whitelist decision
|
participant RO as 🔒 access-roster (private repo)
|
||||||
|– logbook/recorder history
|
participant D as ⚡ doorctl (pfvsvrpi)
|
||||||
|– unknown badge -> instant alert
|
participant S as 🚪 Door strike
|
||||||
'- unlock (e-lock or relay)
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Fail-closed by construction: if HA is unreachable, the scan is logged and
|
### The three decision lanes
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
## 🛡️ Security model
|
||||||
└─ GET http://pfvsvrpi.knel.net:8333/unlock/<token>
|
|
||||||
(token = DOORMAN_UNLOCK_TOKEN, on-box 0600; URL treated as secret)
|
- **HA is the single authentication brain** — the listener never decides.
|
||||||
└─ doorctl.socket — systemd socket activation, bound to the
|
- **Fail-closed**: HA unreachable → scan logged, door does not open.
|
||||||
Pi's Tailscale IP ONLY, per-connection doorctl@.service:
|
- **Badge IDs never live in this repo.** The whitelist is the private
|
||||||
· source IP must be in DOORMAN_ALLOWED_SRC (HA + the Pi)
|
[`KNEL/access-roster`](https://git.knownelement.com/KNEL/access-roster)
|
||||||
· relay =1 for DOORMAN_HOLD seconds, then =0
|
repo (two-human PR gate); HA fetches it live via API. All fixtures in
|
||||||
· EXIT trap guarantees the relay returns to 0 (secure)
|
this repo use synthetic IDs — real ones were redacted 2026-09-03.
|
||||||
even if the hold is killed
|
- **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,
|
Record width auto-detects (24 B on 64-bit kernels, 16 B on 32-bit Pis).
|
||||||
every deny with source. Disarm = flip `input_boolean.doorman_unlock_enabled`
|
Logs go to journald (`-t doorman`); `DOORMAN_DEBUG=true` mirrors to stderr.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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 <DOORMAN_WEBHOOK_URL>` with JSON:
|
`POST <DOORMAN_WEBHOOK_URL>` 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"}
|
{"badge_id": "0000000001", "reader": "pfvsvrpi", "ts": "2026-09-02T19:12:44-0500"}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Values are digits + env-provided strings; the JSON cannot carry
|
Values are digits + env-provided strings; the JSON cannot carry
|
||||||
user-controlled quotes/backslashes.
|
user-controlled quotes/backslashes. Any 2xx satisfies the listener.
|
||||||
- Any 2xx satisfies the listener; HA automations do the real work.
|
The token lives in the URL path — treat it as a secret.
|
||||||
- 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)
|
---
|
||||||
|
|
||||||
```bash
|
## 📜 Lineage
|
||||||
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).
|
Successor of the 2018 Perl doorman
|
||||||
Logs go to syslog/journald (`-t doorman`); `DOORMAN_DEBUG=true` mirrors
|
([KNEL/LegacyTechops `doorman/`](https://git.knownelement.com/KNEL/LegacyTechops/src/branch/master/doorman/doorman.pl)),
|
||||||
to stderr. Deployment discipline: **ultix-field (dev) first, verify
|
carried under [`legacy/`](legacy/doorman.pl). Same reader class
|
||||||
end-to-end, then pfvsvrpi (prod)** — never both at once (#356).
|
(keyboard-emulating USB RFID), same decode semantics — the defunct
|
||||||
|
plain-HTTP auth portal (`doors.pfv.turnsys.net`) is **not** replicated.
|
||||||
|
|
||||||
## Testing (no hardware needed)
|
## 🗺️ Repo map
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `bin/doorman.sh` | The listener daemon |
|
| `bin/doorman.sh` | The listener daemon |
|
||||||
| `deploy/doorman.service` | systemd unit (`/etc/default/doorman` env file) |
|
| `bin/doorctl.sh` | Socket-activated unlock endpoint |
|
||||||
| `tests/run-tests.sh` | Offline decoder suite (9 tests) |
|
| `deploy/doorman.service` | systemd unit (`/etc/default/doorman` env) |
|
||||||
| `legacy/` | Verbatim 2018 Perl snapshot (not maintained) |
|
| `tests/` | Offline suites — decoder, break-glass, doorctl, lint |
|
||||||
|
| `legacy/` | Verbatim 2018 Perl snapshot (badge IDs redacted) |
|
||||||
| `scripts/` | Rule engine + hooks (adopted from TSYSGroupAIOS) |
|
| `scripts/` | Rule engine + hooks (adopted from TSYSGroupAIOS) |
|
||||||
| `questions-v1.md` | Points to the active round (PFVCluster questions-v4.md) |
|
|
||||||
|
|||||||
Reference in New Issue
Block a user