From 4fefd10abb337b3d344d059c42ea8c8161f14c2c Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 3 Sep 2026 07:04:12 -0500 Subject: [PATCH] =?UTF-8?q?feat(doorman):=20doorctl=20unlock=20endpoint=20?= =?UTF-8?q?=E2=80=94=20HA->Pi=20callback=20over=20Tailscale=20[#345]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Founder-approved 2026-09-03 (relay load side disconnected during build). systemd socket-activated, Tailscale-bound, token+source allowlist, EXIT-trap self-securing relay. 9/9 doorctl tests (auth matrix + relay cycle, mocked relay), full suite + shellcheck clean. https://projects.knownelement.com/issues/345 --- README.md | 23 +++++++++++ bin/doorctl.sh | 61 +++++++++++++++++++++++++++++ tests/test-doorctl.sh | 89 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100755 bin/doorctl.sh create mode 100755 tests/test-doorctl.sh diff --git a/README.md b/README.md index 87155a9..4669774 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,29 @@ dropped — the door does not open. The optional local relay bridge **off**) for the transition period; native HA webhooks always answer 200, so that flag must stay off until HA returns real accept/deny codes. +### Door control (unlock path — built 2026-09-03, founder-approved) + +Decision: HA → Pi authenticated callback over Tailscale. + +``` +HA automation (badge valid + input_boolean.doorman_unlock_enabled ON) + └─ rest_command.doorman_unlock + └─ GET http://pfvsvrpi.knel.net:8333/unlock/ + (token = DOORMAN_UNLOCK_TOKEN, on-box 0600; URL treated as secret) + └─ doorctl.socket — systemd socket activation, bound to the + Pi's Tailscale IP ONLY, per-connection doorctl@.service: + · source IP must be in DOORMAN_ALLOWED_SRC (HA + the Pi) + · relay =1 for DOORMAN_HOLD seconds, then =0 + · EXIT trap guarantees the relay returns to 0 (secure) + even if the hold is killed +``` + +Everything is logged to syslog/journald (`-t doorctl`): every accept, +every deny with source. Disarm = flip `input_boolean.doorman_unlock_enabled` +off — valid badges then log and notify but the door stays shut. +Arming is a founder-level action; the strike side of the relay was +disconnected during build/test (founder confirmed) and gets wired onsite. + ### Webhook contract (for the HA side) `POST ` with JSON: diff --git a/bin/doorctl.sh b/bin/doorctl.sh new file mode 100755 index 0000000..d54007c --- /dev/null +++ b/bin/doorctl.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# bin/doorctl.sh — door unlock endpoint, systemd socket-activated. +# +# One instance per connection: systemd hands the accepted socket to +# stdin/stdout and exports REMOTE_ADDR. Reads a single HTTP request +# line; fires the door relay only for GET /unlock/ from an +# allowlisted source. Responds BEFORE the hold so callers are never +# blocked for the hold duration. +# +# Env (from /etc/default/doorman via the service unit): +# DOORMAN_UNLOCK_TOKEN shared secret in the URL path (required) +# DOORMAN_ALLOWED_SRC space-separated source IPs allowed to unlock +# DOORMAN_RELAY_DEV usbrelay device.channel (e.g. 3X9XI_1) +# DOORMAN_USBRELAY path to usbrelay binary +# DOORMAN_HOLD relay hold seconds +# +set -u + +log() { logger -t doorctl -- "$1" 2>/dev/null || true; } + +respond() { printf '%s\r\nContent-Length: 0\r\nConnection: close\r\n\r\n' "$1"; } + +deny() { + respond 'HTTP/1.0 403 Forbidden' + log "DENY from ${REMOTE_ADDR:-unknown}: $2" + exit 0 +} + +read -r reqline || true +reqline=${reqline:-$'\r'} +method=${reqline%% *} + +if [ "$method" != "GET" ]; then + deny "bad method" +fi + +rest=${reqline#* } +path=${rest%% *} + +[ "$path" = "/unlock/${DOORMAN_UNLOCK_TOKEN:-}" ] || deny "bad path or token" + +allowed="${DOORMAN_ALLOWED_SRC:-}" +if [ -n "$allowed" ]; then + case " $allowed " in + *" ${REMOTE_ADDR:-} "*) : ;; + *) deny "source not allowlisted" ;; + esac +fi + +hold="${DOORMAN_HOLD:-10}" +secure() { "${DOORMAN_USBRELAY:-/usr/bin/usbrelay}" "${DOORMAN_RELAY_DEV}=0" >/dev/null 2>&1; } +# Self-securing: whatever kills this script (systemd timeout, OOM, signal), +# the relay returns to 0 = door secure. Never rely on the happy path alone. +trap secure EXIT +log "UNLOCK from ${REMOTE_ADDR:-unknown}: ${DOORMAN_RELAY_DEV}=1 for ${hold}s" +respond 'HTTP/1.0 200 OK' +"${DOORMAN_USBRELAY:-/usr/bin/usbrelay}" "${DOORMAN_RELAY_DEV}=1" >/dev/null 2>&1 +sleep "$hold" +secure +log "secure: ${DOORMAN_RELAY_DEV}=0" diff --git a/tests/test-doorctl.sh b/tests/test-doorctl.sh new file mode 100755 index 0000000..819d778 --- /dev/null +++ b/tests/test-doorctl.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# tests/test-doorctl.sh — doorctl endpoint unit tests (offline). +# +# Drives bin/doorctl.sh the way systemd socket-activation does: request +# line on stdin, REMOTE_ADDR in the environment, DOORMAN_USBRELAY mocked +# to a script that records invocations. Asserts the auth matrix and the +# relay fire/secure cycle without any hardware. +# +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="$ROOT/bin/doorctl.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +pass=0 +fail=0 +ok() { printf 'ok - %s\n' "$1"; pass=$((pass + 1)); } +no() { printf 'FAIL - %s\n' "$1"; fail=$((fail + 1)); } + +# Fake usbrelay: records "set " lines; reports last state in state file. +FAKE_RELAY="$TMP/usbrelay" +cat > "$FAKE_RELAY" <<'EOF' +#!/usr/bin/env bash +echo "set $*" >> "$DOORMAN_TEST_LOG" +EOF +chmod +x "$FAKE_RELAY" + +# run_doorctl [hold] — runs one connection. +run_doorctl() { + local reqline="$1" addr="$2" hold="${3:-2}" + ( export DOORMAN_UNLOCK_TOKEN="testtoken123" + export DOORMAN_ALLOWED_SRC="100.67.108.125 100.91.151.113" + export DOORMAN_RELAY_DEV="3X9XI_1" + export DOORMAN_USBRELAY="$FAKE_RELAY" + export DOORMAN_HOLD="$hold" + export DOORMAN_TEST_LOG="$TMP/relay.log" + export REMOTE_ADDR="$addr" + printf '%s\r\nHost: x\r\n\r\n' "$reqline" | "$BIN" + ) +} + +relay_log() { cat "$TMP/relay.log" 2>/dev/null || true; } +reset_log() { : > "$TMP/relay.log"; } + +# T1: valid token + allowed source -> HTTP 200, relay set then released. +reset_log +out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 200 OK" ]; then ok "T1a valid request -> 200"; else no "T1a expected 200, got: $out"; fi +if grep -q 'set 3X9XI_1=1' "$TMP/relay.log" && grep -q 'set 3X9XI_1=0' "$TMP/relay.log"; then + ok "T1b relay fired then secured" +else + no "T1b relay cycle missing: $(relay_log | tr '\n' ' ')" +fi + +# T2: wrong token -> 403, relay untouched. +reset_log +out="$(run_doorctl 'GET /unlock/wrongtoken HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T2a bad token -> 403"; else no "T2a expected 403, got: $out"; fi +if [ -z "$(relay_log)" ]; then ok "T2b relay untouched on bad token"; else no "T2b relay fired on bad token!"; fi + +# T3: valid token but disallowed source -> 403, relay untouched. +reset_log +out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '203.0.113.9' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T3a disallowed source -> 403"; else no "T3a expected 403, got: $out"; fi +if [ -z "$(relay_log)" ]; then ok "T3b relay untouched on bad source"; else no "T3b relay fired from bad source!"; fi + +# T4: non-unlock path -> 403. +reset_log +out="$(run_doorctl 'GET /status HTTP/1.1' '100.67.108.125' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 403 Forbidden" ]; then ok "T4 unknown path -> 403"; else no "T4 expected 403, got: $out"; fi + +# T5: garbage request line -> 403, no crash. +reset_log +out="$(run_doorctl 'GARBAGE' '100.67.108.125' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 403 Forbidden" ] || [ "$out" = "HTTP/1.0 400 Bad Request" ]; then + ok "T5 garbage line rejected" +else + no "T5 garbage line not rejected cleanly (got: $out)" +fi + +# T6: second allowed source (the Pi itself) also passes auth. +reset_log +out="$(run_doorctl 'GET /unlock/testtoken123 HTTP/1.1' '100.91.151.113' | head -1 | tr -d "\r")" +if [ "$out" = "HTTP/1.0 200 OK" ]; then ok "T6 second allowed source -> 200"; else no "T6 expected 200, got: $out"; fi + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]