feat(doorman): doorctl unlock endpoint — HA->Pi callback over Tailscale [#345]

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
This commit is contained in:
2026-09-03 07:04:12 -05:00
parent 37d27f1158
commit 4fefd10abb
3 changed files with 173 additions and 0 deletions
+23
View File
@@ -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>
(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 <DOORMAN_WEBHOOK_URL>` with JSON:
Executable
+61
View File
@@ -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/<token> 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"
+89
View File
@@ -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 <arg>" 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 <request-line> <remote-addr> [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 ]