#!/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` (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