diff --git a/packages/gateway-v2/gateway.go b/packages/gateway-v2/gateway.go index 7220cd9b..df427776 100644 --- a/packages/gateway-v2/gateway.go +++ b/packages/gateway-v2/gateway.go @@ -35,6 +35,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" @@ -703,7 +704,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 @@ -866,7 +867,7 @@ 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 { // RDP only: prior bridge must fully tear down before the new one starts, // else overlapping drains write non-monotonic elapsedMs to the recording. if forwardConfig.PAMConfig.ResourceType == session.ResourceTypeWindows { @@ -874,26 +875,19 @@ 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 { + 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 { log.Error().Err(err).Msg("PAM proxy handler ended with error") } } - 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") - } - } return } else if forwardConfig.Mode == ForwardModePAMCancellation { if err := pam.HandlePAMCancellation(g.ctx, tlsConn, &forwardConfig.PAMConfig, g.httpClient, g.CancelPAMSession); err != nil { @@ -950,6 +944,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 e6816821..c454c833 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.PriorElapsedNs, p.config.SessionUploader) }() - // 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..c09a47cc 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,13 +54,18 @@ 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) defer C.free(unsafe.Pointer(cDomain)) } + var cAcceptor *C.char + if acceptorUsername != "" { + cAcceptor = C.CString(acceptorUsername) + defer C.free(unsafe.Pointer(cAcceptor)) + } + var handle C.uint64_t rc := C.rdp_bridge_start_unix_fd( C.int(dupFd), @@ -63,6 +74,7 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, cUser, cPass, cDomain, + cAcceptor, &handle, ) if rc != C.RDP_BRIDGE_OK { @@ -72,9 +84,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 +129,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..dfdfdb01 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 { @@ -50,6 +60,12 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor defer C.free(unsafe.Pointer(cDomain)) } + var cAcceptor *C.char + if acceptorUsername != "" { + cAcceptor = C.CString(acceptorUsername) + defer C.free(unsafe.Pointer(cAcceptor)) + } + var handle C.uint64_t rc := C.rdp_bridge_start_windows_socket( C.uintptr_t(dupSocket), @@ -58,6 +74,7 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor cUser, cPass, cDomain, + cAcceptor, &handle, ) if rc != C.RDP_BRIDGE_OK { @@ -68,6 +85,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 +128,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..e325818a 100644 --- a/packages/pam/handlers/rdp/native/include/rdp_bridge.h +++ b/packages/pam/handlers/rdp/native/include/rdp_bridge.h @@ -17,9 +17,9 @@ 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, @@ -28,6 +28,7 @@ int32_t rdp_bridge_start_unix_fd( const char *username, const char *password, const char *domain, + const char *acceptor_username, uint64_t *out_handle ); #endif @@ -40,6 +41,7 @@ int32_t rdp_bridge_start_windows_socket( const char *username, const char *password, const char *domain, + const char *acceptor_username, uint64_t *out_handle ); #endif diff --git a/packages/pam/handlers/rdp/native/src/bridge.rs b/packages/pam/handlers/rdp/native/src/bridge.rs index d7ccab1a..f9dde70e 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}; @@ -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, @@ -119,7 +119,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, @@ -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) } @@ -515,7 +509,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, @@ -588,8 +582,10 @@ 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 (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); @@ -766,7 +762,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, @@ -849,7 +845,7 @@ where Ok(()) } -fn build_acceptor_tls() -> Result<(tokio_rustls::rustls::ServerConfig, 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()]; @@ -863,13 +859,20 @@ 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 key_der = rustls::pki_types::PrivateKeyDer::Pkcs8(cert.key_pair.serialize_der().into()); - let config = tokio_rustls::rustls::ServerConfig::builder() + let cert_der_bytes = cert_der.as_ref().to_vec(); + 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)) + .context("rustls ServerConfig") } pub trait AsyncReadWrite: AsyncRead + AsyncWrite {} diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index fb637e99..0ff061ea 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -14,8 +14,9 @@ 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; 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_native(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 + } + } }) })?; @@ -234,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( @@ -244,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() { @@ -261,13 +293,24 @@ 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) }; - match spawn_session(client_tcp, host, target_port, username, password, domain) { + match spawn_session( + client_tcp, + host, + target_port, + username, + password, + domain, + flow, + ) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK @@ -291,6 +334,7 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( 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() { @@ -309,11 +353,23 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( None => return RDP_BRIDGE_BAD_ARG, }; 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::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, + flow, + ) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK @@ -343,12 +399,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) => { + Err(_) => { 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..13bf0bfe 100644 --- a/packages/pam/handlers/rdp/native/src/lib.rs +++ b/packages/pam/handlers/rdp/native/src/lib.rs @@ -7,3 +7,4 @@ pub mod cap_filter; 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..fac3e54f --- /dev/null +++ b/packages/pam/handlers/rdp/native/src/rdcleanpath.rs @@ -0,0 +1,328 @@ +use anyhow::{anyhow, Context, Result}; +use bytes::BytesMut; +use ironrdp_acceptor::{Acceptor, 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::{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::info; + +use crate::bridge::{ + bridge_pdus, filter_client_mcs_connect_initial, generate_acceptor_cert, + perform_connector_credssp, ErasedStream, TargetEndpoint, +}; +use crate::config::{connector_config, DEFAULT_HEIGHT, DEFAULT_WIDTH}; +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 = run_mitm_rdcleanpath_inner(client_tcp, target, acceptor_username, tx) => result, + _ = cancel.cancelled() => { + info!("rdcleanpath session canceled by caller"); + Ok(()) + } + } +} + +/// Browser MITM flow for clients that speak RDCleanPath (IronRDP WASM). +/// +/// 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. +/// 6. Bridge MCS/capabilities + PDUs (shared with the native path). +async fn run_mitm_rdcleanpath_inner( + 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(); + + let (request_pdu, client_leftover) = read_rdcleanpath_pdu(&mut client_tcp) + .await + .context("read 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"); + + let target_tcp = TcpStream::connect((target.host.as_str(), target.port)) + .await + .with_context(|| format!("connect target {}:{}", target.host, target.port))?; + 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")?; + + let (initial_stream, target_leftover) = target_framed.into_inner(); + let (upgraded_stream, target_cert) = ironrdp_tls::upgrade(initial_stream, &target.host) + .await + .context("TLS upgrade to target")?; + + 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(), + 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")?; + + // --- Connector: advance past X.224, then CredSSP only --- + + let config = connector_config( + target.username.clone(), + target.password.clone(), + target.domain.clone(), + ); + 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 _ = mark_as_upgraded(should_upgrade, &mut connector); + + let target_erased: ErasedStream = Box::new(upgraded_stream); + 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"))?; + + 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: acceptor_username, + password: "infisical".to_owned(), + domain: None, + }; + let mut acceptor = Acceptor::new( + SecurityProtocol::SSL, + AcceptorDesktopSize { + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + }, + Vec::new(), + 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:?}"))?; + + 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 mut client_framed: TokioFramed = + TokioFramed::new_with_leftover(client_erased, client_leftover); + + 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 + .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")?; + + 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); + +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() + ) + })? +} + +/// 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 { + 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 { + 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 => {} + DetectionResult::Failed => { + anyhow::bail!("not a valid RDCleanPath PDU"); + } + } + if buf.len() > 64 * 1024 { + anyhow::bail!("RDCleanPath PDU exceeded 64KB while incomplete"); + } + } +} + +/// 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, +{ + 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()) +} diff --git a/packages/pam/handlers/rdp/proxy.go b/packages/pam/handlers/rdp/proxy.go index 18bef66f..299a2172 100644 --- a/packages/pam/handlers/rdp/proxy.go +++ b/packages/pam/handlers/rdp/proxy.go @@ -36,6 +36,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 SessionEvent.Data for ChannelType=RDP. type rdpTargetFrameEnvelope struct { Type string `json:"type"` // "target_frame" 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 edf273af..4ff5a37d 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -168,7 +168,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") @@ -478,7 +481,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, handlerConn) + } return proxy.HandleConnection(ctx, handlerConn) default: return fmt.Errorf("unsupported resource type: %s", pamConfig.ResourceType) 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()