Files
KNELDoorman/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

5.3 KiB
Raw Blame History

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 · Tracking: Redmine #345 (umbrella) → #355 (code & testing) → #356 (deployment) · Ops status: Discourse t/297 · Fleet context: KNEL/PFVCluster

Lineage

Successor of the 2018 Perl doorman (KNEL/LegacyTechops doorman/), carried verbatim under legacy/. 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:

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

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