diff --git a/packages/api/model.go b/packages/api/model.go index 5c79c664..5dd3fe95 100644 --- a/packages/api/model.go +++ b/packages/api/model.go @@ -937,6 +937,7 @@ type PAMSessionCredentials struct { ServiceAccountToken string `json:"serviceAccountToken,omitempty"` ServiceAccountName string `json:"serviceAccountName,omitempty"` Namespace string `json:"namespace,omitempty"` + Domain string `json:"domain,omitempty"` } type MFASessionStatus string diff --git a/packages/pam/handlers/rdp/bridge_cgo_shared.go b/packages/pam/handlers/rdp/bridge_cgo_shared.go index 9a822e6f..9c8d8fba 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_shared.go +++ b/packages/pam/handlers/rdp/bridge_cgo_shared.go @@ -30,6 +30,7 @@ func (p *RDPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) er p.config.TargetPort, p.config.InjectUsername, p.config.InjectPassword, + p.config.InjectDomain, ) if err != nil { return fmt.Errorf("rdp proxy: start bridge: %w", err) diff --git a/packages/pam/handlers/rdp/bridge_cgo_unix.go b/packages/pam/handlers/rdp/bridge_cgo_unix.go index 91b24d38..37e7d2ee 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_unix.go +++ b/packages/pam/handlers/rdp/bridge_cgo_unix.go @@ -22,16 +22,18 @@ import ( // StartWithConn hands an independent dup of conn's fd to the bridge. // For TLS-wrapped or otherwise non-fd-backed conns, use StartWithReadWriter. -func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password string) (*Bridge, error) { +// `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) + 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 string) (*Bridge, error) { +func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { success := false defer func() { if !success { @@ -46,6 +48,13 @@ 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 handle C.uint64_t rc := C.rdp_bridge_start_unix_fd( C.int(dupFd), @@ -53,6 +62,7 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, C.uint16_t(targetPort), cUser, cPass, + cDomain, &handle, ) if rc != C.RDP_BRIDGE_OK { @@ -75,7 +85,7 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, // // Cost: two extra in-process copies and a loopback round-trip per byte. // Negligible vs. the TLS + CredSSP work on either side. -func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password string) (*Bridge, error) { +func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain 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) @@ -110,7 +120,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) + bridge, err := startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain) 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 c28d5f89..d706b8ee 100644 --- a/packages/pam/handlers/rdp/bridge_cgo_windows.go +++ b/packages/pam/handlers/rdp/bridge_cgo_windows.go @@ -21,15 +21,15 @@ import ( "golang.org/x/sys/windows" ) -func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password string) (*Bridge, error) { +func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain 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) + return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain) } -func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password string) (*Bridge, error) { +func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) { success := false defer func() { if !success { @@ -44,6 +44,12 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor cPass := C.CString(password) defer C.free(unsafe.Pointer(cPass)) + var cDomain *C.char + if domain != "" { + cDomain = C.CString(domain) + defer C.free(unsafe.Pointer(cDomain)) + } + var handle C.uint64_t rc := C.rdp_bridge_start_windows_socket( C.uintptr_t(dupSocket), @@ -51,6 +57,7 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor C.uint16_t(targetPort), cUser, cPass, + cDomain, &handle, ) if rc != C.RDP_BRIDGE_OK { @@ -60,7 +67,7 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor return &Bridge{handle: uint64(handle)}, nil } -func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password string) (*Bridge, error) { +func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain 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) @@ -95,7 +102,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) + bridge, err := startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain) 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 37a3bcdf..2c488000 100644 --- a/packages/pam/handlers/rdp/bridge_stub.go +++ b/packages/pam/handlers/rdp/bridge_stub.go @@ -12,11 +12,11 @@ import ( // where the Rust bridge isn't compiled. All entry points return // ErrRdpUnavailable. -func StartWithConn(_ net.Conn, _ string, _ uint16, _, _ string) (*Bridge, error) { +func StartWithConn(_ net.Conn, _ string, _ uint16, _, _, _ string) (*Bridge, error) { return nil, ErrRdpUnavailable } -func StartWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _ string) (*Bridge, error) { +func StartWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _, _ string) (*Bridge, error) { return nil, ErrRdpUnavailable } diff --git a/packages/pam/handlers/rdp/native/include/rdp_bridge.h b/packages/pam/handlers/rdp/native/include/rdp_bridge.h index 83088768..65200f5f 100644 --- a/packages/pam/handlers/rdp/native/include/rdp_bridge.h +++ b/packages/pam/handlers/rdp/native/include/rdp_bridge.h @@ -20,6 +20,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. #if defined(__unix__) || defined(__APPLE__) int32_t rdp_bridge_start_unix_fd( int client_fd, @@ -27,6 +30,7 @@ int32_t rdp_bridge_start_unix_fd( uint16_t target_port, const char *username, const char *password, + const char *domain, uint64_t *out_handle ); #endif @@ -38,6 +42,7 @@ int32_t rdp_bridge_start_windows_socket( uint16_t target_port, const char *username, const char *password, + const char *domain, 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 cfe5e992..1eac7b1e 100644 --- a/packages/pam/handlers/rdp/native/src/bridge.rs +++ b/packages/pam/handlers/rdp/native/src/bridge.rs @@ -36,6 +36,8 @@ pub struct TargetEndpoint { pub port: u16, pub username: String, pub password: String, + /// Set for AD domain accounts; flows into NTLM CredSSP via connector config. + pub domain: Option, } pub async fn run_mitm( @@ -260,7 +262,11 @@ async fn run_connector_half(target: TargetEndpoint) -> Result<(ErasedStream, byt let client_addr = target_tcp.local_addr().context("connector: local_addr")?; let mut target_framed = ironrdp_tokio::TokioFramed::new(target_tcp); - let config = connector_config(target.username.clone(), target.password.clone()); + let config = connector_config( + target.username.clone(), + target.password.clone(), + target.domain.clone(), + ); let mut connector = ClientConnector::new(config, client_addr); let should_upgrade = ironrdp_tokio::connect_begin(&mut target_framed, &mut connector) diff --git a/packages/pam/handlers/rdp/native/src/config.rs b/packages/pam/handlers/rdp/native/src/config.rs index b1f9a77a..ba223311 100644 --- a/packages/pam/handlers/rdp/native/src/config.rs +++ b/packages/pam/handlers/rdp/native/src/config.rs @@ -9,7 +9,7 @@ use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; pub const DEFAULT_WIDTH: u16 = 1920; pub const DEFAULT_HEIGHT: u16 = 1080; -pub fn connector_config(username: String, password: String) -> Config { +pub fn connector_config(username: String, password: String, domain: Option) -> Config { Config { desktop_size: DesktopSize { width: DEFAULT_WIDTH, @@ -25,7 +25,9 @@ pub fn connector_config(username: String, password: String) -> Config { enable_credssp: true, credentials: Credentials::UsernamePassword { username, password }, - domain: None, + // Set for AD domain accounts; IronRDP forwards this in NTLM CredSSP so + // the target's LSA authenticates against AD rather than the local SAM. + domain, // Shape-fillers: unused after CredSSP (see module doc). client_build: 0, diff --git a/packages/pam/handlers/rdp/native/src/ffi.rs b/packages/pam/handlers/rdp/native/src/ffi.rs index ecef7782..96a2fd52 100644 --- a/packages/pam/handlers/rdp/native/src/ffi.rs +++ b/packages/pam/handlers/rdp/native/src/ffi.rs @@ -59,6 +59,7 @@ fn spawn_session( port: u16, username: String, password: String, + domain: Option, ) -> anyhow::Result { client_tcp.set_nonblocking(true)?; let cancel = CancellationToken::new(); @@ -77,6 +78,7 @@ fn spawn_session( port, username, password, + domain, }; run_mitm(client, endpoint, cancel_for_thread).await }) @@ -91,7 +93,8 @@ fn spawn_session( /// # Safety /// /// `client_fd` ownership transfers to the bridge on OK, stays with the -/// caller on error. Strings must be NUL-terminated valid UTF-8. +/// caller on error. Strings must be NUL-terminated valid UTF-8. `domain` +/// may be NULL or empty for non-domain sessions. #[cfg(unix)] #[no_mangle] pub unsafe extern "C" fn rdp_bridge_start_unix_fd( @@ -100,6 +103,7 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd( 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() { @@ -117,11 +121,13 @@ 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()); 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) { + match spawn_session(client_tcp, host, target_port, username, password, domain) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK @@ -144,6 +150,7 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( 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() { @@ -161,11 +168,12 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket( 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) { + match spawn_session(client_tcp, host, target_port, username, password, domain) { Ok(id) => { unsafe { *out_handle = id }; RDP_BRIDGE_OK diff --git a/packages/pam/handlers/rdp/proxy.go b/packages/pam/handlers/rdp/proxy.go index e113902a..2bd6aa8d 100644 --- a/packages/pam/handlers/rdp/proxy.go +++ b/packages/pam/handlers/rdp/proxy.go @@ -9,7 +9,10 @@ type RDPProxyConfig struct { TargetPort uint16 InjectUsername string InjectPassword string - SessionID 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 // Retained for API symmetry with other PAM handlers; not yet written // through (no RDP session recording in this MVP). SessionLogger session.SessionLogger diff --git a/packages/pam/pam-proxy.go b/packages/pam/pam-proxy.go index 0cd6c29e..2995e99b 100644 --- a/packages/pam/pam-proxy.go +++ b/packages/pam/pam-proxy.go @@ -422,6 +422,7 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo TargetPort: uint16(credentials.Port), InjectUsername: credentials.Username, InjectPassword: credentials.Password, + InjectDomain: credentials.Domain, SessionID: pamConfig.SessionId, SessionLogger: sessionLogger, } diff --git a/packages/pam/session/credentials.go b/packages/pam/session/credentials.go index c3173ec8..fcc9e3f1 100644 --- a/packages/pam/session/credentials.go +++ b/packages/pam/session/credentials.go @@ -34,6 +34,7 @@ type PAMCredentials struct { ServiceAccountToken string ServiceAccountName string Namespace string + Domain string PolicyRules *api.PAMPolicyRules } @@ -186,6 +187,7 @@ func (cm *CredentialsManager) GetPAMSessionCredentials(sessionId string, expiryT ServiceAccountToken: response.Credentials.ServiceAccountToken, ServiceAccountName: response.Credentials.ServiceAccountName, Namespace: response.Credentials.Namespace, + Domain: response.Credentials.Domain, PolicyRules: response.PolicyRules, }