From 620f882fe2d9de9f59daa183e93a03226fd322e6 Mon Sep 17 00:00:00 2001 From: cabbage Date: Mon, 14 Sep 2026 07:16:39 +0000 Subject: [PATCH] feat: isolate simultaneous RDP fixture profiles --- docs/implementation-plan.v1.md | 6 +++ docs/testing-vm.md | 4 ++ docs/testing.md | 4 +- test/rdp-access/README.md | 31 +++++++++++++ test/rdp-access/rdp-access | 54 +++++++++++++++++------ test/windowsnative/native_fixture_test.go | 12 +++++ 6 files changed, 97 insertions(+), 14 deletions(-) diff --git a/docs/implementation-plan.v1.md b/docs/implementation-plan.v1.md index 0356290..558c7d9 100644 --- a/docs/implementation-plan.v1.md +++ b/docs/implementation-plan.v1.md @@ -904,6 +904,12 @@ 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. +The helper preserves the single-fixture `default` profile for compatibility; +simultaneous already-running VM gateways use a unique `RDP_ACCESS_PROFILE` (or +`--profile`) with separate runtime/project state and HTTP/tunnel ports, while +`RVBOX_TEST_VBOX_HOST`, the documented VM/snapshot identity variables, and +`RDP_ACCESS_VRDE_PORT` select the actual target. Profile isolation does not +override the native controller's exclusive lease for prepare/reset operations. 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 ca9e197..eed7005 100644 --- a/docs/testing-vm.md +++ b/docs/testing-vm.md @@ -94,6 +94,10 @@ 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”. +When more than one already-running VM needs browser access, set matching +`RVBOX_TEST_VBOX_*`/`RDP_ACCESS_VRDE_PORT` values and a unique +`RDP_ACCESS_PROFILE` plus HTTP/tunnel ports; the helper README has the +copy/paste example and explains the native-host lease boundary. ## Snapshots and reset contract diff --git a/docs/testing.md b/docs/testing.md index 8e7f2c1..5024f3a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -210,7 +210,9 @@ 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”. +a browser stuck at “Waiting for response”. If several already-running VMs need +browser access at once, use the documented `RDP_ACCESS_PROFILE` and matching +host/VRDE/HTTP/tunnel-port overrides in the helper README. 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/test/rdp-access/README.md b/test/rdp-access/README.md index d1d2b76..9ef52b6 100644 --- a/test/rdp-access/README.md +++ b/test/rdp-access/README.md @@ -125,6 +125,7 @@ All fixture-specific values have embedded, working defaults: the `rvboxtest`, the browser listener (`127.0.0.1:5002`), and the private tunnel 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_PROFILE`, `RDP_ACCESS_WEB_USER`, `RDP_ACCESS_RDP_USER`, `RDP_ACCESS_BIND`, `RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and `RDP_ACCESS_PUBLIC_HOST`, `RDP_ACCESS_IPV6_BIND`, and @@ -134,6 +135,36 @@ 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`. +The VM selector and the gateway profile are separate. Set the matching +`RVBOX_TEST_VBOX_*` identity variables (and `RDP_ACCESS_VRDE_PORT`) for the +VM you want; the complete identity set is listed in +[`docs/testing-vm.md`](../../docs/testing-vm.md). For two already-running VMs, +give the second gateway a distinct profile and listener ports, for example: + +```sh +RDP_ACCESS_PROFILE=vm-b \ +RVBOX_TEST_VBOX_HOST=helium-remote-b \ +RDP_ACCESS_VRDE_PORT=3391 \ +RDP_ACCESS_HTTP_PORT=5003 \ +RDP_ACCESS_TUNNEL_PORT=54002 \ +test/rdp-access/rdp-access up --bind 0.0.0.0 --public-host vm-b.example.net +``` + +Replace `helium-remote-b`, `3391`, and the public hostname with the second +fixture's recorded values, and export its matching VM/snapshot UUID variables +before running `up` or `status`. A non-`default` profile stores its verifier, +certificate, tunnel state, and logs under `.runtime//` and uses the +Compose project `rvbox-rdp-access-`. Keep the HTTP port, public +hostname, and profile unique so IPv4/IPv6 listeners and browser sessions do +not collide. Inspect, repair, stop, or clean that instance by repeating the +same `RDP_ACCESS_PROFILE` and endpoint variables on `status`, `repair`, +`down`, or `clean`. + +Profile isolation does not bypass the native controller's exclusive lease: +`test-host prepare`/`reset` must still be coordinated when VMs share one +fixture host and staging root. For VMs that are already running, the profile +and matching identity/VRDE variables are sufficient to select the endpoint. + 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 diff --git a/test/rdp-access/rdp-access b/test/rdp-access/rdp-access index c2bb475..42cb8e4 100755 --- a/test/rdp-access/rdp-access +++ b/test/rdp-access/rdp-access @@ -5,11 +5,11 @@ set -eu helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd) -runtime_dir=$helper_dir/.runtime +runtime_root=$helper_dir/.runtime compose_file=$helper_dir/compose.yaml mapping_template=$helper_dir/user-mapping.xml.in -project=rvbox-rdp-access +: "${RDP_ACCESS_PROFILE:=default}" : "${RDP_ACCESS_BIND:=127.0.0.1}" : "${RDP_ACCESS_HTTP_PORT:=5002}" : "${RDP_ACCESS_TUNNEL_PORT:=54001}" @@ -38,6 +38,8 @@ Actions: the exact unused Guacamole/nginx images up options: + --profile NAME isolated VM/gateway profile (default default; use one + distinct profile per simultaneous VM gateway) --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) @@ -47,7 +49,7 @@ up options: --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, +Environment equivalents: RDP_ACCESS_PROFILE, 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. In public mode, RDP_ACCESS_IPV6_BIND and @@ -63,6 +65,16 @@ safe_name() { case $2 in ''|*[!A-Za-z0-9.-]*) fail "$1 contains unsupported characters" ;; esac } +safe_profile() { + case $2 in + [a-z0-9]* ) ;; + * ) fail "$1 must start with a lowercase alphanumeric" ;; + esac + case $2 in + *[!a-z0-9-]*) fail "$1 must contain only lowercase letters, digits, and hyphens" ;; + 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" @@ -102,15 +114,6 @@ wait_for_gateway() { 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 -ipv6_pid_file=$runtime_dir/ipv6-forward.pid -ipv6_log_file=$runtime_dir/ipv6-forward.log - stop_tunnel() { mkdir -p "$runtime_dir" : >"$tunnel_stop_file" @@ -429,6 +432,7 @@ password_stdin=false remove_images=false while [ "$#" -gt 0 ]; do case $1 in + --profile) [ "$#" -ge 2 ] || fail "--profile needs a value"; RDP_ACCESS_PROFILE=$2; shift 2 ;; --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 ;; @@ -442,6 +446,26 @@ while [ "$#" -gt 0 ]; do esac done +safe_profile RDP_ACCESS_PROFILE "$RDP_ACCESS_PROFILE" +profile=$RDP_ACCESS_PROFILE +if [ "$profile" = default ]; then + runtime_dir=$runtime_root + project=rvbox-rdp-access +else + runtime_dir=$runtime_root/$profile + project=rvbox-rdp-access-$profile +fi +[ ! -L "$runtime_root" ] || fail "runtime root must not be a symlink" +[ ! -L "$runtime_dir" ] || fail "runtime directory must not be a symlink" +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 +ipv6_pid_file=$runtime_dir/ipv6-forward.pid +ipv6_log_file=$runtime_dir/ipv6-forward.log + 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" @@ -520,6 +544,7 @@ case $action in ;; status) [ "$#" -eq 0 ] || { usage >&2; fail "status accepts no options"; } + printf 'profile=%s project=%s runtime=%s\n' "$profile" "$project" "$runtime_dir" "$repo_root/scripts/windows/test-host" status || true if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi compose ps @@ -568,7 +593,10 @@ case $action in 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 + case "$runtime_dir" in + "$runtime_root"|"$runtime_root/$profile") 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 diff --git a/test/windowsnative/native_fixture_test.go b/test/windowsnative/native_fixture_test.go index f73072e..6fe35fc 100644 --- a/test/windowsnative/native_fixture_test.go +++ b/test/windowsnative/native_fixture_test.go @@ -49,6 +49,18 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) { t.Fatalf("native test-host is missing compressed transfer contract %q", required) } } + rdpAccess := read("test/rdp-access/rdp-access") + for _, required := range []string{"RDP_ACCESS_PROFILE", "--profile", "rvbox-rdp-access-$profile", "runtime_root", "ipv6_forward_status", "compose restart guacd", "compose down --remove-orphans"} { + if !strings.Contains(rdpAccess, required) { + t.Fatalf("RDP helper is missing isolated lifecycle contract %q", required) + } + } + rdpReadme := read("test/rdp-access/README.md") + for _, required := range []string{"Current fixture quick start", "RDP_ACCESS_PROFILE=vm-b", "Partial stack or changed endpoint", "Inspect, repair, stop, or clean that instance"} { + if !strings.Contains(rdpReadme, required) { + t.Fatalf("RDP helper documentation is missing %q", required) + } + } compose := read("test/linux-server/compose.yaml") for _, required := range []string{"../../bin/rvbox-server", "nginx:1.27-alpine", "rvbox.native.run_id", "RVBOX_NATIVE_RUNTIME_DIR", "certgen", "networks: [native]"} { if !strings.Contains(compose, required) {