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
This commit is contained in:
@@ -45,6 +45,17 @@ log() {
|
|||||||
fi
|
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() {
|
usage() {
|
||||||
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
|
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
}
|
}
|
||||||
@@ -119,13 +130,24 @@ process_scan() {
|
|||||||
ts="$(date +%Y-%m-%dT%H:%M:%S%z)"
|
ts="$(date +%Y-%m-%dT%H:%M:%S%z)"
|
||||||
reader="${DOORMAN_READER_NAME:-$(hostname)}"
|
reader="${DOORMAN_READER_NAME:-$(hostname)}"
|
||||||
log "ID $id scanned."
|
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
|
if [ -z "$DOORMAN_WEBHOOK_URL" ]; then
|
||||||
log "no webhook configured (DOORMAN_WEBHOOK_URL) — scan logged only"
|
log "no webhook configured (DOORMAN_WEBHOOK_URL) — scan logged only"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
# badge IDs are digits by construction; reader/ts are env/hostname —
|
# badge IDs are digits by construction; reader/ts are env/hostname —
|
||||||
# the JSON below cannot carry user-controlled quotes or backslashes
|
# 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")"
|
body="$(printf '{"badge_id":"%s","reader":"%s","ts":"%s"}' "$id" "$reader" "$ts")"
|
||||||
|
fi
|
||||||
code="$(curl -sS --max-time "$DOORMAN_HTTP_TIMEOUT" \
|
code="$(curl -sS --max-time "$DOORMAN_HTTP_TIMEOUT" \
|
||||||
-H 'Content-Type: application/json' -d "$body" \
|
-H 'Content-Type: application/json' -d "$body" \
|
||||||
-w '%{http_code}' "$DOORMAN_WEBHOOK_URL" 2>/dev/null)"
|
-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}"
|
SELFTEST_FILE="${2:?--selftest requires a fixture path}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--check-breakglass)
|
||||||
|
MODE="checkbreak"
|
||||||
|
BREAKGLASS_ID="${2:?--check-breakglass requires a badge id}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--width)
|
--width)
|
||||||
WIDTH="${2:?--width requires 24 or 16}"
|
WIDTH="${2:?--width requires 24 or 16}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -216,5 +243,8 @@ case "$MODE" in
|
|||||||
fi
|
fi
|
||||||
stdbuf -oL od -v -A n -t u1 -w"$WIDTH" "$SELFTEST_FILE" | decode_stream "$WIDTH"
|
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 ;;
|
live) run_live ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
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 ]
|
||||||
Reference in New Issue
Block a user