Skip to content

feat(server): add run_connection_pre_authenticated for transport-encrypted streams#1281

Open
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/server-run-connection-pre-authenticated
Open

feat(server): add run_connection_pre_authenticated for transport-encrypted streams#1281
Greg Lamberson (glamberson) wants to merge 1 commit into
Devolutions:masterfrom
lamco-admin:feat/server-run-connection-pre-authenticated

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

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:

pub async fn run_connection_pre_authenticated<S>(&mut self, stream: S) -> Result<()>
where S: AsyncRead + AsyncWrite + Send + Sync + Unpin

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)

  1. 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.

  2. 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.

  3. 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

  • All five xtask gates pass locally: fmt, lints, tests, typos, locks
  • No public API breakage (additive method only)
  • No new dependencies
  • No new feature flags
  • Doc comment names preconditions, wire-level invariant, synchronization requirement, and variant-rejected rationale
  • NOTE comment records the keep-in-sync requirement against run_connection

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.
@glamberson Greg Lamberson (glamberson) force-pushed the feat/server-run-connection-pre-authenticated branch from 87432e8 to afca966 Compare May 21, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant