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
251 lines
9.2 KiB
Bash
Executable File
251 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# bin/doorman.sh — badge scan listener (modern doorman).
|
|
#
|
|
# Successor of the 2018 Perl doorman (LegacyTechops/doorman/doorman.pl):
|
|
# decodes badge IDs from a keyboard-emulating USB RFID reader on
|
|
# /dev/input/by-id and hands each completed scan to a Home Assistant
|
|
# webhook. Auth decision, whitelist, logging and the door actuator live
|
|
# in HA (#345 architecture); this daemon is intentionally dumb so a
|
|
# compromised reader host cannot hold the whitelist.
|
|
#
|
|
# Zero non-coreutils dependencies (no CPAN, no package installs): events
|
|
# are read with od(1) and decoded with awk(1). Record width auto-detects
|
|
# 24 bytes (64-bit kernel) vs 16 bytes (32-bit kernel, e.g. older Pis).
|
|
#
|
|
# Usage:
|
|
# bin/doorman.sh # live listener
|
|
# bin/doorman.sh --selftest FILE [--width 24|16]
|
|
# # decode a fixture, print IDs
|
|
#
|
|
# Config (env; see .env.example):
|
|
# DOORMAN_DEVICE glob of input devices (default *-event-kbd)
|
|
# DOORMAN_WEBHOOK_URL HA webhook; unset = log-only mode
|
|
# DOORMAN_READER_NAME reader label for webhook payloads (default hostname)
|
|
# DOORMAN_UNLOCK_ON_2XX drive the relay on a 2xx webhook response
|
|
# (default false — native HA webhooks always 200,
|
|
# so leave false and let HA drive the actuator)
|
|
# DOORMAN_RELAY_DEV usbrelay device.channel (e.g. 3X9XI_1)
|
|
# DOORMAN_USBRELAY path to usbrelay (default /usr/bin/usbrelay)
|
|
# DOORMAN_HOLD relay hold seconds (default 10)
|
|
# DOORMAN_HTTP_TIMEOUT webhook curl --max-time (default 10)
|
|
# DOORMAN_DEBUG "true" also mirrors logs to stderr
|
|
#
|
|
set -u
|
|
|
|
SELF="$(basename "$0")"
|
|
WIDTH=""
|
|
MODE="live"
|
|
SELFTEST_FILE=""
|
|
|
|
log() {
|
|
logger -t doorman -- "$1" 2>/dev/null || true
|
|
if [ "${DOORMAN_DEBUG:-false}" = "true" ]; then
|
|
printf '%s %s: %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$SELF" "$1" >&2
|
|
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\}//'
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Decoding — the 2018 state machine, kept bit-identical:
|
|
# * MSC_SCAN (type 4, code 4) events only; value & 127 is the HID usage.
|
|
# * keytranslate: usages 0-9 pass through as digits (2018 quirk kept),
|
|
# 30-38 -> 1-9, 39 -> 0, 40 = Enter terminator, anything else = bad.
|
|
# * Parity: readers emit MSC_SCAN on BOTH press and release; a scan code
|
|
# equal to the previous one is processed only on odd occurrences of the
|
|
# run, so down/up pairs collapse to one digit.
|
|
# * Bad usages log and skip WITHOUT clearing the partial badge (2018
|
|
# behavior); Enter on an empty buffer emits nothing (defensive fix).
|
|
# ---------------------------------------------------------------------------
|
|
decode_stream() { # decode_stream <width> — od -t u1 records on stdin -> IDs
|
|
# stdbuf exec's binaries only — it can NEVER wrap this function, so
|
|
# line-buffering happens here inside (piped awk block-buffers otherwise).
|
|
# shellcheck disable=SC2016 # awk program is single-quoted by design; '"$SELF"' re-opens quotes deliberately
|
|
stdbuf -oL awk -v w="$1" '
|
|
{
|
|
if (NF != w) { next }
|
|
ti = w - 7; ci = w - 5; vi = w - 3
|
|
if ($ti != 4 || $ci != 4) { next }
|
|
v = $vi + 256 * $(vi + 1) + 65536 * $(vi + 2) + 16777216 * $(vi + 3)
|
|
n = v % 128
|
|
if (n == old && dupe == 0) { dupe = 1; next }
|
|
else { dupe = 0 }
|
|
# 2018 order matters: a bad usage nexts BEFORE the parity anchor
|
|
# (oldvalue) is advanced — never move `old = n` above this check
|
|
if ((n >= 10 && n <= 29) || n > 40) {
|
|
printf "%s: problem reading ID (usage %d)\n", "'"$SELF"'", n > "/dev/stderr"
|
|
next
|
|
}
|
|
old = n
|
|
if (n <= 9) { id = id n; next } # 2018 keytranslate passthrough
|
|
if (n <= 38) { id = id (n - 29); next } # usages 30-38 = digits 1-9
|
|
if (n == 39) { id = id 0; next } # usage 39 = digit 0
|
|
if (id != "") { print id } # usage 40 = Enter
|
|
id = ""
|
|
}
|
|
' 2>/dev/null
|
|
}
|
|
|
|
detect_width() {
|
|
case "$(uname -m)" in
|
|
i386 | i486 | i586 | i686 | armv*) printf '16\n' ;;
|
|
*) printf '24\n' ;;
|
|
esac
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Live mode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
fire_relay() {
|
|
if [ -z "$DOORMAN_RELAY_DEV" ]; then
|
|
log "unlock requested but DOORMAN_RELAY_DEV unset — ignoring"
|
|
return 0
|
|
fi
|
|
log "unlock: relay ${DOORMAN_RELAY_DEV}=1 for ${DOORMAN_HOLD}s"
|
|
(
|
|
"$DOORMAN_USBRELAY" "${DOORMAN_RELAY_DEV}=1" >/dev/null 2>&1
|
|
sleep "${DOORMAN_HOLD}"
|
|
"$DOORMAN_USBRELAY" "${DOORMAN_RELAY_DEV}=0" >/dev/null 2>&1
|
|
log "lock: relay ${DOORMAN_RELAY_DEV}=0"
|
|
) &
|
|
}
|
|
|
|
process_scan() {
|
|
local id="$1" code rc body reader ts
|
|
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)"
|
|
rc=$?
|
|
if [ "$rc" -ne 0 ] || [ -z "$code" ]; then
|
|
log "webhook error (curl rc=$rc) — scan dropped, fail-closed"
|
|
return 0
|
|
fi
|
|
log "webhook responded $code"
|
|
if [ "${DOORMAN_UNLOCK_ON_2XX:-false}" = "true" ] \
|
|
&& [ "$code" -ge 200 ] 2>/dev/null && [ "$code" -lt 300 ] 2>/dev/null; then
|
|
fire_relay
|
|
fi
|
|
}
|
|
|
|
stream_device() {
|
|
local dev="$1"
|
|
log "listening on $dev (record width $WIDTH)"
|
|
# stdbuf: od/awk block-buffer when piped; scans must not lag behind
|
|
stdbuf -oL od -v -A n -t u1 -w"$WIDTH" "$dev" 2>/dev/null \
|
|
| decode_stream "$WIDTH" \
|
|
| while read -r bid; do
|
|
process_scan "$bid"
|
|
done
|
|
}
|
|
|
|
run_live() {
|
|
local -a devs=()
|
|
# shellcheck disable=SC2206 # DOORMAN_DEVICE is an intentional glob
|
|
devs=($DOORMAN_DEVICE)
|
|
local d
|
|
for d in "${devs[@]}"; do
|
|
if [ ! -r "$d" ]; then
|
|
log "input device not readable, skipping: $d"
|
|
else
|
|
stream_device "$d" &
|
|
fi
|
|
done
|
|
if [ "${#devs[@]}" -eq 0 ]; then
|
|
log "no input devices matched: $DOORMAN_DEVICE"
|
|
exit 1
|
|
fi
|
|
log "doorman started ($SELF, webhook ${DOORMAN_WEBHOOK_URL:-<unset>})"
|
|
# shellcheck disable=SC2046 # jobs -p is a plain pid list
|
|
trap 'kill -- $(jobs -p) 2>/dev/null' INT TERM
|
|
wait
|
|
log "all input streams ended — exiting (systemd will restart)"
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry
|
|
# ---------------------------------------------------------------------------
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--selftest)
|
|
MODE="selftest"
|
|
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
|
|
;;
|
|
-h | --help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
printf '%s: unknown argument: %s\n' "$SELF" "$1" >&2
|
|
usage >&2
|
|
exit 2
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[ -n "$WIDTH" ] || WIDTH="$(detect_width)"
|
|
DOORMAN_HOLD="${DOORMAN_HOLD:-10}"
|
|
DOORMAN_HTTP_TIMEOUT="${DOORMAN_HTTP_TIMEOUT:-10}"
|
|
DOORMAN_USBRELAY="${DOORMAN_USBRELAY:-/usr/bin/usbrelay}"
|
|
DOORMAN_DEVICE="${DOORMAN_DEVICE:-/dev/input/by-id/*-event-kbd}"
|
|
|
|
case "$MODE" in
|
|
selftest)
|
|
if [ ! -r "$SELFTEST_FILE" ]; then
|
|
printf '%s: fixture not readable: %s\n' "$SELF" "$SELFTEST_FILE" >&2
|
|
exit 1
|
|
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
|