Compare commits

Author SHA1 Message Date
mrcharles 681cb56421 docs(doorman): showpiece README — mermaid scan lifecycle, badges, security model [#345]
Detail: https://projects.knownelement.com/issues/345
2026-09-03 13:34:22 -05:00
mrcharles 31de03d401 feat(doorman): break-glass local unlock — disaster path without HA [#345]
Founder ruling 2026-09-03: cover HA-down / network-down / power
recovery. DOORMAN_BREAKGLASS_IDS badges fire the relay LOCALLY (no HA
dependency), then best-effort POST flagged local_unlock:true so HA
logs without re-dispatching. --check-breakglass decision mode; 7/7
breakglass tests, full suites green.

https://projects.knownelement.com/issues/345
2026-09-03 08:10:29 -05:00
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
mrcharles 37d27f1158 docs(agents): gate re-scope — code fast-moving, human gate moved to KNEL/access-roster [#345]
Founder ruling 2026-09-02: no gates for code/host work; two-human
approval applies to the badge-ID control file (separate private repo).

https://projects.knownelement.com/issues/345
2026-09-02 21:59:06 -05:00
reachableceo 90aa0e8abc Merge pull request 'fix: stdbuf cannot exec shell functions — buffer inside decode_stream [#355]' (#1) from fix/stdbuf-function-pipeline into main 2026-09-03 02:54:59 +00:00
mrcharles f6357418e9 fix(doorman): stdbuf cannot exec shell functions — buffer inside decode_stream [#355]
Deployment rehearsal on ultix-field 2026-09-02 caught it live: the
listener showed active but decode_stream never ran (stdbuf exec's
binaries only). Line-buffering moved inside decode_stream; the od
stage keeps its own stdbuf.

Rehearsal note: https://projects.knownelement.com/issues/355
2026-09-02 21:45:44 -05:00
mrcharles 92956a9ae4 docs(governance): PR template + two-approver human gate for access work [#345]
Founder ruling 2026-09-02: physical server-room access 100% human
required — logged, approved, signed off. Branch protection lands
immediately after this commit. Also excludes .gitea/ from the
doc-pointer scan (framework dir, not docs).

https://projects.knownelement.com/issues/345
2026-09-02 21:27:16 -05:00
mrcharles 5ae0e7d5be docs(agents): scope ruling — server-room door only, no other locks [#345]
Founder directive 2026-09-02: badge work fully contained to the
server-room door; no other smart locks in scope.

https://projects.knownelement.com/issues/345
2026-09-02 21:18:46 -05:00
mrcharles 704b671d92 chore(doorman): close session WORKING.md scratchpad [#355] 2026-09-02 21:14:48 -05:00
mrcharles fbb43e7035 docs(doorman): README — architecture, webhook contract, deploy runbook [#355]
Cross-links: Discourse t/318 (docs SoR), Redmine #345/#355/#356,
PFVCluster, LegacyTechops lineage.

https://community.turnsys.com/t/318
2026-09-02 21:13:07 -05:00
mrcharles 84ea6e931e feat(doorman): badge listener + decoder TDD + deploy scaffolding [#355]
Modern successor of the 2018 Perl doorman (snapshot in legacy/):
bash+coreutils HID decoder, HA webhook dispatch, systemd unit.
Offline decoder suite green (9 tests); shellcheck zero-warning.

Umbrella #345. Work item: https://projects.knownelement.com/issues/355
2026-09-02 21:11:13 -05:00
8 changed files with 384 additions and 88 deletions
+12 -12
View File
@@ -26,19 +26,19 @@ House rules layering: `~/.zcode/AGENTS.md` (global) < PFVCluster
[tests/remote.sh](https://git.knownelement.com/KNEL/PFVCluster/src/branch/main/tests/remote.sh).
- DNS names only — never IP literals.
## PR process (founder ruling 2026-09-02) — physical access is 100% human-gated
## Gate scope (founder ruling 2026-09-02, revised same day)
- `main` is BRANCH-PROTECTED: no direct pushes, by anyone. All changes
land via PR with the sign-off template (`.gitea/pull_request_template.md`).
- **Two human approvals required to merge.** Gitea enforces the count.
Agents may author and push PR branches and implement review feedback —
agents NEVER approve, NEVER merge, NEVER bypass (`--no-verify` is not
a bypass for the approval count).
- Access-control changes (badge roster, unlock policy, door-adjacent
code) additionally require the template's checklist and a
founder-approved ticket.
- Full audit chain per change: Redmine ticket → PR description → two
named human approvals → merge → deployment note (#356).
- **This repo (code) is fast-moving**: tests + shellcheck are the only
gates; commit+push per standing policy. The two-approval requirement
on `main` was removed by founder ruling — "no gates" for code/host
work on pfvsvrpi and ultix-field.
- **The human gate lives on the badge-ID control file**:
[KNEL/access-roster](https://git.knownelement.com/KNEL/access-roster)
(private, PR-only, two human approvals enforced). The listener NEVER
carries badge data — a change to who can open the door means a PR
there, never a change here.
- Deployment discipline still applies: ultix-field (dev) first, verify,
then pfvsvrpi (prod) — serial, health-gated.
## Scope boundary: Home Assistant
+131 -65
View File
@@ -1,99 +1,165 @@
# 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/<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
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
### Webhook contract (for the HA side)
| 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 |
`POST <DOORMAN_WEBHOOK_URL>` with JSON:
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.
```json
{"badge_id": "0009399422", "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.
## 🛡️ Security model
## Deploy (reader host)
- **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 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).
Logs go to journald (`-t doorman`); `DOORMAN_DEBUG=true` mirrors to stderr.
## Testing (no hardware needed)
## 🧪 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
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` 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).
`--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).
## Repo map
## 🔗 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 |
| `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) |
Executable
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# bin/doorctl.sh — door unlock endpoint, systemd socket-activated.
#
# One instance per connection: systemd hands the accepted socket to
# stdin/stdout and exports REMOTE_ADDR. Reads a single HTTP request
# line; fires the door relay only for GET /unlock/<token> from an
# allowlisted source. Responds BEFORE the hold so callers are never
# blocked for the hold duration.
#
# Env (from /etc/default/doorman via the service unit):
# DOORMAN_UNLOCK_TOKEN shared secret in the URL path (required)
# DOORMAN_ALLOWED_SRC space-separated source IPs allowed to unlock
# DOORMAN_RELAY_DEV usbrelay device.channel (e.g. 3X9XI_1)
# DOORMAN_USBRELAY path to usbrelay binary
# DOORMAN_HOLD relay hold seconds
#
set -u
log() { logger -t doorctl -- "$1" 2>/dev/null || true; }
respond() { printf '%s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n' "$1"; }
deny() {
respond 'HTTP/1.0 403 Forbidden'
log "DENY from ${REMOTE_ADDR:-unknown}: $2"
exit 0
}
read -r reqline || true
reqline=${reqline:-$'\r'}
method=${reqline%% *}
if [ "$method" != "GET" ]; then
deny "bad method"
fi
rest=${reqline#* }
path=${rest%% *}
[ "$path" = "/unlock/${DOORMAN_UNLOCK_TOKEN:-}" ] || deny "bad path or token"
allowed="${DOORMAN_ALLOWED_SRC:-}"
if [ -n "$allowed" ]; then
case " $allowed " in
*" ${REMOTE_ADDR:-} "*) : ;;
*) deny "source not allowlisted" ;;
esac
fi
hold="${DOORMAN_HOLD:-10}"
secure() { "${DOORMAN_USBRELAY:-/usr/bin/usbrelay}" "${DOORMAN_RELAY_DEV}=0" >/dev/null 2>&1; }
# Self-securing: whatever kills this script (systemd timeout, OOM, signal),
# the relay returns to 0 = door secure. Never rely on the happy path alone.
trap secure EXIT
log "UNLOCK from ${REMOTE_ADDR:-unknown}: ${DOORMAN_RELAY_DEV}=1 for ${hold}s"
respond 'HTTP/1.0 200 OK'
"${DOORMAN_USBRELAY:-/usr/bin/usbrelay}" "${DOORMAN_RELAY_DEV}=1" >/dev/null 2>&1
sleep "$hold"
secure
log "secure: ${DOORMAN_RELAY_DEV}=0"
+31 -1
View File
@@ -45,6 +45,17 @@ log() {
fi
}
# Break-glass: disaster-path badges that unlock LOCALLY (relay fires on
# the reader host, no HA, no network). Covers HA crash / power recovery
# / network loss. Founder-designated IDs only; list lives in the 0600
# env file, mirrored in the ticket trail (#345).
is_breakglass() {
case " ${DOORMAN_BREAKGLASS_IDS:-} " in
*" $1 "*) return 0 ;;
*) return 1 ;;
esac
}
usage() {
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
}
@@ -119,13 +130,24 @@ process_scan() {
ts="$(date +%Y-%m-%dT%H:%M:%S%z)"
reader="${DOORMAN_READER_NAME:-$(hostname)}"
log "ID $id scanned."
# Break-glass first: disaster badges unlock LOCALLY regardless of
# HA/network state (founder ruling 2026-09-03, #345). HA is still
# notified best-effort, flagged so it logs without re-dispatching.
if is_breakglass "$id"; then
log "BREAKGLASS badge $id — local unlock, no HA dependency."
fire_relay
fi
if [ -z "$DOORMAN_WEBHOOK_URL" ]; then
log "no webhook configured (DOORMAN_WEBHOOK_URL) — scan logged only"
return 0
fi
# badge IDs are digits by construction; reader/ts are env/hostname —
# the JSON below cannot carry user-controlled quotes or backslashes
body="$(printf '{"badge_id":"%s","reader":"%s","ts":"%s"}' "$id" "$reader" "$ts")"
if is_breakglass "$id"; then
body="$(printf '{"badge_id":"%s","reader":"%s","ts":"%s","local_unlock":true}' "$id" "$reader" "$ts")"
else
body="$(printf '{"badge_id":"%s","reader":"%s","ts":"%s"}' "$id" "$reader" "$ts")"
fi
code="$(curl -sS --max-time "$DOORMAN_HTTP_TIMEOUT" \
-H 'Content-Type: application/json' -d "$body" \
-w '%{http_code}' "$DOORMAN_WEBHOOK_URL" 2>/dev/null)"
@@ -186,6 +208,11 @@ while [ $# -gt 0 ]; do
SELFTEST_FILE="${2:?--selftest requires a fixture path}"
shift 2
;;
--check-breakglass)
MODE="checkbreak"
BREAKGLASS_ID="${2:?--check-breakglass requires a badge id}"
shift 2
;;
--width)
WIDTH="${2:?--width requires 24 or 16}"
shift 2
@@ -216,5 +243,8 @@ case "$MODE" in
fi
stdbuf -oL od -v -A n -t u1 -w"$WIDTH" "$SELFTEST_FILE" | decode_stream "$WIDTH"
;;
checkbreak)
if is_breakglass "$BREAKGLASS_ID"; then exit 0; else exit 1; fi
;;
live) run_live ;;
esac
+2 -2
View File
@@ -34,8 +34,8 @@ my $rsyslog = "/usr/bin/logger";
# Card IDs that bypass network auth
my %backdoors = (
"0009399422" => 'Charles NW',
"0009106067" => 'Josef C',
"0000000001" => 'Charles NW',
"0000000002" => 'Josef C',
);
+8 -8
View File
@@ -112,12 +112,12 @@ assert_ids() { # assert_ids <desc> <fixture> <expected...> [--width N]
# down/up parity state machine on consecutive identical scan codes).
f1="$TMP/f1.bin"
{
emit_digits 24 "0009399422"; emit_enter 24
emit_digits 24 "0009106067"; emit_enter 24
emit_digits 24 "0000000001"; emit_enter 24
emit_digits 24 "0000000002"; emit_enter 24
emit_digits 24 "11"; emit_enter 24
} > "$f1"
assert_ids "T1 decodes two 10-digit badges + repeated-digit badge" "$f1" \
"0009399422" "0009106067" "11"
"0000000001" "0000000002" "11"
# T2: garbage MSC_SCAN usage mid-badge (e.g. media key) must not corrupt
# or truncate the scan (2018 behavior: log+skip, id preserved).
@@ -125,10 +125,10 @@ f2="$TMP/f2.bin"
{
emit_digits 24 "000"
emit_scan 24 42 # garbage usage (backspace) — must be skipped
emit_digits 24 "9399422"
emit_digits 24 "0000001"
emit_enter 24
} > "$f2"
assert_ids "T2 garbage scan usage mid-badge is skipped" "$f2" "0009399422"
assert_ids "T2 garbage scan usage mid-badge is skipped" "$f2" "0000000001"
# T3: lone Enter (empty scan) emits nothing.
f3="$TMP/f3.bin"
@@ -175,11 +175,11 @@ fi
# T9: known 2018 backdoor badges replay end-to-end (regression anchor).
f9="$TMP/f9.bin"
{
emit_digits 24 "0009399422"; emit_enter 24
emit_digits 24 "0009106067"; emit_enter 24
emit_digits 24 "0000000001"; emit_enter 24
emit_digits 24 "0000000002"; emit_enter 24
} > "$f9"
assert_ids "T9 2018 backdoor badges decode verbatim" "$f9" \
"0009399422" "0009106067"
"0000000001" "0000000002"
# ---------------------------------------------------------------------------
printf '\n%d passed, %d failed\n' "$pass" "$fail"
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
#
# tests/test-breakglass.sh — break-glass decision tests (offline).
#
# The break-glass list (DOORMAN_BREAKGLASS_IDS) unlocks LOCALLY with no
# HA involvement — the disaster path (HA down / network down / power
# recovery). `doorman.sh --check-breakglass <id>` is the decision
# function: exit 0 = break-glass badge, exit 1 = not.
#
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="$ROOT/bin/doorman.sh"
pass=0
fail=0
ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); }
no() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); }
check() { # check <id> <list> -> exit 0 if breakglass
DOORMAN_BREAKGLASS_IDS="$2" "$BIN" --check-breakglass "$1" >/dev/null 2>&1
}
# T1: listed badge -> breakglass.
if check 0000000001 "0000000001"; then ok "T1 listed badge matches"; else no "T1 listed badge did not match"; fi
# T2: multiple entries, whitespace-separated, partial match must not hit.
if check 0000000001 "0000000002 0000000001"; then ok "T2 second list entry matches"; else no "T2 second entry failed"; fi
# T3: unlisted badge -> not breakglass.
if check 9999999999 "0000000001"; then no "T3 unlisted badge matched!"; else ok "T3 unlisted badge rejected"; fi
# T4: prefix of a listed badge must NOT match (word boundary check).
if check 000000000 "0000000001"; then no "T4 prefix matched!"; else ok "T4 prefix rejected"; fi
# T5: suffix likewise.
if check "000000001" "0000000001"; then no "T5 suffix matched!"; else ok "T5 suffix rejected"; fi
# T6: empty list -> nothing is breakglass.
if check 0000000001 ""; then no "T6 empty list matched!"; else ok "T6 empty list rejects all"; fi
# T7: unset list (env not set at all) -> nothing is breakglass.
if DOORMAN_BREAKGLASS_IDS='' "$BIN" --check-breakglass 0000000001 >/dev/null 2>&1; then
no "T7 unset list matched!"
else
ok "T7 unset list rejects all"
fi
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# tests/test-doorctl.sh — doorctl endpoint unit tests (offline).
#
# Drives bin/doorctl.sh the way systemd socket-activation does: request
# line on stdin, REMOTE_ADDR in the environment, DOORMAN_USBRELAY mocked
# to a script that records invocations. Asserts the auth matrix and the
# relay fire/secure cycle without any hardware.
#
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BIN="$ROOT/bin/doorctl.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
pass=0
fail=0
ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); }
no() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); }
# Fake usbrelay: records "set <arg>" lines; reports last state in state file.
FAKE_RELAY="$TMP/usbrelay"
cat > "$FAKE_RELAY" <<'EOF'
#!/usr/bin/env bash
echo "set $*" >> "$DOORMAN_TEST_LOG"
EOF
chmod +x "$FAKE_RELAY"
# run_doorctl <request-line> <remote-addr> [hold] — runs one connection.
run_doorctl() {
local reqline="$1" addr="$2" hold="${3:-2}"
( export DOORMAN_UNLOCK_TOKEN="testtoken123"
export DOORMAN_ALLOWED_SRC="100.67.108.125 100.91.151.113"
export DOORMAN_RELAY_DEV="3X9XI_1"
export DOORMAN_USBRELAY="$FAKE_RELAY"
export DOORMAN_HOLD="$hold"
export DOORMAN_TEST_LOG="$TMP/relay.log"
export REMOTE_ADDR="$addr"
printf '%s\r\nHost: x\r\n\r\n' "$reqline" | "$BIN"
)
}
relay_log() { cat "$TMP/relay.log" 2>/dev/null || true; }
reset_log() { : > "$TMP/relay.log"; }
# T1: valid token + allowed source -> HTTP 200, relay set then released.
reset_log
out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 200 OK" ]; then ok "T1a valid request -> 200"; else no "T1a expected 200, got: $out"; fi
if grep -q 'set 3X9XI_1=1' "$TMP/relay.log" && grep -q 'set 3X9XI_1=0' "$TMP/relay.log"; then
ok "T1b relay fired then secured"
else
no "T1b relay cycle missing: $(relay_log | tr '\n' ' ')"
fi
# T2: wrong token -> 403, relay untouched.
reset_log
out="$(run_doorctl 'GET /unlock/wrongtoken HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T2a bad token -> 403"; else no "T2a expected 403, got: $out"; fi
if [ -z "$(relay_log)" ]; then ok "T2b relay untouched on bad token"; else no "T2b relay fired on bad token!"; fi
# T3: valid token but disallowed source -> 403, relay untouched.
reset_log
out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '203.0.113.9' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T3a disallowed source -> 403"; else no "T3a expected 403, got: $out"; fi
if [ -z "$(relay_log)" ]; then ok "T3b relay untouched on bad source"; else no "T3b relay fired from bad source!"; fi
# T4: non-unlock path -> 403.
reset_log
out="$(run_doorctl 'GET /status HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T4 unknown path -> 403"; else no "T4 expected 403, got: $out"; fi
# T5: garbage request line -> 403, no crash.
reset_log
out="$(run_doorctl 'GARBAGE' '100.67.108.125' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 403 Forbidden" ] || [ "$out" = "HTTP/1.0 400 Bad Request" ]; then
ok "T5 garbage line rejected"
else
no "T5 garbage line not rejected cleanly (got: $out)"
fi
# T6: second allowed source (the Pi itself) also passes auth.
reset_log
out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '100.91.151.113' | head -1 | tr -d "\r")"
if [ "$out" = "HTTP/1.0 200 OK" ]; then ok "T6 second allowed source -> 200"; else no "T6 expected 200, got: $out"; fi
printf '\n%d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]