Files
porthole/tests/live_test.sh
Overlord 2dd61206a9 Add live_test.sh: full automated integration suite against a real SSH server
Mirrors vmic's tests/live_test.sh convention (colored PASS/FAIL/SKIP,
section headers, an EXIT trap that always cleans up, safety scoped to
test-prefixed names) but adapted for porthole: whole run sandboxed under
a temp PORTHOLE_STATE_DIR_OVERRIDE so it never touches the real
~/.config/porthole or ~/.local/state/porthole.

15 phases covering every mapping kind (-l/-r/-d) against the real
server, a real 2-hop -J ProxyJump (self-jump - only one server is
available), real auth success/failure classification (not just the
synthetic DNS-failure cases cargo test covers), open --all/--once,
close --force vs. graceful vs. idempotent, edit-while-running, the
remove --keep-running orphan path, a transfer export/import round trip
that actually reopens the imported profile, and a guarded wipe phase
that verifies kill_orphaned_supervisors for real.

wipe kills any __supervise process system-wide by design (not scoped to
the sandboxed state dir), so before running it the script scans the
real process table and skips the phase entirely if it finds a live
supervisor that isn't one of its own test profiles - a real tunnel
left open elsewhere is never killed as a side effect of running this
suite.

Verified the CLI-surface and error-path phases (no server needed)
directly against the built binary; phases requiring the real server
are left for a manual run, per the established pattern in this project
of live-server tests being run by hand rather than automated in CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 12:02:04 +02:00

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