From 2dd61206a97d156fbf88668a777ebdcbc59e34a3 Mon Sep 17 00:00:00 2001 From: Overlord Date: Fri, 14 Aug 2026 12:02:04 +0200 Subject: [PATCH] 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 --- tests/live_test.sh | 462 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100755 tests/live_test.sh diff --git a/tests/live_test.sh b/tests/live_test.sh new file mode 100755 index 0000000..e68a589 --- /dev/null +++ b/tests/live_test.sh @@ -0,0 +1,462 @@ +#!/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 + 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 + 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 -> 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 [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 +wait_port_closed() { # wait_port_closed [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" "" +expect_contains "required positional renders as " "add " +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