#!/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 — 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:-})" # 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