From 31de03d4017bd4b2dca7c92e505ad1bbb884a720 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 3 Sep 2026 08:10:29 -0500 Subject: [PATCH] =?UTF-8?q?feat(doorman):=20break-glass=20local=20unlock?= =?UTF-8?q?=20=E2=80=94=20disaster=20path=20without=20HA=20[#345]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/doorman.sh | 32 ++++++++++++++++++++++++- tests/test-breakglass.sh | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100755 tests/test-breakglass.sh diff --git a/bin/doorman.sh b/bin/doorman.sh index 28a8d16..9ec6683 100755 --- a/bin/doorman.sh +++ b/bin/doorman.sh @@ -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 diff --git a/tests/test-breakglass.sh b/tests/test-breakglass.sh new file mode 100755 index 0000000..8b0a21a --- /dev/null +++ b/tests/test-breakglass.sh @@ -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 ` 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 -> 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 ]