From c111230dda3a09555fa3acfee36a5f60f0f983cc Mon Sep 17 00:00:00 2001 From: bernie-g Date: Sun, 10 May 2026 20:50:06 -0400 Subject: [PATCH 01/19] feat(pam): add browser-based RDP session support and recording Add RDCleanPath protocol support, browser RDP session handling via Gateway, and session recording capture for Windows RDP connections. --- packages/gateway-v2/gateway.go | 12 +- .../pam/handlers/rdp/bridge_cgo_shared.go | 56 ++-- packages/pam/handlers/rdp/bridge_cgo_unix.go | 67 ++-- .../pam/handlers/rdp/bridge_cgo_windows.go | 59 +++- packages/pam/handlers/rdp/bridge_stub.go | 13 + packages/pam/handlers/rdp/native/Cargo.lock | 10 + packages/pam/handlers/rdp/native/Cargo.toml | 1 + .../handlers/rdp/native/include/rdp_bridge.h | 31 ++ .../pam/handlers/rdp/native/src/bridge.rs | 16 +- packages/pam/handlers/rdp/native/src/caps.rs | 98 ++++++ .../pam/handlers/rdp/native/src/config.rs | 41 ++- packages/pam/handlers/rdp/native/src/ffi.rs | 177 +++++++++- packages/pam/handlers/rdp/native/src/lib.rs | 2 + .../handlers/rdp/native/src/rdcleanpath.rs | 309 ++++++++++++++++++ packages/pam/handlers/rdp/proxy.go | 11 +- packages/pam/local/database-proxy.go | 1 + packages/pam/local/rdp-proxy.go | 22 +- packages/pam/pam-proxy.go | 9 +- 18 files changed, 841 insertions(+), 94 deletions(-) create mode 100644 packages/pam/handlers/rdp/native/src/caps.rs create mode 100644 packages/pam/handlers/rdp/native/src/rdcleanpath.rs diff --git a/packages/gateway-v2/gateway.go b/packages/gateway-v2/gateway.go index 07c32120..acb288b2 100644 --- a/packages/gateway-v2/gateway.go +++ b/packages/gateway-v2/gateway.go @@ -34,6 +34,7 @@ const ( ForwardModeHTTP ForwardMode = "HTTP" ForwardModeTCP ForwardMode = "TCP" ForwardModePAM ForwardMode = "PAM" + ForwardModePAMRDPBrowser ForwardMode = "PAM_RDP_BROWSER" ForwardModePAMCancellation ForwardMode = "PAM_CANCELLATION" ForwardModePAMCapabilities ForwardMode = "PAM_CAPABILITIES" ForwardModePing ForwardMode = "PING" @@ -586,7 +587,7 @@ func (g *Gateway) setupTLSConfig() error { ClientCAs: clientCAPool, ClientAuth: tls.RequireAndVerifyClientCert, MinVersion: tls.VersionTLS12, - NextProtos: []string{"infisical-http-proxy", "infisical-tcp-proxy", "infisical-ping", "infisical-pam-proxy", "infisical-pam-session-cancellation", "infisical-pam-capabilities"}, + NextProtos: []string{"infisical-http-proxy", "infisical-tcp-proxy", "infisical-ping", "infisical-pam-proxy", "infisical-pam-rdp-browser", "infisical-pam-session-cancellation", "infisical-pam-capabilities"}, } return nil @@ -749,11 +750,12 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) { log.Info().Msg("TCP proxy handler completed") } return - } else if forwardConfig.Mode == ForwardModePAM { + } else if forwardConfig.Mode == ForwardModePAM || forwardConfig.Mode == ForwardModePAMRDPBrowser { sessionCtx, sessionCancel := context.WithCancel(g.ctx) g.RegisterPAMSession(forwardConfig.PAMConfig.SessionId, sessionCancel, tlsConn) defer g.DeregisterPAMSession(forwardConfig.PAMConfig.SessionId, tlsConn) - if err := pam.HandlePAMProxy(sessionCtx, tlsConn, &forwardConfig.PAMConfig, g.httpClient); err != nil { + browserRDP := forwardConfig.Mode == ForwardModePAMRDPBrowser + if err := pam.HandlePAMProxy(sessionCtx, tlsConn, &forwardConfig.PAMConfig, g.httpClient, browserRDP); err != nil { if err.Error() == "unexpected EOF" { log.Debug().Err(err).Msg("PAM proxy handler ended with unexpected connection termination") } else { @@ -816,6 +818,10 @@ func (g *Gateway) parseForwardConfigFromALPN(tlsConn *tls.Conn, reader *bufio.Re config.Mode = ForwardModePAM return config, nil + case "infisical-pam-rdp-browser": + config.Mode = ForwardModePAMRDPBrowser + return config, nil + case "infisical-pam-session-cancellation": config.Mode = ForwardModePAMCancellation return config, nil diff --git a/packages/pam/handlers/rdp/bridge_cgo_shared.go b/packages/pam/handlers/rdp/bridge_cgo_shared.go index e6d8c7d1..b2e155f1 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_shared.go +++ b/packages/pam/handlers/rdp/bridge_cgo_shared.go @@ -20,6 +20,34 @@ import ( ) func (p *RDPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) error { + return p.handleConnectionWith(ctx, clientConn, func() (*Bridge, error) { + return StartWithReadWriter( + clientConn, + p.config.TargetHost, + p.config.TargetPort, + p.config.InjectUsername, + p.config.InjectPassword, + p.config.InjectDomain, + ) + }) +} + +// HandleConnectionRDCleanPath is the browser-flow variant (RDCleanPath instead of X.224). +func (p *RDPProxy) HandleConnectionRDCleanPath(ctx context.Context, clientConn net.Conn) error { + return p.handleConnectionWith(ctx, clientConn, func() (*Bridge, error) { + return StartRDCleanPathWithReadWriter( + clientConn, + p.config.TargetHost, + p.config.TargetPort, + p.config.InjectUsername, + p.config.InjectPassword, + p.config.InjectDomain, + BrowserAcceptorUsername, + ) + }) +} + +func (p *RDPProxy) handleConnectionWith(ctx context.Context, clientConn net.Conn, start func() (*Bridge, error)) error { defer clientConn.Close() if p.config.SessionLogger != nil { defer func() { @@ -27,38 +55,24 @@ func (p *RDPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) er }() } - bridge, err := StartWithReadWriter( - clientConn, - p.config.TargetHost, - p.config.TargetPort, - p.config.InjectUsername, - p.config.InjectPassword, - p.config.InjectDomain, - ) + bridge, err := start() if err != nil { return fmt.Errorf("rdp proxy: start bridge: %w", err) } defer bridge.Close() - // Drain bridge tap events into the session logger. The Rust side closes - // the events channel when the session ends, so the goroutine exits via - // PollEnded without needing an explicit shutdown signal. drainCtx, cancelDrain := context.WithCancel(ctx) drainDone := make(chan struct{}) go func() { defer close(drainDone) drainBridgeEvents(drainCtx, bridge, p.config.SessionLogger, p.config.SessionID, p.config.SessionStartedAt) }() - // Wait for the drain to finish naturally on the normal-end path so the - // tail of the recording isn't dropped: PollEnded fires after the Rust - // side closes the events channel (post bridge.Wait return). Cancellation - // paths trigger cancelDrain() explicitly below to bail early. + // Let drain finish so recording tail isn't dropped; cancel paths bail early defer func() { select { case <-drainDone: case <-time.After(2 * pollTimeout): } - // Always release the drain context (no-op if already cancelled). cancelDrain() }() @@ -95,8 +109,7 @@ func (b *Bridge) Wait() error { } } -// Cancel is idempotent and safe from any goroutine, including -// concurrently with Wait. +// Cancel is idempotent and safe from any goroutine. func (b *Bridge) Cancel() error { rc := C.rdp_bridge_cancel(C.uint64_t(b.handle)) if rc == C.RDP_BRIDGE_INVALID_HANDLE { @@ -120,9 +133,7 @@ func (b *Bridge) Close() error { // True when the real bridge is compiled in (vs the stub). func IsSupported() bool { return true } -// PollEvent drains one tap event with the given timeout. The returned Event -// is only meaningful when result == PollOK. PollEvent is not safe to call -// concurrently for the same Bridge; serialize calls in a single goroutine. +// PollEvent drains one tap event. Not safe to call concurrently for the same Bridge. func (b *Bridge) PollEvent(timeout time.Duration) (PollResult, Event, error) { timeoutMs := timeout.Milliseconds() if timeoutMs < 0 { @@ -164,8 +175,7 @@ func (b *Bridge) PollEvent(timeout time.Duration) (PollResult, Event, error) { ev.X = uint16(raw.value_a) ev.Y = uint16(raw.value_b) case EventTypeTargetFrame: - // Always free the libc-malloc'd buffer Rust handed us, even if - // the copy below is empty -- ownership transfer is unconditional. + // Ownership transferred from Rust; always free even if empty if raw.payload_ptr != nil { defer C.free(unsafe.Pointer(raw.payload_ptr)) if raw.payload_len > 0 { diff --git a/packages/pam/handlers/rdp/bridge_cgo_unix.go b/packages/pam/handlers/rdp/bridge_cgo_unix.go index f5d3f454..b074faf1 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_unix.go +++ b/packages/pam/handlers/rdp/bridge_cgo_unix.go @@ -21,19 +21,25 @@ import ( ) // StartWithConn hands an independent dup of conn's fd to the bridge. -// For TLS-wrapped or otherwise non-fd-backed conns, use StartWithReadWriter. -// `domain` is empty for local accounts; set to the AD domain name for -// domain-joined NTLM CredSSP. func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { dupFd, err := dupConnFD(conn) if err != nil { return nil, fmt.Errorf("rdp bridge: dup client fd: %w", err) } - return startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain) + return startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain, "") } -// Ownership of dupFd transfers to Rust on success; we close it on failure. -func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { +// StartRDCleanPathWithConn is the browser-flow analog of StartWithConn. +func StartRDCleanPathWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { + dupFd, err := dupConnFD(conn) + if err != nil { + return nil, fmt.Errorf("rdp bridge: dup client fd: %w", err) + } + return startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain, acceptorUsername) +} + +// Ownership of dupFd transfers to Rust on success; closed on failure. +func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { success := false defer func() { if !success { @@ -48,7 +54,6 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, cPass := C.CString(password) defer C.free(unsafe.Pointer(cPass)) - // Empty domain -> NULL pointer; bridge treats both the same way. var cDomain *C.char if domain != "" { cDomain = C.CString(domain) @@ -56,15 +61,31 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, } var handle C.uint64_t - rc := C.rdp_bridge_start_unix_fd( - C.int(dupFd), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - &handle, - ) + var rc C.int32_t + if acceptorUsername == "" { + rc = C.rdp_bridge_start_unix_fd( + C.int(dupFd), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + &handle, + ) + } else { + cAcceptor := C.CString(acceptorUsername) + defer C.free(unsafe.Pointer(cAcceptor)) + rc = C.rdp_bridge_start_rdcleanpath_unix_fd( + C.int(dupFd), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + cAcceptor, + &handle, + ) + } if rc != C.RDP_BRIDGE_OK { return nil, fmt.Errorf("rdp bridge: start failed (status %d)", int32(rc)) } @@ -72,9 +93,17 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, return &Bridge{handle: uint64(handle)}, nil } -// Adapts an fd-less Go byte stream to the Rust bridge (which needs a real fd -// for tokio's TcpStream::from_raw_fd) by routing through a loopback TCP pair. +// Routes fd-less Go streams through a loopback TCP pair for tokio. func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { + return startWithReadWriterCommon(rw, targetHost, targetPort, username, password, domain, "") +} + +// Browser-flow analog of StartWithReadWriter. +func StartRDCleanPathWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { + return startWithReadWriterCommon(rw, targetHost, targetPort, username, password, domain, acceptorUsername) +} + +func startWithReadWriterCommon(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("rdp bridge: loopback listen: %w", err) @@ -109,7 +138,7 @@ func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, return nil, fmt.Errorf("rdp bridge: dup accepted fd: %w", err) } - bridge, err := startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain) + bridge, err := startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain, acceptorUsername) if err != nil { _ = peer.Close() return nil, err diff --git a/packages/pam/handlers/rdp/bridge_cgo_windows.go b/packages/pam/handlers/rdp/bridge_cgo_windows.go index d706b8ee..6608d430 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_windows.go +++ b/packages/pam/handlers/rdp/bridge_cgo_windows.go @@ -26,10 +26,20 @@ func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username if err != nil { return nil, fmt.Errorf("rdp bridge: dup client socket: %w", err) } - return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain) + return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain, "") } -func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { +// Browser-flow analog of StartWithConn. +func StartRDCleanPathWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { + dupSocket, err := dupConnSocket(conn) + if err != nil { + return nil, fmt.Errorf("rdp bridge: dup client socket: %w", err) + } + return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain, acceptorUsername) +} + +// Empty acceptorUsername selects native flow; non-empty selects RDCleanPath. +func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { success := false defer func() { if !success { @@ -51,15 +61,31 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor } var handle C.uint64_t - rc := C.rdp_bridge_start_windows_socket( - C.uintptr_t(dupSocket), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - &handle, - ) + var rc C.int32_t + if acceptorUsername == "" { + rc = C.rdp_bridge_start_windows_socket( + C.uintptr_t(dupSocket), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + &handle, + ) + } else { + cAcceptor := C.CString(acceptorUsername) + defer C.free(unsafe.Pointer(cAcceptor)) + rc = C.rdp_bridge_start_rdcleanpath_windows_socket( + C.uintptr_t(dupSocket), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + cAcceptor, + &handle, + ) + } if rc != C.RDP_BRIDGE_OK { return nil, fmt.Errorf("rdp bridge: start failed (status %d)", int32(rc)) } @@ -68,6 +94,15 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor } func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { + return startWithReadWriterCommon(rw, targetHost, targetPort, username, password, domain, "") +} + +// Browser-flow analog of StartWithReadWriter. +func StartRDCleanPathWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { + return startWithReadWriterCommon(rw, targetHost, targetPort, username, password, domain, acceptorUsername) +} + +func startWithReadWriterCommon(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain, acceptorUsername string) (*Bridge, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return nil, fmt.Errorf("rdp bridge: loopback listen: %w", err) @@ -102,7 +137,7 @@ func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, return nil, fmt.Errorf("rdp bridge: dup accepted socket: %w", err) } - bridge, err := startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain) + bridge, err := startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain, acceptorUsername) if err != nil { _ = peer.Close() return nil, err diff --git a/packages/pam/handlers/rdp/bridge_stub.go b/packages/pam/handlers/rdp/bridge_stub.go index 28da7815..aa7f9b13 100644 --- a/packages/pam/handlers/rdp/bridge_stub.go +++ b/packages/pam/handlers/rdp/bridge_stub.go @@ -17,15 +17,28 @@ func StartWithConn(_ net.Conn, _ string, _ uint16, _, _, _ string) (*Bridge, err return nil, ErrRdpUnavailable } +func StartRDCleanPathWithConn(_ net.Conn, _ string, _ uint16, _, _, _, _ string) (*Bridge, error) { + return nil, ErrRdpUnavailable +} + func StartWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _, _ string) (*Bridge, error) { return nil, ErrRdpUnavailable } +func StartRDCleanPathWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _, _, _ string) (*Bridge, error) { + return nil, ErrRdpUnavailable +} + func (p *RDPProxy) HandleConnection(_ context.Context, clientConn net.Conn) error { _ = clientConn.Close() return ErrRdpUnavailable } +func (p *RDPProxy) HandleConnectionRDCleanPath(_ context.Context, clientConn net.Conn) error { + _ = clientConn.Close() + return ErrRdpUnavailable +} + func (b *Bridge) Wait() error { return ErrRdpUnavailable } func (b *Bridge) Cancel() error { return ErrRdpUnavailable } func (b *Bridge) Close() error { return ErrRdpUnavailable } diff --git a/packages/pam/handlers/rdp/native/Cargo.lock b/packages/pam/handlers/rdp/native/Cargo.lock index c4652505..edfc4a04 100644 --- a/packages/pam/handlers/rdp/native/Cargo.lock +++ b/packages/pam/handlers/rdp/native/Cargo.lock @@ -1311,6 +1311,7 @@ dependencies = [ "ironrdp-connector", "ironrdp-core", "ironrdp-pdu", + "ironrdp-rdcleanpath", "ironrdp-tls", "ironrdp-tokio", "libc", @@ -1448,6 +1449,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "ironrdp-rdcleanpath" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e9011b8f48be356a51c75757a09075af787601a03542815424da493d2d2757" +dependencies = [ + "der 0.7.10", +] + [[package]] name = "ironrdp-svc" version = "0.6.0" diff --git a/packages/pam/handlers/rdp/native/Cargo.toml b/packages/pam/handlers/rdp/native/Cargo.toml index cb53a5d2..52ef126c 100644 --- a/packages/pam/handlers/rdp/native/Cargo.toml +++ b/packages/pam/handlers/rdp/native/Cargo.toml @@ -17,6 +17,7 @@ ironrdp-core = "0.1" ironrdp-tokio = { version = "0.8", features = ["reqwest"] } ironrdp-pdu = "0.7" ironrdp-tls = { version = "0.2", features = ["rustls"] } +ironrdp-rdcleanpath = "0.2" x509-cert = { version = "0.2", features = ["std"] } libc = "0.2" diff --git a/packages/pam/handlers/rdp/native/include/rdp_bridge.h b/packages/pam/handlers/rdp/native/include/rdp_bridge.h index 150c6b6f..9d9f39cc 100644 --- a/packages/pam/handlers/rdp/native/include/rdp_bridge.h +++ b/packages/pam/handlers/rdp/native/include/rdp_bridge.h @@ -44,6 +44,37 @@ int32_t rdp_bridge_start_windows_socket( ); #endif +/* Browser-flow start. Same as the native start, plus `acceptor_username`: + * the username the browser is configured to present during the acceptor's + * CredSSP exchange (decoupled from the real target username injected into + * the connector). The browser speaks RDCleanPath over the inbound stream; + * the gateway's WS upstream has already stripped framing. */ +#if defined(__unix__) || defined(__APPLE__) +int32_t rdp_bridge_start_rdcleanpath_unix_fd( + int client_fd, + const char *target_host, + uint16_t target_port, + const char *username, + const char *password, + const char *domain, + const char *acceptor_username, + uint64_t *out_handle +); +#endif + +#if defined(_WIN32) || defined(_WIN64) +int32_t rdp_bridge_start_rdcleanpath_windows_socket( + uintptr_t client_socket, + const char *target_host, + uint16_t target_port, + const char *username, + const char *password, + const char *domain, + const char *acceptor_username, + uint64_t *out_handle +); +#endif + int32_t rdp_bridge_wait(uint64_t handle); int32_t rdp_bridge_cancel(uint64_t handle); int32_t rdp_bridge_free(uint64_t handle); diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index 98503597..434bd5f5 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -118,7 +118,7 @@ async fn run_mitm_inner( bridge_pdus(client_framed, target_framed, tx).await } -async fn bridge_pdus( +pub async fn bridge_pdus( client_framed: ironrdp_tokio::TokioFramed, target_framed: ironrdp_tokio::TokioFramed, tx: EventSender, @@ -465,7 +465,7 @@ fn decode_fast_path_input(frame: &[u8]) -> anyhow::Result { // wrong value, and target servers reject mismatched echoes) // - clear CS_NET.channels so the target doesn't try to open virtual // channels (clipboard, drives, audio, USB) the bridge can't service -async fn filter_client_mcs_connect_initial( +pub(crate) async fn filter_client_mcs_connect_initial( client_stream: &mut ErasedStream, target_stream: &mut ErasedStream, leftover: bytes::BytesMut, @@ -538,8 +538,8 @@ async fn run_acceptor_half( client_tcp: TcpStream, username: String, ) -> Result<(ErasedStream, bytes::BytesMut)> { - let (server_tls, acceptor_public_key) = - build_acceptor_tls().context("build acceptor TLS config")?; + let (server_tls, acceptor_public_key, _cert_der) = + build_acceptor_tls_with_cert().context("build acceptor TLS config")?; let server_tls = Arc::new(server_tls); let acceptor_framed = ironrdp_tokio::TokioFramed::new(client_tcp); @@ -716,7 +716,7 @@ where // Replicated from ironrdp-async's private perform_credssp_step so we can // stop before connect_finalize (which would start MCS/capability exchange). -async fn perform_connector_credssp( +pub(crate) async fn perform_connector_credssp( connector: &mut ClientConnector, framed: &mut ironrdp_tokio::TokioFramed, network_client: &mut ReqwestNetworkClient, @@ -799,7 +799,8 @@ where Ok(()) } -fn build_acceptor_tls() -> Result<(tokio_rustls::rustls::ServerConfig, Vec)> { +pub(crate) fn build_acceptor_tls_with_cert( +) -> Result<(tokio_rustls::rustls::ServerConfig, Vec, Vec)> { use x509_cert::der::Decode; let subject_alt_names = vec!["localhost".to_string(), "infisical-rdp-bridge".to_string()]; @@ -813,13 +814,14 @@ fn build_acceptor_tls() -> Result<(tokio_rustls::rustls::ServerConfig, Vec)> .ok_or_else(|| anyhow::anyhow!("extract public key from self-signed cert"))? .to_vec(); + let cert_der_bytes = cert_der.as_ref().to_vec(); let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(cert.key_pair.serialize_der().into()); let config = tokio_rustls::rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert(vec![cert_der], key_der) .context("rustls ServerConfig")?; - Ok((config, public_key)) + Ok((config, public_key, cert_der_bytes)) } pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} diff --git a/packages/pam/handlers/rdp/native/src/caps.rs b/packages/pam/handlers/rdp/native/src/caps.rs new file mode 100644 index 00000000..44cd00b7 --- /dev/null +++ b/packages/pam/handlers/rdp/native/src/caps.rs @@ -0,0 +1,98 @@ +//! Capability sets advertised by the acceptor (browser-facing half). +//! +//! Copied almost verbatim from `ironrdp-server::capabilities`. The goal is a +//! conservative, widely-supported set so both the inbound client and the +//! outbound target agree on formats without transcoding. Advanced codecs +//! (RemoteFX, EGFX) are left out deliberately: we want both handshakes to +//! land on basic bitmap. + +use ironrdp_pdu::rdp::capability_sets::{ + self, BitmapCodecs, BitmapDrawingFlags, CapabilitySet, CmdFlags, GeneralExtraFlags, InputFlags, + OrderFlags, OrderSupportExFlags, VirtualChannelFlags, server_codecs_capabilities, +}; + +const DEFAULT_WIDTH: u16 = 1920; +const DEFAULT_HEIGHT: u16 = 1080; +const MULTIFRAGMENT_MAX_REQUEST_SIZE: u32 = 8 * 1024 * 1024; + +pub fn acceptor_capabilities(width: u16, height: u16) -> Vec { + vec![ + CapabilitySet::General(general()), + CapabilitySet::Bitmap(bitmap(width, height)), + CapabilitySet::Order(order()), + CapabilitySet::SurfaceCommands(surface()), + CapabilitySet::Pointer(pointer()), + CapabilitySet::Input(input()), + CapabilitySet::VirtualChannel(virtual_channel()), + CapabilitySet::MultiFragmentUpdate(capability_sets::MultifragmentUpdate { + max_request_size: MULTIFRAGMENT_MAX_REQUEST_SIZE, + }), + // Advertise RemoteFX to the browser-side client. Matched by + // the connector config so both handshakes agree and the bridge + // forwards RFX-encoded bitmap updates byte-for-byte. + CapabilitySet::BitmapCodecs( + server_codecs_capabilities(&[]).unwrap_or_else(|_| BitmapCodecs(Vec::new())), + ), + ] +} + +pub fn default_desktop_size() -> (u16, u16) { + (DEFAULT_WIDTH, DEFAULT_HEIGHT) +} + +fn general() -> capability_sets::General { + capability_sets::General { + extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED, + ..Default::default() + } +} + +fn bitmap(width: u16, height: u16) -> capability_sets::Bitmap { + capability_sets::Bitmap { + pref_bits_per_pix: 32, + desktop_width: width, + desktop_height: height, + desktop_resize_flag: true, + drawing_flags: BitmapDrawingFlags::empty(), + } +} + +fn order() -> capability_sets::Order { + capability_sets::Order::new(OrderFlags::empty(), OrderSupportExFlags::empty(), 2048, 224) +} + +fn surface() -> capability_sets::SurfaceCommands { + capability_sets::SurfaceCommands { + flags: CmdFlags::all(), + } +} + +fn pointer() -> capability_sets::Pointer { + capability_sets::Pointer { + color_pointer_cache_size: 2048, + pointer_cache_size: 2048, + } +} + +fn input() -> capability_sets::Input { + capability_sets::Input { + input_flags: InputFlags::SCANCODES + | InputFlags::MOUSE_RELATIVE + | InputFlags::MOUSEX + | InputFlags::FASTPATH_INPUT + | InputFlags::UNICODE + | InputFlags::FASTPATH_INPUT_2, + keyboard_layout: 0, + keyboard_type: None, + keyboard_subtype: 0, + keyboard_function_key: 128, + keyboard_ime_filename: String::new(), + } +} + +fn virtual_channel() -> capability_sets::VirtualChannel { + capability_sets::VirtualChannel { + flags: VirtualChannelFlags::NO_COMPRESSION, + chunk_size: None, + } +} diff --git a/packages/pam/handlers/rdp/native/src/config.rs b/packages/pam/handlers/rdp/native/src/config.rs index d959fe18..d70a3d1f 100644 --- a/packages/pam/handlers/rdp/native/src/config.rs +++ b/packages/pam/handlers/rdp/native/src/config.rs @@ -3,7 +3,7 @@ use ironrdp_connector::{BitmapConfig, Config, Credentials, DesktopSize}; use ironrdp_pdu::gcc::KeyboardType; -use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, MajorPlatformType}; +use ironrdp_pdu::rdp::capability_sets::{client_codecs_capabilities, BitmapCodecs, MajorPlatformType}; use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; pub const DEFAULT_WIDTH: u16 = 1920; @@ -54,3 +54,42 @@ pub fn connector_config(username: String, password: String, domain: Option Config { + Config { + desktop_size: DesktopSize { width, height }, + desktop_scale_factor: 0, + + enable_tls: false, + enable_credssp: true, + + credentials: Credentials::UsernamePassword { username, password }, + domain: None, + + client_build: 0, + client_name: "infisical-pam".to_owned(), + keyboard_type: KeyboardType::IbmEnhanced, + keyboard_subtype: 0, + keyboard_functional_keys_count: 12, + keyboard_layout: 0, + ime_file_name: String::new(), + + bitmap: Some(BitmapConfig { + lossy_compression: false, + color_depth: 32, + codecs: client_codecs_capabilities(&[]).unwrap_or_else(|_| BitmapCodecs(Vec::new())), + }), + dig_product_id: String::new(), + client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), + platform: MajorPlatformType::UNSPECIFIED, + hardware_id: None, + request_data: None, + autologon: false, + enable_audio_playback: false, + performance_flags: PerformanceFlags::default(), + license_cache: None, + timezone_info: TimezoneInfo::default(), + enable_server_pointer: false, + pointer_software_rendering: false, + } +} diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index fb637e99..9a5f2d89 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -16,6 +16,7 @@ use tracing::{error, info}; use crate::bridge::{run_mitm, TargetEndpoint}; use crate::events::{self, SessionEvent}; +use crate::rdcleanpath::run_mitm_rdcleanpath; pub const RDP_BRIDGE_OK: i32 = 0; pub const RDP_BRIDGE_SESSION_ERROR: i32 = 1; @@ -189,6 +190,19 @@ unsafe fn c_str_to_owned(ptr: *const c_char) -> Option { .map(str::to_owned) } +/// Selects which MITM flow runs on the spawned session thread. Both flows +/// share the connector / event-tap halves and only differ in how the +/// acceptor is bootstrapped. +enum SessionFlow { + /// Native client → CLI loopback → gateway. Acceptor does X.224 + TLS + + /// CredSSP normally. + Native, + /// Browser → backend WS pump → gateway. Acceptor short-circuits X.224 + /// via RDCleanPath; `acceptor_username` is the browser-presented NLA + /// username (typically a fixed string like "infisical"). + Rdcleanpath { acceptor_username: String }, +} + fn spawn_session( client_tcp: StdTcpStream, host: String, @@ -196,6 +210,7 @@ fn spawn_session( username: String, password: String, domain: Option, + flow: SessionFlow, ) -> anyhow::Result { client_tcp.set_nonblocking(true)?; let cancel = CancellationToken::new(); @@ -218,7 +233,21 @@ fn spawn_session( password, domain, }; - run_mitm(client, endpoint, cancel_for_thread, events_tx).await + match flow { + SessionFlow::Native => { + run_mitm(client, endpoint, cancel_for_thread, events_tx).await + } + SessionFlow::Rdcleanpath { acceptor_username } => { + run_mitm_rdcleanpath( + client, + endpoint, + acceptor_username, + cancel_for_thread, + events_tx, + ) + .await + } + } }) })?; @@ -267,7 +296,15 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( use std::os::unix::io::FromRawFd; let client_tcp = unsafe { StdTcpStream::from_raw_fd(client_fd) }; - match spawn_session(client_tcp, host, target_port, username, password, domain) { + match spawn_session( + client_tcp, + host, + target_port, + username, + password, + domain, + SessionFlow::Native, + ) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK @@ -313,7 +350,15 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( use std::os::windows::io::{FromRawSocket, RawSocket}; let client_tcp = unsafe { StdTcpStream::from_raw_socket(client_socket as RawSocket) }; - match spawn_session(client_tcp, host, target_port, username, password, domain) { + match spawn_session( + client_tcp, + host, + target_port, + username, + password, + domain, + SessionFlow::Native, + ) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK @@ -325,6 +370,130 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( } } +/// Browser-flow start. Same shape as `rdp_bridge_start_unix_fd`, plus +/// `acceptor_username`: the username the browser is configured to present +/// during the acceptor-side CredSSP exchange (decoupled from the real +/// target username we inject into the connector). +/// +/// # Safety +/// +/// `client_fd` ownership transfers to the bridge on OK, stays with the +/// caller on error. Strings must be NUL-terminated valid UTF-8. +#[cfg(unix)] +#[no_mangle] +pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_unix_fd( + client_fd: std::ffi::c_int, + target_host: *const c_char, + target_port: u16, + username: *const c_char, + password: *const c_char, + domain: *const c_char, + acceptor_username: *const c_char, + out_handle: *mut u64, +) -> i32 { + if out_handle.is_null() { + return RDP_BRIDGE_BAD_ARG; + } + let host = match unsafe { c_str_to_owned(target_host) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let username = match unsafe { c_str_to_owned(username) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let password = match unsafe { c_str_to_owned(password) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); + let acceptor_username = match unsafe { c_str_to_owned(acceptor_username) } { + Some(v) if !v.is_empty() => v, + _ => return RDP_BRIDGE_BAD_ARG, + }; + + use std::os::unix::io::FromRawFd; + let client_tcp = unsafe { StdTcpStream::from_raw_fd(client_fd) }; + + match spawn_session( + client_tcp, + host, + target_port, + username, + password, + domain, + SessionFlow::Rdcleanpath { acceptor_username }, + ) { + Ok(id) => { + unsafe { *out_handle = id }; + RDP_BRIDGE_OK + } + Err(e) => { + error!(error = ?e, "rdp_bridge_start_rdcleanpath_unix_fd: failed to spawn session"); + RDP_BRIDGE_RUNTIME_ERROR + } + } +} + +/// # Safety +/// +/// See `rdp_bridge_start_rdcleanpath_unix_fd`. +#[cfg(windows)] +#[no_mangle] +pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_windows_socket( + client_socket: usize, + target_host: *const c_char, + target_port: u16, + username: *const c_char, + password: *const c_char, + domain: *const c_char, + acceptor_username: *const c_char, + out_handle: *mut u64, +) -> i32 { + if out_handle.is_null() { + return RDP_BRIDGE_BAD_ARG; + } + let host = match unsafe { c_str_to_owned(target_host) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let username = match unsafe { c_str_to_owned(username) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let password = match unsafe { c_str_to_owned(password) } { + Some(v) => v, + None => return RDP_BRIDGE_BAD_ARG, + }; + let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); + let acceptor_username = match unsafe { c_str_to_owned(acceptor_username) } { + Some(v) if !v.is_empty() => v, + _ => return RDP_BRIDGE_BAD_ARG, + }; + + use std::os::windows::io::{FromRawSocket, RawSocket}; + let client_tcp = unsafe { StdTcpStream::from_raw_socket(client_socket as RawSocket) }; + + match spawn_session( + client_tcp, + host, + target_port, + username, + password, + domain, + SessionFlow::Rdcleanpath { acceptor_username }, + ) { + Ok(id) => { + unsafe { *out_handle = id }; + RDP_BRIDGE_OK + } + Err(e) => { + error!(error = ?e, "rdp_bridge_start_rdcleanpath_windows_socket: failed to spawn session"); + RDP_BRIDGE_RUNTIME_ERROR + } + } +} + #[no_mangle] pub extern "C" fn rdp_bridge_wait(handle: u64) -> i32 { let join = { @@ -343,12 +512,10 @@ pub extern "C" fn rdp_bridge_wait(handle: u64) -> i32 { } Ok(Err(e)) => { error!(handle, error = ?e, "rdp_bridge_wait: session failed"); - eprintln!("rdp bridge session failed (handle={handle}): {e:?}"); RDP_BRIDGE_SESSION_ERROR } Err(panic) => { error!(handle, "rdp_bridge_wait: session thread panicked"); - eprintln!("rdp bridge session thread panicked (handle={handle}): {panic:?}"); RDP_BRIDGE_THREAD_PANIC } }, diff --git a/packages/pam/handlers/rdp/native/src/lib.rs b/packages/pam/handlers/rdp/native/src/lib.rs index abb6f0bd..b92496dd 100644 --- a/packages/pam/handlers/rdp/native/src/lib.rs +++ b/packages/pam/handlers/rdp/native/src/lib.rs @@ -4,6 +4,8 @@ pub mod bridge; pub mod cap_filter; +pub mod caps; pub mod config; pub mod events; pub mod ffi; +pub mod rdcleanpath; diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs new file mode 100644 index 00000000..44dec494 --- /dev/null +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -0,0 +1,309 @@ +use anyhow::{anyhow, Context, Result}; +use ironrdp_acceptor::{accept_finalize, Acceptor, AcceptorResult, DesktopSize as AcceptorDesktopSize}; +use ironrdp_connector::{ClientConnector, Sequence}; +use ironrdp_core::{encode_buf, WriteBuf}; +use ironrdp_pdu::nego::{ + ConnectionConfirm, ConnectionRequest, RequestFlags, ResponseFlags, SecurityProtocol, +}; +use ironrdp_pdu::rdp::client_info::Credentials as AcceptorCredentials; +use ironrdp_pdu::x224::X224; +use ironrdp_rdcleanpath::{DetectionResult, RDCleanPath, RDCleanPathPdu}; +use ironrdp_tokio::reqwest::ReqwestNetworkClient; +use ironrdp_tokio::{ + connect_finalize, mark_as_upgraded, skip_connect_begin, FramedWrite, TokioFramed, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::{timeout, Duration}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, warn}; + +use crate::bridge::{bridge_pdus, ErasedStream, TargetEndpoint}; +use crate::caps::{acceptor_capabilities, default_desktop_size}; +use crate::config::connector_config_browser; +use crate::events::EventSender; + +pub async fn run_mitm_rdcleanpath( + client_tcp: TcpStream, + target: TargetEndpoint, + acceptor_username: String, + cancel: CancellationToken, + tx: EventSender, +) -> Result<()> { + tokio::select! { + result = handle_browser_session(client_tcp, target, acceptor_username, tx) => result, + _ = cancel.cancelled() => { + info!("rdcleanpath session canceled by caller"); + Ok(()) + } + } +} + +async fn handle_browser_session( + mut client_tcp: TcpStream, + target: TargetEndpoint, + acceptor_username: String, + tx: EventSender, +) -> Result<()> { + info!(host = %target.host, port = target.port, "rdcleanpath: starting browser session"); + let _ = rustls::crypto::ring::default_provider().install_default(); + + debug!("rdcleanpath: reading RDCleanPath request from client"); + let request_pdu = read_rdcleanpath_pdu(&mut client_tcp) + .await + .context("read RDCleanPath Request")?; + debug!("rdcleanpath: received RDCleanPath request"); + let request = request_pdu + .into_enum() + .map_err(|e| anyhow!("RDCleanPath enum: {e}"))?; + let (x224_cr, destination) = match request { + RDCleanPath::Request { + destination, + x224_connection_request, + .. + } => (x224_connection_request.as_bytes().to_vec(), destination), + other => anyhow::bail!("expected RDCleanPath::Request, got {other:?}"), + }; + info!(destination, "RDCleanPath: received Request"); + + debug!("rdcleanpath: connecting to target"); + let target_tcp = TcpStream::connect((target.host.as_str(), target.port)) + .await + .with_context(|| format!("connect target {}:{}", target.host, target.port))?; + debug!("rdcleanpath: target TCP connected"); + let target_addr = target_tcp.local_addr().context("local_addr")?; + let mut target_framed = TokioFramed::new(target_tcp); + + target_framed + .write_all(&x224_cr) + .await + .context("write X.224 CR to target")?; + let x224_cc_target = read_tpkt_pdu(&mut target_framed) + .await + .context("read X.224 CC")?; + debug!(len = x224_cc_target.len(), "received X.224 CC from target"); + + let (initial_stream, leftover) = target_framed.into_inner(); + debug!("rdcleanpath: TLS upgrading target"); + let (upgraded_stream, target_cert) = ironrdp_tls::upgrade(initial_stream, &target.host) + .await + .context("TLS upgrade to target")?; + debug!("rdcleanpath: target TLS upgraded"); + + let throwaway_cert_der = generate_throwaway_cert().context("throwaway cert")?; + + let fake_cc_bytes = encode_x224(X224(ConnectionConfirm::Response { + flags: ResponseFlags::empty(), + protocol: SecurityProtocol::SSL, + })) + .context("encode SSL-only X.224 CC")?; + + let response = RDCleanPathPdu::new_response( + format!("{}:{}", target.host, target.port), + fake_cc_bytes, + std::iter::once(throwaway_cert_der), + ) + .map_err(|e| anyhow!("build RDCleanPath Response: {e:?}"))?; + let response_der = response + .to_der() + .map_err(|e| anyhow!("encode RDCleanPath Response: {e:?}"))?; + client_tcp + .write_all(&response_der) + .await + .context("write RDCleanPath Response to client")?; + debug!(len = response_der.len(), "rdcleanpath: sent RDCleanPath response"); + + let (width, height) = default_desktop_size(); + + let config = connector_config_browser( + target.username.clone(), + target.password.clone(), + width, + height, + ); + let mut connector = ClientConnector::new(config, target_addr); + + let mut scratch = WriteBuf::new(); + connector + .step_no_input(&mut scratch) + .map_err(|e| anyhow!("connector step_no_input: {e:?}"))?; + scratch.clear(); + connector + .step(&x224_cc_target, &mut scratch) + .map_err(|e| anyhow!("connector step CC: {e:?}"))?; + + let should_upgrade = skip_connect_begin(&mut connector); + let upgraded = mark_as_upgraded(should_upgrade, &mut connector); + + let target_erased: ErasedStream = Box::new(upgraded_stream); + let mut target_upgraded_framed = TokioFramed::new_with_leftover(target_erased, leftover); + + let server_public_key = ironrdp_tls::extract_tls_server_public_key(&target_cert) + .ok_or_else(|| anyhow!("extract target public key"))?; + + debug!("rdcleanpath: running connect_finalize on target"); + let connection_result = connect_finalize( + upgraded, + connector, + &mut target_upgraded_framed, + &mut ReqwestNetworkClient::new(), + ironrdp_connector::ServerName::new(&target.host), + server_public_key.to_owned(), + None, + ) + .await + .map_err(|e| anyhow!("target connect_finalize: {e:?}"))?; + info!( + width = connection_result.desktop_size.width, + height = connection_result.desktop_size.height, + "rdcleanpath: target reached active stage" + ); + + let placeholder_creds = AcceptorCredentials { + username: if acceptor_username.is_empty() { + "infisical".to_owned() + } else { + acceptor_username + }, + password: "infisical".to_owned(), + domain: None, + }; + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + AcceptorDesktopSize { width, height }, + acceptor_capabilities(width, height), + Some(placeholder_creds), + ); + + let fake_cr_bytes = encode_x224(X224(ConnectionRequest { + nego_data: None, + flags: RequestFlags::empty(), + protocol: SecurityProtocol::SSL, + })) + .context("encode SSL-only X.224 CR")?; + + let mut acc_scratch = WriteBuf::new(); + acceptor + .step(&fake_cr_bytes, &mut acc_scratch) + .map_err(|e| anyhow!("acceptor step CR: {e:?}"))?; + acc_scratch.clear(); + acceptor + .step(&[], &mut acc_scratch) + .map_err(|e| anyhow!("acceptor step empty: {e:?}"))?; + acc_scratch.clear(); + + if acceptor.reached_security_upgrade().is_none() { + anyhow::bail!("acceptor did not reach SecurityUpgrade after synthetic CR/CC"); + } + acceptor.mark_security_upgrade_as_done(); + + let client_erased: ErasedStream = Box::new(client_tcp); + let client_framed: TokioFramed = TokioFramed::new(client_erased); + + debug!("rdcleanpath: running accept_finalize on client"); + let (client_final_framed, acceptor_result): (TokioFramed, AcceptorResult) = + accept_finalize(client_framed, &mut acceptor) + .await + .map_err(|e| anyhow!("accept_finalize: {e:?}"))?; + info!( + user_ch = acceptor_result.user_channel_id, + io_ch = acceptor_result.io_channel_id, + "rdcleanpath: client reached active stage" + ); + + debug!("rdcleanpath: bridging PDUs"); + bridge_pdus(client_final_framed, target_upgraded_framed, tx).await +} + +const RDCLEANPATH_READ_TIMEOUT: Duration = Duration::from_secs(30); + +async fn read_rdcleanpath_pdu(tcp: &mut TcpStream) -> Result { + timeout(RDCLEANPATH_READ_TIMEOUT, read_rdcleanpath_pdu_inner(tcp)) + .await + .map_err(|_| anyhow!("timed out waiting for RDCleanPath PDU ({}s)", RDCLEANPATH_READ_TIMEOUT.as_secs()))? +} + +async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result { + let mut buf = Vec::with_capacity(512); + loop { + let mut chunk = [0u8; 512]; + let n = tcp + .read(&mut chunk) + .await + .context("read RDCleanPath bytes")?; + if n == 0 { + anyhow::bail!("peer closed during RDCleanPath PDU read"); + } + buf.extend_from_slice(&chunk[..n]); + match RDCleanPathPdu::detect(&buf) { + DetectionResult::Detected { total_length, .. } => { + if buf.len() >= total_length { + if buf.len() > total_length { + warn!( + extra = buf.len() - total_length, + "extra bytes after RDCleanPath PDU; ignoring" + ); + buf.truncate(total_length); + } + return RDCleanPathPdu::from_der(&buf) + .map_err(|e| anyhow!("decode RDCleanPath PDU: {e:?}")); + } + } + DetectionResult::NotEnoughBytes => {} + DetectionResult::Failed => { + anyhow::bail!("not a valid RDCleanPath PDU"); + } + } + if buf.len() > 64 * 1024 { + anyhow::bail!("RDCleanPath PDU exceeded 64KB while incomplete"); + } + } +} + +async fn read_tpkt_pdu(framed: &mut TokioFramed) -> Result> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static, +{ + let (stream, _leftover) = framed.get_inner_mut(); + let mut header = [0u8; 4]; + stream + .read_exact(&mut header) + .await + .context("read TPKT header")?; + if header[0] != 0x03 { + anyhow::bail!("not a TPKT frame: {:02x}", header[0]); + } + let total_len = u16::from_be_bytes([header[2], header[3]]) as usize; + if total_len < 4 { + anyhow::bail!("TPKT length too small"); + } + let mut body = vec![0u8; total_len - 4]; + stream + .read_exact(&mut body) + .await + .context("read TPKT body")?; + let mut full = Vec::with_capacity(total_len); + full.extend_from_slice(&header); + full.extend_from_slice(&body); + Ok(full) +} + +fn encode_x224(pdu: T) -> Result> +where + T: ironrdp_core::Encode, +{ + let mut buf = WriteBuf::new(); + encode_buf(&pdu, &mut buf).map_err(|e| anyhow!("encode X.224 PDU: {e:?}"))?; + Ok(buf.filled().to_vec()) +} + +fn generate_throwaway_cert() -> Result> { + let subject_alt_names = vec![ + "localhost".to_string(), + "infisical-rdp-gateway".to_string(), + ]; + let cert = + rcgen::generate_simple_self_signed(subject_alt_names).context("generate self-signed cert")?; + Ok(cert.cert.der().to_vec()) +} + diff --git a/packages/pam/handlers/rdp/proxy.go b/packages/pam/handlers/rdp/proxy.go index 1c3f38cb..658f5c13 100644 --- a/packages/pam/handlers/rdp/proxy.go +++ b/packages/pam/handlers/rdp/proxy.go @@ -16,14 +16,10 @@ type RDPProxyConfig struct { TargetPort uint16 InjectUsername string InjectPassword string - // Empty for local accounts; AD domain name (e.g. "CORP.EXAMPLE.COM") for - // domain-joined NTLM CredSSP. Backend session credentials populate this. InjectDomain string SessionID string SessionLogger session.SessionLogger - // Session-anchored origin for elapsedNs. The Rust bridge restarts its - // own clock per RDP client connection; we rewrite each event's elapsedNs - // against this anchor so timestamps stay monotonic across reconnects. + // Rewrite bridge elapsedNs against this anchor so timestamps stay monotonic across reconnects SessionStartedAt time.Time } @@ -35,6 +31,9 @@ func NewRDPProxy(config RDPProxyConfig) *RDPProxy { return &RDPProxy{config: config} } +// Fixed NLA username the browser presents; carries no security weight +const BrowserAcceptorUsername = "infisical" + // Wire envelopes carried inside TerminalEvent.Data for ChannelType=RDP. type rdpTargetFrameEnvelope struct { Type string `json:"type"` // "target_frame" @@ -71,8 +70,6 @@ const pollTimeout = 250 * time.Millisecond var errUnknownRdpEventType = errors.New("rdp: unknown event type") -// Logger errors are warned but don't stop the drain; dropping one event is -// better than back-pressuring the bridge byte stream. func drainBridgeEvents(ctx context.Context, b *Bridge, logger session.SessionLogger, sessionID string, sessionStartedAt time.Time) { if logger == nil { return diff --git a/packages/pam/local/database-proxy.go b/packages/pam/local/database-proxy.go index 9f51f4e3..ef1cf6d5 100644 --- a/packages/pam/local/database-proxy.go +++ b/packages/pam/local/database-proxy.go @@ -26,6 +26,7 @@ type ALPN string const ( ALPNInfisicalPAMProxy ALPN = "infisical-pam-proxy" + ALPNInfisicalPAMRDPBrowser ALPN = "infisical-pam-rdp-browser" ALPNInfisicalPAMCancellation ALPN = "infisical-pam-session-cancellation" ALPNInfisicalPAMCapabilities ALPN = "infisical-pam-capabilities" ) diff --git a/packages/pam/local/rdp-proxy.go b/packages/pam/local/rdp-proxy.go index a128e763..de021915 100644 --- a/packages/pam/local/rdp-proxy.go +++ b/packages/pam/local/rdp-proxy.go @@ -164,8 +164,7 @@ func (p *RDPProxyServer) gracefulShutdown() { p.shutdownOnce.Do(func() { log.Info().Msg("Starting graceful shutdown of RDP proxy...") - // p.cancel() below can return main before this goroutine finishes; - // remove the .rdp file before risking that race. + // Remove before cancel() can return main if p.rdpFilePath != "" { if err := os.Remove(p.rdpFilePath); err != nil && !os.IsNotExist(err) { log.Debug().Err(err).Str("path", p.rdpFilePath).Msg("Failed to remove .rdp file on exit") @@ -233,9 +232,7 @@ func (p *RDPProxyServer) Run() { } } -// handleConnection forwards bytes between the RDP client and the gateway -// tunnel. Identical shape to the database proxy; the gateway's RDP -// handler takes over on the other side. +// handleConnection forwards bytes between the RDP client and the gateway tunnel. func (p *RDPProxyServer) handleConnection(clientConn net.Conn) { defer func() { clientConn.Close() @@ -306,8 +303,7 @@ func (p *RDPProxyServer) handleConnection(clientConn net.Conn) { log.Info().Msgf("RDP connection closed for client: %s", clientConn.RemoteAddr().String()) } -// Generates a per-session .rdp file under ~/.infisical/rdp/ pointing at -// the loopback listener. Removed on graceful shutdown. +// Generates a per-session .rdp file; removed on graceful shutdown. func writeRDPFile(listenPort int, sessionID, username string) (string, error) { filename := fmt.Sprintf("infisical-rdp-%s.rdp", sessionID) @@ -320,10 +316,7 @@ func writeRDPFile(listenPort int, sessionID, username string) (string, error) { } path := filepath.Join(dir, filename) - // authentication level:i:0 -> mstsc connects even if it can't verify the - // server's TLS cert. The bridge presents a self-signed cert, so without - // this mstsc terminates with "unexpected server authentication certificate". - // FreeRDP/Windows App ignore the cert by default; mstsc is the strict one. + // auth level 0: bridge presents self-signed cert, mstsc rejects without this content := fmt.Sprintf( "full address:s:127.0.0.1:%d\r\n"+ "username:s:%s\r\n"+ @@ -338,8 +331,7 @@ func writeRDPFile(listenPort int, sessionID, username string) (string, error) { return path, nil } -// rdpFileDir returns ~/.infisical/rdp (the conventional per-user state -// location for CLI data; see util.CONFIG_FOLDER_NAME). +// rdpFileDir returns ~/.infisical/rdp. func rdpFileDir() (string, error) { home, err := util.GetHomeDir() if err != nil { @@ -348,9 +340,7 @@ func rdpFileDir() (string, error) { return filepath.Join(home, util.CONFIG_FOLDER_NAME, "rdp"), nil } -// launchRDPClient opens the given .rdp file with the user's default RDP -// client. Failure is non-fatal; the caller can still manually connect -// using the printed connection details. +// launchRDPClient opens the .rdp file with the default client. Non-fatal on failure. func launchRDPClient(rdpFilePath string) error { var cmd *exec.Cmd switch runtime.GOOS { diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index bdfa6a86..c7b15164 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -139,7 +139,10 @@ func compilePolicyPatterns(config *api.PAMPolicyRuleConfig, sessionID string, ru return compiled } -func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMConfig, httpClient *resty.Client) error { +// HandlePAMProxy handles a PAM session connection. `browserRDP` selects +// the browser RDP flow (RDCleanPath over the inbound stream) when the +// resource is Windows; ignored for other resource types. +func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMConfig, httpClient *resty.Client, browserRDP bool) error { credentials, err := pamConfig.CredentialsManager.GetPAMSessionCredentials(pamConfig.SessionId, pamConfig.ExpiryTime) if err != nil { log.Error().Err(err).Str("sessionId", pamConfig.SessionId).Msg("Failed to retrieve PAM session credentials") @@ -434,7 +437,11 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo log.Info(). Str("sessionId", pamConfig.SessionId). Str("target", fmt.Sprintf("%s:%d", credentials.Host, credentials.Port)). + Bool("browser", browserRDP). Msg("Starting RDP PAM proxy") + if browserRDP { + return proxy.HandleConnectionRDCleanPath(ctx, conn) + } return proxy.HandleConnection(ctx, conn) default: return fmt.Errorf("unsupported resource type: %s", pamConfig.ResourceType) From 7aeed67d86a2f5c5992dd6d73e093e78f698b7d7 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 11 May 2026 00:29:55 -0400 Subject: [PATCH 02/19] fix(pam): address bot review issues in RDP browser session - Pass domain to connector_config_browser for AD credential injection - Preserve leftover bytes after RDCleanPath PDU parsing - Update caps.rs module doc to reflect actual codec advertisement --- packages/pam/handlers/rdp/native/src/caps.rs | 8 +++---- .../pam/handlers/rdp/native/src/config.rs | 4 ++-- .../handlers/rdp/native/src/rdcleanpath.rs | 24 +++++++++---------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/caps.rs b/packages/pam/handlers/rdp/native/src/caps.rs index 44cd00b7..838eb9d7 100644 --- a/packages/pam/handlers/rdp/native/src/caps.rs +++ b/packages/pam/handlers/rdp/native/src/caps.rs @@ -1,10 +1,8 @@ //! Capability sets advertised by the acceptor (browser-facing half). //! -//! Copied almost verbatim from `ironrdp-server::capabilities`. The goal is a -//! conservative, widely-supported set so both the inbound client and the -//! outbound target agree on formats without transcoding. Advanced codecs -//! (RemoteFX, EGFX) are left out deliberately: we want both handshakes to -//! land on basic bitmap. +//! Both the acceptor and connector advertise codec support (including RemoteFX) +//! so the browser client and target server agree on formats and the bridge can +//! forward encoded bitmap updates byte-for-byte without transcoding. use ironrdp_pdu::rdp::capability_sets::{ self, BitmapCodecs, BitmapDrawingFlags, CapabilitySet, CmdFlags, GeneralExtraFlags, InputFlags, diff --git a/packages/pam/handlers/rdp/native/src/config.rs b/packages/pam/handlers/rdp/native/src/config.rs index d70a3d1f..ba2d42c6 100644 --- a/packages/pam/handlers/rdp/native/src/config.rs +++ b/packages/pam/handlers/rdp/native/src/config.rs @@ -55,7 +55,7 @@ pub fn connector_config(username: String, password: String, domain: Option Config { +pub fn connector_config_browser(username: String, password: String, domain: Option, width: u16, height: u16) -> Config { Config { desktop_size: DesktopSize { width, height }, desktop_scale_factor: 0, @@ -64,7 +64,7 @@ pub fn connector_config_browser(username: String, password: String, width: u16, enable_credssp: true, credentials: Credentials::UsernamePassword { username, password }, - domain: None, + domain, client_build: 0, client_name: "infisical-pam".to_owned(), diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 44dec494..61d4c645 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -12,6 +12,7 @@ use ironrdp_tokio::reqwest::ReqwestNetworkClient; use ironrdp_tokio::{ connect_finalize, mark_as_upgraded, skip_connect_begin, FramedWrite, TokioFramed, }; +use bytes::BytesMut; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{timeout, Duration}; @@ -49,7 +50,7 @@ async fn handle_browser_session( let _ = rustls::crypto::ring::default_provider().install_default(); debug!("rdcleanpath: reading RDCleanPath request from client"); - let request_pdu = read_rdcleanpath_pdu(&mut client_tcp) + let (request_pdu, client_leftover) = read_rdcleanpath_pdu(&mut client_tcp) .await .context("read RDCleanPath Request")?; debug!("rdcleanpath: received RDCleanPath request"); @@ -118,6 +119,7 @@ async fn handle_browser_session( let config = connector_config_browser( target.username.clone(), target.password.clone(), + target.domain.clone(), width, height, ); @@ -198,7 +200,7 @@ async fn handle_browser_session( acceptor.mark_security_upgrade_as_done(); let client_erased: ErasedStream = Box::new(client_tcp); - let client_framed: TokioFramed = TokioFramed::new(client_erased); + let client_framed: TokioFramed = TokioFramed::new_with_leftover(client_erased, client_leftover); debug!("rdcleanpath: running accept_finalize on client"); let (client_final_framed, acceptor_result): (TokioFramed, AcceptorResult) = @@ -217,13 +219,13 @@ async fn handle_browser_session( const RDCLEANPATH_READ_TIMEOUT: Duration = Duration::from_secs(30); -async fn read_rdcleanpath_pdu(tcp: &mut TcpStream) -> Result { +async fn read_rdcleanpath_pdu(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, BytesMut)> { timeout(RDCLEANPATH_READ_TIMEOUT, read_rdcleanpath_pdu_inner(tcp)) .await .map_err(|_| anyhow!("timed out waiting for RDCleanPath PDU ({}s)", RDCLEANPATH_READ_TIMEOUT.as_secs()))? } -async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result { +async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, BytesMut)> { let mut buf = Vec::with_capacity(512); loop { let mut chunk = [0u8; 512]; @@ -238,15 +240,11 @@ async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result { if buf.len() >= total_length { - if buf.len() > total_length { - warn!( - extra = buf.len() - total_length, - "extra bytes after RDCleanPath PDU; ignoring" - ); - buf.truncate(total_length); - } - return RDCleanPathPdu::from_der(&buf) - .map_err(|e| anyhow!("decode RDCleanPath PDU: {e:?}")); + let leftover = BytesMut::from(&buf[total_length..]); + buf.truncate(total_length); + let pdu = RDCleanPathPdu::from_der(&buf) + .map_err(|e| anyhow!("decode RDCleanPath PDU: {e:?}"))?; + return Ok((pdu, leftover)); } } DetectionResult::NotEnoughBytes => {} From d56cde6641fc89f3941c6cf53fd5d3d522567ccc Mon Sep 17 00:00:00 2001 From: bernie-g Date: Mon, 11 May 2026 00:44:21 -0400 Subject: [PATCH 03/19] style(pam): apply cargo fmt formatting --- packages/pam/handlers/rdp/native/src/caps.rs | 4 +-- .../pam/handlers/rdp/native/src/config.rs | 12 ++++++-- .../handlers/rdp/native/src/rdcleanpath.rs | 30 ++++++++++++------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/caps.rs b/packages/pam/handlers/rdp/native/src/caps.rs index 838eb9d7..0f036ad1 100644 --- a/packages/pam/handlers/rdp/native/src/caps.rs +++ b/packages/pam/handlers/rdp/native/src/caps.rs @@ -5,8 +5,8 @@ //! forward encoded bitmap updates byte-for-byte without transcoding. use ironrdp_pdu::rdp::capability_sets::{ - self, BitmapCodecs, BitmapDrawingFlags, CapabilitySet, CmdFlags, GeneralExtraFlags, InputFlags, - OrderFlags, OrderSupportExFlags, VirtualChannelFlags, server_codecs_capabilities, + self, server_codecs_capabilities, BitmapCodecs, BitmapDrawingFlags, CapabilitySet, CmdFlags, + GeneralExtraFlags, InputFlags, OrderFlags, OrderSupportExFlags, VirtualChannelFlags, }; const DEFAULT_WIDTH: u16 = 1920; diff --git a/packages/pam/handlers/rdp/native/src/config.rs b/packages/pam/handlers/rdp/native/src/config.rs index ba2d42c6..a879425a 100644 --- a/packages/pam/handlers/rdp/native/src/config.rs +++ b/packages/pam/handlers/rdp/native/src/config.rs @@ -3,7 +3,9 @@ use ironrdp_connector::{BitmapConfig, Config, Credentials, DesktopSize}; use ironrdp_pdu::gcc::KeyboardType; -use ironrdp_pdu::rdp::capability_sets::{client_codecs_capabilities, BitmapCodecs, MajorPlatformType}; +use ironrdp_pdu::rdp::capability_sets::{ + client_codecs_capabilities, BitmapCodecs, MajorPlatformType, +}; use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; pub const DEFAULT_WIDTH: u16 = 1920; @@ -55,7 +57,13 @@ pub fn connector_config(username: String, password: String, domain: Option, width: u16, height: u16) -> Config { +pub fn connector_config_browser( + username: String, + password: String, + domain: Option, + width: u16, + height: u16, +) -> Config { Config { desktop_size: DesktopSize { width, height }, desktop_scale_factor: 0, diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 61d4c645..89b81fef 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -1,5 +1,8 @@ use anyhow::{anyhow, Context, Result}; -use ironrdp_acceptor::{accept_finalize, Acceptor, AcceptorResult, DesktopSize as AcceptorDesktopSize}; +use bytes::BytesMut; +use ironrdp_acceptor::{ + accept_finalize, Acceptor, AcceptorResult, DesktopSize as AcceptorDesktopSize, +}; use ironrdp_connector::{ClientConnector, Sequence}; use ironrdp_core::{encode_buf, WriteBuf}; use ironrdp_pdu::nego::{ @@ -12,7 +15,6 @@ use ironrdp_tokio::reqwest::ReqwestNetworkClient; use ironrdp_tokio::{ connect_finalize, mark_as_upgraded, skip_connect_begin, FramedWrite, TokioFramed, }; -use bytes::BytesMut; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{timeout, Duration}; @@ -112,7 +114,10 @@ async fn handle_browser_session( .write_all(&response_der) .await .context("write RDCleanPath Response to client")?; - debug!(len = response_der.len(), "rdcleanpath: sent RDCleanPath response"); + debug!( + len = response_der.len(), + "rdcleanpath: sent RDCleanPath response" + ); let (width, height) = default_desktop_size(); @@ -200,7 +205,8 @@ async fn handle_browser_session( acceptor.mark_security_upgrade_as_done(); let client_erased: ErasedStream = Box::new(client_tcp); - let client_framed: TokioFramed = TokioFramed::new_with_leftover(client_erased, client_leftover); + let client_framed: TokioFramed = + TokioFramed::new_with_leftover(client_erased, client_leftover); debug!("rdcleanpath: running accept_finalize on client"); let (client_final_framed, acceptor_result): (TokioFramed, AcceptorResult) = @@ -222,7 +228,12 @@ const RDCLEANPATH_READ_TIMEOUT: Duration = Duration::from_secs(30); async fn read_rdcleanpath_pdu(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, BytesMut)> { timeout(RDCLEANPATH_READ_TIMEOUT, read_rdcleanpath_pdu_inner(tcp)) .await - .map_err(|_| anyhow!("timed out waiting for RDCleanPath PDU ({}s)", RDCLEANPATH_READ_TIMEOUT.as_secs()))? + .map_err(|_| { + anyhow!( + "timed out waiting for RDCleanPath PDU ({}s)", + RDCLEANPATH_READ_TIMEOUT.as_secs() + ) + })? } async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, BytesMut)> { @@ -296,12 +307,9 @@ where } fn generate_throwaway_cert() -> Result> { - let subject_alt_names = vec![ - "localhost".to_string(), - "infisical-rdp-gateway".to_string(), - ]; - let cert = - rcgen::generate_simple_self_signed(subject_alt_names).context("generate self-signed cert")?; + let subject_alt_names = vec!["localhost".to_string(), "infisical-rdp-gateway".to_string()]; + let cert = rcgen::generate_simple_self_signed(subject_alt_names) + .context("generate self-signed cert")?; Ok(cert.cert.der().to_vec()) } From 4b25773d2ad94b743777c50245635caa8bc7f3a1 Mon Sep 17 00:00:00 2001 From: Saif Ur Rahman Date: Wed, 13 May 2026 22:25:54 +0530 Subject: [PATCH 04/19] fix(relay): fix misleading "packet length too long" error when gateway is unreachable (#234) * fix(relay): stop writing plaintext errors on TLS client connections When the relay can't route a client to a gateway (gateway disconnected or SSH channel open fails), it wrote plaintext error strings like "ERROR: Gateway not connected\n" on the TLS connection. The client performs a nested TLS handshake over this connection, so its TLS parser receives the plaintext and misinterprets it as a malformed TLS record, producing a cryptic OpenSSL "packet length too long" error. Remove the plaintext writes. The connection close alone is sufficient for the client to detect the failure, and produces a clean EOF instead of a misleading OpenSSL error. * fix(relay): replace stale gateway SSH connections on reconnect When a gateway reconnects (e.g. after a network blip or restart), the relay rejected the new SSH connection if the old entry still existed in the tunnel map. The old connection was often dead (half-open), creating a window where neither the old nor new connection could serve traffic. Accept the new connection and close the old one instead. The defer cleanup is guarded so the old goroutine's teardown doesn't accidentally remove the new connection's map entry. * Revert "fix(relay): replace stale gateway SSH connections on reconnect" This reverts commit 7dccfe40487b47f6af44c51a80259daa02780bac. --------- Co-authored-by: saif <11242541+saifsmailbox98@users.noreply.github.com> --- packages/relay/relay.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/relay/relay.go b/packages/relay/relay.go index cce24431..91ceb98f 100644 --- a/packages/relay/relay.go +++ b/packages/relay/relay.go @@ -559,7 +559,6 @@ func (r *Relay) handleClient(tlsConn *tls.Conn) { if !exists { log.Warn().Msgf("Gateway '%s' (%s) not connected", gatewayName, gatewayId) - tlsConn.Write([]byte("ERROR: Gateway not connected\n")) return } @@ -568,7 +567,6 @@ func (r *Relay) handleClient(tlsConn *tls.Conn) { channel, _, err := conn.OpenChannel("direct-tcpip", nil) if err != nil { log.Error().Msgf("Failed to connect to gateway: %v", err) - tlsConn.Write([]byte("ERROR: Failed to connect to gateway\n")) return } defer channel.Close() From f53a670b5274bb0d439332dbcaad319f67dae86e Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 17:13:55 -0400 Subject: [PATCH 05/19] style(pam-rdp): remove trailing blank line for cargo fmt --- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 89b81fef..6af50c07 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -312,4 +312,3 @@ fn generate_throwaway_cert() -> Result> { .context("generate self-signed cert")?; Ok(cert.cert.der().to_vec()) } - From c6dcfb171bd89b7e5336e6a94106bca346a871d6 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 17:51:43 -0400 Subject: [PATCH 06/19] refactor(pam-rdp): reuse native bridge path for browser RDP Stop after CredSSP on both sides and bridge MCS/capability PDUs through filter_client_mcs_connect_initial, matching the native client flow. Removes caps.rs, connector_config_browser, and the independent accept_finalize/connect_finalize calls. --- packages/pam/handlers/rdp/native/src/caps.rs | 96 ------------- .../pam/handlers/rdp/native/src/config.rs | 49 +------ packages/pam/handlers/rdp/native/src/lib.rs | 1 - .../handlers/rdp/native/src/rdcleanpath.rs | 134 ++++++++++-------- 4 files changed, 79 insertions(+), 201 deletions(-) delete mode 100644 packages/pam/handlers/rdp/native/src/caps.rs diff --git a/packages/pam/handlers/rdp/native/src/caps.rs b/packages/pam/handlers/rdp/native/src/caps.rs deleted file mode 100644 index 0f036ad1..00000000 --- a/packages/pam/handlers/rdp/native/src/caps.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Capability sets advertised by the acceptor (browser-facing half). -//! -//! Both the acceptor and connector advertise codec support (including RemoteFX) -//! so the browser client and target server agree on formats and the bridge can -//! forward encoded bitmap updates byte-for-byte without transcoding. - -use ironrdp_pdu::rdp::capability_sets::{ - self, server_codecs_capabilities, BitmapCodecs, BitmapDrawingFlags, CapabilitySet, CmdFlags, - GeneralExtraFlags, InputFlags, OrderFlags, OrderSupportExFlags, VirtualChannelFlags, -}; - -const DEFAULT_WIDTH: u16 = 1920; -const DEFAULT_HEIGHT: u16 = 1080; -const MULTIFRAGMENT_MAX_REQUEST_SIZE: u32 = 8 * 1024 * 1024; - -pub fn acceptor_capabilities(width: u16, height: u16) -> Vec { - vec![ - CapabilitySet::General(general()), - CapabilitySet::Bitmap(bitmap(width, height)), - CapabilitySet::Order(order()), - CapabilitySet::SurfaceCommands(surface()), - CapabilitySet::Pointer(pointer()), - CapabilitySet::Input(input()), - CapabilitySet::VirtualChannel(virtual_channel()), - CapabilitySet::MultiFragmentUpdate(capability_sets::MultifragmentUpdate { - max_request_size: MULTIFRAGMENT_MAX_REQUEST_SIZE, - }), - // Advertise RemoteFX to the browser-side client. Matched by - // the connector config so both handshakes agree and the bridge - // forwards RFX-encoded bitmap updates byte-for-byte. - CapabilitySet::BitmapCodecs( - server_codecs_capabilities(&[]).unwrap_or_else(|_| BitmapCodecs(Vec::new())), - ), - ] -} - -pub fn default_desktop_size() -> (u16, u16) { - (DEFAULT_WIDTH, DEFAULT_HEIGHT) -} - -fn general() -> capability_sets::General { - capability_sets::General { - extra_flags: GeneralExtraFlags::FASTPATH_OUTPUT_SUPPORTED, - ..Default::default() - } -} - -fn bitmap(width: u16, height: u16) -> capability_sets::Bitmap { - capability_sets::Bitmap { - pref_bits_per_pix: 32, - desktop_width: width, - desktop_height: height, - desktop_resize_flag: true, - drawing_flags: BitmapDrawingFlags::empty(), - } -} - -fn order() -> capability_sets::Order { - capability_sets::Order::new(OrderFlags::empty(), OrderSupportExFlags::empty(), 2048, 224) -} - -fn surface() -> capability_sets::SurfaceCommands { - capability_sets::SurfaceCommands { - flags: CmdFlags::all(), - } -} - -fn pointer() -> capability_sets::Pointer { - capability_sets::Pointer { - color_pointer_cache_size: 2048, - pointer_cache_size: 2048, - } -} - -fn input() -> capability_sets::Input { - capability_sets::Input { - input_flags: InputFlags::SCANCODES - | InputFlags::MOUSE_RELATIVE - | InputFlags::MOUSEX - | InputFlags::FASTPATH_INPUT - | InputFlags::UNICODE - | InputFlags::FASTPATH_INPUT_2, - keyboard_layout: 0, - keyboard_type: None, - keyboard_subtype: 0, - keyboard_function_key: 128, - keyboard_ime_filename: String::new(), - } -} - -fn virtual_channel() -> capability_sets::VirtualChannel { - capability_sets::VirtualChannel { - flags: VirtualChannelFlags::NO_COMPRESSION, - chunk_size: None, - } -} diff --git a/packages/pam/handlers/rdp/native/src/config.rs b/packages/pam/handlers/rdp/native/src/config.rs index a879425a..d959fe18 100644 --- a/packages/pam/handlers/rdp/native/src/config.rs +++ b/packages/pam/handlers/rdp/native/src/config.rs @@ -3,9 +3,7 @@ use ironrdp_connector::{BitmapConfig, Config, Credentials, DesktopSize}; use ironrdp_pdu::gcc::KeyboardType; -use ironrdp_pdu::rdp::capability_sets::{ - client_codecs_capabilities, BitmapCodecs, MajorPlatformType, -}; +use ironrdp_pdu::rdp::capability_sets::{BitmapCodecs, MajorPlatformType}; use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; pub const DEFAULT_WIDTH: u16 = 1920; @@ -56,48 +54,3 @@ pub fn connector_config(username: String, password: String, domain: Option, - width: u16, - height: u16, -) -> Config { - Config { - desktop_size: DesktopSize { width, height }, - desktop_scale_factor: 0, - - enable_tls: false, - enable_credssp: true, - - credentials: Credentials::UsernamePassword { username, password }, - domain, - - client_build: 0, - client_name: "infisical-pam".to_owned(), - keyboard_type: KeyboardType::IbmEnhanced, - keyboard_subtype: 0, - keyboard_functional_keys_count: 12, - keyboard_layout: 0, - ime_file_name: String::new(), - - bitmap: Some(BitmapConfig { - lossy_compression: false, - color_depth: 32, - codecs: client_codecs_capabilities(&[]).unwrap_or_else(|_| BitmapCodecs(Vec::new())), - }), - dig_product_id: String::new(), - client_dir: "C:\\Windows\\System32\\mstscax.dll".to_owned(), - platform: MajorPlatformType::UNSPECIFIED, - hardware_id: None, - request_data: None, - autologon: false, - enable_audio_playback: false, - performance_flags: PerformanceFlags::default(), - license_cache: None, - timezone_info: TimezoneInfo::default(), - enable_server_pointer: false, - pointer_software_rendering: false, - } -} diff --git a/packages/pam/handlers/rdp/native/src/lib.rs b/packages/pam/handlers/rdp/native/src/lib.rs index b92496dd..13bf0bfe 100644 --- a/packages/pam/handlers/rdp/native/src/lib.rs +++ b/packages/pam/handlers/rdp/native/src/lib.rs @@ -4,7 +4,6 @@ pub mod bridge; pub mod cap_filter; -pub mod caps; pub mod config; pub mod events; pub mod ffi; diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 6af50c07..9af047b1 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -1,8 +1,6 @@ use anyhow::{anyhow, Context, Result}; use bytes::BytesMut; -use ironrdp_acceptor::{ - accept_finalize, Acceptor, AcceptorResult, DesktopSize as AcceptorDesktopSize, -}; +use ironrdp_acceptor::{Acceptor, DesktopSize as AcceptorDesktopSize}; use ironrdp_connector::{ClientConnector, Sequence}; use ironrdp_core::{encode_buf, WriteBuf}; use ironrdp_pdu::nego::{ @@ -12,18 +10,18 @@ use ironrdp_pdu::rdp::client_info::Credentials as AcceptorCredentials; use ironrdp_pdu::x224::X224; use ironrdp_rdcleanpath::{DetectionResult, RDCleanPath, RDCleanPathPdu}; use ironrdp_tokio::reqwest::ReqwestNetworkClient; -use ironrdp_tokio::{ - connect_finalize, mark_as_upgraded, skip_connect_begin, FramedWrite, TokioFramed, -}; +use ironrdp_tokio::{mark_as_upgraded, skip_connect_begin, FramedWrite, TokioFramed}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +use tracing::{debug, info}; -use crate::bridge::{bridge_pdus, ErasedStream, TargetEndpoint}; -use crate::caps::{acceptor_capabilities, default_desktop_size}; -use crate::config::connector_config_browser; +use crate::bridge::{ + bridge_pdus, build_acceptor_tls_with_cert, filter_client_mcs_connect_initial, + perform_connector_credssp, ErasedStream, TargetEndpoint, +}; +use crate::config::{connector_config, DEFAULT_HEIGHT, DEFAULT_WIDTH}; use crate::events::EventSender; pub async fn run_mitm_rdcleanpath( @@ -86,14 +84,15 @@ async fn handle_browser_session( .context("read X.224 CC")?; debug!(len = x224_cc_target.len(), "received X.224 CC from target"); - let (initial_stream, leftover) = target_framed.into_inner(); + let (initial_stream, target_leftover) = target_framed.into_inner(); debug!("rdcleanpath: TLS upgrading target"); let (upgraded_stream, target_cert) = ironrdp_tls::upgrade(initial_stream, &target.host) .await .context("TLS upgrade to target")?; debug!("rdcleanpath: target TLS upgraded"); - let throwaway_cert_der = generate_throwaway_cert().context("throwaway cert")?; + let (_tls_config, acceptor_public_key, throwaway_cert_der) = + build_acceptor_tls_with_cert().context("build throwaway cert")?; let fake_cc_bytes = encode_x224(X224(ConnectionConfirm::Response { flags: ResponseFlags::empty(), @@ -119,14 +118,12 @@ async fn handle_browser_session( "rdcleanpath: sent RDCleanPath response" ); - let (width, height) = default_desktop_size(); + // --- Connector: advance past X.224, then CredSSP only --- - let config = connector_config_browser( + let config = connector_config( target.username.clone(), target.password.clone(), target.domain.clone(), - width, - height, ); let mut connector = ClientConnector::new(config, target_addr); @@ -140,31 +137,30 @@ async fn handle_browser_session( .map_err(|e| anyhow!("connector step CC: {e:?}"))?; let should_upgrade = skip_connect_begin(&mut connector); - let upgraded = mark_as_upgraded(should_upgrade, &mut connector); + let _ = mark_as_upgraded(should_upgrade, &mut connector); let target_erased: ErasedStream = Box::new(upgraded_stream); - let mut target_upgraded_framed = TokioFramed::new_with_leftover(target_erased, leftover); + let mut target_framed = TokioFramed::new_with_leftover(target_erased, target_leftover); let server_public_key = ironrdp_tls::extract_tls_server_public_key(&target_cert) .ok_or_else(|| anyhow!("extract target public key"))?; - debug!("rdcleanpath: running connect_finalize on target"); - let connection_result = connect_finalize( - upgraded, - connector, - &mut target_upgraded_framed, - &mut ReqwestNetworkClient::new(), - ironrdp_connector::ServerName::new(&target.host), - server_public_key.to_owned(), - None, - ) - .await - .map_err(|e| anyhow!("target connect_finalize: {e:?}"))?; - info!( - width = connection_result.desktop_size.width, - height = connection_result.desktop_size.height, - "rdcleanpath: target reached active stage" - ); + debug!("rdcleanpath: connector CredSSP"); + if connector.should_perform_credssp() { + perform_connector_credssp( + &mut connector, + &mut target_framed, + &mut ReqwestNetworkClient::new(), + ironrdp_connector::ServerName::new(&target.host), + server_public_key.to_vec(), + None, + ) + .await + .context("connector: CredSSP")?; + } + info!("rdcleanpath: connector CredSSP complete"); + + // --- Acceptor: advance past X.224, then CredSSP only --- let placeholder_creds = AcceptorCredentials { username: if acceptor_username.is_empty() { @@ -177,8 +173,11 @@ async fn handle_browser_session( }; let mut acceptor = Acceptor::new( SecurityProtocol::SSL, - AcceptorDesktopSize { width, height }, - acceptor_capabilities(width, height), + AcceptorDesktopSize { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }, + Vec::new(), Some(placeholder_creds), ); @@ -197,7 +196,6 @@ async fn handle_browser_session( acceptor .step(&[], &mut acc_scratch) .map_err(|e| anyhow!("acceptor step empty: {e:?}"))?; - acc_scratch.clear(); if acceptor.reached_security_upgrade().is_none() { anyhow::bail!("acceptor did not reach SecurityUpgrade after synthetic CR/CC"); @@ -205,22 +203,53 @@ async fn handle_browser_session( acceptor.mark_security_upgrade_as_done(); let client_erased: ErasedStream = Box::new(client_tcp); - let client_framed: TokioFramed = + let mut client_framed: TokioFramed = TokioFramed::new_with_leftover(client_erased, client_leftover); - debug!("rdcleanpath: running accept_finalize on client"); - let (client_final_framed, acceptor_result): (TokioFramed, AcceptorResult) = - accept_finalize(client_framed, &mut acceptor) + debug!("rdcleanpath: acceptor CredSSP"); + if acceptor.should_perform_credssp() { + ironrdp_acceptor::accept_credssp( + &mut client_framed, + &mut acceptor, + &mut ReqwestNetworkClient::new(), + ironrdp_connector::ServerName::new("infisical-rdp-bridge"), + acceptor_public_key, + None, + ) + .await + .context("acceptor: CredSSP")?; + } + info!("rdcleanpath: acceptor CredSSP complete"); + + // --- Bridge MCS/capabilities + PDUs (same as native path) --- + + let (mut client_stream, client_lo) = client_framed.into_inner(); + let (mut target_stream, target_lo) = target_framed.into_inner(); + + filter_client_mcs_connect_initial(&mut client_stream, &mut target_stream, client_lo) + .await + .context("filter client MCS Connect Initial")?; + + if !target_lo.is_empty() { + client_stream + .write_all(&target_lo) .await - .map_err(|e| anyhow!("accept_finalize: {e:?}"))?; - info!( - user_ch = acceptor_result.user_channel_id, - io_ch = acceptor_result.io_channel_id, - "rdcleanpath: client reached active stage" - ); + .context("flush target leftover to client")?; + } + + client_stream + .flush() + .await + .context("flush client stream before passthrough")?; + target_stream + .flush() + .await + .context("flush target stream before passthrough")?; debug!("rdcleanpath: bridging PDUs"); - bridge_pdus(client_final_framed, target_upgraded_framed, tx).await + let client_framed = ironrdp_tokio::TokioFramed::new(client_stream); + let target_framed = ironrdp_tokio::TokioFramed::new(target_stream); + bridge_pdus(client_framed, target_framed, tx).await } const RDCLEANPATH_READ_TIMEOUT: Duration = Duration::from_secs(30); @@ -305,10 +334,3 @@ where encode_buf(&pdu, &mut buf).map_err(|e| anyhow!("encode X.224 PDU: {e:?}"))?; Ok(buf.filled().to_vec()) } - -fn generate_throwaway_cert() -> Result> { - let subject_alt_names = vec!["localhost".to_string(), "infisical-rdp-gateway".to_string()]; - let cert = rcgen::generate_simple_self_signed(subject_alt_names) - .context("generate self-signed cert")?; - Ok(cert.cert.der().to_vec()) -} From 40741f0cdb0cef2144722de62db8bebb6a987d01 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 17:59:34 -0400 Subject: [PATCH 07/19] refactor(pam-rdp): consolidate 4 FFI start functions into 2 Make acceptor_username an optional parameter on the existing per-platform start functions instead of having separate rdcleanpath variants. NULL/empty selects native flow, non-empty selects browser RDCleanPath flow. --- packages/pam/handlers/rdp/bridge_cgo_unix.go | 39 ++--- .../pam/handlers/rdp/bridge_cgo_windows.go | 39 ++--- .../handlers/rdp/native/include/rdp_bridge.h | 37 +---- packages/pam/handlers/rdp/native/src/ffi.rs | 141 ++---------------- 4 files changed, 48 insertions(+), 208 deletions(-) diff --git a/packages/pam/handlers/rdp/bridge_cgo_unix.go b/packages/pam/handlers/rdp/bridge_cgo_unix.go index b074faf1..c09a47cc 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_unix.go +++ b/packages/pam/handlers/rdp/bridge_cgo_unix.go @@ -60,32 +60,23 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, defer C.free(unsafe.Pointer(cDomain)) } - var handle C.uint64_t - var rc C.int32_t - if acceptorUsername == "" { - rc = C.rdp_bridge_start_unix_fd( - C.int(dupFd), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - &handle, - ) - } else { - cAcceptor := C.CString(acceptorUsername) + var cAcceptor *C.char + if acceptorUsername != "" { + cAcceptor = C.CString(acceptorUsername) defer C.free(unsafe.Pointer(cAcceptor)) - rc = C.rdp_bridge_start_rdcleanpath_unix_fd( - C.int(dupFd), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - cAcceptor, - &handle, - ) } + + var handle C.uint64_t + rc := C.rdp_bridge_start_unix_fd( + C.int(dupFd), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + cAcceptor, + &handle, + ) if rc != C.RDP_BRIDGE_OK { return nil, fmt.Errorf("rdp bridge: start failed (status %d)", int32(rc)) } diff --git a/packages/pam/handlers/rdp/bridge_cgo_windows.go b/packages/pam/handlers/rdp/bridge_cgo_windows.go index 6608d430..dfdfdb01 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_windows.go +++ b/packages/pam/handlers/rdp/bridge_cgo_windows.go @@ -60,32 +60,23 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor defer C.free(unsafe.Pointer(cDomain)) } - var handle C.uint64_t - var rc C.int32_t - if acceptorUsername == "" { - rc = C.rdp_bridge_start_windows_socket( - C.uintptr_t(dupSocket), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - &handle, - ) - } else { - cAcceptor := C.CString(acceptorUsername) + var cAcceptor *C.char + if acceptorUsername != "" { + cAcceptor = C.CString(acceptorUsername) defer C.free(unsafe.Pointer(cAcceptor)) - rc = C.rdp_bridge_start_rdcleanpath_windows_socket( - C.uintptr_t(dupSocket), - cHost, - C.uint16_t(targetPort), - cUser, - cPass, - cDomain, - cAcceptor, - &handle, - ) } + + var handle C.uint64_t + rc := C.rdp_bridge_start_windows_socket( + C.uintptr_t(dupSocket), + cHost, + C.uint16_t(targetPort), + cUser, + cPass, + cDomain, + cAcceptor, + &handle, + ) if rc != C.RDP_BRIDGE_OK { return nil, fmt.Errorf("rdp bridge: start failed (status %d)", int32(rc)) } diff --git a/packages/pam/handlers/rdp/native/include/rdp_bridge.h b/packages/pam/handlers/rdp/native/include/rdp_bridge.h index 9d9f39cc..e325818a 100644 --- a/packages/pam/handlers/rdp/native/include/rdp_bridge.h +++ b/packages/pam/handlers/rdp/native/include/rdp_bridge.h @@ -17,40 +17,11 @@ extern "C" { #define RDP_BRIDGE_BAD_ARG -2 #define RDP_BRIDGE_RUNTIME_ERROR -3 -// `domain` is optional. NULL or empty string means no domain (NTLM falls back -// to local-account auth). Set this for AD domain accounts so NTLM CredSSP -// authenticates against the target's AD binding rather than its local SAM. +/* `domain` and `acceptor_username` are optional (NULL or empty = unused). + * When `acceptor_username` is non-empty, the bridge runs the RDCleanPath + * (browser) flow; otherwise it runs the native RDP flow. */ #if defined(__unix__) || defined(__APPLE__) int32_t rdp_bridge_start_unix_fd( - int client_fd, - const char *target_host, - uint16_t target_port, - const char *username, - const char *password, - const char *domain, - uint64_t *out_handle -); -#endif - -#if defined(_WIN32) || defined(_WIN64) -int32_t rdp_bridge_start_windows_socket( - uintptr_t client_socket, - const char *target_host, - uint16_t target_port, - const char *username, - const char *password, - const char *domain, - uint64_t *out_handle -); -#endif - -/* Browser-flow start. Same as the native start, plus `acceptor_username`: - * the username the browser is configured to present during the acceptor's - * CredSSP exchange (decoupled from the real target username injected into - * the connector). The browser speaks RDCleanPath over the inbound stream; - * the gateway's WS upstream has already stripped framing. */ -#if defined(__unix__) || defined(__APPLE__) -int32_t rdp_bridge_start_rdcleanpath_unix_fd( int client_fd, const char *target_host, uint16_t target_port, @@ -63,7 +34,7 @@ int32_t rdp_bridge_start_rdcleanpath_unix_fd( #endif #if defined(_WIN32) || defined(_WIN64) -int32_t rdp_bridge_start_rdcleanpath_windows_socket( +int32_t rdp_bridge_start_windows_socket( uintptr_t client_socket, const char *target_host, uint16_t target_port, diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index 9a5f2d89..14026260 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -263,7 +263,9 @@ fn spawn_session( /// /// `client_fd` ownership transfers to the bridge on OK, stays with the /// caller on error. Strings must be NUL-terminated valid UTF-8. `domain` -/// may be NULL or empty for non-domain sessions. +/// and `acceptor_username` may be NULL or empty. When `acceptor_username` +/// is non-empty the bridge runs the RDCleanPath (browser) flow; otherwise +/// it runs the native RDP flow. #[cfg(unix)] #[no_mangle] pub unsafe extern "C" fn rdp_bridge_start_unix_fd( @@ -273,6 +275,7 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( username: *const c_char, password: *const c_char, domain: *const c_char, + acceptor_username: *const c_char, out_handle: *mut u64, ) -> i32 { if out_handle.is_null() { @@ -290,8 +293,11 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( Some(v) => v, None => return RDP_BRIDGE_BAD_ARG, }; - // Empty domain string is treated the same as NULL: no domain. let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); + let flow = match unsafe { c_str_to_owned(acceptor_username) }.filter(|s| !s.is_empty()) { + Some(acceptor_username) => SessionFlow::Rdcleanpath { acceptor_username }, + None => SessionFlow::Native, + }; use std::os::unix::io::FromRawFd; let client_tcp = unsafe { StdTcpStream::from_raw_fd(client_fd) }; @@ -303,7 +309,7 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( username, password, domain, - SessionFlow::Native, + flow, ) { Ok(id) => { unsafe { *out_handle = id }; @@ -322,125 +328,6 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( #[cfg(windows)] #[no_mangle] pub unsafe extern "C" fn rdp_bridge_start_windows_socket( - client_socket: usize, - target_host: *const c_char, - target_port: u16, - username: *const c_char, - password: *const c_char, - domain: *const c_char, - out_handle: *mut u64, -) -> i32 { - if out_handle.is_null() { - return RDP_BRIDGE_BAD_ARG; - } - let host = match unsafe { c_str_to_owned(target_host) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let username = match unsafe { c_str_to_owned(username) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let password = match unsafe { c_str_to_owned(password) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); - - use std::os::windows::io::{FromRawSocket, RawSocket}; - let client_tcp = unsafe { StdTcpStream::from_raw_socket(client_socket as RawSocket) }; - - match spawn_session( - client_tcp, - host, - target_port, - username, - password, - domain, - SessionFlow::Native, - ) { - Ok(id) => { - unsafe { *out_handle = id }; - RDP_BRIDGE_OK - } - Err(e) => { - error!(error = ?e, "rdp_bridge_start_windows_socket: failed to spawn session"); - RDP_BRIDGE_RUNTIME_ERROR - } - } -} - -/// Browser-flow start. Same shape as `rdp_bridge_start_unix_fd`, plus -/// `acceptor_username`: the username the browser is configured to present -/// during the acceptor-side CredSSP exchange (decoupled from the real -/// target username we inject into the connector). -/// -/// # Safety -/// -/// `client_fd` ownership transfers to the bridge on OK, stays with the -/// caller on error. Strings must be NUL-terminated valid UTF-8. -#[cfg(unix)] -#[no_mangle] -pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_unix_fd( - client_fd: std::ffi::c_int, - target_host: *const c_char, - target_port: u16, - username: *const c_char, - password: *const c_char, - domain: *const c_char, - acceptor_username: *const c_char, - out_handle: *mut u64, -) -> i32 { - if out_handle.is_null() { - return RDP_BRIDGE_BAD_ARG; - } - let host = match unsafe { c_str_to_owned(target_host) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let username = match unsafe { c_str_to_owned(username) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let password = match unsafe { c_str_to_owned(password) } { - Some(v) => v, - None => return RDP_BRIDGE_BAD_ARG, - }; - let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); - let acceptor_username = match unsafe { c_str_to_owned(acceptor_username) } { - Some(v) if !v.is_empty() => v, - _ => return RDP_BRIDGE_BAD_ARG, - }; - - use std::os::unix::io::FromRawFd; - let client_tcp = unsafe { StdTcpStream::from_raw_fd(client_fd) }; - - match spawn_session( - client_tcp, - host, - target_port, - username, - password, - domain, - SessionFlow::Rdcleanpath { acceptor_username }, - ) { - Ok(id) => { - unsafe { *out_handle = id }; - RDP_BRIDGE_OK - } - Err(e) => { - error!(error = ?e, "rdp_bridge_start_rdcleanpath_unix_fd: failed to spawn session"); - RDP_BRIDGE_RUNTIME_ERROR - } - } -} - -/// # Safety -/// -/// See `rdp_bridge_start_rdcleanpath_unix_fd`. -#[cfg(windows)] -#[no_mangle] -pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_windows_socket( client_socket: usize, target_host: *const c_char, target_port: u16, @@ -466,9 +353,9 @@ pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_windows_socket( None => return RDP_BRIDGE_BAD_ARG, }; let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty()); - let acceptor_username = match unsafe { c_str_to_owned(acceptor_username) } { - Some(v) if !v.is_empty() => v, - _ => return RDP_BRIDGE_BAD_ARG, + let flow = match unsafe { c_str_to_owned(acceptor_username) }.filter(|s| !s.is_empty()) { + Some(acceptor_username) => SessionFlow::Rdcleanpath { acceptor_username }, + None => SessionFlow::Native, }; use std::os::windows::io::{FromRawSocket, RawSocket}; @@ -481,14 +368,14 @@ pub unsafe extern "C" fn rdp_bridge_start_rdcleanpath_windows_socket( username, password, domain, - SessionFlow::Rdcleanpath { acceptor_username }, + flow, ) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK } Err(e) => { - error!(error = ?e, "rdp_bridge_start_rdcleanpath_windows_socket: failed to spawn session"); + error!(error = ?e, "rdp_bridge_start_windows_socket: failed to spawn session"); RDP_BRIDGE_RUNTIME_ERROR } } From 8980609e75a1d64c0416a2a75fd010b2d43796ff Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:02:40 -0400 Subject: [PATCH 08/19] chore(pam-rdp): remove debug logging from bridge --- packages/pam/handlers/rdp/native/src/bridge.rs | 8 +------- .../pam/handlers/rdp/native/src/rdcleanpath.rs | 16 +--------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index 48177236..87269991 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -31,7 +31,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use tracing::{debug, info, warn}; +use tracing::{info, warn}; use crate::cap_filter; use crate::config::{connector_config, DEFAULT_HEIGHT, DEFAULT_WIDTH}; @@ -184,10 +184,6 @@ where if offset == NOT_ACTIVE && action == Action::FastPath { offset = elapsed_ns_since(started_at); recording_offset_ns.store(offset, Ordering::Relaxed); - debug!( - skip_ms = offset / 1_000_000, - "first FastPath target frame, recording starts" - ); } if offset != NOT_ACTIVE { tap_target_to_client(action, &frame, started_at, offset, &tx_t2c); @@ -375,7 +371,6 @@ fn try_filter_client_info(frame: &[u8]) -> Option> { if !cap_filter::client_info::clear_compression(user_data.slice_mut(&mut out)) { return None; } - debug!("Client Info PDU: cleared INFO_COMPRESSION + CompressionTypeMask"); Some(out) } @@ -409,7 +404,6 @@ fn try_filter_confirm_active(frame: &[u8]) -> Option> { if let Some(codecs_offset) = codecs_body_offset_in_frame { cap_filter::bitmap_codecs_cap::clear_codec_count(&mut out[codecs_offset..]); } - debug!("Confirm Active: cleared Order support + BitmapCodecs count"); Some(out) } diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 9af047b1..0b81bab5 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -15,7 +15,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; -use tracing::{debug, info}; +use tracing::info; use crate::bridge::{ bridge_pdus, build_acceptor_tls_with_cert, filter_client_mcs_connect_initial, @@ -49,11 +49,9 @@ async fn handle_browser_session( info!(host = %target.host, port = target.port, "rdcleanpath: starting browser session"); let _ = rustls::crypto::ring::default_provider().install_default(); - debug!("rdcleanpath: reading RDCleanPath request from client"); let (request_pdu, client_leftover) = read_rdcleanpath_pdu(&mut client_tcp) .await .context("read RDCleanPath Request")?; - debug!("rdcleanpath: received RDCleanPath request"); let request = request_pdu .into_enum() .map_err(|e| anyhow!("RDCleanPath enum: {e}"))?; @@ -67,11 +65,9 @@ async fn handle_browser_session( }; info!(destination, "RDCleanPath: received Request"); - debug!("rdcleanpath: connecting to target"); let target_tcp = TcpStream::connect((target.host.as_str(), target.port)) .await .with_context(|| format!("connect target {}:{}", target.host, target.port))?; - debug!("rdcleanpath: target TCP connected"); let target_addr = target_tcp.local_addr().context("local_addr")?; let mut target_framed = TokioFramed::new(target_tcp); @@ -82,14 +78,11 @@ async fn handle_browser_session( let x224_cc_target = read_tpkt_pdu(&mut target_framed) .await .context("read X.224 CC")?; - debug!(len = x224_cc_target.len(), "received X.224 CC from target"); let (initial_stream, target_leftover) = target_framed.into_inner(); - debug!("rdcleanpath: TLS upgrading target"); let (upgraded_stream, target_cert) = ironrdp_tls::upgrade(initial_stream, &target.host) .await .context("TLS upgrade to target")?; - debug!("rdcleanpath: target TLS upgraded"); let (_tls_config, acceptor_public_key, throwaway_cert_der) = build_acceptor_tls_with_cert().context("build throwaway cert")?; @@ -113,10 +106,6 @@ async fn handle_browser_session( .write_all(&response_der) .await .context("write RDCleanPath Response to client")?; - debug!( - len = response_der.len(), - "rdcleanpath: sent RDCleanPath response" - ); // --- Connector: advance past X.224, then CredSSP only --- @@ -145,7 +134,6 @@ async fn handle_browser_session( let server_public_key = ironrdp_tls::extract_tls_server_public_key(&target_cert) .ok_or_else(|| anyhow!("extract target public key"))?; - debug!("rdcleanpath: connector CredSSP"); if connector.should_perform_credssp() { perform_connector_credssp( &mut connector, @@ -206,7 +194,6 @@ async fn handle_browser_session( let mut client_framed: TokioFramed = TokioFramed::new_with_leftover(client_erased, client_leftover); - debug!("rdcleanpath: acceptor CredSSP"); if acceptor.should_perform_credssp() { ironrdp_acceptor::accept_credssp( &mut client_framed, @@ -246,7 +233,6 @@ async fn handle_browser_session( .await .context("flush target stream before passthrough")?; - debug!("rdcleanpath: bridging PDUs"); let client_framed = ironrdp_tokio::TokioFramed::new(client_stream); let target_framed = ironrdp_tokio::TokioFramed::new(target_stream); bridge_pdus(client_framed, target_framed, tx).await From a031f0a790d8769ed7b9622e989bfde87daf47f5 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:04:59 -0400 Subject: [PATCH 09/19] fix(pam-rdp): silence unused variable warning in ffi.rs --- packages/pam/handlers/rdp/native/src/ffi.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index 14026260..cfab2f3b 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -401,7 +401,7 @@ pub extern "C" fn rdp_bridge_wait(handle: u64) -> i32 { error!(handle, error = ?e, "rdp_bridge_wait: session failed"); RDP_BRIDGE_SESSION_ERROR } - Err(panic) => { + Err(_) => { error!(handle, "rdp_bridge_wait: session thread panicked"); RDP_BRIDGE_THREAD_PANIC } From ba8ce28b44082a048817ac7079c2790562dacc17 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:12:01 -0400 Subject: [PATCH 10/19] refactor(pam-rdp): rename MITM entry points for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_mitm → run_mitm_native, handle_browser_session → run_mitm_rdcleanpath_inner --- packages/pam/handlers/rdp/native/src/bridge.rs | 6 +++--- packages/pam/handlers/rdp/native/src/ffi.rs | 4 ++-- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index 87269991..23722f9c 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -56,14 +56,14 @@ pub struct TargetEndpoint { pub domain: Option, } -pub async fn run_mitm( +pub async fn run_mitm_native( client_tcp: TcpStream, target: TargetEndpoint, cancel: CancellationToken, tx: EventSender, ) -> Result<()> { tokio::select! { - result = run_mitm_inner(client_tcp, target, tx) => result, + result = run_mitm_native_inner(client_tcp, target, tx) => result, _ = cancel.cancelled() => { info!("session canceled by caller"); Ok(()) @@ -71,7 +71,7 @@ pub async fn run_mitm( } } -async fn run_mitm_inner( +async fn run_mitm_native_inner( client_tcp: TcpStream, target: TargetEndpoint, tx: EventSender, diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index cfab2f3b..0ff061ea 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -14,7 +14,7 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tracing::{error, info}; -use crate::bridge::{run_mitm, TargetEndpoint}; +use crate::bridge::{run_mitm_native, TargetEndpoint}; use crate::events::{self, SessionEvent}; use crate::rdcleanpath::run_mitm_rdcleanpath; @@ -235,7 +235,7 @@ fn spawn_session( }; match flow { SessionFlow::Native => { - run_mitm(client, endpoint, cancel_for_thread, events_tx).await + run_mitm_native(client, endpoint, cancel_for_thread, events_tx).await } SessionFlow::Rdcleanpath { acceptor_username } => { run_mitm_rdcleanpath( diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 0b81bab5..ac00b98b 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -32,7 +32,7 @@ pub async fn run_mitm_rdcleanpath( tx: EventSender, ) -> Result<()> { tokio::select! { - result = handle_browser_session(client_tcp, target, acceptor_username, tx) => result, + result = run_mitm_rdcleanpath_inner(client_tcp, target, acceptor_username, tx) => result, _ = cancel.cancelled() => { info!("rdcleanpath session canceled by caller"); Ok(()) @@ -40,7 +40,7 @@ pub async fn run_mitm_rdcleanpath( } } -async fn handle_browser_session( +async fn run_mitm_rdcleanpath_inner( mut client_tcp: TcpStream, target: TargetEndpoint, acceptor_username: String, From 059e6efec303e7d54907c41ba99c617c7ca317f0 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:15:49 -0400 Subject: [PATCH 11/19] docs(pam-rdp): add comments to read_rdcleanpath_pdu_inner and read_tpkt_pdu --- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index ac00b98b..84df2efc 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -251,6 +251,7 @@ async fn read_rdcleanpath_pdu(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, By })? } +/// Accumulates TCP reads until a complete DER-encoded RDCleanPath PDU is detected. async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result<(RDCleanPathPdu, BytesMut)> { let mut buf = Vec::with_capacity(512); loop { @@ -284,6 +285,7 @@ async fn read_rdcleanpath_pdu_inner(tcp: &mut TcpStream) -> Result<(RDCleanPathP } } +/// Reads a single TPKT-framed PDU (4-byte header with big-endian length) from raw TCP. async fn read_tpkt_pdu(framed: &mut TokioFramed) -> Result> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Sync + Unpin + 'static, From f795c1fec9fcbaa7b75f5d003258bd16fdb9329b Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:17:47 -0400 Subject: [PATCH 12/19] docs(pam-rdp): document run_mitm_rdcleanpath_inner step-by-step --- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 84df2efc..4a0f4ee2 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -40,6 +40,14 @@ pub async fn run_mitm_rdcleanpath( } } +/// Browser MITM flow for clients that speak RDCleanPath (IronRDP WASM). +/// +/// 1. Read RDCleanPath Request from client, extract the X.224 CR. +/// 2. Forward CR to target, read CC, TLS-upgrade the target connection. +/// 3. Build a throwaway cert, wrap a fake CC + cert in an RDCleanPath Response, send to client. +/// 4. Advance connector past X.224, run CredSSP to the target. +/// 5. Advance acceptor with synthetic X.224 CR/CC, run CredSSP to the client. +/// 6. Bridge MCS/capabilities + PDUs (shared with the native path). async fn run_mitm_rdcleanpath_inner( mut client_tcp: TcpStream, target: TargetEndpoint, From d1552c24f3d685bd76f5a00ebbb3b5bab8e1a709 Mon Sep 17 00:00:00 2001 From: bernie-g Date: Wed, 13 May 2026 18:19:15 -0400 Subject: [PATCH 13/19] docs(pam-rdp): expand CR/CC abbreviations in rdcleanpath comment --- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index 4a0f4ee2..d72e3ce1 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -42,8 +42,8 @@ pub async fn run_mitm_rdcleanpath( /// Browser MITM flow for clients that speak RDCleanPath (IronRDP WASM). /// -/// 1. Read RDCleanPath Request from client, extract the X.224 CR. -/// 2. Forward CR to target, read CC, TLS-upgrade the target connection. +/// 1. Read RDCleanPath Request from client, extract the X.224 CR (Connection Request). +/// 2. Forward CR to target, read CC (Connection Confirm), TLS-upgrade the target connection. /// 3. Build a throwaway cert, wrap a fake CC + cert in an RDCleanPath Response, send to client. /// 4. Advance connector past X.224, run CredSSP to the target. /// 5. Advance acceptor with synthetic X.224 CR/CC, run CredSSP to the client. From a7fea58535430882ee4fe0011a4073930293dd49 Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 13 May 2026 22:45:05 -0400 Subject: [PATCH 14/19] fix: early session cleanup that broke proxies --- packages/gateway-v2/gateway.go | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/gateway-v2/gateway.go b/packages/gateway-v2/gateway.go index 7220cd9b..b061f5f8 100644 --- a/packages/gateway-v2/gateway.go +++ b/packages/gateway-v2/gateway.go @@ -883,17 +883,7 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) { } } sessionCancel() - // RDP reconnects via a stable .rdp file within the session's validity - // window; terminating on disconnect would break that. Idle reaper / - // expiry / explicit cancel still end the session normally. - isRDP := forwardConfig.PAMConfig.ResourceType == session.ResourceTypeWindows - if lastConn := g.DeregisterPAMSession(forwardConfig.PAMConfig.SessionId, tlsConn); lastConn && !isRDP { - if err := forwardConfig.PAMConfig.SessionUploader.CleanupPAMSession( - forwardConfig.PAMConfig.SessionId, "connection_closed", - ); err != nil { - log.Error().Err(err).Str("sessionId", forwardConfig.PAMConfig.SessionId).Msg("Failed to cleanup PAM session") - } - } + g.DeregisterPAMSession(forwardConfig.PAMConfig.SessionId, tlsConn) return } else if forwardConfig.Mode == ForwardModePAMCancellation { if err := pam.HandlePAMCancellation(g.ctx, tlsConn, &forwardConfig.PAMConfig, g.httpClient, g.CancelPAMSession); err != nil { From 0bd205608213487841adda453a45a755000f86c6 Mon Sep 17 00:00:00 2001 From: x032205 Date: Wed, 13 May 2026 22:49:04 -0400 Subject: [PATCH 15/19] move cleanup to defer --- packages/gateway-v2/gateway.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/gateway-v2/gateway.go b/packages/gateway-v2/gateway.go index b061f5f8..0a421769 100644 --- a/packages/gateway-v2/gateway.go +++ b/packages/gateway-v2/gateway.go @@ -874,6 +874,10 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) { } sessionCtx, sessionCancel := context.WithCancel(g.ctx) touchSession := g.RegisterPAMSession(forwardConfig.PAMConfig.SessionId, sessionCancel, tlsConn) + defer func() { + sessionCancel() + g.DeregisterPAMSession(forwardConfig.PAMConfig.SessionId, tlsConn) + }() forwardConfig.PAMConfig.OnActivity = touchSession if err := pam.HandlePAMProxy(sessionCtx, tlsConn, &forwardConfig.PAMConfig, g.httpClient); err != nil { if err.Error() == "unexpected EOF" { @@ -882,8 +886,6 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) { log.Error().Err(err).Msg("PAM proxy handler ended with error") } } - sessionCancel() - g.DeregisterPAMSession(forwardConfig.PAMConfig.SessionId, tlsConn) return } else if forwardConfig.Mode == ForwardModePAMCancellation { if err := pam.HandlePAMCancellation(g.ctx, tlsConn, &forwardConfig.PAMConfig, g.httpClient, g.CancelPAMSession); err != nil { From 498dcb81a28fbc7ffaca5dd5e64bb2cb0e489518 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 15 May 2026 15:58:40 -0400 Subject: [PATCH 16/19] remove unnecessary if statement --- packages/pam/handlers/rdp/native/src/rdcleanpath.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index d72e3ce1..e7c6f3bb 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -159,11 +159,7 @@ async fn run_mitm_rdcleanpath_inner( // --- Acceptor: advance past X.224, then CredSSP only --- let placeholder_creds = AcceptorCredentials { - username: if acceptor_username.is_empty() { - "infisical".to_owned() - } else { - acceptor_username - }, + username: acceptor_username, password: "infisical".to_owned(), domain: None, }; From 6ec59075cc7a6be322640eaefeb038220d2b3e55 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 15 May 2026 16:49:06 -0400 Subject: [PATCH 17/19] split acceptor cert generation from TLS config build --- .../pam/handlers/rdp/native/src/bridge.rs | 24 ++++++++++++------- .../handlers/rdp/native/src/rdcleanpath.rs | 6 ++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index 99c85f14..5e5a3cc7 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -582,8 +582,9 @@ async fn run_acceptor_half( client_tcp: TcpStream, username: String, ) -> Result<(ErasedStream, bytes::BytesMut)> { - let (server_tls, acceptor_public_key, _cert_der) = - build_acceptor_tls_with_cert().context("build acceptor TLS config")?; + let (acceptor_public_key, _cert_der, certified_key) = + generate_acceptor_cert().context("generate acceptor cert")?; + let server_tls = build_acceptor_tls_config(certified_key).context("build acceptor TLS config")?; let server_tls = Arc::new(server_tls); let acceptor_framed = ironrdp_tokio::TokioFramed::new(client_tcp); @@ -843,8 +844,7 @@ where Ok(()) } -pub(crate) fn build_acceptor_tls_with_cert( -) -> Result<(tokio_rustls::rustls::ServerConfig, Vec, Vec)> { +pub(crate) fn generate_acceptor_cert() -> Result<(Vec, Vec, rcgen::CertifiedKey)> { use x509_cert::der::Decode; let subject_alt_names = vec!["localhost".to_string(), "infisical-rdp-bridge".to_string()]; @@ -859,13 +859,19 @@ pub(crate) fn build_acceptor_tls_with_cert( .to_vec(); let cert_der_bytes = cert_der.as_ref().to_vec(); - let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(cert.key_pair.serialize_der().into()); - let config = tokio_rustls::rustls::ServerConfig::builder() + Ok((public_key, cert_der_bytes, cert)) +} + +pub(crate) fn build_acceptor_tls_config( + certified: rcgen::CertifiedKey, +) -> Result { + let cert_der = certified.cert.der().clone(); + let key_der = + rustls::pki_types::PrivateKeyDer::Pkcs8(certified.key_pair.serialize_der().into()); + tokio_rustls::rustls::ServerConfig::builder() .with_no_client_auth() .with_single_cert(vec![cert_der], key_der) - .context("rustls ServerConfig")?; - - Ok((config, public_key, cert_der_bytes)) + .context("rustls ServerConfig") } pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} diff --git a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs index e7c6f3bb..fac3e54f 100644 --- a/packages/pam/handlers/rdp/native/src/rdcleanpath.rs +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -18,7 +18,7 @@ use tokio_util::sync::CancellationToken; use tracing::info; use crate::bridge::{ - bridge_pdus, build_acceptor_tls_with_cert, filter_client_mcs_connect_initial, + bridge_pdus, filter_client_mcs_connect_initial, generate_acceptor_cert, perform_connector_credssp, ErasedStream, TargetEndpoint, }; use crate::config::{connector_config, DEFAULT_HEIGHT, DEFAULT_WIDTH}; @@ -92,8 +92,8 @@ async fn run_mitm_rdcleanpath_inner( .await .context("TLS upgrade to target")?; - let (_tls_config, acceptor_public_key, throwaway_cert_der) = - build_acceptor_tls_with_cert().context("build throwaway cert")?; + let (acceptor_public_key, throwaway_cert_der, _certified_key) = + generate_acceptor_cert().context("generate throwaway cert")?; let fake_cc_bytes = encode_x224(X224(ConnectionConfirm::Response { flags: ResponseFlags::empty(), From 7ac33085e84ef9e0333ffc2c4f612be7adf280f6 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 15 May 2026 19:25:37 -0400 Subject: [PATCH 18/19] remove unused constant --- packages/pam/local/database-proxy.go | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/pam/local/database-proxy.go b/packages/pam/local/database-proxy.go index 8d4c180b..c418795c 100644 --- a/packages/pam/local/database-proxy.go +++ b/packages/pam/local/database-proxy.go @@ -27,7 +27,6 @@ type ALPN string const ( ALPNInfisicalPAMProxy ALPN = "infisical-pam-proxy" - ALPNInfisicalPAMRDPBrowser ALPN = "infisical-pam-rdp-browser" ALPNInfisicalPAMCancellation ALPN = "infisical-pam-session-cancellation" ALPNInfisicalPAMCapabilities ALPN = "infisical-pam-capabilities" ) From 8d4ee9c4ca99dd76d640de5aad85e93edb36a822 Mon Sep 17 00:00:00 2001 From: x032205 Date: Fri, 15 May 2026 20:12:03 -0400 Subject: [PATCH 19/19] format --- packages/pam/handlers/rdp/native/src/bridge.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index 5e5a3cc7..f9dde70e 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -584,7 +584,8 @@ async fn run_acceptor_half( ) -> Result<(ErasedStream, bytes::BytesMut)> { let (acceptor_public_key, _cert_der, certified_key) = generate_acceptor_cert().context("generate acceptor cert")?; - let server_tls = build_acceptor_tls_config(certified_key).context("build acceptor TLS config")?; + let server_tls = + build_acceptor_tls_config(certified_key).context("build acceptor TLS config")?; let server_tls = Arc::new(server_tls); let acceptor_framed = ironrdp_tokio::TokioFramed::new(client_tcp);