feat(server): add run_connection_pre_authenticated for transport-encrypted streams#1281
Open
Greg Lamberson (glamberson) wants to merge 1 commit into
Conversation
Greg Lamberson (glamberson)
added a commit
to lamco-admin/IronRDP
that referenced
this pull request
May 20, 2026
Reshape the public surface introduced earlier in this PR before it lands, to avoid a future breaking touch when ironrdp-server's anyhow-removal pass arrives. Mirrors the post-Devolutions#1264 hand-rolled error pattern (Display + core::error::Error impls, no thiserror). Trait signature was `Result<bool>` (i.e. anyhow::Result through the file-level `use anyhow::Result`). Two issues: - New public surface depending on anyhow at a moment when the workspace is moving the other way (Devolutions#1277 just removed anyhow from rdpsnd-native). - `Ok(true)` vs `Ok(false)` is stringly typed at the call site: a bare bool with no semantic clue. New shape: - `CredentialDecision::{Accept, Reject}` for the binary outcome. - `CredentialValidationError` wrapping any `core::error::Error + Send + Sync` for the case where the validator backend itself failed (LDAP unreachable, PAM transport error, etc.). Manual Display + Error impls; source chains through. - `validate(&self, &Credentials) -> Result<CredentialDecision, CredentialValidationError>`. The accept/reject/backend-error trichotomy is now explicit at every call site. Rejection is no longer an error; backend failure is. Log + bail strings updated to match the trichotomy. Also addresses the API consistency note: every other configurable on `RdpServerBuilder<BuilderDone>` goes through `with_*` on the builder. Added `with_credential_validator(Option<Arc<dyn ...>>)` following the same shape as `with_connection_handler`. The post-construction `set_credential_validator` setter is kept (now takes `Option` to match the field and the builder's setter shape) for dynamic reconfiguration after `build()`. Tests added in `server.rs` cover Accept / Reject / backend-error propagation and `Arc<dyn CredentialValidator>` Send + Sync + 'static bounds. Integration with `client_accepted` is exercised through the existing acceptor-side tests once the validator is wired via the builder; no new integration test in this commit (server-side precedent per Devolutions#1181 / Devolutions#1187 / Devolutions#1281). Verification: `cargo test -p ironrdp-server --lib` (14 passed), `cargo clippy -p ironrdp-server --all-targets -- -D warnings` clean, `cargo check -p ironrdp-server` clean. Refs Devolutions#1154, Devolutions#1150, Devolutions#1155.
…ypted streams Adds a sibling to RdpServer::run_connection that walks the same per-connection state machine but skips the IronRDP-managed TLS handshake. The caller's stream must already be transport-encrypted at a lower layer (typically a WebSocket Secure terminator in an RDCleanPath-shaped deployment). The implementation mirrors run_connection except for one step: on BeginResult::ShouldUpgrade, instead of calling tls_acceptor.accept(stream), the new method calls Acceptor::mark_security_upgrade_as_done() to advance the state machine and re-wraps the inner stream as already-post-TLS. The Hybrid CredSSP block, accept_finalize, and shutdown sequence are identical to run_connection because CredSSP carries its own crypto via TSRequest and does not require the underlying transport's TLS. Builds on PR Devolutions#1181 which made run_connection generic over any AsyncRead+AsyncWrite stream. This method extends the same design intent to streams that have been TLS-terminated by a lower layer. Wire-level invariant preserved: the X.224 negotiation is untouched. The acceptor still advertises whatever SecurityProtocol it was constructed with; only the TLS-handshake step is skipped. Earlier attempts at a wire-level signal (PR Devolutions#1210, RdpServerSecurity::PreSecured) failed interop with vanilla clients and were closed; this method sidesteps that approach by relying on a higher-layer protocol (RDCleanPath) to inform the client that TLS happened elsewhere. Considered and rejected: a new RdpServerSecurity::PreAuthenticated variant. The canonical deployment serves both vanilla TCP+TLS clients and WSS+RDCleanPath clients from a single server instance on different listeners; per-connection choice fits that use case, while a variant would force splitting into two server instances and break exhaustive matches downstream. Sibling method has zero API breakage. A NOTE comment in the source records the synchronization requirement with run_connection's ShouldUpgrade arm so future rebases catch upstream divergence. The motivating downstream consumer is lamco-rdp-server's WebSocket plus RDCleanPath listener, which retires its external ws-rdp-proxy from the production WASM-client path.
87432e8 to
afca966
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a sibling to RdpServer::run_connection that walks the same per-connection state machine but skips the IronRDP-managed TLS handshake. The caller's stream must already be transport-encrypted at a lower layer (typically a WebSocket Secure terminator in an RDCleanPath-shaped deployment).
Motivation
Browser-based RDP clients connect to the server via WSS rather than the standard TCP+TLS shape. Both Cloudflare's IronRDP-WASM deployment and Devolutions Gateway's web client terminate the TLS at the WebSocket layer and use the RDCleanPath protocol to surface the backend's certificate chain to the client without a redundant inner TLS handshake on top of the already-encrypted WebSocket transport.
Today, ironrdp-server has no entry point for this shape. RdpServer::run_connection unconditionally calls tls_acceptor.accept(stream) on BeginResult::ShouldUpgrade, so any caller that has already terminated TLS at a lower layer has nowhere to plug in.
This PR adds run_connection_pre_authenticated as the missing entry point. It walks the same state machine as run_connection but, on ShouldUpgrade, calls Acceptor::mark_security_upgrade_as_done() to advance past the security upgrade gate without performing a TLS handshake.
Builds on #1181, which made run_connection generic over any AsyncRead+AsyncWrite stream. This method extends the same design intent to streams that have already been TLS-terminated by a lower layer (a class of streams #1181's review explicitly cited as motivating examples).
What changes
One method added to ironrdp-server::server::RdpServer:
The body mirrors run_connection except for one step: on BeginResult::ShouldUpgrade, instead of calling tls_acceptor.accept(stream), the new method calls Acceptor::mark_security_upgrade_as_done() to advance the state machine and re-wraps the inner stream as already-post-TLS. The Hybrid CredSSP block, accept_finalize, and shutdown sequence are identical to run_connection, because CredSSP carries its own crypto via TSRequest and does not require the underlying transport's TLS.
A NOTE comment in the source records the synchronization requirement with run_connection's ShouldUpgrade arm so future rebases catch any divergence.
Wire-level invariant preserved
The X.224 negotiation is untouched. The acceptor still advertises whatever SecurityProtocol it was constructed with; only the TLS-handshake step on the byte stream is skipped.
This is deliberate. Earlier attempts at a wire-level signal (#1210, RdpServerSecurity::PreSecured advertising PROTOCOL_SSL while skipping the actual TLS handshake) failed interop with vanilla xfreerdp3, because PROTOCOL_SSL selected on the X.224 wire is a mandatory TLS signal per MS-RDPBCGR with no provision for "TLS already happened elsewhere". This method sidesteps that approach by relying on a higher-layer protocol (RDCleanPath) to inform the client that TLS happened elsewhere, never lying about wire-level negotiation.
The doc comment names this explicitly so the wire-level question doesn't recur.
Why a sibling method instead of an RdpServerSecurity variant
Considered and rejected: a new RdpServerSecurity::PreAuthenticated variant.
The canonical deployment serves both vanilla TCP+TLS clients and WSS+RDCleanPath clients from a single server instance, on different listeners, with one RdpServerSecurity::Tls(_) configuration. Per-connection choice of entry point (run_connection vs run_connection_pre_authenticated) fits that use case directly. A variant on RdpServerSecurity would force the choice at server construction time, splitting the deployment into two server instances, and would also be an API break for any downstream code matching exhaustively on RdpServerSecurity.
The sibling-method shape has zero API breakage and matches the additive-extension pattern used by #1181, #1187, and #1194.
Preconditions (caller MUST guarantee)
The stream is already transport-encrypted by another layer (WSS, in-process, etc). Calling this method on a plain TCP stream exposes RDP traffic in plaintext on the wire.
The connecting RDP client speaks a proxy protocol that informs it that TLS happened at a lower layer (typically RDCleanPath). Vanilla RDP clients (mstsc, xfreerdp, FreeRDP) negotiate TLS based on the X.224 selectedProtocol and have no concept of TLS-already-done. They must use run_connection over the standard TCP+TLS path.
If RdpServerSecurity::Hybrid is configured, the caller is responsible for ensuring the client supports CredSSP over this transport. CredSSP carries its own crypto and does not require the underlying transport's TLS, so it works the same as in run_connection.
The doc comment names all three preconditions.
Downstream consumer
The motivating downstream consumer is lamco-rdp-server's WebSocket plus RDCleanPath listener, which uses this method to retire its external ws-rdp-proxy from the production WASM-client path. The pattern has been proven on the lamco-admin/IronRDP fork before this filing.
Testing
No tests are added in this PR. The three closest analogous merged PRs on this exact surface (#1181 generic run_connection, #1187 IPv6 dual-stack, #1194 ConnectionHandler) each shipped without tests; the building blocks (Acceptor::mark_security_upgrade_as_done, accept_begin, accept_finalize, the Hybrid CredSSP path) are exercised by existing acceptor and server tests. Happy to add a duplex-pair smoke test in testsuite-core if reviewers prefer that shape.
Checklist