Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c111230
feat(pam): add browser-based RDP session support and recording
bernie-g May 11, 2026
7aeed67
fix(pam): address bot review issues in RDP browser session
bernie-g May 11, 2026
d56cde6
style(pam): apply cargo fmt formatting
bernie-g May 11, 2026
286b7e2
Merge feat/pam-rdp-recording into feat/pam-rdp-browser-client
bernie-g May 13, 2026
f53a670
style(pam-rdp): remove trailing blank line for cargo fmt
bernie-g May 13, 2026
c6dcfb1
refactor(pam-rdp): reuse native bridge path for browser RDP
bernie-g May 13, 2026
40741f0
refactor(pam-rdp): consolidate 4 FFI start functions into 2
bernie-g May 13, 2026
8980609
chore(pam-rdp): remove debug logging from bridge
bernie-g May 13, 2026
a031f0a
fix(pam-rdp): silence unused variable warning in ffi.rs
bernie-g May 13, 2026
ba8ce28
refactor(pam-rdp): rename MITM entry points for clarity
bernie-g May 13, 2026
059e6ef
docs(pam-rdp): add comments to read_rdcleanpath_pdu_inner and read_tp…
bernie-g May 13, 2026
f795c1f
docs(pam-rdp): document run_mitm_rdcleanpath_inner step-by-step
bernie-g May 13, 2026
d1552c2
docs(pam-rdp): expand CR/CC abbreviations in rdcleanpath comment
bernie-g May 13, 2026
2d9aa09
Merge branch 'main' into feat/pam-rdp-browser-client
x032205 May 14, 2026
498dcb8
remove unnecessary if statement
x032205 May 15, 2026
6ec5907
split acceptor cert generation from TLS config build
x032205 May 15, 2026
7ac3308
remove unused constant
x032205 May 15, 2026
8d4ee9c
format
x032205 May 16, 2026
1ccc772
Merge pull request #227 from Infisical/feat/pam-rdp-browser-client
x032205 May 18, 2026
5f5a8eb
Merge branch 'main' into feat/pam-rdp-recording
x032205 May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/gateway-v2/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -706,7 +707,7 @@ func (g *Gateway) setupTLSConfig() error {
ClientCAs: clientCAPool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"infisical-http-proxy", "infisical-tcp-proxy", "infisical-health", "infisical-ping", "infisical-pam-proxy", "infisical-pam-session-cancellation", "infisical-pam-capabilities"},
NextProtos: []string{"infisical-http-proxy", "infisical-tcp-proxy", "infisical-health", "infisical-ping", "infisical-pam-proxy", "infisical-pam-rdp-browser", "infisical-pam-session-cancellation", "infisical-pam-capabilities"},
}

return nil
Expand Down Expand Up @@ -869,7 +870,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 {
Expand All @@ -882,7 +883,8 @@ func (g *Gateway) handleIncomingChannel(newChannel ssh.NewChannel) {
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 {
Expand Down Expand Up @@ -953,6 +955,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
Expand Down
56 changes: 33 additions & 23 deletions packages/pam/handlers/rdp/bridge_cgo_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,45 +20,59 @@ 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() {
_ = p.config.SessionLogger.Close()
}()
}

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()
}()

Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 30 additions & 10 deletions packages/pam/handlers/rdp/bridge_cgo_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand All @@ -63,6 +74,7 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username,
cUser,
cPass,
cDomain,
cAcceptor,
&handle,
)
if rc != C.RDP_BRIDGE_OK {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions packages/pam/handlers/rdp/bridge_cgo_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
Expand All @@ -58,6 +74,7 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor
cUser,
cPass,
cDomain,
cAcceptor,
&handle,
)
if rc != C.RDP_BRIDGE_OK {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/pam/handlers/rdp/bridge_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
10 changes: 10 additions & 0 deletions packages/pam/handlers/rdp/native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/pam/handlers/rdp/native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading