RDP Worker
Active C++ RDP Adapter worker for the Remote Access Platform.
Current test Docker deployment:
rap-rdp-worker:stage5-2-download
This image is deployed on the canonical test Docker host
docker-test / 192.168.200.61 for Stage 5.2 proof. The accepted
ordered-region RDP path remains protected; the current focus is finishing
server-to-client file download desktop UI proof, not new platform expansion.
Scope
- standalone C++ worker service
- Redis-backed worker registration, heartbeat, assignment consumption, lease renewal, and worker event publishing
- FreeRDP-based server-side RDP connection runtime
- persistent remote session process that stays connected when the client detaches
- reattach and takeover-aware assignment updates without recreating the server-side RDP session
- terminate and failure reporting back into the backend control plane
Reproducible build environments
Devcontainer
The repository now includes a worker-focused devcontainer in .devcontainer/devcontainer.json and .devcontainer/Dockerfile.
Environment assumptions:
- Debian
12.10-slim - CMake from Debian 12 packages
- Ninja from Debian 12 packages
- GCC/G++ from
build-essential - FreeRDP headers/libs from
freerdp2-dev - WinPR headers/libs from
libwinpr2-dev - no extra Redis client library is required because the worker uses its own socket-based RESP client
The devcontainer runs:
cmake --preset dev --directory workers/rdp-worker
after creation, so configure success is part of the expected environment contract.
Docker image build
The worker Docker build is defined in Dockerfile.
Environment assumptions:
- Debian
12.10-slim build-essentialcmakeninja-buildpkg-configfreerdp2-devlibwinpr2-dev
The image uses CMakePresets.json and installs the final binary to the deterministic path:
/usr/local/bin/rdp-worker
Exact build commands
Local native build
From the repository root:
cmake --preset dev --directory workers/rdp-worker
cmake --build --preset dev --directory workers/rdp-worker
Expected binary path:
workers/rdp-worker/build/rdp-worker
Docker build
From the repository root:
docker build --tag rap-rdp-worker:dev --file workers/rdp-worker/Dockerfile workers/rdp-worker
docker run --rm --entrypoint /bin/sh rap-rdp-worker:dev -lc "test -x /usr/local/bin/rdp-worker"
Expected result:
docker buildcompletes successfully- the second command exits with code
0 - the runtime binary path inside the image is
/usr/local/bin/rdp-worker
Runtime configuration
Environment variables:
RDP_WORKER_IDRDP_WORKER_REDIS_HOSTRDP_WORKER_REDIS_PORTRDP_WORKER_REDIS_PASSWORDRDP_WORKER_REDIS_DBRDP_WORKER_HEARTBEAT_INTERVAL_SECONDSRDP_WORKER_LEASE_RENEW_INTERVAL_SECONDSRDP_WORKER_ASSIGNMENT_POLL_INTERVAL_SECONDSRDP_WORKER_CAPABILITIESRDP_WORKER_INSECURE_SKIP_VERIFYRDP_WORKER_DATA_PLANE_ENABLEDRDP_WORKER_DATA_PLANE_LISTEN_HOSTRDP_WORKER_DATA_PLANE_LISTEN_PORTRDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILERDP_WORKER_DATA_PLANE_PUBLIC_KEY_PEMRDP_WORKER_DATA_PLANE_TLS_CERT_FILERDP_WORKER_DATA_PLANE_TLS_KEY_FILERDP_WORKER_RDPGFX_ENABLED
Data Plane v1 Direct WSS Boundary
Stage DP-1C adds an optional direct worker WSS endpoint at
/rap/v1/data-plane. It is disabled by default and does not change Windows
client behavior by itself; the existing backend gateway remains the active
fallback path unless the backend advertises a data-capable direct candidate.
When enabled, the endpoint:
- requires TLS certificate and private key files
- validates the backend-issued RS256
data_plane_tokenwith a public key - verifies token scope for session, attachment, user, organization, worker,
resource, allowed channels, expiry, audience, and
jti - binds only to an already-running
SessionRuntime - rejects token
jtireplay with a bounded in-memory TTL cache - rejects missing runtime, wrong worker, expired token, invalid signature, and malformed token cases without creating a new RDP session
- accepts the existing JSON realtime envelopes for
input,control,clipboard, andfile_upload - emits the existing JSON session state, render frame, clipboard, and file upload progress events
- emits
session.frameas DP-2 binary WebSocket frames when the client requestsrender_transport=binary_v1; JSON/base64 render remains available for backend gateway fallback/debug compatibility - drains direct input before Redis fallback input, keeps the direct inbound queue bounded, and coalesces stale mouse moves
- keeps render latest-frame-only and droppable on the direct WSS writer
- tags direct inbound envelopes with token-bound session/attachment/user/org claims so old attachments after takeover cannot keep controlling the runtime
Required environment:
RDP_WORKER_DATA_PLANE_ENABLED=true
RDP_WORKER_DATA_PLANE_LISTEN_HOST=0.0.0.0
RDP_WORKER_DATA_PLANE_LISTEN_PORT=8443
RDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILE=/path/to/data-plane-public.pem
RDP_WORKER_DATA_PLANE_TLS_CERT_FILE=/path/to/cert.pem
RDP_WORKER_DATA_PLANE_TLS_KEY_FILE=/path/to/key.pem
Production direct worker WSS trust is coordinated by the backend candidate
metadata described in docs/architecture/DIRECT_WORKER_TLS_PKI.md.
- Smoke/dev can use self-signed certificates only when the backend advertises
the candidate as
smoke_only=trueand the Windows client has the explicit smoke override enabled. - Production workers must use certificates valid for their advertised direct WSS hostname/IP subject alternative names.
- Production direct candidates should be advertised by the backend only with
DATA_PLANE_DIRECT_WORKER_TLS_TRUST_MODE=public_caorplatform_ca.
The image also installs a token validation smoke helper:
rdp-worker-dataplane-token-probe --token <jwt> --public-key-file <public.pem> --worker-id <worker_id>
rdp-worker-dataplane-bind-probe --scenario valid
rdp-worker-dataplane-bind-probe --scenario wrong-attachment
rdp-worker-dataplane-bind-probe --scenario channels-too-broad
Run the endpoint validation smoke from the repository root after building
rap-rdp-worker:dp1c-hardened on the test Docker host:
pwsh -ExecutionPolicy Bypass -File scripts/smoke/data-plane-v1c-smoke.ps1
Stage DP-2.1 removes the internal base64 encode/decode hop from the direct
render path. FreeRDP frame capture now carries raw BGRA bytes through the worker
runtime to the direct binary render sink, and direct WSS sends those bytes as
RAP2 binary WebSocket frames. Compatibility JSON/base64 session_frame events
are still generated for the backend-gateway fallback boundary only. Backend
gateway fallback remains supported and must stay enabled.
Stage DP-3A adds direct binary color mode selection. full_color sends the raw
BGRA frame unchanged. grayscale converts the direct binary frame to grayscale
inside the worker direct render sink, preserves BGRA32 output, and does not
modify the backend-gateway JSON/base64 fallback path. Direct frame diagnostics
include requested_color_mode, applied_color_mode,
grayscale_conversion_applied, raw_frame_bytes_before,
raw_frame_bytes_after, binary_direct_bytes, and conversion_time_ms.
RDPGFX Gated Smoke Mode
RDP_WORKER_RDPGFX_ENABLED=true enables an experimental RDPGFX advertisement
path for target compatibility testing only.
Default behavior:
- RDPGFX is disabled.
- Classic GDI region-first rendering remains the safe production/dev path.
- Direct binary WSS and backend gateway fallback continue to work without this flag.
When the flag is enabled, the worker logs:
rdp.gfx config requested=truerdp.gfx channel_subscription ...rdp.gfx channel_connected ...if the target opens the RDPGFX channelrdp.gfx pipeline_init_successorrdp.gfx pipeline_init_failedrdp.gfx surface_event ...when FreeRDP surface callbacks are available- RDPGFX counters in
rdp.perf callback_summary
The current live smoke target resets the connection when RDPGFX is advertised,
so this flag must not be enabled for normal operation. See
artifacts/rdp-perf4-report.md.
Cursor Adapter Boundary
RDP-A4 adds a dedicated cursor boundary inside the C++ RDP Adapter.
The worker installs FreeRDP pointer callbacks and normalizes them into
session_cursor_updated events. Direct worker WSS exposes those events as
cursor.update envelopes. Cursor is scheduled as latest-only/droppable and does
not wait behind binary display frames. Backend gateway fallback remains
compatible with the same worker event payload.
The current implementation publishes cursor metadata:
- update kind
- sequence
- desktop width/height
- x/y position
- visibility
- shape changed flag
- cache index
- hotspot
- cursor width/height
- XOR bpp
- mask byte size
- system cursor type
Smoke-proven image:
rap-rdp-worker:rdp-a4-cursor-adapter
Probe:
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-a4-cursor-adapter /usr/local/bin/rdp-worker-cursor-adapter-probe
Expected result:
cursor_adapter_probe ok
See artifacts/rdp-a4-cursor-adapter-report.md.
GDI Repaint Cadence Hardening
RDP-Perf-5A keeps the default classic GDI region-first path and improves its runtime cadence without changing backend contracts or session lifecycle.
The worker now:
- drains already-signaled FreeRDP event handles in a bounded loop
- rate-limits no-change detector logs
- renews worker leases outside the hot render/input loop
- publishes region and interactive render frames at a 33 ms interval
- keeps full-frame fallback at a 100 ms interval
- keeps direct worker WSS binary render and backend gateway JSON/base64 fallback compatible
Smoke-proven image:
rap-rdp-worker:rdp-perf5a-repaint-cadence
Report:
artifacts/rdp-perf5a-report.md
Manual UX validation after subsequent adapter work showed that keyboard, mouse, idle repaint, and general interaction are usable enough for the current MVP baseline, but small redraw artifacts remain and are the next hardening target.
Direct Attach Baseline And Region Repair
After P1 manual visual smoke, the current accepted baseline image is:
rap-rdp-worker:rdp-p1-region-order2
This baseline keeps the classic GDI region-first path and adds two important visual correctness safeguards:
- direct attach baseline full-frame capture so a newly attached client does not start from a black or incomplete framebuffer
- throttled full-frame repair after detected region loss so dropped dirty regions do not leave persistent holes
The direct worker WSS writer treats attach-baseline frames as non-droppable reliable events. Normal display updates remain region-first and droppable where safe. Full frames are reserved for baseline/attach/recovery/fallback repair, not normal rendering.
Probes verified during the P1 baseline freeze:
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-graphics-adapter-probe
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-cursor-adapter-probe
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-service-adapter-protocol-probe
docker -H ssh://docker-test run --rm rap-rdp-worker:rdp-p1-region-order2 rdp-worker-dataplane-bind-probe --scenario valid
Expected results:
graphics_adapter_probe okcursor_adapter_probe ok- service adapter channel list printed without error
PASS scenario=valid
Known remaining visual limitation:
- drag/release repaint is usable but not polished; drag behaves like an older RDP client on a weak link by moving a frame rather than continuously repainting the full window.
Certificate Verification Policy
strictis the default per-resource mode and keeps normal FreeRDP certificate validation enabledignoreis a saved per-resource mode coming from backend session assignment and enablesFreeRDP_IgnoreCertificateonly for that connectionRDP_WORKER_INSECURE_SKIP_VERIFY=trueremains available only as a smoke/debug fallback when assignment data does not include an explicit mode; it is not the production configuration path
Resource metadata contract
The worker currently expects target connection data inside resources.metadata:
{
"rdp_host": "10.0.0.10",
"rdp_port": 3389,
"username": "Administrator",
"password": "secret",
"domain": ""
}
address is used as a fallback host when rdp_host is absent.
Current limitations
- rendering is implemented as a classic GDI region-first BGRA path with direct binary WSS support and baseline/region-loss repair; encoded graphics, codecs, tiles, and RDPGFX production mode are not accepted yet
- clipboard is text-only; image/HTML/RTF/binary clipboard formats are intentionally blocked
- file upload is implemented through controlled worker storage and restricted
drive visibility; server-to-client download is build-proven through the
restricted
RAP_Transfers\ToClientdrop zone, with live runtime acceptance still pending - audio, printer, webcam, multi-monitor, and advanced graphics pipeline optimizations are intentionally not implemented yet
- worker-to-backend communication is currently Redis-only; no dedicated worker API exists yet
- connection secrets are still sourced from resource metadata for this minimal end-to-end proof and are not yet integrated with encrypted secret storage
- FreeRDP session loop remains focused on lifecycle correctness and the region-first adapter path; remaining redraw artifacts must be fixed before starting another graphics feature
- the Docker/native build can now be reproduced in the defined environment, but a full real-target smoke run still depends on a reachable RDP host and a machine where Docker can run
RDP_WORKER_INSECURE_SKIP_VERIFY=trueis only a smoke/debug fallback and should not be used instead of the per-resource certificate verification policy
Verification levels
- local native: expected on machines that match the documented Debian/devcontainer package set
- container-proven: the Dockerfile is the canonical reproducible worker build environment and a successful
docker buildproves configure + compile + install - CI-defined:
.github/workflows/build.ymlis prepared to build backend and worker image and verify the installed worker binary path - smoke-path: see scripts/smoke/README.md for what is and is not proven yet
Troubleshooting
- If
pkg-configcannot findfreerdp2orwinpr2, verify that the development packages are installed and thatpkg-config --list-allincludes both modules. - If
cmake --preset dev --directory workers/rdp-workerfails, confirm that the environment matches the Debian 12 package set from the Dockerfile or devcontainer. - If
docker buildsucceeds but runtime startup fails, verify Redis connectivity and worker environment variables. - If
freerdp_connectfails, confirm that the seeded metadata containsrdp_host,rdp_port,username,password, and optionaldomain. - If the worker registers but never receives assignments, inspect Redis keys
worker:registration:<worker_id>andworker:control:<worker_id>.
Clipboard Text Boundary
The worker receives clipboard control envelopes from the backend session
gateway only after backend policy checks have passed. It enforces
assignment.policy.clipboard_mode again before touching the FreeRDP runtime
boundary, so UI or gateway mistakes do not become the only clipboard guard.
Supported policy values are disabled, client_to_server,
server_to_client, and bidirectional. The default is disabled.
Current implementation is text-only. It deliberately rejects images, HTML,
RTF, binary payloads, and file-like clipboard data by never accepting those
formats in the worker envelope contract.
Stage 4.1 integrates the FreeRDP cliprdr virtual channel for text clipboard.
The worker advertises CF_UNICODETEXT, requests server CF_UNICODETEXT data,
and converts UTF-16LE clipboard payloads to UTF-8 before publishing normalized
session_clipboard_text events to the backend.
Current limitation: only text is supported. Images, HTML, RTF, file lists, binary formats, and file-like clipboard payloads remain intentionally blocked.
File Upload Boundary
Stage 5.1.1 supports client-to-server upload into a restricted per-session directory that is exposed to the remote Windows session through FreeRDP drive redirection. It does not expose a remote Windows filesystem browser, arbitrary paths, shared worker folders, or server-to-client download.
The worker receives file_upload control envelopes only after backend gateway
policy checks pass, then enforces assignment.policy.file_transfer_mode again
before writing any bytes. Supported policy values are disabled,
client_to_server, server_to_client, and bidirectional; only
client_to_server and bidirectional allow upload in this stage. Default is
disabled.
Uploaded files are finalized under:
${RDP_WORKER_TRANSFER_ROOT:-/tmp/rap-rdp-worker-transfers}/<session_id>/visible/<sanitized_file_name>
Only this visible directory is mapped to the remote RDP session as the
restricted drive RAP_Transfers. The worker passes only that path to FreeRDP's
RDPDR drive-redirection channel; it never maps ${RDP_WORKER_TRANSFER_ROOT},
the session parent directory, or arbitrary client-provided paths.
Safety constraints in the worker:
- maximum file size is 25 MiB
- maximum chunk size is 256 KiB
- file names must be simple names only, with no path separators, drive
prefixes,
.., or empty names - existing target files are never overwritten silently
- chunks must arrive in order with the expected offset
- final FNV-1a content hash must match when the client supplies one
- files are stored only; the worker never executes uploaded content
- upload temp files are kept inside the same restricted
visibledirectory and renamed into place only after validation completes - the
visibledirectory is cleaned up on detach, and the full per-session transfer directory is removed on termination or session failure
Stage 5.2 adds a server-to-client download path using only the
restricted ToClient directory inside the existing RAP_Transfers drive. A
remote user copies a regular file into RAP_Transfers\ToClient; the worker
detects stable files in that directory only, publishes file_download.available,
and streams chunks after client file_download.start / file_download.ack.
The worker still does not expose a general remote filesystem browser, arbitrary paths, shared worker folders, SMB/WebDAV, or a Windows agent. Core download transport is runtime-proven for direct worker WSS and backend gateway fallback; lifecycle blocking is runtime-proven for detach, old-controller takeover, and worker failure. Full Stage 5.2 acceptance still needs manual desktop UI proof.
Runtime proof report:
artifacts/stage5-2-file-download-runtime-report.md