11use std:: io:: { self , ErrorKind } ;
22use std:: net:: SocketAddr ;
33use std:: sync:: Arc ;
4+ use std:: time:: Duration ;
45
56use anyhow:: Context as _;
67use ironrdp_pdu:: nego;
@@ -11,6 +12,9 @@ use thiserror::Error;
1112use tokio:: io:: { AsyncRead , AsyncReadExt as _, AsyncWrite , AsyncWriteExt as _} ;
1213use tracing:: field;
1314
15+ /// MS-RDPEPS upper bound for transmitting the complete Preconnection Blob after TCP connect.
16+ const PCB_TRANSMIT_DEADLINE : Duration = Duration :: from_secs ( 10 ) ;
17+
1418use crate :: config:: Conf ;
1519use crate :: credential_injection_kdc:: { CredentialInjectionKdc , CredentialService } ;
1620use crate :: proxy:: Proxy ;
@@ -233,6 +237,50 @@ struct ConnectedRdpServer {
233237 x224_rsp : Option < Vec < u8 > > ,
234238}
235239
240+ /// Explicit VMConnect request: no X.224 and a non-empty Unicode PCB V2 payload.
241+ ///
242+ /// Matches IronRDP `RDCleanPathMessage::VmConnectRequest` / `new_vmconnect_request`.
243+ fn is_vmconnect_request ( cleanpath_pdu : & RDCleanPathPdu ) -> bool {
244+ cleanpath_pdu. x224_connection_pdu . is_none ( )
245+ && cleanpath_pdu
246+ . preconnection_blob
247+ . as_ref ( )
248+ . is_some_and ( |pcb| !pcb. trim ( ) . is_empty ( ) )
249+ }
250+
251+ /// Encode the Hyper-V PCB V2 that the proxy writes before TLS.
252+ ///
253+ /// `payload` is the opaque Unicode string from RDCleanPath (`GUID` or `GUID;EnhancedMode=1`).
254+ fn encode_vmconnect_pcb_v2 ( payload : String ) -> anyhow:: Result < Vec < u8 > > {
255+ let pcb = ironrdp_pdu:: pcb:: PreconnectionBlob {
256+ id : 0 ,
257+ version : ironrdp_pdu:: pcb:: PcbVersion :: V2 ,
258+ v2_payload : Some ( payload) ,
259+ } ;
260+ ironrdp_core:: encode_vec ( & pcb) . context ( "encode VMConnect preconnection blob" )
261+ }
262+
263+ /// Cert-chain-only success response after VMConnect PCB + TLS (no X.224).
264+ ///
265+ /// Wire-compatible with IronRDP `RDCleanPathMessage::VmConnectResponse`.
266+ fn build_vmconnect_response (
267+ server_addr : String ,
268+ x509_chain : impl IntoIterator < Item = Vec < u8 > > ,
269+ ) -> anyhow:: Result < RDCleanPathPdu > {
270+ Ok ( RDCleanPathPdu {
271+ version : ironrdp_rdcleanpath:: VERSION_1 ,
272+ server_cert_chain : Some (
273+ x509_chain
274+ . into_iter ( )
275+ . map ( OctetString :: new)
276+ . collect :: < Result < _ , _ > > ( )
277+ . context ( "build VMConnect RDCleanPath cert chain" ) ?,
278+ ) ,
279+ server_addr : Some ( server_addr) ,
280+ ..RDCleanPathPdu :: default ( )
281+ } )
282+ }
283+
236284/// Establish a connection to the RDP server and perform the requested front sequence.
237285///
238286/// The routing pipeline (explicit agent → subnet/domain match → direct) is shared with
@@ -271,40 +319,54 @@ async fn connect_rdp_server(
271319 debug ! ( %selected_target, "Connected to destination server" ) ;
272320 tracing:: Span :: current ( ) . record ( "target" , selected_target. to_string ( ) ) ;
273321
274- let is_vmconnect = cleanpath_pdu. x224_connection_pdu . is_none ( )
275- && cleanpath_pdu
276- . preconnection_blob
277- . as_ref ( )
278- . is_some_and ( |pcb| !pcb. trim ( ) . is_empty ( ) ) ;
322+ // MS-RDPEPS: complete the PCB write within 10s of TCP connect. Bound the front write(s)
323+ // from this point so a stalled tunnel/target cannot hold the PCB open indefinitely.
324+ let front_deadline = tokio:: time:: Instant :: now ( ) + PCB_TRANSMIT_DEADLINE ;
279325
280- let x224_rsp = if is_vmconnect {
326+ let x224_rsp = if is_vmconnect_request ( & cleanpath_pdu ) {
281327 // Client sent Unicode PCB payload only; proxy encodes binary PCB V2 and skips X.224.
282328 let pcb_payload = cleanpath_pdu
283329 . preconnection_blob
284330 . context ( "VMConnect request missing preconnection_blob" )
285331 . map_err ( CleanPathError :: BadRequest ) ?;
286- let pcb = ironrdp_pdu:: pcb:: PreconnectionBlob {
287- id : 0 ,
288- version : ironrdp_pdu:: pcb:: PcbVersion :: V2 ,
289- v2_payload : Some ( pcb_payload) ,
290- } ;
291- let pcb = ironrdp_core:: encode_vec ( & pcb)
292- . context ( "encode VMConnect preconnection blob" )
293- . map_err ( CleanPathError :: BadRequest ) ?;
332+ let pcb = encode_vmconnect_pcb_v2 ( pcb_payload) . map_err ( CleanPathError :: BadRequest ) ?;
294333 debug ! ( pcb_len = pcb. len( ) , "Writing encoded VMConnect PCB before TLS" ) ;
295- server_stream. write_all ( & pcb) . await ?;
334+ tokio:: time:: timeout_at ( front_deadline, async {
335+ server_stream. write_all ( & pcb) . await ?;
336+ // Ensure the Hyper-V listener sees the PCB before ClientHello is queued
337+ // (especially on agent-tunnel legs that may buffer).
338+ server_stream. flush ( ) . await
339+ } )
340+ . await
341+ . map_err ( |_| {
342+ CleanPathError :: Io ( io:: Error :: new (
343+ ErrorKind :: TimedOut ,
344+ "timed out writing VMConnect preconnection blob" ,
345+ ) )
346+ } ) ??;
296347 None
297348 } else {
298349 // Ordinary: optional legacy complete PCB bytes, then X.224 CR/CC, then TLS.
299- if let Some ( pcb) = cleanpath_pdu. preconnection_blob {
300- server_stream. write_all ( pcb. as_bytes ( ) ) . await ?;
301- }
350+ tokio:: time:: timeout_at ( front_deadline, async {
351+ if let Some ( pcb) = cleanpath_pdu. preconnection_blob {
352+ server_stream. write_all ( pcb. as_bytes ( ) ) . await ?;
353+ }
302354
303- let x224_req = cleanpath_pdu
304- . x224_connection_pdu
305- . context ( "request is missing X224 connection PDU" )
306- . map_err ( CleanPathError :: BadRequest ) ?;
307- server_stream. write_all ( x224_req. as_bytes ( ) ) . await ?;
355+ let x224_req = cleanpath_pdu
356+ . x224_connection_pdu
357+ . context ( "request is missing X224 connection PDU" )
358+ . map_err ( CleanPathError :: BadRequest ) ?;
359+ server_stream. write_all ( x224_req. as_bytes ( ) ) . await ?;
360+ server_stream. flush ( ) . await ?;
361+ Ok :: < _ , CleanPathError > ( ( ) )
362+ } )
363+ . await
364+ . map_err ( |_| {
365+ CleanPathError :: Io ( io:: Error :: new (
366+ ErrorKind :: TimedOut ,
367+ "timed out writing RDCleanPath front sequence" ,
368+ ) )
369+ } ) ??;
308370
309371 trace ! ( "Receiving X224 response" ) ;
310372
@@ -575,6 +637,14 @@ pub async fn handle(
575637 && let Some ( entry) = credentials. get ( jti)
576638 && entry. mapping . is_some ( )
577639 {
640+ // VMConnect needs pre-X.224 CredSSP against the Hyper-V host cert on the client.
641+ // Proxy CredSSP MITM is X.224-first and is not supported for this ordering.
642+ if is_vmconnect_request ( & cleanpath_pdu) {
643+ let response = RDCleanPathPdu :: new_http_error ( 400 ) ;
644+ send_clean_path_response ( & mut client_stream, & response) . await ?;
645+ anyhow:: bail!( "credential injection is not supported for VMConnect RDCleanPath" ) ;
646+ }
647+
578648 let credential_injection_kdc = credentials. kdc_for ( jti) ?;
579649 anyhow:: ensure!( token == credential_injection_kdc. raw_token( ) , "token mismatch" ) ;
580650 debug ! (
@@ -653,17 +723,7 @@ pub async fn handle(
653723 RDCleanPathPdu :: new_response ( server_addr. to_string ( ) , x224_rsp, x509_chain)
654724 . context ( "build RDCleanPath response" ) ?
655725 } else {
656- RDCleanPathPdu {
657- version : ironrdp_rdcleanpath:: VERSION_1 ,
658- server_cert_chain : Some (
659- x509_chain
660- . map ( OctetString :: new)
661- . collect :: < Result < _ , _ > > ( )
662- . context ( "build VMConnect RDCleanPath cert chain" ) ?,
663- ) ,
664- server_addr : Some ( server_addr. to_string ( ) ) ,
665- ..RDCleanPathPdu :: default ( )
666- }
726+ build_vmconnect_response ( server_addr. to_string ( ) , x509_chain) . context ( "build VMConnect RDCleanPath response" ) ?
667727 } ;
668728
669729 send_clean_path_response ( & mut client_stream, & rdcleanpath_rsp) . await ?;
@@ -863,3 +923,83 @@ impl From<&io::Error> for WsaError {
863923 }
864924 }
865925}
926+
927+ #[ cfg( test) ]
928+ mod tests {
929+ use super :: * ;
930+
931+ fn empty_x224 ( ) -> OctetString {
932+ OctetString :: new ( vec ! [ 0x03 , 0x00 , 0x00 , 0x13 ] ) . expect ( "static X.224 bytes" )
933+ }
934+
935+ #[ test]
936+ fn detects_vmconnect_when_pcb_payload_present_without_x224 ( ) {
937+ let pdu = RDCleanPathPdu {
938+ version : ironrdp_rdcleanpath:: VERSION_1 ,
939+ destination : Some ( "10.10.0.3:2179" . to_owned ( ) ) ,
940+ proxy_auth : Some ( "token" . to_owned ( ) ) ,
941+ preconnection_blob : Some ( "21c82e1f-2368-43d5-9cb6-a7c99c449bba;EnhancedMode=1" . to_owned ( ) ) ,
942+ ..RDCleanPathPdu :: default ( )
943+ } ;
944+ assert ! ( is_vmconnect_request( & pdu) ) ;
945+ }
946+
947+ #[ test]
948+ fn ordinary_request_with_x224_is_not_vmconnect ( ) {
949+ let pdu = RDCleanPathPdu {
950+ version : ironrdp_rdcleanpath:: VERSION_1 ,
951+ destination : Some ( "10.10.0.3:3389" . to_owned ( ) ) ,
952+ proxy_auth : Some ( "token" . to_owned ( ) ) ,
953+ preconnection_blob : Some ( "legacy-pcb-bytes" . to_owned ( ) ) ,
954+ x224_connection_pdu : Some ( empty_x224 ( ) ) ,
955+ ..RDCleanPathPdu :: default ( )
956+ } ;
957+ assert ! ( !is_vmconnect_request( & pdu) ) ;
958+ }
959+
960+ #[ test]
961+ fn empty_or_whitespace_pcb_without_x224_is_not_vmconnect ( ) {
962+ for pcb in [ None , Some ( String :: new ( ) ) , Some ( " " . to_owned ( ) ) ] {
963+ let pdu = RDCleanPathPdu {
964+ version : ironrdp_rdcleanpath:: VERSION_1 ,
965+ destination : Some ( "10.10.0.3:2179" . to_owned ( ) ) ,
966+ proxy_auth : Some ( "token" . to_owned ( ) ) ,
967+ preconnection_blob : pcb,
968+ ..RDCleanPathPdu :: default ( )
969+ } ;
970+ assert ! ( !is_vmconnect_request( & pdu) ) ;
971+ }
972+ }
973+
974+ #[ test]
975+ fn encodes_enhanced_pcb_v2_matching_lab_size ( ) {
976+ // Lab GUID with EnhancedMode; IronRDP observed 122-byte PCB on the wire.
977+ let payload = "21c82e1f-2368-43d5-9cb6-a7c99c449bba;EnhancedMode=1" . to_owned ( ) ;
978+ let bytes = encode_vmconnect_pcb_v2 ( payload. clone ( ) ) . expect ( "encode" ) ;
979+ assert_eq ! ( bytes. len( ) , 122 ) ;
980+
981+ let decoded: ironrdp_pdu:: pcb:: PreconnectionBlob = ironrdp_core:: decode ( & bytes) . expect ( "decode round-trip" ) ;
982+ assert_eq ! ( decoded. id, 0 ) ;
983+ assert_eq ! ( decoded. version, ironrdp_pdu:: pcb:: PcbVersion :: V2 ) ;
984+ assert_eq ! ( decoded. v2_payload. as_deref( ) , Some ( payload. as_str( ) ) ) ;
985+ }
986+
987+ #[ test]
988+ fn encodes_basic_pcb_v2_matching_lab_size ( ) {
989+ let payload = "21c82e1f-2368-43d5-9cb6-a7c99c449bba" . to_owned ( ) ;
990+ let bytes = encode_vmconnect_pcb_v2 ( payload) . expect ( "encode" ) ;
991+ assert_eq ! ( bytes. len( ) , 92 ) ;
992+ }
993+
994+ #[ test]
995+ fn vmconnect_response_has_cert_chain_without_x224 ( ) {
996+ let rsp = build_vmconnect_response ( "10.10.0.3:2179" . to_owned ( ) , [ vec ! [ 0xDE , 0xAD ] , vec ! [ 0xBE , 0xEF ] ] )
997+ . expect ( "build response" ) ;
998+
999+ assert_eq ! ( rsp. version, ironrdp_rdcleanpath:: VERSION_1 ) ;
1000+ assert_eq ! ( rsp. server_addr. as_deref( ) , Some ( "10.10.0.3:2179" ) ) ;
1001+ assert ! ( rsp. x224_connection_pdu. is_none( ) ) ;
1002+ assert_eq ! ( rsp. server_cert_chain. as_ref( ) . map( |c| c. len( ) ) . unwrap_or( 0 ) , 2 ) ;
1003+ assert ! ( rsp. error. is_none( ) ) ;
1004+ }
1005+ }
0 commit comments