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:
Executable
+89
@@ -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 ]
|
||||
Reference in New Issue
Block a user