20 KiB
Local Smoke Test
This smoke path is for proving the minimal end-to-end server-side session lifecycle without any UI.
VPN Runtime CI Guards
Fast one-shot validation for agents/rap-node-agent/internal/vpnruntime:
pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/smoke/run-vpnruntime-ci-guards.ps1 -AntiFlakeCount 20 -TargetedCount 50
Windows cmd wrapper:
scripts\smoke\run-vpnruntime-ci-guards.cmd
With custom parameters:
scripts\smoke\run-vpnruntime-ci-guards.cmd -AntiFlakeCount 5 -TargetedCount 10 -SkipRace
Linux/macOS/WSL wrapper:
./scripts/smoke/run-vpnruntime-ci-guards.sh
Both wrappers forward all arguments to the PowerShell script.
Verification matrix
Locally proven in this repository work
- backend
go build ./...succeeds - worker build environment files exist and are aligned across devcontainer, Docker, and CI
- worker Docker image contract now has a deterministic runtime binary path:
/usr/local/bin/rdp-worker - worker source had minimal compile fixes applied for missing declarations/includes needed by the reproducible build environment
Container-proven
- the canonical worker build environment is workers/rdp-worker/Dockerfile
- a successful
docker buildin that environment proves CMake configure + compile + install for the worker - Data Plane v1 Stage DP-1C builds the optional worker direct WSS endpoint and
installs
/usr/local/bin/rdp-worker-dataplane-token-probe - Data Plane v1 Stage DP-1D.1 builds the worker direct JSON realtime bridge for the same JSON envelopes used by the backend gateway
- DP-1C endpoint validation is proven for malformed-token rejection and
valid-token-without-runtime rejection, and replayed
jtirejection; successful direct attach to a live runtime and direct JSON traffic proof are still live smoke targets
CI-defined but not yet executed in this verification pass
.github/workflows/build.ymlbuilds the backend- the same workflow builds the worker Docker image
- the workflow verifies that
/usr/local/bin/rdp-workerexists inside the image
Still not proven automatically
- behavior on host reboot during an active real RDP session
- automated assertion of actual viewer-side rendering correctness
- Stage 5.1 file upload is build-proven in this pass, but live upload proof
requires the RAP stack to be running on the current test Docker host
192.168.200.61
Data Plane v1C worker WSS validation smoke
Build the worker image on the test Docker host:
$env:DOCKER_BUILDKIT='0'
docker --context test-ubuntu build --tag rap-rdp-worker:dp1c-hardened --file workers/rdp-worker/Dockerfile workers/rdp-worker
Run the narrow endpoint smoke:
pwsh -ExecutionPolicy Bypass -File scripts/smoke/data-plane-v1c-smoke.ps1
Expected evidence:
- worker logs
direct data-plane WSS endpoint listening - malformed token receives
401 Unauthorized - valid token with no existing session runtime receives
404 Not Foundwithmissing_runtime - replaying the same token receives
401 Unauthorizedwithjti_replay_rejected - worker logs
event=token_validation_failed reason=malformed_token - worker logs
event=data_plane_bind_failed ... reason=missing_runtime - worker logs
event=jti_replay_rejected
This smoke intentionally does not route Windows client traffic through the direct worker WSS endpoint. The backend gateway remains the runtime path until DP-1D.
Data Plane v1D.1 direct JSON bridge smoke status
Build the DP-1D.1 worker image on the test Docker host:
$env:DOCKER_BUILDKIT='0'
docker --context test-ubuntu build --tag rap-rdp-worker:dp1d1 --file workers/rdp-worker/Dockerfile workers/rdp-worker
Backend metadata must remain explicitly gated:
DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=false # default, client falls back
DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=true # advertise runtime_transport=json_v1
Locally/container-proven in DP-1D.1:
- backend tests prove direct candidate metadata appears only when
DATA_PLANE_DIRECT_WORKER_JSON_RUNTIME=true - Windows client build still succeeds and remains capability-gated
- worker Docker image builds with direct JSON envelope bridge
- endpoint/token smoke still proves invalid token, missing runtime, and replay rejection
Still requiring live RDP smoke:
- direct WSS connects to an already-running runtime
- direct WSS carries input/render/clipboard/file_upload JSON envelopes
- direct WSS does not recreate the RDP runtime
- backend gateway fallback activates when direct WSS is unavailable
- direct vs fallback latency comparison on the same RDP target
Prerequisites
- Docker Desktop or Docker Engine with
docker compose - Go
1.23.xfor local backend runs - a reachable RDP host for the seeded resource
- a machine where the worker Docker image can actually be built and started
0. Verify raw TCP reachability to the target first
Run this from the same machine or container host that will run the worker:
python scripts/smoke/check-rdp-target.py --host 192.168.60.210 --port 60210
Expected result:
tcp_connect=ok ...
If this step fails, FreeRDP connect proof is blocked by target reachability and the later lifecycle steps cannot be considered proven.
Canonical build environments
- worker devcontainer:
.devcontainer/devcontainer.json - worker Docker image: workers/rdp-worker/Dockerfile
- worker CMake preset: workers/rdp-worker/CMakePresets.json
1. Start infra
pwsh -File scripts/smoke/start-infra.ps1
Expected result:
- PostgreSQL is reachable on
127.0.0.1:5432 - Redis is reachable on
127.0.0.1:6379
2. Apply backend migrations
pwsh -File scripts/smoke/apply-migrations.ps1
Expected result:
- backend tables exist in
remote_access_platform
3. Seed a smoke-test user, trusted device, resource, and policy
Edit the connection parameters to point to a reachable RDP host:
pwsh -File scripts/smoke/seed-resource.ps1 `
-RdpHost 10.0.0.10 `
-RdpPort 3389 `
-RdpUsername Administrator `
-RdpPassword secret `
-RdpDomain "" `
-CertificateVerificationMode strict
Expected result:
- the script creates or reuses the
defaultorganization created by the v2 migrations - the script creates an active default-organization membership for the seeded smoke user
- the script prints
user_id - the script prints
device_id - the script prints
resource_id
4. Start backend
pwsh -File scripts/smoke/run-backend.ps1
Expected result:
- backend listens on
http://192.168.200.61:8080from the Windows client and on127.0.0.1:8080inside the Docker host network
Containerized fallback when the smoke host does not have go installed:
docker run -d --name rap_backend_smoke --network host \
-v /absolute/path/to/repo/backend:/workspace/backend \
-w /workspace/backend \
-e APP_NAME=rap-api \
-e APP_ENV=development \
-e HTTP_HOST=0.0.0.0 \
-e HTTP_PORT=8080 \
-e POSTGRES_DSN=postgres://rap_user:rap_password@127.0.0.1:5432/remote_access_platform?sslmode=disable \
-e REDIS_ADDR=127.0.0.1:6379 \
-e AUTH_ACCESS_TOKEN_SECRET=smoke-access-secret \
-e AUTH_REFRESH_HASH_SECRET=smoke-refresh-secret \
golang:1.23.8-bookworm /bin/sh -lc '/usr/local/go/bin/go run ./cmd/api'
5. Build the worker image
pwsh -File scripts/smoke/build-worker-image.ps1
docker run --rm --entrypoint /bin/sh rap-rdp-worker:dev -lc "test -x /usr/local/bin/rdp-worker"
Expected result:
- image build succeeds
- the binary exists at
/usr/local/bin/rdp-worker
6. Run the worker container
pwsh -File scripts/smoke/run-worker-container.ps1
Expected result:
- the worker process starts
- Redis contains
worker:registration:rdp-worker-1
If the test RDP host uses a self-signed or mismatched certificate and smoke verification needs to continue, set:
RDP_WORKER_INSECURE_SKIP_VERIFY=true
This override is worker-runtime-only and is intended strictly for smoke verification.
7. Start a session
$body = @{
resource_id = "<resource_id>"
user_id = "<user_id>"
device_id = "<device_id>"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions `
-ContentType 'application/json' `
-Body $body
Expected result:
- the response includes
session.id - the response includes
attachment.id - the response includes
attach_token - the response session payload carries
organization_id - backend logs a session start and assignment path
- worker logs a new assignment and FreeRDP connect attempt
- Redis
worker:eventsemitssession_connectedand then periodicsession_heartbeat
8. Detach
$body = @{
attachment_id = "<attachment_id>"
user_id = "<user_id>"
reason = "manual_smoke_detach"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/detach `
-ContentType 'application/json' `
-Body $body
Expected result:
- PostgreSQL session state becomes
detached - worker keeps the RDP connection alive
- worker does not emit
session_terminated
9. Reattach
$body = @{
user_id = "<user_id>"
device_id = "<device_id>"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/attach `
-ContentType 'application/json' `
-Body $body
Expected result:
- backend returns a new short-lived
attach_token - worker does not recreate the remote RDP session
- worker continues heartbeating the same
session_id
10. Takeover
Create or seed a second trusted device for the same user, then:
$body = @{
user_id = "<user_id>"
device_id = "<second_device_id>"
reason = "manual_smoke_takeover"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/takeover `
-ContentType 'application/json' `
-Body $body
Expected result:
- backend atomically supersedes the previous attachment
- previous controller WebSocket session receives
session.taken_overif connected - worker stays on the same remote RDP session and only updates controller ownership
10A. Prove WebSocket takeover delivery
Use the real smoke client built into the backend module:
cd backend
go run ./cmd/ws-smoke-client \
-attach-token "<controller_a_attach_token>" \
-duration 120s
Expected result:
- controller A first receives
session.state - after takeover from controller B, controller A receives
session.taken_over - PostgreSQL shows the new attachment for controller B as
active - worker logs only
updated assignment for existing session ...and does not log a new runtime start
11. Terminate
$body = @{
user_id = "<user_id>"
reason = "manual_smoke_terminate"
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri http://192.168.200.61:8080/api/v1/sessions/<session_id>/terminate `
-ContentType 'application/json' `
-Body $body
Expected result:
- backend marks the session
terminated - worker receives a
terminatecontrol envelope - worker disconnects the FreeRDP session and emits
session_terminated
12. Prove stale lease and worker-death recovery
With a live active session still running:
docker rm -f rap_worker_smoke
Wait at least:
WORKER_HEARTBEAT_TTL- plus
WORKER_STALE_LEASE_GRACE_PERIOD - plus one lease-monitor interval
With current defaults from backend/configs/api.example.env, waiting about 90s is sufficient for a manual smoke pass.
Expected result:
worker:registration:<worker_id>disappears from Redisworker:session-lease:<session_id>is releasedlive:session:<session_id>,live:binding:<session_id>, andlive:route:<session_id>are cleared- PostgreSQL moves the session to
failed - non-superseded attachments become
closed audit_eventscontainssession_failedwith reasonworker_lease_stale_or_worker_missing
Troubleshooting
- If the worker image build fails, run
docker build --tag rap-rdp-worker:dev --file workers/rdp-worker/Dockerfile workers/rdp-workerdirectly and inspect the compiler output. - If raw TCP reachability to the RDP target fails, stop there and fix host/network/firewall/port access before evaluating FreeRDP behavior.
- If the worker starts but never receives assignments, verify
worker:registration:<worker_id>andworker:control:<worker_id>in Redis. - If session start returns
access deniedafter the v2 migrations, verify that the seeded user has an active membership in the resource organization. - If takeover does not produce
session.taken_over, confirm that controller A is attached through/api/v1/gateway/wsusing a still-valid attach token and that the broker binding changed. - If worker death does not transition the session quickly enough, verify
WORKER_HEARTBEAT_TTL,WORKER_STALE_LEASE_GRACE_PERIOD, and the lease monitor interval before treating the session as stuck. - If FreeRDP cannot connect, verify the seeded host, port, username, password, domain, certificate verification mode, and network reachability from Docker to the target host.
- If attach tokens expire during manual testing, repeat the attach or takeover call to mint a new token.
Stage 5.1 / 5.1.1 File Upload Smoke
Current target Docker host for this project is 192.168.200.61 via Docker
context test-ubuntu. Verify before running:
docker context use test-ubuntu
docker ps
Policy setup:
UPDATE resource_policies
SET file_transfer_mode = 'client_to_server',
file_transfer_enabled = TRUE,
updated_at = now()
WHERE resource_id = '<resource_id>';
Disabled-policy regression:
UPDATE resource_policies
SET file_transfer_mode = 'disabled',
file_transfer_enabled = FALSE,
updated_at = now()
WHERE resource_id = '<resource_id>';
Manual upload proof from the Windows client:
- start or attach an active RDP session
- open the session window
- click
Upload File - choose a small text file, then a small binary file
- verify the UI progress reaches 100%
- inspect backend logs for
session gateway file upload start acceptedandsession gateway file upload chunk accepted - inspect worker logs for
file upload completed - verify the file and hash inside the worker container:
docker exec rap_worker_smoke sh -lc `
"find /tmp/rap-rdp-worker-transfers -path '*/visible/*' -type f -maxdepth 4 -print -exec wc -c {} \;"
Stage 5.1.1 visibility proof:
Automated smoke command used for the accepted proof:
pwsh -ExecutionPolicy Bypass -File scripts\smoke\drive-visibility-smoke.ps1 `
-WorkerImage rap-rdp-worker:rdp-p1-region-order2 `
-OutputFrame artifacts\stage5-drive-visibility-frame-p1-rerun.bmp
- worker logs must show
visible transfer directory ready - worker logs must show
FreeRDP restricted transfer drive configured name=RAP_Transfers - inside the remote Windows session, open File Explorer and verify the
redirected drive
RAP_Transfersis present - verify uploaded files are visible under
RAP_Transfers - open the uploaded text file and verify the content matches
- for binary files, verify size and hash through worker storage evidence unless a remote-side hash tool is available
- after detach, takeover old-client, and worker failure, upload must be blocked and the visible transfer directory must be cleaned up
Required PASS cases for accepting Stage 5.1:
disabledblocks uploadclient_to_serverallows upload- small text file hash matches
- small binary file hash matches
- file larger than 25 MiB is blocked by client/gateway policy
- path traversal names are blocked by gateway/worker validation
- upload is blocked after detach, old-client takeover, and worker failure
- rendering, mouse input, keyboard input, clipboard, reconnect, and takeover still work
Accepted Stage 5.1.1 proof artifact:
artifacts/stage5-drive-visibility-frame-p1-rerun.bmpshows the uploadedstage5-upload-text.txtopened inside remote Windows from the restrictedRAP_Transfersdrive.
Important limitation for Stage 5.1.1: it intentionally exposed only the
restricted per-session visible directory as RAP_Transfers. It must not be
expanded to arbitrary paths, full shared folders, SMB/WebDAV, or Windows agent
delivery.
Stage 5.2 File Download Smoke
Stage 5.2 server-to-client download has a runtime-proven core data path and lifecycle blocking proof. Manual desktop UI proof remains before full acceptance.
Build-proven images:
rap-backend-smoke:stage5-2-download
rap-rdp-worker:stage5-2-download
Headless core data-path proof:
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-fixed2
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode bidirectional `
-Transport direct_worker_wss `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-bidirectional
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode client_to_server `
-Transport direct_worker_wss `
-ExpectBlocked `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-client-to-server-block-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode disabled `
-Transport direct_worker_wss `
-ExpectBlocked `
-OutputDirectory artifacts/stage5-2-download-smoke-direct-disabled-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport backend_gateway `
-OutputDirectory artifacts/stage5-2-download-smoke-backend-regression-after-direct-block
Lifecycle proof:
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario detach `
-OutputDirectory artifacts/stage5-2-download-lifecycle-detach-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario takeover_old_controller `
-OutputDirectory artifacts/stage5-2-download-lifecycle-takeover-fixed
pwsh -NoProfile -ExecutionPolicy Bypass `
-File scripts\smoke\file-download-smoke.ps1 `
-AllowMode server_to_client `
-Transport direct_worker_wss `
-LifecycleScenario worker_failure `
-OutputDirectory artifacts/stage5-2-download-lifecycle-worker-failure
Accepted core evidence:
- direct worker WSS
server_to_client: text and binary size/hash match - direct worker WSS
bidirectional: text and binary download succeeds - direct worker WSS
client_to_server: download blocked withaccess denied - direct worker WSS
disabled: download blocked withaccess denied - backend gateway fallback
server_to_client: text and binary size/hash match - detach blocks download with
file_download.blocked - old controller after takeover receives
session.taken_overand cannot continue download - worker failure transitions PostgreSQL state to
failed; direct WebSocket closes and download cannot continue
Report:
artifacts/stage5-2-file-download-runtime-report.md
Remaining manual live proof:
- keep the Stage 5.2 backend and worker images on
docker-test - set
resource_policies.file_transfer_mode = 'server_to_client' - start or attach a real RDP session
- inside remote Windows, copy a small text file to
RAP_Transfers\ToClient - verify the Windows client shows
file_download.available - click
Download File, choose a local save path, and verify completion - compare size and hash with worker evidence
- repeat with a small binary file
- verify
disabledandclient_to_serverblock download - verify
bidirectionalallows upload and download - verify rendering, mouse, keyboard, clipboard, upload, reconnect, takeover, and backend gateway fallback do not regress