#!/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}"
: "${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
  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.
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_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

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"
}

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_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"
            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
        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
        ;;
    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_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_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
