281 lines
12 KiB
Bash
Executable File
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
|