feat(doorman): badge listener + decoder TDD + deploy scaffolding [#355]
Modern successor of the 2018 Perl doorman (snapshot in legacy/): bash+coreutils HID decoder, HA webhook dispatch, systemd unit. Offline decoder suite green (9 tests); shellcheck zero-warning. Umbrella #345. Work item: https://projects.knownelement.com/issues/355
This commit is contained in:
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/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
|
||||
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 \
|
||||
| stdbuf -oL 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
|
||||
Reference in New Issue
Block a user