#!/bin/sh
# Temporary browser access to the Helium fixture's loopback-only VirtualBox
# VRDE endpoint. This is a manual-recovery helper, never a normal test channel.
set -eu

helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd)
runtime_dir=$helper_dir/.runtime
compose_file=$helper_dir/compose.yaml
mapping_template=$helper_dir/user-mapping.xml.in
project=rvbox-rdp-access

: "${RDP_ACCESS_BIND:=127.0.0.1}"
: "${RDP_ACCESS_HTTP_PORT:=5002}"
: "${RDP_ACCESS_TUNNEL_PORT:=54001}"
: "${RDP_ACCESS_PUBLIC_HOST:=localhost}"
: "${RDP_ACCESS_WEB_USER:=rvboxtest}"
: "${RDP_ACCESS_RDP_USER:=rvboxtest}"
: "${RDP_ACCESS_GUACD_LOG_LEVEL:=info}"
: "${RDP_ACCESS_IPV6_BIND:=::}"
: "${RDP_ACCESS_IPV6_FORWARD:=auto}"
: "${RVBOX_TEST_VBOX_HOST:=helium-remote}"
: "${RDP_ACCESS_VRDE_HOST:=127.0.0.1}"
: "${RDP_ACCESS_VRDE_PORT:=3389}"

usage() {
    cat <<'EOF'
usage: test/rdp-access/rdp-access ACTION [OPTIONS]

Actions:
  up       start a private VRDE SSH tunnel and self-signed HTTPS Guacamole
  status   show gateway, tunnel, and fixture status without changing anything
  repair   restart only guacd to release a stale single-VRDE-slot worker
  url      print the current browser URL
  logs     follow or print Compose logs (pass Docker Compose log options)
  down     stop containers and the private SSH watchdog/tunnel; retain generated state
  clean    run down and delete generated state; pass --images to also remove
           the exact unused Guacamole/nginx images

up options:
  --bind ADDRESS        listener address (default 127.0.0.1; use 0.0.0.0 only
                        for a temporary, deliberately public endpoint)
  --http-port PORT      HTTPS listener port (default 5002)
  --tunnel-port PORT    private VRDE tunnel port (default 54001)
  --public-host NAME    browser-visible hostname or IP for the URL and cert SAN
  --web-user USER       Guacamole and Windows account (default rvboxtest)
  --reset-auth          discard the saved password hash and prompt again
  --web-password-stdin read the password once from stdin instead of prompting

Environment equivalents: RDP_ACCESS_BIND, RDP_ACCESS_HTTP_PORT,
RDP_ACCESS_TUNNEL_PORT, RDP_ACCESS_PUBLIC_HOST, RDP_ACCESS_WEB_USER,
RDP_ACCESS_RDP_USER, RVBOX_TEST_VBOX_HOST, RDP_ACCESS_VRDE_HOST, and
RDP_ACCESS_VRDE_PORT. In public mode, RDP_ACCESS_IPV6_BIND and
RDP_ACCESS_IPV6_FORWARD (auto, always, or never) control the reversible
IPv6-to-IPv4 listener for dual-stack hostnames. Set
RDP_ACCESS_GUACD_LOG_LEVEL=debug for temporary protocol diagnostics.
EOF
}

fail() { printf '%s\n' "rdp-access: $*" >&2; exit 2; }

safe_name() {
    case $2 in ''|*[!A-Za-z0-9.-]*) fail "$1 contains unsupported characters" ;; esac
}

safe_port() {
    case $2 in ''|*[!0-9]*) fail "$1 must be a port number" ;; esac
    [ "$2" -ge 1024 ] && [ "$2" -le 65535 ] || fail "$1 must be between 1024 and 65535"
}

safe_bind() {
    case $1 in 127.0.0.1|0.0.0.0) ;; *) fail "--bind must be 127.0.0.1 or 0.0.0.0" ;; esac
}

compose() {
    RDP_ACCESS_RUNTIME_DIR=$runtime_dir \
    RDP_ACCESS_BIND=$RDP_ACCESS_BIND \
    RDP_ACCESS_HTTP_PORT=$RDP_ACCESS_HTTP_PORT \
    RDP_ACCESS_GUACD_LOG_LEVEL=$RDP_ACCESS_GUACD_LOG_LEVEL \
    RDP_ACCESS_CERT_NAME=$RDP_ACCESS_PUBLIC_HOST \
    RDP_ACCESS_CERT_SAN=$cert_san \
    RDP_ACCESS_HOST_UID=$(id -u) \
    RDP_ACCESS_HOST_GID=$(id -g) \
    docker compose --project-name "$project" -f "$compose_file" "$@"
}

wait_for_gateway() {
    command -v curl >/dev/null 2>&1 || fail "curl is required for the Guacamole readiness check"
    attempts=0
    while [ "$attempts" -lt 60 ]; do
        # The listener is bound on the controller, even when the public
        # address is 0.0.0.0. Ignore the self-signed certificate here: this
        # check is only proving that nginx can reach the Guacamole servlet.
        if curl -kfsS --connect-timeout 1 --max-time 3 \
            "https://127.0.0.1:$RDP_ACCESS_HTTP_PORT/guacamole/" \
            >/dev/null 2>&1; then
            return 0
        fi
        attempts=$((attempts + 1))
        sleep 1
    done
    return 1
}

socket_path=$runtime_dir/ssh-control.socket
session_file=$runtime_dir/session.env
cert_name_file=$runtime_dir/cert-name
tunnel_stop_file=$runtime_dir/tunnel.stop
tunnel_pid_file=$runtime_dir/tunnel-watchdog.pid
tunnel_log_file=$runtime_dir/tunnel.log
ipv6_pid_file=$runtime_dir/ipv6-forward.pid
ipv6_log_file=$runtime_dir/ipv6-forward.log

stop_tunnel() {
    mkdir -p "$runtime_dir"
    : >"$tunnel_stop_file"
    if [ -S "$socket_path" ]; then
        ssh -S "$socket_path" -O exit -o ConnectTimeout=5 "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || true
    fi
    if [ -f "$tunnel_pid_file" ]; then
        tunnel_pid=$(cat "$tunnel_pid_file" 2>/dev/null || true)
        case $tunnel_pid in
            ''|*[!0-9]*) ;;
            *)
                # Never trust a stale numeric PID file. Confirm that it is
                # still this helper's watchdog before signalling it.
                tunnel_cmd=$(ps -p "$tunnel_pid" -o args= 2>/dev/null || true)
                case $tunnel_cmd in
                    *rdp-tunnel-watchdog*"$tunnel_stop_file"*)
                        kill "$tunnel_pid" >/dev/null 2>&1 || true
                        # The watchdog normally exits within its two-second
                        # retry interval. Do not leave a stale reconnect loop
                        # behind, but only force the verified process.
                        sleep 1
                        tunnel_cmd=$(ps -p "$tunnel_pid" -o args= 2>/dev/null || true)
                        case $tunnel_cmd in
                            *rdp-tunnel-watchdog*"$tunnel_stop_file"*) kill -KILL "$tunnel_pid" >/dev/null 2>&1 || true ;;
                        esac
                        ;;
                esac
                ;;
        esac
    fi
    rm -f "$socket_path"
    rm -f "$tunnel_pid_file" "$tunnel_stop_file"
}

ipv6_process_matches() {
    ipv6_pid=$1
    ipv6_cmd=$(ps -p "$ipv6_pid" -o args= 2>/dev/null || true)
    case $ipv6_cmd in
        *"TCP6-LISTEN:$RDP_ACCESS_HTTP_PORT"*"TCP4:127.0.0.1:$RDP_ACCESS_HTTP_PORT"*) return 0 ;;
        *) return 1 ;;
    esac
}

ipv6_listener_active() {
    command -v ss >/dev/null 2>&1 || return 1
    ss -H -ltn6 2>/dev/null | awk -v port=":$RDP_ACCESS_HTTP_PORT" \
        '{ if ($4 ~ (port "$")) found=1 } END { exit !found }'
}

stop_ipv6_forward() {
    [ -f "$ipv6_pid_file" ] || return 0
    ipv6_pid=$(cat "$ipv6_pid_file" 2>/dev/null || true)
    case $ipv6_pid in
        ''|*[!0-9]*) ;;
        *)
            if ipv6_process_matches "$ipv6_pid"; then
                kill "$ipv6_pid" >/dev/null 2>&1 || true
                sleep 1
                if ipv6_process_matches "$ipv6_pid"; then
                    kill -KILL "$ipv6_pid" >/dev/null 2>&1 || true
                fi
            fi
            ;;
    esac
    rm -f "$ipv6_pid_file"
}

start_ipv6_forward() {
    [ "$RDP_ACCESS_BIND" = 0.0.0.0 ] || return 0
    case $RDP_ACCESS_IPV6_FORWARD in
        never) stop_ipv6_forward; return 0 ;;
        auto|always) ;;
        *) fail "RDP_ACCESS_IPV6_FORWARD must be auto, always, or never" ;;
    esac
    if ! command -v socat >/dev/null 2>&1; then
        if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
            fail "socat is required for RDP_ACCESS_IPV6_FORWARD=always"
        fi
        printf 'rdp-access: warning: socat unavailable; IPv6 forwarding disabled (IPv4 remains active)\n' >&2
        return 0
    fi
    if [ -f "$ipv6_pid_file" ] && ipv6_process_matches "$(cat "$ipv6_pid_file" 2>/dev/null || true)" && ipv6_listener_active; then
        return 0
    fi
    if ipv6_listener_active; then
        # Do not kill an unrelated listener.  The operator can inspect it via
        # status; down only stops a PID that this helper started and verified.
        if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
            fail "IPv6 port $RDP_ACCESS_HTTP_PORT is already occupied by an unowned listener"
        fi
        printf 'rdp-access: IPv6 port %s is already occupied; retaining the existing listener\n' \
            "$RDP_ACCESS_HTTP_PORT" >&2
        return 0
    fi
    stop_ipv6_forward
    umask 077
    mkdir -p "$runtime_dir"
    : >"$ipv6_log_file"
    chmod 600 "$ipv6_log_file"
    setsid nohup socat \
        "TCP6-LISTEN:$RDP_ACCESS_HTTP_PORT,ipv6only=1,reuseaddr,fork,bind=$RDP_ACCESS_IPV6_BIND" \
        "TCP4:127.0.0.1:$RDP_ACCESS_HTTP_PORT" \
        </dev/null >>"$ipv6_log_file" 2>&1 &
    ipv6_pid=$!
    printf '%s\n' "$ipv6_pid" >"$ipv6_pid_file"
    chmod 600 "$ipv6_pid_file"
    attempts=0
    while [ "$attempts" -lt 10 ]; do
        if ipv6_process_matches "$ipv6_pid" && ipv6_listener_active; then
            return 0
        fi
        attempts=$((attempts + 1))
        sleep 1
    done
    stop_ipv6_forward
    if [ "$RDP_ACCESS_IPV6_FORWARD" = always ]; then
        fail "could not start IPv6 forwarding listener; inspect $ipv6_log_file"
    fi
    printf 'rdp-access: warning: IPv6 forwarding could not start (IPv4 remains active); inspect %s\n' \
        "$ipv6_log_file" >&2
}

ipv6_forward_status() {
    if [ -f "$ipv6_pid_file" ] && ipv6_process_matches "$(cat "$ipv6_pid_file" 2>/dev/null || true)" && ipv6_listener_active; then
        printf 'ipv6_forward=active\n'
        printf 'ipv6_forward_supervisor=helper\n'
    elif ipv6_listener_active; then
        printf 'ipv6_forward=active_external\n'
        printf 'ipv6_forward_supervisor=external\n'
    elif [ "$RDP_ACCESS_BIND" != 0.0.0.0 ] || [ "$RDP_ACCESS_IPV6_FORWARD" = never ]; then
        printf 'ipv6_forward=disabled\n'
    else
        printf 'ipv6_forward=inactive\n'
    fi
}

gateway_for_network() {
    docker network inspect --format '{{(index .IPAM.Config 0).Gateway}}' "${project}_default"
}

assert_fixture_running() {
    fixture_status=$("$repo_root/scripts/windows/test-host" status) || fail "fixture identity check failed"
    printf '%s\n' "$fixture_status"
    case $fixture_status in *'state=running') ;; *) fail "VM is not running; prepare it first with scripts/windows/test-host prepare --run-id interactive-rdp" ;; esac
}

password_hash_from_terminal() {
    password=
    restore_tty=false
    if [ "$password_stdin" = true ]; then
        # Password files commonly omit a final newline. POSIX read returns
        # non-zero at that EOF even after assigning the final nonempty line.
        IFS= read -r password || [ -n "$password" ] || fail "could not read password from stdin"
    else
        [ -t 0 ] || fail "stdin is not a terminal; use --web-password-stdin"
        printf 'Fixture password for %s: ' "$RDP_ACCESS_WEB_USER" >&2
        stty -echo
        restore_tty=true
        trap 'test "$restore_tty" = true && stty echo || true' EXIT HUP INT TERM
        IFS= read -r password || fail "could not read password"
        stty echo
        restore_tty=false
        trap - EXIT HUP INT TERM
        printf '\n' >&2
    fi
    [ -n "$password" ] || fail "password must not be empty"
    hash=$(printf '%s' "$password" | docker run --rm -i --entrypoint md5sum alpine:3.20 | awk '{print $1}')
    unset password
    case $hash in ''|*[!0-9a-f]*) fail "could not generate password hash" ;; esac
    [ "${#hash}" -eq 32 ] || fail "could not generate password hash"
    printf '%s\n' "$hash"
}

render_mapping() {
    umask 077
    mkdir -p "$runtime_dir/config" "$runtime_dir/tls"
    chmod 700 "$runtime_dir" "$runtime_dir/config" "$runtime_dir/tls"
    if [ "$reset_auth" = true ]; then rm -f "$runtime_dir/config/user-mapping.xml"; fi
    if [ -s "$runtime_dir/config/user-mapping.xml" ]; then
        password_hash=$(sed -n 's/.*password="\([0-9a-f][0-9a-f]*\)".*/\1/p' "$runtime_dir/config/user-mapping.xml" | head -n 1)
        case $password_hash in ''|*[!0-9a-f]*) password_hash=$(password_hash_from_terminal) ;; esac
        [ "${#password_hash}" -eq 32 ] || password_hash=$(password_hash_from_terminal)
    else
        password_hash=$(password_hash_from_terminal)
    fi
    sed \
        -e "s/@WEB_USER@/$RDP_ACCESS_WEB_USER/g" \
        -e "s/@WEB_PASSWORD_MD5@/$password_hash/g" \
        -e "s/@DOCKER_GATEWAY@/$docker_gateway/g" \
        -e "s/@TUNNEL_PORT@/$RDP_ACCESS_TUNNEL_PORT/g" \
        -e "s/@RDP_USER@/$RDP_ACCESS_RDP_USER/g" \
        "$mapping_template" >"$runtime_dir/config/user-mapping.xml"
    chmod 600 "$runtime_dir/config/user-mapping.xml"
    if [ -f "$cert_name_file" ] && [ "$(cat "$cert_name_file")" != "$RDP_ACCESS_PUBLIC_HOST" ]; then
        rm -f "$runtime_dir/tls/cert.pem" "$runtime_dir/tls/key.pem"
    fi
    printf '%s\n' "$RDP_ACCESS_PUBLIC_HOST" >"$cert_name_file"
    chmod 600 "$cert_name_file"
    printf 'url=https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT" >"$session_file"
    printf 'docker_gateway=%s\n' "$docker_gateway" >>"$session_file"
    printf 'tunnel_port=%s\n' "$RDP_ACCESS_TUNNEL_PORT" >>"$session_file"
    printf 'bind=%s\n' "$RDP_ACCESS_BIND" >>"$session_file"
    printf 'http_port=%s\n' "$RDP_ACCESS_HTTP_PORT" >>"$session_file"
    printf 'public_host=%s\n' "$RDP_ACCESS_PUBLIC_HOST" >>"$session_file"
    printf 'web_user=%s\n' "$RDP_ACCESS_WEB_USER" >>"$session_file"
    printf 'rdp_user=%s\n' "$RDP_ACCESS_RDP_USER" >>"$session_file"
    chmod 600 "$session_file"
}

session_has() {
    grep -Fqx "$1=$2" "$session_file"
}

session_matches_current() {
    [ -f "$session_file" ] || return 1
    session_has url "https://$RDP_ACCESS_PUBLIC_HOST:$RDP_ACCESS_HTTP_PORT/guacamole/" || return 1
    session_has docker_gateway "$docker_gateway" || return 1
    session_has tunnel_port "$RDP_ACCESS_TUNNEL_PORT" || return 1
    session_has bind "$RDP_ACCESS_BIND" || return 1
    session_has http_port "$RDP_ACCESS_HTTP_PORT" || return 1
    session_has public_host "$RDP_ACCESS_PUBLIC_HOST" || return 1
    session_has web_user "$RDP_ACCESS_WEB_USER" || return 1
    session_has rdp_user "$RDP_ACCESS_RDP_USER"
}

start_tunnel() {
    stop_tunnel
    umask 077
    : >"$tunnel_log_file"
    chmod 600 "$tunnel_log_file"

    # Keep the SSH master outside the short-lived controller process. Helium
    # may drop an idle or metered route; the watchdog reconnects it and keeps
    # the same local listener for Guacamole. The stop marker is deliberately
    # inside .runtime so down/clean can terminate the loop deterministically.
    watchdog_script='
        set -eu
        stop_file=$1
        socket=$2
        host=$3
        bind=$4
        tunnel_port=$5
        vrde_host=$6
        vrde_port=$7
        while [ ! -e "$stop_file" ]; do
            if [ -S "$socket" ] && ssh -S "$socket" -O check "$host" >/dev/null 2>&1; then
                sleep 2
                continue
            fi
            rm -f "$socket"
            ssh -M -S "$socket" -fN \
                -o BatchMode=yes \
                -o ExitOnForwardFailure=yes \
                -o ServerAliveInterval=30 \
                -o ServerAliveCountMax=3 \
                -L "$bind:$tunnel_port:$vrde_host:$vrde_port" \
                "$host" || true
            sleep 2
        done
    '
    if command -v setsid >/dev/null 2>&1; then
        setsid nohup sh -c "$watchdog_script" rdp-tunnel-watchdog \
            "$tunnel_stop_file" "$socket_path" "$RVBOX_TEST_VBOX_HOST" \
            "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" "$RDP_ACCESS_VRDE_HOST" \
            "$RDP_ACCESS_VRDE_PORT" </dev/null >>"$tunnel_log_file" 2>&1 &
    else
        nohup sh -c "$watchdog_script" rdp-tunnel-watchdog \
            "$tunnel_stop_file" "$socket_path" "$RVBOX_TEST_VBOX_HOST" \
            "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" "$RDP_ACCESS_VRDE_HOST" \
            "$RDP_ACCESS_VRDE_PORT" </dev/null >>"$tunnel_log_file" 2>&1 &
    fi
    tunnel_watchdog_pid=$!
    printf '%s\n' "$tunnel_watchdog_pid" >"$tunnel_pid_file"
    chmod 600 "$tunnel_pid_file"

    # A listener is useful only after the SSH master has authenticated and
    # bound the Docker-network gateway. Give the reconnect loop a bounded
    # window, then fail with a clear recovery path.
    attempts=0
    while [ "$attempts" -lt 20 ]; do
        if [ -S "$socket_path" ] && ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1; then
            return 0
        fi
        if command -v nc >/dev/null 2>&1 && nc -z -w 2 "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" >/dev/null 2>&1; then
            return 0
        fi
        attempts=$((attempts + 1))
        sleep 1
    done
    stop_tunnel
    return 1
}

tunnel_listener_active() {
    [ -n "${docker_gateway-}" ] || return 1
    command -v nc >/dev/null 2>&1 || return 1
    nc -z -w 2 "$docker_gateway" "$RDP_ACCESS_TUNNEL_PORT" >/dev/null 2>&1
}

tunnel_control_active() {
    [ -S "$socket_path" ] || return 1
    ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1
}

tunnel_active() {
    tunnel_control_active || tunnel_listener_active
}

action=${1-}
[ -n "$action" ] || { usage >&2; exit 2; }
shift || true
case $action in --help|-h) usage; exit 0 ;; esac

reset_auth=false
password_stdin=false
remove_images=false
while [ "$#" -gt 0 ]; do
    case $1 in
        --bind) [ "$#" -ge 2 ] || fail "--bind needs a value"; RDP_ACCESS_BIND=$2; shift 2 ;;
        --http-port) [ "$#" -ge 2 ] || fail "--http-port needs a value"; RDP_ACCESS_HTTP_PORT=$2; shift 2 ;;
        --tunnel-port) [ "$#" -ge 2 ] || fail "--tunnel-port needs a value"; RDP_ACCESS_TUNNEL_PORT=$2; shift 2 ;;
        --public-host) [ "$#" -ge 2 ] || fail "--public-host needs a value"; RDP_ACCESS_PUBLIC_HOST=$2; shift 2 ;;
        --web-user) [ "$#" -ge 2 ] || fail "--web-user needs a value"; RDP_ACCESS_WEB_USER=$2; RDP_ACCESS_RDP_USER=$2; shift 2 ;;
        --reset-auth) reset_auth=true; shift ;;
        --web-password-stdin) password_stdin=true; shift ;;
        --images) remove_images=true; shift ;;
        --help|-h) usage; exit 0 ;;
        *) break ;;
    esac
done

safe_bind "$RDP_ACCESS_BIND"
safe_port RDP_ACCESS_HTTP_PORT "$RDP_ACCESS_HTTP_PORT"
safe_port RDP_ACCESS_TUNNEL_PORT "$RDP_ACCESS_TUNNEL_PORT"
[ "$RDP_ACCESS_HTTP_PORT" != "$RDP_ACCESS_TUNNEL_PORT" ] || fail "HTTPS and tunnel ports must differ"
safe_name RDP_ACCESS_PUBLIC_HOST "$RDP_ACCESS_PUBLIC_HOST"
safe_name RDP_ACCESS_WEB_USER "$RDP_ACCESS_WEB_USER"
safe_name RDP_ACCESS_RDP_USER "$RDP_ACCESS_RDP_USER"
safe_name RVBOX_TEST_VBOX_HOST "$RVBOX_TEST_VBOX_HOST"
case $RDP_ACCESS_VRDE_HOST in 127.0.0.1|localhost) ;; *) fail "RDP_ACCESS_VRDE_HOST must be 127.0.0.1 or localhost" ;; esac
safe_port RDP_ACCESS_VRDE_PORT "$RDP_ACCESS_VRDE_PORT"
case $RDP_ACCESS_IPV6_BIND in ::|::1) ;; *) fail "RDP_ACCESS_IPV6_BIND must be :: or ::1" ;; esac
case $RDP_ACCESS_IPV6_FORWARD in auto|always|never) ;; *) fail "RDP_ACCESS_IPV6_FORWARD must be auto, always, or never" ;; esac

case $RDP_ACCESS_PUBLIC_HOST in
    *[!0-9.]* ) cert_san="DNS:$RDP_ACCESS_PUBLIC_HOST" ;;
    * ) cert_san="IP:$RDP_ACCESS_PUBLIC_HOST" ;;
esac

[ "$remove_images" = false ] || [ "$action" = clean ] || fail "--images is valid only with clean"
[ "$reset_auth" = false ] || [ "$action" = up ] || fail "--reset-auth is valid only with up"
[ "$password_stdin" = false ] || [ "$action" = up ] || fail "--web-password-stdin is valid only with up"

case $action in
    up)
        [ "$#" -eq 0 ] || { usage >&2; fail "unknown up option $1"; }
        if [ "$RDP_ACCESS_BIND" = 0.0.0.0 ] && [ "$RDP_ACCESS_PUBLIC_HOST" = localhost ]; then
            fail "a public bind requires --public-host with the browser-visible hostname or IP"
        fi
        docker version >/dev/null
        docker compose version >/dev/null
        assert_fixture_running
        if compose ps -q | grep -q .; then
            # A short-lived controller (or a dropped SSH route) can leave the
            # Compose stack running after its tunnel has disappeared. Reuse
            # that stack and repair only the private forwarding path instead
            # of forcing the operator to tear down a usable Guacamole session.
            mkdir -p "$runtime_dir"
            docker_gateway=$(gateway_for_network) || fail "could not determine private Docker gateway"
            session_matches_current || fail "gateway already exists with a different configuration; run down first, then run up with the desired options"
            if tunnel_active; then
                printf 'Guacamole stack and private VRDE tunnel are already active.\n'
            elif start_tunnel; then
                printf 'Existing Guacamole stack reused; private VRDE tunnel restored.\n'
            else
                fail "existing Guacamole stack was found but the private VRDE tunnel could not be restored; inspect $tunnel_log_file"
            fi
            wait_for_gateway || fail "Guacamole stack is running but its HTTPS endpoint did not become ready; inspect Compose logs"
            start_ipv6_forward
            if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
            exit 0
        fi
        mkdir -p "$runtime_dir"
        compose up -d guacd
        docker_gateway=$(gateway_for_network) || { compose down --remove-orphans; fail "could not determine private Docker gateway"; }
        render_mapping
        if ! start_tunnel; then
            compose down --remove-orphans
            fail "could not create private SSH tunnel"
        fi
        if ! compose run --rm certgen; then
            stop_tunnel
            compose down --remove-orphans
            fail "could not generate self-signed certificate"
        fi
        if ! compose up -d guacamole gateway; then
            stop_tunnel
            compose down --remove-orphans
            fail "could not start Guacamole gateway"
        fi
        if ! wait_for_gateway; then
            fail "Guacamole gateway started but its HTTPS endpoint did not become ready; inspect Compose logs"
        fi
        start_ipv6_forward
        printf 'Guacamole is ready at https://%s:%s/guacamole/\n' "$RDP_ACCESS_PUBLIC_HOST" "$RDP_ACCESS_HTTP_PORT"
        printf 'Accept the self-signed certificate warning, then sign in as %s with the fixture password.\n' "$RDP_ACCESS_WEB_USER"
        ;;
    status)
        [ "$#" -eq 0 ] || { usage >&2; fail "status accepts no options"; }
        "$repo_root/scripts/windows/test-host" status || true
        if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
        compose ps
        docker_gateway=$(gateway_for_network 2>/dev/null || true)
        if tunnel_control_active; then
            printf 'private_tunnel=active\n'
            printf 'private_tunnel_supervisor=helper\n'
        elif tunnel_listener_active; then
            # A tunnel started by an interactive shell or another supervisor
            # has no control socket owned by this helper, but it is still a
            # valid path when the Docker listener is reachable.
            printf 'private_tunnel=active_external\n'
            printf 'private_tunnel_supervisor=external\n'
        else
            printf 'private_tunnel=inactive\n'
        fi
        ipv6_forward_status
        ;;
    repair)
        [ "$#" -eq 0 ] || { usage >&2; fail "repair accepts no options"; }
        docker version >/dev/null
        docker compose version >/dev/null
        assert_fixture_running
        [ -n "$(compose ps -q guacd 2>/dev/null || true)" ] || fail "Guacamole stack is not running; run up first"
        compose restart guacd >/dev/null
        wait_for_gateway || fail "Guacamole gateway did not become ready after guacd repair"
        printf 'guacd restarted; stale VRDE workers released. Close any old browser tab and sign in again.\n'
        ;;
    url)
        [ "$#" -eq 0 ] || { usage >&2; fail "url accepts no options"; }
        [ -f "$session_file" ] || fail "no saved gateway session; run up first"
        sed -n '1s/^url=//p' "$session_file"
        ;;
    logs)
        compose logs "$@"
        ;;
    down)
        [ "$#" -eq 0 ] || { usage >&2; fail "down accepts no options"; }
        stop_ipv6_forward
        stop_tunnel
        compose down --remove-orphans || true
        printf 'Temporary gateway and private tunnel stopped; generated certificate and password hash retained in %s.\n' "$runtime_dir"
        ;;
    clean)
        [ "$#" -eq 0 ] || { usage >&2; fail "clean accepts only --images"; }
        stop_ipv6_forward
        stop_tunnel
        compose down --remove-orphans || true
        case $runtime_dir in "$helper_dir"/.runtime) rm -rf "$runtime_dir" ;; *) fail "unsafe runtime path" ;; esac
        if [ "$remove_images" = true ]; then
            docker image rm guacamole/guacamole:1.6.0 guacamole/guacd:1.6.0 nginx:1.27-alpine >/dev/null 2>&1 || true
        fi
        printf 'Temporary gateway state removed.\n'
        ;;
    *) usage >&2; fail "unknown action $action" ;;
esac
