Files
doorman/README.md
T
mrcharles 4fefd10abb feat(doorman): doorctl unlock endpoint — HA->Pi callback over Tailscale [#345]
Founder-approved 2026-09-03 (relay load side disconnected during
build). systemd socket-activated, Tailscale-bound, token+source
allowlist, EXIT-trap self-securing relay. 9/9 doorctl tests (auth
matrix + relay cycle, mocked relay), full suite + shellcheck clean.

https://projects.knownelement.com/issues/345
2026-09-03 07:04:12 -05:00

123 lines
5.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](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
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.
## 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.
### Door control (unlock path — built 2026-09-03, founder-approved)
Decision: HA → Pi authenticated callback over Tailscale.
```
HA automation (badge valid + input_boolean.doorman_unlock_enabled ON)
└─ rest_command.doorman_unlock
└─ GET http://pfvsvrpi.knel.net:8333/unlock/<token>
(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
```
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.
### Webhook contract (for the 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; 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)
```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 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
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) |