Files
rvbox/test/rdp-access/rdp-access
T

281 lines
12 KiB
Bash
Executable File

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