Files
doorman/bin/doorman.sh
T
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

221 lines
8.1 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
}
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."
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")"
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
;;
--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"
;;
live) run_live ;;
esac