Skip to content

Commit 87432e8

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 0282d18 commit 87432e8

1 file changed

Lines changed: 123 additions & 1 deletion

File tree

crates/ironrdp-server/src/server.rs

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use ironrdp_cliprdr::backend::ClipboardMessage;
1111
use ironrdp_core::{decode, encode_vec, impl_as_any};
1212
use ironrdp_displaycontrol::pdu::DisplayControlMonitorLayout;
1313
use ironrdp_displaycontrol::server::{DisplayControlHandler, DisplayControlServer};
14+
use ironrdp_dvc as dvc;
1415
use ironrdp_pdu::input::InputEventPdu;
1516
use ironrdp_pdu::input::fast_path::{FastPathInput, FastPathInputEvent};
1617
use ironrdp_pdu::mcs::{SendDataIndication, SendDataRequest};
@@ -19,6 +20,7 @@ pub use ironrdp_pdu::rdp::client_info::Credentials;
1920
use ironrdp_pdu::rdp::headers::{ServerDeactivateAll, ShareControlPdu};
2021
use ironrdp_pdu::x224::X224;
2122
use ironrdp_pdu::{Action, PduResult, decode_err, mcs, nego, rdp};
23+
use ironrdp_rdpsnd as rdpsnd;
2224
use ironrdp_svc::{ChannelFlags, StaticChannelId, StaticChannelSet, SvcProcessor, server_encode_svc_messages};
2325
use ironrdp_tokio::{FramedRead, FramedWrite, TokioFramed, split_tokio_framed, unsplit_tokio_framed};
2426
use rdpsnd::server::{RdpsndServer, RdpsndServerMessage};
@@ -28,7 +30,6 @@ use tokio::sync::{Mutex, mpsc, oneshot};
2830
use tokio::task;
2931
use tokio_rustls::TlsAcceptor;
3032
use tracing::{debug, error, trace, warn};
31-
use {ironrdp_dvc as dvc, ironrdp_rdpsnd as rdpsnd};
3233

3334
use crate::autodetect::{AutoDetectManager, RttSnapshot};
3435
use crate::clipboard::CliprdrServerFactory;
@@ -523,6 +524,127 @@ impl RdpServer {
523524
Ok(())
524525
}
525526

527+
/// Run a single RDP connection over a byte stream that has ALREADY been
528+
/// transport-encrypted at a lower layer (typically a WSS terminator in a
529+
/// proxy-shaped deployment).
530+
///
531+
/// This is the proxy-shaped counterpart to [`run_connection`]: it walks
532+
/// the same per-connection state machine but skips the IronRDP-managed
533+
/// TLS handshake step, because the caller's `stream` is already past TLS.
534+
/// After [`accept_begin`] reaches the security-upgrade gate,
535+
/// [`Acceptor::mark_security_upgrade_as_done`] advances the state machine
536+
/// without performing TLS, then the standard finalization runs.
537+
///
538+
/// # Use case
539+
///
540+
/// The motivating use case is the [RDCleanPath] protocol used by
541+
/// Devolutions Gateway and Cloudflare's IronRDP-WASM deployment: an
542+
/// RDCleanPath-aware client connects via WSS to a proxy (or, in the
543+
/// embedded shape supported by this method, to a server that has its
544+
/// own WSS terminator). The WSS layer terminates TLS end-to-end with
545+
/// the client; the server side sees a post-TLS plaintext byte stream
546+
/// and must not re-run TLS on it.
547+
///
548+
/// # Preconditions (caller MUST guarantee)
549+
///
550+
/// 1. The `stream` is already transport-encrypted by another layer
551+
/// (WSS, in-process, etc.). Calling this method on a plain TCP
552+
/// stream exposes RDP traffic in plaintext on the wire.
553+
///
554+
/// 2. The connecting RDP client speaks a proxy protocol that informs
555+
/// it that TLS happened at a lower layer (e.g. RDCleanPath). Vanilla
556+
/// RDP clients (mstsc, xfreerdp) negotiate TLS based on the X.224
557+
/// selectedProtocol and have no concept of "TLS already done at a
558+
/// lower layer" — they will hang or fail. Vanilla clients must use
559+
/// [`run_connection`] over the standard TCP+TLS path.
560+
///
561+
/// 3. If `self.opts.security` is [`RdpServerSecurity::Hybrid`], the
562+
/// caller is responsible for ensuring the client supports CredSSP
563+
/// over this transport. The CredSSP exchange itself does not require
564+
/// the underlying transport's TLS (CredSSP carries its own crypto
565+
/// via TSRequest), so it works the same as in [`run_connection`].
566+
///
567+
/// [RDCleanPath]: https://docs.rs/ironrdp-rdcleanpath
568+
///
569+
/// # Wire-level invariant
570+
///
571+
/// This method does NOT alter the X.224 negotiation. The acceptor still
572+
/// advertises whatever `SecurityProtocol` it was constructed with, and the
573+
/// connecting client still negotiates as normal. The only behaviour change
574+
/// is that after the negotiation reaches the security-upgrade gate, no
575+
/// TLS handshake is performed on the byte stream — because the caller's
576+
/// stream is already past TLS at a lower layer (typically WSS).
577+
///
578+
/// Earlier attempts to express "TLS already done" via wire-level signalling
579+
/// (a new `RdpServerSecurity::PreSecured` variant that advertised
580+
/// `PROTOCOL_SSL` while skipping the actual TLS handshake) failed
581+
/// interop with vanilla clients — `PROTOCOL_SSL` selected on the X.224
582+
/// wire is a mandatory TLS signal per MS-RDPBCGR, with no provision for
583+
/// "TLS already happened elsewhere". RDCleanPath sidesteps this at a
584+
/// higher layer rather than at the wire layer; that distinction is the
585+
/// invariant this method preserves.
586+
///
587+
/// # Synchronization with [`run_connection`]
588+
///
589+
/// The body of this method mirrors [`run_connection`] except for the
590+
/// single `tls_acceptor.accept(stream).await` call. If `run_connection`
591+
/// is modified upstream, this method must be updated to match.
592+
// NOTE: keep in sync with run_connection ShouldUpgrade arm (currently server.rs:477-518).
593+
pub async fn run_connection_pre_authenticated<S>(&mut self, stream: S) -> Result<()>
594+
where
595+
S: AsyncRead + AsyncWrite + Send + Sync + Unpin,
596+
{
597+
let framed = TokioFramed::new(stream);
598+
599+
let size = self.display.lock().await.size().await;
600+
let capabilities = capabilities::capabilities(&self.opts, size);
601+
let mut acceptor = Acceptor::new(self.opts.security.flag(), size, capabilities, self.creds.clone());
602+
603+
self.attach_channels(&mut acceptor);
604+
605+
let res = ironrdp_acceptor::accept_begin(framed, &mut acceptor)
606+
.await
607+
.context("accept_begin failed")?;
608+
609+
match res {
610+
BeginResult::ShouldUpgrade(inner_stream) => {
611+
// The stream is already post-TLS (WSS terminator did the TLS at
612+
// a lower layer). Re-wrap the inner stream in a fresh Framed —
613+
// we do NOT call tls_acceptor.accept here.
614+
let mut framed = TokioFramed::new(inner_stream);
615+
616+
acceptor.mark_security_upgrade_as_done();
617+
618+
if let RdpServerSecurity::Hybrid((_, pub_key)) = &self.opts.security {
619+
let client_name = "rdp-client".to_owned();
620+
621+
ironrdp_acceptor::accept_credssp(
622+
&mut framed,
623+
&mut acceptor,
624+
&mut ironrdp_tokio::reqwest::ReqwestNetworkClient::new(),
625+
client_name.into(),
626+
pub_key.clone(),
627+
None,
628+
)
629+
.await?;
630+
}
631+
632+
let framed = self.accept_finalize(framed, acceptor).await?;
633+
debug!("Shutting down pre-authenticated stream");
634+
let (mut inner, _) = framed.into_inner();
635+
if let Err(e) = inner.shutdown().await {
636+
debug!(?e, "pre-authenticated stream shutdown error");
637+
}
638+
}
639+
640+
BeginResult::Continue(framed) => {
641+
self.accept_finalize(framed, acceptor).await?;
642+
}
643+
};
644+
645+
Ok(())
646+
}
647+
526648
pub async fn run(&mut self) -> Result<()> {
527649
// Create socket with control over options before binding.
528650
// Using TcpSocket instead of TcpListener::bind() allows setting

0 commit comments

Comments
 (0)