diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index 0821097..2f8ac5b 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -893,6 +893,14 @@ watchdog/tunnel is bound only to the helper's private Docker gateway. Stop the helper before the normal `test-host reset`. It is a recovery interface, not a product component or a replacement for Guest Control/native test automation. +Because Windows can power off the virtual monitor while the VM remains +running, `test-host prepare` must also reapply the disposable display/sleep +keepalive and record `prepare-display-keepalive`. A zero-bpp VRDE framebuffer +otherwise leaves an otherwise authenticated Guacamole browser at “Waiting for +response”. If the public browser hostname has an AAAA record, `rdp-access up +--bind 0.0.0.0` must own a tracked IPv6-to-IPv4 forward and expose its +`ipv6_forward` status; this prevents a browser selecting IPv6 for the WebSocket +from silently taking a different, refused path. For this provisioned lane, the approved host-only credential-file location is `/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must diff --git a/docs/testing-vm.md b/docs/testing-vm.md index 6c54079..ca9e197 100644 --- a/docs/testing-vm.md +++ b/docs/testing-vm.md @@ -91,6 +91,9 @@ self-signed HTTPS Guacamole gateway while retaining VRDE on Helium loopback and the reconnecting SSH watchdog/tunnel on a private Docker gateway. Follow its full lease/prepare/up/ down/reset lifecycle; it is not an alternative to the native test controller. +In public-bind mode the helper also owns a tracked IPv6-to-IPv4 `socat` forward +when the browser hostname has an AAAA record; check `ipv6_forward=active` in +`rdp-access status` before diagnosing a browser-side “Waiting for response”. ## Snapshots and reset contract @@ -112,10 +115,14 @@ stateful run must: 1. Acquire the run lease and verify the VM name, UUID, and snapshot UUID. 2. Restore `baseline-clean-administrator` if the current state is not the baseline. 3. Start headless and wait for `VMState=running` plus Guest Additions readiness. -4. Run the bounded test, collect redacted artifacts, and close every Guest +4. Apply the disposable display/sleep keepalive (`monitor-timeout`, standby, + and hibernate timers set to zero) so VirtualBox VRDE cannot expose a + zero-bpp framebuffer after Windows idle timeout. This is recorded as + `prepare-display-keepalive` and is reapplied after every snapshot restore. +5. Run the bounded test, collect redacted artifacts, and close every Guest Control process that was opened by the run. -5. Request a graceful guest shutdown and wait for `VMState=poweroff`. -6. Restore `baseline-clean-administrator` again and leave the VM powered off. +6. Request a graceful guest shutdown and wait for `VMState=poweroff`. +7. Restore `baseline-clean-administrator` again and leave the VM powered off. Use `controlvm ... poweroff` only for a hung, disposable test; it can lose guest state. Never delete any clean snapshot, unregister the VM, or alter diff --git a/docs/testing.md b/docs/testing.md index ad44ef4..8e7f2c1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -208,6 +208,9 @@ Interactive browser access is a deliberately temporary recovery path only. See [`test/rdp-access`](../test/rdp-access/README.md) for the Docker-only, self-signed HTTPS Guacamole lifecycle; it must be started only after the native fixture controller has prepared and leased the VM, and stopped before reset. +For a public hostname with an AAAA record, its `up --bind 0.0.0.0` mode also +tracks an IPv6-to-IPv4 forward; verify `ipv6_forward=active` before debugging +a browser stuck at “Waiting for response”. The Linux production Compose asset has a separate, loopback-only smoke lane. It uses a disposable self-signed key only under the ignored `.test-runs` tree, diff --git a/scripts/windows/test-host b/scripts/windows/test-host index 3ae1095..48ed497 100755 --- a/scripts/windows/test-host +++ b/scripts/windows/test-host @@ -316,6 +316,18 @@ wait_guest_additions() { fail "Guest Additions did not publish version and Windows OS-release properties" } +configure_display_keepalive() { + # VirtualBox VRDE reports a zero-bpp framebuffer when Windows powers off + # the virtual monitor. That leaves an authenticated Guacamole tunnel in + # its "Waiting for response" state even though RDP negotiation succeeded. + # Keep the disposable diagnostic fixture awake; this is deliberately + # applied after every snapshot restore because power-plan state belongs to + # the guest snapshot, not to the controller. + provisioner_run --exe 'C:\Windows\System32\cmd.exe' --wait-stdout --wait-stderr --unquoted-args -- \ + /d /s /c '(powercfg /change monitor-timeout-ac 0 && powercfg /change monitor-timeout-dc 0 && powercfg /change standby-timeout-ac 0 && powercfg /change standby-timeout-dc 0 && powercfg /change hibernate-timeout-ac 0 && powercfg /change hibernate-timeout-dc 0 && echo RVBOX_GUEST_OK)' >/dev/null || \ + fail "fixture provisioner could not disable disposable display/sleep timers" +} + wait_service() { wanted=$1 attempt=0 @@ -366,6 +378,8 @@ case "$action" in step prepare-vm-started wait_guest_additions step prepare-guest-additions-ready + configure_display_keepalive + step prepare-display-keepalive assert_clean_guest step prepare-clean-baseline-verified ;; diff --git a/test/rdp-access/README.md b/test/rdp-access/README.md index 8c1594f..36c3dd7 100644 --- a/test/rdp-access/README.md +++ b/test/rdp-access/README.md @@ -9,6 +9,7 @@ The helper builds this private path: ```text browser -- HTTPS/self-signed --> nginx + Guacamole containers + (IPv4 and, in public mode, a tracked IPv6 forward) | private Docker gateway | @@ -69,9 +70,23 @@ port (`54001`). They can be overridden without editing tracked files through `RVBOX_TEST_VBOX_HOST`, `RDP_ACCESS_VRDE_HOST`, `RDP_ACCESS_VRDE_PORT`, `RDP_ACCESS_WEB_USER`, `RDP_ACCESS_RDP_USER`, `RDP_ACCESS_BIND`, `RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and -`RDP_ACCESS_PUBLIC_HOST`. The helper deliberately limits the VRDE host to +`RDP_ACCESS_PUBLIC_HOST`, `RDP_ACCESS_IPV6_BIND`, and +`RDP_ACCESS_IPV6_FORWARD`. The helper deliberately limits the VRDE host to Helium loopback (`127.0.0.1` or `localhost`) so an override cannot accidentally turn the diagnostic server into a remote target. +Set `RDP_ACCESS_GUACD_LOG_LEVEL=debug` temporarily when collecting detailed +guacd/RDP negotiation diagnostics; the default is `info`. + +When `--bind 0.0.0.0` is used, `up` also starts a tracked `socat` listener on +`[::]:$RDP_ACCESS_HTTP_PORT` and forwards it to the IPv4 gateway listener. This +matters when the public hostname has an AAAA record: some browsers choose IPv6 +for the WebSocket even if the initial page used IPv4. The listener is recorded +under `.runtime/ipv6-forward.pid` and is removed by `down` and `clean`. Its +default bind is `::` (`RDP_ACCESS_IPV6_BIND`); set +`RDP_ACCESS_IPV6_FORWARD=never` to deliberately use IPv4 only, `always` to make +IPv6 startup a hard requirement, or leave the default `auto` for a warning and +an IPv4-only fallback when `socat` is unavailable. `status` reports +`ipv6_forward=active`, `active_external`, `inactive`, or `disabled`. If `up` is run again while the Compose services are still running, it is idempotent: an existing healthy tunnel is reused, and a missing tunnel is @@ -123,7 +138,9 @@ test/rdp-access/rdp-access url If the browser reaches Guacamole but stays on “Waiting for response”, run `status` first. Confirm `private_tunnel=active` (or -`active_external`), then check the VM state and the last lines of +`active_external`) and, for a public dual-stack hostname, +`ipv6_forward=active` (or a known-good external forward). Then check the VM +state and the last lines of `.runtime/tunnel.log`. A tunnel can be recreated without losing the Compose stack by running `up` again. This helper already uses `security=rdp` and disables Guacamole's GFX extension, which are required by the fixture's legacy @@ -132,9 +149,11 @@ VRDE server. Do not switch the helper to native Windows RDP: unusable, stop this helper and use Guest Control for the deterministic portion of the work; record the blocked interactive step in the native test report. -The helper requires Docker/Docker Compose, SSH access through the existing -`helium-remote` alias, and the fixture password file documented in +The helper requires Docker/Docker Compose, `socat` for the optional public +dual-stack forward, SSH access through the existing `helium-remote` alias, and +the fixture password file documented in [`docs/testing-vm.md`](../../docs/testing-vm.md). It does not install host -packages, write credentials into Git, or alter VM settings. The tracked files -are Docker-only configuration and the controller script; all generated content -is ignored beneath `.runtime/`. +packages, write credentials into Git, or alter VM identity/settings. The +authoritative `test-host prepare` step applies only the disposable +display/sleep keepalive needed by VirtualBox VRDE; all generated content is +ignored beneath `.runtime/`. diff --git a/test/rdp-access/compose.yaml b/test/rdp-access/compose.yaml index c7f19a1..b2dbc27 100644 --- a/test/rdp-access/compose.yaml +++ b/test/rdp-access/compose.yaml @@ -1,6 +1,8 @@ services: guacd: image: guacamole/guacd:1.6.0 + environment: + LOG_LEVEL: ${RDP_ACCESS_GUACD_LOG_LEVEL:-info} guacamole: image: guacamole/guacamole:1.6.0 diff --git a/test/rdp-access/nginx.conf b/test/rdp-access/nginx.conf index 16f0fc2..2896ea5 100644 --- a/test/rdp-access/nginx.conf +++ b/test/rdp-access/nginx.conf @@ -15,5 +15,8 @@ server { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_buffering off; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + proxy_set_header X-Accel-Buffering no; } } diff --git a/test/rdp-access/rdp-access b/test/rdp-access/rdp-access index 3b5b634..56a5296 100755 --- a/test/rdp-access/rdp-access +++ b/test/rdp-access/rdp-access @@ -16,6 +16,9 @@ project=rvbox-rdp-access : "${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}" @@ -46,7 +49,10 @@ up options: 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. +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 } @@ -69,6 +75,7 @@ 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) \ @@ -100,6 +107,8 @@ 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" @@ -135,6 +144,102 @@ stop_tunnel() { 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 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. + 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" \ + >"$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" } @@ -340,6 +445,8 @@ 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" ;; @@ -375,6 +482,7 @@ case $action in 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 @@ -399,6 +507,7 @@ case $action in 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" ;; @@ -420,6 +529,7 @@ case $action in else printf 'private_tunnel=inactive\n' fi + ipv6_forward_status ;; url) [ "$#" -eq 0 ] || { usage >&2; fail "url accepts no options"; } @@ -431,12 +541,14 @@ case $action in ;; 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 diff --git a/test/rdp-access/user-mapping.xml.in b/test/rdp-access/user-mapping.xml.in index 065ee97..060848d 100644 --- a/test/rdp-access/user-mapping.xml.in +++ b/test/rdp-access/user-mapping.xml.in @@ -15,6 +15,7 @@ false false false + 24 true