463 lines
25 KiB
Bash
Executable File
463 lines
25 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Live integration test suite for porthole.
|
|
#
|
|
# Exercises every command against a REAL SSH server: real tunnels, real
|
|
# auth (success and failure), a real two-hop ProxyJump, a real SOCKS proxy
|
|
# carrying real traffic. Deliberately NOT part of `cargo test` (same reason
|
|
# as vmic's tests/live_test.sh: this needs a real remote server, not a CI
|
|
# sandbox) - live-only, opt-in, run by hand.
|
|
#
|
|
# Usage:
|
|
# tests/live_test.sh [options]
|
|
#
|
|
# Options (env var or flag; flag wins if both given):
|
|
# --bin PATH PORTHOLE_TEST_BIN porthole binary to test (default: target/debug/porthole)
|
|
# --host HOST PORTHOLE_TEST_HOST test server hostname (default: vpn.security-command.org)
|
|
# --user USER PORTHOLE_TEST_USER test server login user (default: overlord)
|
|
# --identity PATH PORTHOLE_TEST_IDENTITY identity file (default: ~/.ssh/id_ed25519_vpn)
|
|
# --name PREFIX PORTHOLE_TEST_NAME profile name prefix (default: porttestsuite)
|
|
# --skip-network PORTHOLE_TEST_SKIP_NETWORK=1 skip the SOCKS/icanhazip.com phase
|
|
# --skip-wipe PORTHOLE_TEST_SKIP_WIPE=1 skip the destructive `wipe` phase
|
|
# --no-build skip the `cargo build` preflight
|
|
# -h, --help
|
|
#
|
|
# Known limitation: only one real server is available, so multi-hop (`-J`)
|
|
# is tested by chaining the server through itself (--via user@host,user@host)
|
|
# - a real two-hop ProxyJump handshake, just with both hops the same box.
|
|
# There is no way to test a genuine distinct-host chain without a second
|
|
# server.
|
|
#
|
|
# Safety:
|
|
# - Profiles/instances/locks/logs are sandboxed for the whole run under
|
|
# one `PORTHOLE_STATE_DIR_OVERRIDE` temp dir (profile.rs/instance.rs
|
|
# both honor it) - this suite NEVER touches the real
|
|
# ~/.config/porthole or ~/.local/state/porthole.
|
|
# - Every profile created is named "$NAME_..." (default prefix
|
|
# porttestsuite); no operation targets anything outside that prefix.
|
|
# - `wipe` (spec §5.8 / src/commands/wipe.rs) kills ANY process on the
|
|
# whole system whose cmdline contains "__supervise", regardless of
|
|
# which state dir it belongs to - it is NOT scoped by the sandboxing
|
|
# above. Before running it, this script scans the real process table
|
|
# and skips the wipe phase entirely (not "wipe only the safe parts")
|
|
# if it finds a live __supervise process that isn't one of this run's
|
|
# own test profiles, so a real tunnel you have open elsewhere is never
|
|
# killed as a side effect of running this suite.
|
|
# - Never runs a bare ssh with interactive prompting: every verification
|
|
# ssh call this script itself makes uses BatchMode=yes plus a
|
|
# throwaway UserKnownHostsFile=/dev/null, so it never prompts and
|
|
# never writes to your real ~/.ssh/known_hosts (only porthole's own
|
|
# spawned ssh does, against the real file, using the accept-new policy
|
|
# already forced in src/ssh.rs).
|
|
# - An EXIT trap always attempts full cleanup - closes/removes every
|
|
# test-prefixed profile in every sandbox dir used, force-kills any
|
|
# stray matching __supervise process, deletes the throwaway bad-auth
|
|
# key - even on failure or Ctrl-C.
|
|
|
|
set -uo pipefail
|
|
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
|
|
|
BIN="${PORTHOLE_TEST_BIN:-target/debug/porthole}"
|
|
HOST="${PORTHOLE_TEST_HOST:-vpn.security-command.org}"
|
|
USER_="${PORTHOLE_TEST_USER:-overlord}"
|
|
IDENTITY="${PORTHOLE_TEST_IDENTITY:-$HOME/.ssh/id_ed25519_vpn}"
|
|
NAME="${PORTHOLE_TEST_NAME:-porttestsuite}"
|
|
SKIP_NETWORK="${PORTHOLE_TEST_SKIP_NETWORK:-0}"
|
|
SKIP_WIPE="${PORTHOLE_TEST_SKIP_WIPE:-0}"
|
|
DO_BUILD=1
|
|
|
|
usage() { sed -n '2,/^set -uo/p' "$0" | sed '$d; s/^# \{0,1\}//'; }
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--bin) BIN="$2"; shift 2 ;;
|
|
--host) HOST="$2"; shift 2 ;;
|
|
--user) USER_="$2"; shift 2 ;;
|
|
--identity) IDENTITY="$2"; shift 2 ;;
|
|
--name) NAME="$2"; shift 2 ;;
|
|
--skip-network) SKIP_NETWORK=1; shift ;;
|
|
--skip-wipe) SKIP_WIPE=1; shift ;;
|
|
--no-build) DO_BUILD=0; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
NAME="$(tr '[:upper:]' '[:lower:]' <<<"$NAME")"
|
|
|
|
RED=$'\e[31m'; GREEN=$'\e[32m'; YELLOW=$'\e[33m'; BLUE=$'\e[34m'; RESET=$'\e[0m'
|
|
[[ -t 1 ]] || { RED=""; GREEN=""; YELLOW=""; BLUE=""; RESET=""; }
|
|
|
|
PASS=0; FAIL=0; SKIP=0
|
|
section() { echo; echo "${BLUE}== $1 ==${RESET}"; }
|
|
pass() { PASS=$((PASS+1)); echo " ${GREEN}PASS${RESET} $1"; }
|
|
fail() { FAIL=$((FAIL+1)); echo " ${RED}FAIL${RESET} $1"; [[ -n "${2:-}" ]] && echo " ${2//$'\n'/$'\n '}"; }
|
|
skip() { SKIP=$((SKIP+1)); echo " ${YELLOW}SKIP${RESET} $1"; }
|
|
|
|
LAST_OUT=""; LAST_CODE=0
|
|
porthole_run() { LAST_OUT="$("$BIN" "$@" 2>&1)"; LAST_CODE=$?; }
|
|
|
|
expect_exit() { # expect_exit <desc> <expected_code>
|
|
if [[ "$LAST_CODE" == "$2" ]]; then pass "$1 (exit $LAST_CODE)"
|
|
else fail "$1 (expected exit $2, got $LAST_CODE)" "$LAST_OUT"; fi
|
|
}
|
|
expect_contains() { # expect_contains <desc> <needle>
|
|
if [[ "$LAST_OUT" == *"$2"* ]]; then pass "$1"
|
|
else fail "$1 (expected output to contain: $2)" "$LAST_OUT"; fi
|
|
}
|
|
expect_not_contains() {
|
|
if [[ "$LAST_OUT" != *"$2"* ]]; then pass "$1"
|
|
else fail "$1 (expected output NOT to contain: $2)" "$LAST_OUT"; fi
|
|
}
|
|
assert_eq() { if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1" "expected '$3', got '$2'"; fi; }
|
|
assert_true() { if "${@:2}" >/dev/null 2>&1; then pass "$1"; else fail "$1"; fi; }
|
|
|
|
# Every use below passes an explicit PORTHOLE_STATE_DIR_OVERRIDE, so this
|
|
# never touches ~/.config/porthole or ~/.local/state/porthole.
|
|
p() { PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}"; }
|
|
p_run() { LAST_OUT="$(PORTHOLE_STATE_DIR_OVERRIDE="$1" "$BIN" "${@:2}" 2>&1)"; LAST_CODE=$?; }
|
|
|
|
# status --json's "state" field is always one of closed/up/reconnecting/error
|
|
# (print_json normalizes a dead-supervisor instance file to "error" too, see
|
|
# status.rs) - polling that key is far more robust than scraping the padded
|
|
# human-readable field.
|
|
json_field() { # json_field <state-dir> <name> <field> -> value, empty if absent/no instance
|
|
p "$1" status "$2" --json 2>/dev/null | sed -n "s/.*\"$3\": \"\\{0,1\\}\\([^\",]*\\)\"\\{0,1\\},\\{0,1\\}\$/\\1/p" | head -1
|
|
}
|
|
wait_for_state() { # wait_for_state <state-dir> <name> <want-state> [tries, x0.5s]
|
|
local dir="$1" name="$2" want="$3" tries="${4:-20}"
|
|
for _ in $(seq 1 "$tries"); do
|
|
[[ "$(json_field "$dir" "$name" state)" == "$want" ]] && return 0
|
|
sleep 0.5
|
|
done
|
|
return 1
|
|
}
|
|
port_open() { timeout 1 bash -c "exec 3<>/dev/tcp/127.0.0.1/$1" 2>/dev/null; } # port_open <port>
|
|
wait_port_closed() { # wait_port_closed <port> [tries, x0.5s]
|
|
for _ in $(seq 1 "${2:-10}"); do
|
|
port_open "$1" || return 0
|
|
sleep 0.5
|
|
done
|
|
return 1
|
|
}
|
|
SSH_PROBE_OPTS=(-o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null)
|
|
|
|
# mktemp's output is captured via $(...), which forks a subshell - any
|
|
# array append done *inside* a function called that way would be lost when
|
|
# the subshell exits, so state dirs are appended here at the call site
|
|
# instead of through a helper function.
|
|
STATE_DIRS=()
|
|
STATE_DIR="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR")
|
|
BADKEY="$(mktemp -u)"
|
|
|
|
cleanup() {
|
|
section "Cleanup"
|
|
for d in "${STATE_DIRS[@]:-}"; do
|
|
[[ -z "$d" ]] && continue
|
|
for prof in $(p "$d" list --json 2>/dev/null | sed -n 's/.*"name": "\([^"]*\)".*/\1/p'); do
|
|
p "$d" close --force "$prof" >/dev/null 2>&1 || true
|
|
p "$d" remove "$prof" >/dev/null 2>&1 || true
|
|
done
|
|
rm -rf "$d"
|
|
done
|
|
pkill -f "__supervise ${NAME}_" 2>/dev/null || true
|
|
rm -f "$BADKEY" "$BADKEY.pub" 2>/dev/null || true
|
|
echo " done."
|
|
echo
|
|
echo "${BLUE}== Results ==${RESET} ${GREEN}$PASS passed${RESET}, ${RED}$FAIL failed${RESET}, ${YELLOW}$SKIP skipped${RESET}"
|
|
[[ "$FAIL" -eq 0 ]]
|
|
}
|
|
trap 'cleanup; exit $(( $? ))' EXIT
|
|
|
|
echo "porthole: $BIN"
|
|
echo "server: $USER_@$HOST (identity: $IDENTITY)"
|
|
echo "test name: $NAME (+ suffixes) in $STATE_DIR"
|
|
|
|
if [[ "$DO_BUILD" == "1" ]]; then
|
|
section "Build"
|
|
if cargo build 2>&1 | tee /dev/stderr | grep -q '^error'; then
|
|
echo "build failed, aborting." >&2; exit 1
|
|
fi
|
|
fi
|
|
[[ -x "$BIN" ]] || { echo "binary not found/executable: $BIN" >&2; exit 1; }
|
|
[[ -f "$IDENTITY" ]] || { echo "identity file not found: $IDENTITY" >&2; exit 1; }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 1: CLI surface"
|
|
# ---------------------------------------------------------------------------
|
|
porthole_run; expect_exit "bare 'porthole' shows help" 2
|
|
expect_contains "bare 'porthole' mentions Usage" "Usage:"
|
|
porthole_run -h; expect_exit "'porthole -h'" 0
|
|
porthole_run help; expect_exit "'porthole help'" 0
|
|
porthole_run --version; expect_exit "'porthole --version'" 0
|
|
expect_contains "'--version' mentions porthole" "porthole"
|
|
for cmd in add open close edit status list remove wipe transfer; do
|
|
porthole_run "$cmd" --help; expect_exit "'porthole $cmd --help'" 0
|
|
done
|
|
for shell in bash zsh fish; do
|
|
porthole_run completions "$shell"
|
|
assert_eq "'porthole completions $shell' exits 0" "$LAST_CODE" "0"
|
|
[[ -n "$LAST_OUT" ]] && pass "'porthole completions $shell' produces output" || fail "'porthole completions $shell' produces output" "(empty)"
|
|
done
|
|
|
|
porthole_run --help
|
|
expect_contains "value-name shows real mapping grammar" "<[BIND:]PORT:HOST:PORT>"
|
|
expect_contains "value-name shows PATH.toml for transfer" "<PATH.toml>"
|
|
expect_contains "required positional renders as <name>" "add <name>"
|
|
expect_contains "optional positional renders as [name]" "open [name]"
|
|
expect_contains "optional positional renders as [name] (transfer)" "transfer [name]"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 2: error paths (pre-creation)"
|
|
# ---------------------------------------------------------------------------
|
|
p_run "$STATE_DIR" add; expect_exit "'add' with no name fails" 2
|
|
p_run "$STATE_DIR" add "bad name!"; assert_eq "'add' with an invalid name exits 1" "$LAST_CODE" "1"
|
|
expect_contains "invalid name error message" "invalid name"
|
|
p_run "$STATE_DIR" add "${NAME}_x"; assert_eq "'add' with no mapping kind exits 1" "$LAST_CODE" "1"
|
|
expect_contains "no-mapping-kind error message" "exactly one of -l/--local"
|
|
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1 -r 2:h:2; assert_eq "'add' with conflicting mapping kinds exits 1" "$LAST_CODE" "1"
|
|
expect_contains "conflicting-mapping error message" "only one of -l/--local"
|
|
p_run "$STATE_DIR" add "${NAME}_x" -l 1:h:1; assert_eq "'add' with no --via exits 1" "$LAST_CODE" "1"
|
|
expect_contains "no-via error message" "--via is required"
|
|
p_run "$STATE_DIR" status "${NAME}_nope"; assert_eq "'status' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
|
expect_contains "nonexistent-profile error (status)" "no profile named"
|
|
p_run "$STATE_DIR" close "${NAME}_nope"; assert_eq "'close' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
|
p_run "$STATE_DIR" edit "${NAME}_nope" -l 1:h:1; assert_eq "'edit' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
|
p_run "$STATE_DIR" remove "${NAME}_nope"; assert_eq "'remove' on nonexistent profile exits 1" "$LAST_CODE" "1"
|
|
p_run "$STATE_DIR" transfer; assert_eq "'transfer' with no mode exits 1" "$LAST_CODE" "1"
|
|
expect_contains "transfer no-mode error message" "exactly one of -i/--import"
|
|
p_run "$STATE_DIR" transfer -e /tmp/x.toml -i /tmp/x.toml; assert_eq "'transfer' with both modes exits 1" "$LAST_CODE" "1"
|
|
expect_contains "transfer conflicting-mode error message" "only one of -i/--import"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 3: local forward (-l), single hop - the baseline path"
|
|
# ---------------------------------------------------------------------------
|
|
LOCAL_PORT=28221
|
|
p_run "$STATE_DIR" add "${NAME}_local" -l "$LOCAL_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_local'" 0
|
|
p_run "$STATE_DIR" status "${NAME}_local"; expect_contains "fresh profile is closed" "closed"
|
|
|
|
p_run "$STATE_DIR" open "${NAME}_local"; expect_exit "'open ${NAME}_local'" 0
|
|
assert_true "'${NAME}_local' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_local" up 10
|
|
|
|
if ssh "${SSH_PROBE_OPTS[@]}" -p "$LOCAL_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
|
pass "forwarded port $LOCAL_PORT actually round-trips to the real sshd"
|
|
else
|
|
fail "forwarded port $LOCAL_PORT actually round-trips to the real sshd" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
|
fi
|
|
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
|
|
|
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "'close ${NAME}_local'" 0
|
|
assert_true "port $LOCAL_PORT stops listening after close" wait_port_closed "$LOCAL_PORT"
|
|
p_run "$STATE_DIR" close "${NAME}_local"; expect_exit "re-'close' on an already-closed profile still exits 0" 0
|
|
expect_contains "idempotent-close message" "is not open"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 4: remote forward (-r) - binds on the server, dials back out locally"
|
|
# ---------------------------------------------------------------------------
|
|
REMOTE_PORT=28225
|
|
p_run "$STATE_DIR" add "${NAME}_remote" -r "$REMOTE_PORT:$HOST:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_remote'" 0
|
|
p_run "$STATE_DIR" open "${NAME}_remote"; expect_exit "'open ${NAME}_remote'" 0
|
|
assert_true "'${NAME}_remote' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_remote" up 10
|
|
|
|
remote_check="$(ssh "${SSH_PROBE_OPTS[@]}" -i "$IDENTITY" "$USER_@$HOST" \
|
|
"ssh -p $REMOTE_PORT -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null localhost true && echo REMOTE_FORWARD_OK" 2>&1)"
|
|
if [[ "$remote_check" == *REMOTE_FORWARD_OK* ]]; then
|
|
pass "remote-bound port $REMOTE_PORT round-trips back out through the tunnel"
|
|
else
|
|
fail "remote-bound port $REMOTE_PORT round-trips back out through the tunnel" "$remote_check"
|
|
fi
|
|
|
|
p_run "$STATE_DIR" close "${NAME}_remote"; expect_exit "'close ${NAME}_remote'" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 5: dynamic forward (-d, SOCKS) - proves traffic actually transits"
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$SKIP_NETWORK" == "1" ]]; then
|
|
skip "SOCKS traffic-routing check (--skip-network passed)"
|
|
elif ! command -v curl >/dev/null; then
|
|
skip "SOCKS traffic-routing check (curl not installed)"
|
|
else
|
|
SOCKS_PORT=28226
|
|
p_run "$STATE_DIR" add "${NAME}_dynamic" -d "$SOCKS_PORT" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_dynamic'" 0
|
|
p_run "$STATE_DIR" open "${NAME}_dynamic"; expect_exit "'open ${NAME}_dynamic'" 0
|
|
assert_true "'${NAME}_dynamic' reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_dynamic" up 10
|
|
|
|
direct_ip="$(curl -s --max-time 8 https://icanhazip.com | tr -d '[:space:]')"
|
|
proxied_ip="$(curl -s --max-time 8 -x "socks5h://localhost:$SOCKS_PORT" https://icanhazip.com | tr -d '[:space:]')"
|
|
if [[ -z "$direct_ip" || -z "$proxied_ip" ]]; then
|
|
skip "SOCKS traffic-routing check (icanhazip.com unreachable right now)"
|
|
elif [[ "$proxied_ip" != "$direct_ip" ]]; then
|
|
pass "SOCKS proxy traffic exits via the remote server ($proxied_ip != local $direct_ip)"
|
|
else
|
|
fail "SOCKS proxy traffic exits via the remote server" "proxied IP ($proxied_ip) matched direct IP - traffic didn't actually route through the tunnel"
|
|
fi
|
|
p_run "$STATE_DIR" close "${NAME}_dynamic"; expect_exit "'close ${NAME}_dynamic'" 0
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 6: multi-hop --via (self-jump - see header comment for why)"
|
|
# ---------------------------------------------------------------------------
|
|
HOP_PORT=28223
|
|
p_run "$STATE_DIR" add "${NAME}_multihop" -l "$HOP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST,$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_multihop' with a 2-hop --via" 0
|
|
p_run "$STATE_DIR" open "${NAME}_multihop"; expect_exit "'open ${NAME}_multihop'" 0
|
|
assert_true "'${NAME}_multihop' reaches state: up (real -J handshake, twice)" wait_for_state "$STATE_DIR" "${NAME}_multihop" up 30
|
|
|
|
if ssh "${SSH_PROBE_OPTS[@]}" -p "$HOP_PORT" localhost true 2>/tmp/porthole_test_probe.$$; then
|
|
pass "forwarded port round-trips through the 2-hop chain"
|
|
else
|
|
fail "forwarded port round-trips through the 2-hop chain" "$(cat /tmp/porthole_test_probe.$$ 2>/dev/null)"
|
|
fi
|
|
rm -f "/tmp/porthole_test_probe.$$" 2>/dev/null
|
|
p_run "$STATE_DIR" close "${NAME}_multihop"; expect_exit "'close ${NAME}_multihop'" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 7: -u/--user without an embedded via user - real -l flag auth"
|
|
# ---------------------------------------------------------------------------
|
|
ALTUSER_PORT=28224
|
|
p_run "$STATE_DIR" add "${NAME}_altuser" -l "$ALTUSER_PORT:localhost:22" -i "$IDENTITY" --via "$HOST" -u "$USER_"
|
|
expect_exit "'add ${NAME}_altuser' (--via with no embedded user, -u instead)" 0
|
|
p_run "$STATE_DIR" open "${NAME}_altuser"; expect_exit "'open ${NAME}_altuser'" 0
|
|
assert_true "'${NAME}_altuser' authenticates via -u/-l, reaches state: up" wait_for_state "$STATE_DIR" "${NAME}_altuser" up 10
|
|
p_run "$STATE_DIR" close "${NAME}_altuser"; expect_exit "'close ${NAME}_altuser'" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 8: real auth failure - Fatal classification"
|
|
# ---------------------------------------------------------------------------
|
|
if ! command -v ssh-keygen >/dev/null; then
|
|
skip "real Fatal-classification check (ssh-keygen not installed)"
|
|
else
|
|
ssh-keygen -q -t ed25519 -N '' -f "$BADKEY" >/dev/null
|
|
p_run "$STATE_DIR" add "${NAME}_badauth" -l 28230:localhost:22 -i "$BADKEY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_badauth' with a never-authorized key" 0
|
|
p_run "$STATE_DIR" open --once "${NAME}_badauth"
|
|
assert_eq "'open --once' with bad auth exits 1" "$LAST_CODE" "1"
|
|
assert_eq "'${NAME}_badauth' lands in state: error" "$(json_field "$STATE_DIR" "${NAME}_badauth" state)" "error"
|
|
last_err="$(json_field "$STATE_DIR" "${NAME}_badauth" last_error)"
|
|
[[ "$last_err" == *"Permission denied"* ]] && pass "last_error reports a real Permission-denied rejection" \
|
|
|| fail "last_error reports a real Permission-denied rejection" "got: $last_err"
|
|
p_run "$STATE_DIR" remove "${NAME}_badauth"; expect_exit "'remove ${NAME}_badauth'" 0
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 9: real connection-refused - KnownTransient classification"
|
|
# ---------------------------------------------------------------------------
|
|
p_run "$STATE_DIR" add "${NAME}_deadport" -l 28231:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" \
|
|
-p 9 --retry-interval 2 --backoff-max 4
|
|
expect_exit "'add ${NAME}_deadport' targeting a closed port on the real host" 0
|
|
p_run "$STATE_DIR" open "${NAME}_deadport"; expect_exit "'open ${NAME}_deadport' (backgrounds even though the first attempt fails)" 0
|
|
assert_true "'${NAME}_deadport' keeps reconnecting rather than giving up" wait_for_state "$STATE_DIR" "${NAME}_deadport" reconnecting 20
|
|
rc1="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
|
sleep 6
|
|
rc2="$(json_field "$STATE_DIR" "${NAME}_deadport" reconnect_count)"
|
|
if [[ -n "$rc2" && "$rc2" -gt "${rc1:-0}" ]]; then
|
|
pass "reconnect_count keeps increasing on a real refused connection ($rc1 -> $rc2)"
|
|
else
|
|
fail "reconnect_count keeps increasing on a real refused connection" "rc1=$rc1 rc2=$rc2"
|
|
fi
|
|
p_run "$STATE_DIR" close --force "${NAME}_deadport"; expect_exit "'close --force ${NAME}_deadport'" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 10: open --all only starts reconnect-enabled profiles"
|
|
# ---------------------------------------------------------------------------
|
|
p_run "$STATE_DIR" add "${NAME}_all1" -l 28232:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_all1' (reconnect: true, the default)" 0
|
|
p_run "$STATE_DIR" add "${NAME}_all2" -l 28233:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST" --reconnect false
|
|
expect_exit "'add ${NAME}_all2' (reconnect: false)" 0
|
|
p_run "$STATE_DIR" open --all; expect_exit "'open --all'" 0
|
|
assert_true "'${NAME}_all1' was started by --all" wait_for_state "$STATE_DIR" "${NAME}_all1" up 10
|
|
assert_eq "'${NAME}_all2' was NOT started by --all (reconnect: false)" "$(json_field "$STATE_DIR" "${NAME}_all2" state)" "closed"
|
|
p_run "$STATE_DIR" close "${NAME}_all1"; expect_exit "'close ${NAME}_all1'" 0
|
|
p_run "$STATE_DIR" remove "${NAME}_all2"; expect_exit "'remove ${NAME}_all2' (was never opened)" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 11: close --force skips the graceful wait"
|
|
# ---------------------------------------------------------------------------
|
|
FORCE_PORT=28234
|
|
p_run "$STATE_DIR" add "${NAME}_force" -l "$FORCE_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_force'" 0
|
|
p_run "$STATE_DIR" open "${NAME}_force"; expect_exit "'open ${NAME}_force'" 0
|
|
t0=$(date +%s)
|
|
p_run "$STATE_DIR" close --force "${NAME}_force"; expect_exit "'close --force ${NAME}_force'" 0
|
|
t1=$(date +%s)
|
|
assert_true "'--force' returns fast, without the 5s graceful-wait" bash -c "[[ $((t1 - t0)) -lt 4 ]]"
|
|
assert_true "port $FORCE_PORT stops listening after force-close" wait_port_closed "$FORCE_PORT" 6
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 12: edit while running warns instead of restarting"
|
|
# ---------------------------------------------------------------------------
|
|
p_run "$STATE_DIR" add "${NAME}_edit" -l 28235:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_edit'" 0
|
|
p_run "$STATE_DIR" open "${NAME}_edit"; expect_exit "'open ${NAME}_edit'" 0
|
|
p_run "$STATE_DIR" edit "${NAME}_edit" --keepalive 20
|
|
expect_exit "'edit ${NAME}_edit --keepalive 20' while open" 0
|
|
expect_contains "warns the change won't apply until reopened" "won't take effect until"
|
|
p_run "$STATE_DIR" close "${NAME}_edit"; expect_exit "'close ${NAME}_edit'" 0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 13: remove --keep-running leaves a genuine orphan"
|
|
# ---------------------------------------------------------------------------
|
|
KEEP_PORT=28236
|
|
p_run "$STATE_DIR" add "${NAME}_keep" -l "$KEEP_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_keep'" 0
|
|
p_run "$STATE_DIR" open "${NAME}_keep"; expect_exit "'open ${NAME}_keep'" 0
|
|
p_run "$STATE_DIR" remove "${NAME}_keep" --keep-running
|
|
expect_exit "'remove ${NAME}_keep --keep-running'" 0
|
|
expect_contains "warns it's left running untracked" "left running untracked"
|
|
p_run "$STATE_DIR" status "${NAME}_keep"; assert_eq "profile is gone from tracking" "$LAST_CODE" "1"
|
|
assert_true "the untracked process is still actually alive" pgrep -f "__supervise ${NAME}_keep\$"
|
|
assert_true "port $KEEP_PORT is still live, untracked" port_open "$KEEP_PORT"
|
|
# left running on purpose - phase 15's wipe is what's being tested against it
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 14: transfer round trip against a live profile"
|
|
# ---------------------------------------------------------------------------
|
|
XFER_PORT=28237
|
|
XFER_FILE="$(mktemp -u)"
|
|
STATE_DIR2="$(mktemp -d)"; STATE_DIRS+=("$STATE_DIR2")
|
|
p_run "$STATE_DIR" add "${NAME}_xfer" -l "$XFER_PORT:localhost:22" -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "'add ${NAME}_xfer'" 0
|
|
p_run "$STATE_DIR" transfer -e "$XFER_FILE" "${NAME}_xfer"
|
|
expect_exit "'transfer -e ... ${NAME}_xfer'" 0
|
|
expect_contains "export warns the identity file isn't included" "identity files are not included"
|
|
|
|
p_run "$STATE_DIR2" transfer -i "$XFER_FILE"
|
|
expect_exit "'transfer -i ...' into a fresh state dir" 0
|
|
p_run "$STATE_DIR2" open "${NAME}_xfer"; expect_exit "'open' the imported profile" 0
|
|
assert_true "the imported profile actually connects, not just parses" wait_for_state "$STATE_DIR2" "${NAME}_xfer" up 10
|
|
p_run "$STATE_DIR2" close "${NAME}_xfer"; expect_exit "'close' the imported profile" 0
|
|
rm -f "$XFER_FILE" 2>/dev/null
|
|
|
|
# ---------------------------------------------------------------------------
|
|
section "Phase 15: wipe (guarded - kills every __supervise process system-wide)"
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$SKIP_WIPE" == "1" ]]; then
|
|
skip "wipe phase (--skip-wipe passed)"
|
|
else
|
|
foreign=""
|
|
while read -r pid; do
|
|
[[ -z "$pid" ]] && continue
|
|
cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)"
|
|
[[ "$cmd" == *"__supervise ${NAME}_"* ]] || foreign="$foreign $pid"
|
|
done < <(pgrep -f '__supervise' 2>/dev/null)
|
|
|
|
if [[ -n "$foreign" ]]; then
|
|
skip "wipe phase (found __supervise process(es) not from this run: pid$foreign - not safe to run a system-wide wipe)"
|
|
else
|
|
p_run "$STATE_DIR" add "${NAME}_wa" -l 28238:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "create throwaway closed profile for wipe test" 0
|
|
p_run "$STATE_DIR" add "${NAME}_wb" -l 28239:localhost:22 -i "$IDENTITY" --via "$USER_@$HOST"
|
|
expect_exit "create throwaway open profile for wipe test" 0
|
|
p_run "$STATE_DIR" open "${NAME}_wb"; expect_exit "open it" 0
|
|
|
|
p_run "$STATE_DIR" wipe --yes; expect_exit "'wipe --yes'" 0
|
|
expect_contains "wipe reports what it did" "Wiped all forwards"
|
|
|
|
p_run "$STATE_DIR" list; expect_contains "'list' is empty after wipe" "No profiles saved."
|
|
assert_true "the phase-13 orphan is gone too (kill_orphaned_supervisors)" bash -c \
|
|
"! pgrep -f '__supervise ${NAME}_keep\$' >/dev/null"
|
|
assert_true "port $KEEP_PORT is no longer listening" wait_port_closed "$KEEP_PORT" 6
|
|
fi
|
|
fi
|