Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
681cb56421 | ||
|
|
31de03d401 | ||
|
|
4fefd10abb | ||
|
|
37d27f1158 | ||
|
|
90aa0e8abc | ||
|
|
f6357418e9 | ||
|
|
92956a9ae4 | ||
|
|
5ae0e7d5be | ||
|
|
704b671d92 | ||
|
|
fbb43e7035 | ||
|
|
84ea6e931e |
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 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/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)
|
||||
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).
|
||||
`--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
@@ -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"
|
||||
@@ -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
|
||||
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
@@ -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
@@ -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"
|
||||
|
||||
Executable
+50
@@ -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 ]
|
||||
Executable
+89
@@ -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 ]
|
||||
Reference in New Issue
Block a user