Skip to content

Commit fe10431

Browse files
committed
feat(server): add run_connection_pre_authenticated for transport-encrypted 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 #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 #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.
1 parent 45ec1ef commit fe10431

1 file changed

Lines changed: 124 additions & 0 deletions

File tree

crates/ironrdp-server/src/server.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,130 @@ impl RdpServer {
769769
Ok(())
770770
}
771771

772+
/// Run a single RDP connection over a byte stream that has ALREADY been
773+
/// transport-encrypted at a lower layer (typically a WSS terminator in a
774+
/// proxy-shaped deployment).
775+
///
776+
/// This is the proxy-shaped counterpart to [`run_connection`]: it walks
777+
/// the same per-connection state machine but skips the IronRDP-managed
778+
/// TLS handshake step, because the caller's `stream` is already past TLS.
779+
/// After [`accept_begin`] reaches the security-upgrade gate,
780+
/// [`Acceptor::mark_security_upgrade_as_done`] advances the state machine
781+
/// without performing TLS, then the standard finalization runs.
782+
///
783+
/// # Use case
784+
///
785+
/// The motivating use case is the [RDCleanPath] protocol used by
786+
/// Devolutions Gateway and Cloudflare's IronRDP-WASM deployment: an
787+
/// RDCleanPath-aware client connects via WSS to a proxy (or, in the
788+
/// embedded shape supported by this method, to a server that has its
789+
/// own WSS terminator). The WSS layer terminates TLS end-to-end with
790+
/// the client; the server side sees a post-TLS plaintext byte stream
791+
/// and must not re-run TLS on it.
792+
///
793+
/// # Preconditions (caller MUST guarantee)
794+
///
795+
/// 1. The `stream` is already transport-encrypted by another layer
796+
/// (WSS, in-process, etc.). Calling this method on a plain TCP
797+
/// stream exposes RDP traffic in plaintext on the wire.
798+
///
799+
/// 2. The connecting RDP client speaks a proxy protocol that informs
800+
/// it that TLS happened at a lower layer (e.g. RDCleanPath). Vanilla
801+
/// RDP clients (mstsc, xfreerdp) negotiate TLS based on the X.224
802+
/// selectedProtocol and have no concept of "TLS already done at a
803+
/// lower layer": they will hang or fail. Vanilla clients must use
804+
/// [`run_connection`] over the standard TCP+TLS path.
805+
///
806+
/// 3. If `self.opts.security` is [`RdpServerSecurity::Hybrid`], the
807+
/// caller is responsible for ensuring the client supports CredSSP
808+
/// over this transport. The CredSSP exchange itself does not require
809+
/// the underlying transport's TLS (CredSSP carries its own crypto
810+
/// via TSRequest), so it works the same as in [`run_connection`].
811+
///
812+
/// [RDCleanPath]: https://docs.rs/ironrdp-rdcleanpath
813+
///
814+
/// # Wire-level invariant
815+
///
816+
/// This method does NOT alter the X.224 negotiation. The acceptor still
817+
/// advertises whatever `SecurityProtocol` it was constructed with, and the
818+
/// connecting client still negotiates as normal. The only behaviour change
819+
/// is that after the negotiation reaches the security-upgrade gate, no
820+
/// TLS handshake is performed on the byte stream, because the caller's
821+
/// stream is already past TLS at a lower layer (typically WSS).
822+
///
823+
/// Earlier attempts to express "TLS already done" via wire-level signalling
824+
/// (a new `RdpServerSecurity::PreSecured` variant that advertised
825+
/// `PROTOCOL_SSL` while skipping the actual TLS handshake) failed
826+
/// interop with vanilla clients: `PROTOCOL_SSL` selected on the X.224
827+
/// wire is a mandatory TLS signal per MS-RDPBCGR, with no provision for
828+
/// "TLS already happened elsewhere". RDCleanPath sidesteps this at a
829+
/// higher layer rather than at the wire layer; that distinction is the
830+
/// invariant this method preserves.
831+
///
832+
/// # Synchronization with [`run_connection`]
833+
///
834+
/// The body of this method mirrors [`run_connection`] except for the
835+
/// single `tls_acceptor.accept(stream).await` call. If `run_connection`
836+
/// is modified upstream, this method must be updated to match.
837+
// NOTE: keep in sync with the ShouldUpgrade arm of run_connection above.
838+
pub async fn run_connection_pre_authenticated<S>(&mut self, stream: S) -> Result<()>
839+
where
840+
S: AsyncRead + AsyncWrite + Send + Sync + Unpin,
841+
{
842+
let framed = TokioFramed::new(stream);
843+
844+
let size = self.display.lock().await.size().await;
845+
let capabilities = capabilities::capabilities(&self.opts, size);
846+
let mut acceptor = Acceptor::new(self.opts.security.flag(), size, capabilities, self.creds.clone());
847+
848+
self.attach_channels(&mut acceptor);
849+
850+
let res = ironrdp_acceptor::accept_begin(framed, &mut acceptor)
851+
.await
852+
.context("accept_begin failed")?;
853+
854+
match res {
855+
BeginResult::ShouldUpgrade(inner_stream) => {
856+
// The stream is already post-TLS (WSS terminator did the TLS at
857+
// a lower layer). Re-wrap the inner stream in a fresh Framed;
858+
// we do NOT call tls_acceptor.accept here.
859+
let mut framed = TokioFramed::new(inner_stream);
860+
861+
acceptor.mark_security_upgrade_as_done();
862+
863+
if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security {
864+
// Generic streams don't expose peer address. Use a neutral
865+
// placeholder; it's unclear whether CredSSP/NTLM actually
866+
// uses this value in practice.
867+
let client_name = "rdp-client".to_owned();
868+
869+
ironrdp_acceptor::accept_credssp(
870+
&mut framed,
871+
&mut acceptor,
872+
&mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(),
873+
client_name.into(),
874+
pub_key.clone(),
875+
None,
876+
)
877+
.await?;
878+
}
879+
880+
let framed = self.accept_finalize(framed, acceptor).await?;
881+
debug!("Shutting down pre-authenticated stream");
882+
let (mut inner, _) = framed.into_inner();
883+
if let Err(e) = inner.shutdown().await {
884+
debug!(?e, "pre-authenticated stream shutdown error");
885+
}
886+
}
887+
888+
BeginResult::Continue(framed) => {
889+
self.accept_finalize(framed, acceptor).await?;
890+
}
891+
};
892+
893+
Ok(())
894+
}
895+
772896
pub async fn run(&mut self) -> Result<()> {
773897
// Create socket with control over options before binding.
774898
// Using TcpSocket instead of TcpListener::bind() allows setting

0 commit comments

Comments
 (0)