Files
2026-04-28 22:29:50 +03:00

19 KiB

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-essential
  • cmake
  • ninja-build
  • pkg-config
  • freerdp2-dev
  • libwinpr2-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 build completes 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_ID
  • RDP_WORKER_REDIS_HOST
  • RDP_WORKER_REDIS_PORT
  • RDP_WORKER_REDIS_PASSWORD
  • RDP_WORKER_REDIS_DB
  • RDP_WORKER_HEARTBEAT_INTERVAL_SECONDS
  • RDP_WORKER_LEASE_RENEW_INTERVAL_SECONDS
  • RDP_WORKER_ASSIGNMENT_POLL_INTERVAL_SECONDS
  • RDP_WORKER_CAPABILITIES
  • RDP_WORKER_INSECURE_SKIP_VERIFY
  • RDP_WORKER_DATA_PLANE_ENABLED
  • RDP_WORKER_DATA_PLANE_LISTEN_HOST
  • RDP_WORKER_DATA_PLANE_LISTEN_PORT
  • RDP_WORKER_DATA_PLANE_PUBLIC_KEY_FILE
  • RDP_WORKER_DATA_PLANE_PUBLIC_KEY_PEM
  • RDP_WORKER_DATA_PLANE_TLS_CERT_FILE
  • RDP_WORKER_DATA_PLANE_TLS_KEY_FILE
  • RDP_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_token with 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 jti replay 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, and file_upload
  • emits the existing JSON session state, render frame, clipboard, and file upload progress events
  • emits session.frame as DP-2 binary WebSocket frames when the client requests render_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=true and 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_ca or platform_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=true
  • rdp.gfx channel_subscription ...
  • rdp.gfx channel_connected ... if the target opens the RDPGFX channel
  • rdp.gfx pipeline_init_success or rdp.gfx pipeline_init_failed
  • rdp.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 ok
  • cursor_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

  • strict is the default per-resource mode and keeps normal FreeRDP certificate validation enabled
  • ignore is a saved per-resource mode coming from backend session assignment and enables FreeRDP_IgnoreCertificate only for that connection
  • RDP_WORKER_INSECURE_SKIP_VERIFY=true remains 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\ToClient drop 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=true is 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 build proves configure + compile + install
  • CI-defined: .github/workflows/build.yml is 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-config cannot find freerdp2 or winpr2, verify that the development packages are installed and that pkg-config --list-all includes both modules.
  • If cmake --preset dev --directory workers/rdp-worker fails, confirm that the environment matches the Debian 12 package set from the Dockerfile or devcontainer.
  • If docker build succeeds but runtime startup fails, verify Redis connectivity and worker environment variables.
  • If freerdp_connect fails, confirm that the seeded metadata contains rdp_host, rdp_port, username, password, and optional domain.
  • If the worker registers but never receives assignments, inspect Redis keys worker:registration:<worker_id> and worker: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 visible directory and renamed into place only after validation completes
  • the visible directory 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