feat: isolate simultaneous RDP fixture profiles

This commit is contained in:
2026-09-14 07:16:39 +00:00
parent 9a2b0fd3c1
commit 620f882fe2
6 changed files with 97 additions and 14 deletions
+6
View File
@@ -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 --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 `ipv6_forward` status; this prevents a browser selecting IPv6 for the WebSocket
from silently taking a different, refused path. 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 For this provisioned lane, the approved host-only credential-file location is
`/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must `/home/cabbage/.local/share/rvbox-secrets/rvbox-win10-test.password`. It must
+4
View File
@@ -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 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 when the browser hostname has an AAAA record; check `ipv6_forward=active` in
`rdp-access status` before diagnosing a browser-side “Waiting for response”. `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 ## Snapshots and reset contract
+3 -1
View File
@@ -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. 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 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 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 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, uses a disposable self-signed key only under the ignored `.test-runs` tree,
+31
View File
@@ -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 `rvboxtest`, the browser listener (`127.0.0.1:5002`), and the private tunnel
port (`54001`). They can be overridden without editing tracked files through port (`54001`). They can be overridden without editing tracked files through
`RVBOX_TEST_VBOX_HOST`, `RDP_ACCESS_VRDE_HOST`, `RDP_ACCESS_VRDE_PORT`, `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_WEB_USER`, `RDP_ACCESS_RDP_USER`, `RDP_ACCESS_BIND`,
`RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and `RDP_ACCESS_HTTP_PORT`, `RDP_ACCESS_TUNNEL_PORT`, and
`RDP_ACCESS_PUBLIC_HOST`, `RDP_ACCESS_IPV6_BIND`, 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 Set `RDP_ACCESS_GUACD_LOG_LEVEL=debug` temporarily when collecting detailed
guacd/RDP negotiation diagnostics; the default is `info`. 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/<profile>/` and uses the
Compose project `rvbox-rdp-access-<profile>`. 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 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 `[::]:$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 matters when the public hostname has an AAAA record: some browsers choose IPv6
+41 -13
View File
@@ -5,11 +5,11 @@ set -eu
helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) helper_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd) repo_root=$(CDPATH= cd -- "$helper_dir/../.." && pwd)
runtime_dir=$helper_dir/.runtime runtime_root=$helper_dir/.runtime
compose_file=$helper_dir/compose.yaml compose_file=$helper_dir/compose.yaml
mapping_template=$helper_dir/user-mapping.xml.in 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_BIND:=127.0.0.1}"
: "${RDP_ACCESS_HTTP_PORT:=5002}" : "${RDP_ACCESS_HTTP_PORT:=5002}"
: "${RDP_ACCESS_TUNNEL_PORT:=54001}" : "${RDP_ACCESS_TUNNEL_PORT:=54001}"
@@ -38,6 +38,8 @@ Actions:
the exact unused Guacamole/nginx images the exact unused Guacamole/nginx images
up options: 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 --bind ADDRESS listener address (default 127.0.0.1; use 0.0.0.0 only
for a temporary, deliberately public endpoint) for a temporary, deliberately public endpoint)
--http-port PORT HTTPS listener port (default 5002) --http-port PORT HTTPS listener port (default 5002)
@@ -47,7 +49,7 @@ up options:
--reset-auth discard the saved password hash and prompt again --reset-auth discard the saved password hash and prompt again
--web-password-stdin read the password once from stdin instead of prompting --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_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_RDP_USER, RVBOX_TEST_VBOX_HOST, RDP_ACCESS_VRDE_HOST, and
RDP_ACCESS_VRDE_PORT. In public mode, RDP_ACCESS_IPV6_BIND 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 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() { safe_port() {
case $2 in ''|*[!0-9]*) fail "$1 must be a port number" ;; esac 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" [ "$2" -ge 1024 ] && [ "$2" -le 65535 ] || fail "$1 must be between 1024 and 65535"
@@ -102,15 +114,6 @@ wait_for_gateway() {
return 1 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() { stop_tunnel() {
mkdir -p "$runtime_dir" mkdir -p "$runtime_dir"
: >"$tunnel_stop_file" : >"$tunnel_stop_file"
@@ -429,6 +432,7 @@ password_stdin=false
remove_images=false remove_images=false
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case $1 in 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 ;; --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 ;; --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 ;; --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 esac
done 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_bind "$RDP_ACCESS_BIND"
safe_port RDP_ACCESS_HTTP_PORT "$RDP_ACCESS_HTTP_PORT" safe_port RDP_ACCESS_HTTP_PORT "$RDP_ACCESS_HTTP_PORT"
safe_port RDP_ACCESS_TUNNEL_PORT "$RDP_ACCESS_TUNNEL_PORT" safe_port RDP_ACCESS_TUNNEL_PORT "$RDP_ACCESS_TUNNEL_PORT"
@@ -520,6 +544,7 @@ case $action in
;; ;;
status) status)
[ "$#" -eq 0 ] || { usage >&2; fail "status accepts no options"; } [ "$#" -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 "$repo_root/scripts/windows/test-host" status || true
if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi if [ -f "$session_file" ]; then sed -n '1p' "$session_file"; fi
compose ps compose ps
@@ -568,7 +593,10 @@ case $action in
stop_ipv6_forward stop_ipv6_forward
stop_tunnel stop_tunnel
compose down --remove-orphans || true 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 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 docker image rm guacamole/guacamole:1.6.0 guacamole/guacd:1.6.0 nginx:1.27-alpine >/dev/null 2>&1 || true
fi fi
+12
View File
@@ -49,6 +49,18 @@ func TestNativeFixtureAssets_HP_HARNESS_20(t *testing.T) {
t.Fatalf("native test-host is missing compressed transfer contract %q", required) 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") 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]"} { 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) { if !strings.Contains(compose, required) {