#!/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 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" "$@" } socket_path=$runtime_dir/ssh-control.socket session_file=$runtime_dir/session.env cert_name_file=$runtime_dir/cert-name stop_tunnel() { if [ -S "$socket_path" ]; then ssh -S "$socket_path" -O exit "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || true fi rm -f "$socket_path" } 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 IFS= read -r 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" chmod 600 "$session_file" } start_tunnel() { stop_tunnel ssh -M -S "$socket_path" -fN \ -o BatchMode=yes \ -o ExitOnForwardFailure=yes \ -o ServerAliveInterval=30 \ -o ServerAliveCountMax=3 \ -L "$docker_gateway:$RDP_ACCESS_TUNNEL_PORT:$RDP_ACCESS_VRDE_HOST:$RDP_ACCESS_VRDE_PORT" \ "$RVBOX_TEST_VBOX_HOST" ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1 || fail "private VRDE tunnel did not start" } 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 fail "gateway already exists; use status or down first" 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 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 if [ -S "$socket_path" ] && ssh -S "$socket_path" -O check "$RVBOX_TEST_VBOX_HOST" >/dev/null 2>&1; then printf 'private_tunnel=active\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