Skip to content

Commit 00d06b2

Browse files
committed
feat(pam-rdp): thread AD domain through bridge for NTLM CredSSP
Plumbs the domain string (empty for local accounts, AD domain for domain-joined NTLM CredSSP) end-to-end: API session credentials → CLI session struct → RDPProxyConfig.InjectDomain → cgo wrapper → Rust FFI → IronRDP connector config.
1 parent 85c23d2 commit 00d06b2

11 files changed

Lines changed: 66 additions & 19 deletions

File tree

packages/api/model.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,7 @@ type PAMSessionCredentials struct {
890890
ServiceAccountToken string `json:"serviceAccountToken,omitempty"`
891891
ServiceAccountName string `json:"serviceAccountName,omitempty"`
892892
Namespace string `json:"namespace,omitempty"`
893+
Domain string `json:"domain,omitempty"`
893894
}
894895

895896
type MFASessionStatus string

packages/pam/handlers/rdp/bridge_cgo.go

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,18 @@ import (
2424

2525
// StartWithConn hands an independent dup of conn's fd to the bridge.
2626
// For TLS-wrapped or otherwise non-fd-backed conns, use StartWithReadWriter.
27-
func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
27+
// `domain` is empty for local accounts; set to the AD domain name for
28+
// domain-joined NTLM CredSSP.
29+
func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
2830
dupFd, err := dupConnFD(conn)
2931
if err != nil {
3032
return nil, fmt.Errorf("rdp bridge: dup client fd: %w", err)
3133
}
32-
return startWithDupedFD(dupFd, targetHost, targetPort, username, password)
34+
return startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain)
3335
}
3436

3537
// Ownership of dupFd transfers to Rust on success; we close it on failure.
36-
func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
38+
func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
3739
success := false
3840
defer func() {
3941
if !success {
@@ -48,13 +50,21 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username,
4850
cPass := C.CString(password)
4951
defer C.free(unsafe.Pointer(cPass))
5052

53+
// Empty domain -> NULL pointer; bridge treats both the same way.
54+
var cDomain *C.char
55+
if domain != "" {
56+
cDomain = C.CString(domain)
57+
defer C.free(unsafe.Pointer(cDomain))
58+
}
59+
5160
var handle C.uint64_t
5261
rc := C.rdp_bridge_start_unix_fd(
5362
C.int(dupFd),
5463
cHost,
5564
C.uint16_t(targetPort),
5665
cUser,
5766
cPass,
67+
cDomain,
5868
&handle,
5969
)
6070
if rc != C.RDP_BRIDGE_OK {
@@ -77,7 +87,7 @@ func startWithDupedFD(dupFd int, targetHost string, targetPort uint16, username,
7787
//
7888
// Cost: two extra in-process copies and a loopback round-trip per byte.
7989
// Negligible vs. the TLS + CredSSP work on either side.
80-
func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
90+
func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
8191
listener, err := net.Listen("tcp", "127.0.0.1:0")
8292
if err != nil {
8393
return nil, fmt.Errorf("rdp bridge: loopback listen: %w", err)
@@ -112,7 +122,7 @@ func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16,
112122
return nil, fmt.Errorf("rdp bridge: dup accepted fd: %w", err)
113123
}
114124

115-
bridge, err := startWithDupedFD(dupFd, targetHost, targetPort, username, password)
125+
bridge, err := startWithDupedFD(dupFd, targetHost, targetPort, username, password, domain)
116126
if err != nil {
117127
_ = peer.Close()
118128
return nil, err
@@ -168,6 +178,7 @@ func (p *RDPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) er
168178
p.config.TargetPort,
169179
p.config.InjectUsername,
170180
p.config.InjectPassword,
181+
p.config.InjectDomain,
171182
)
172183
if err != nil {
173184
return fmt.Errorf("rdp proxy: start bridge: %w", err)

packages/pam/handlers/rdp/bridge_cgo_windows.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ import (
2323
"golang.org/x/sys/windows"
2424
)
2525

26-
func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
26+
func StartWithConn(conn net.Conn, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
2727
dupSocket, err := dupConnSocket(conn)
2828
if err != nil {
2929
return nil, fmt.Errorf("rdp bridge: dup client socket: %w", err)
3030
}
31-
return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password)
31+
return startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain)
3232
}
3333

34-
func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
34+
func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
3535
success := false
3636
defer func() {
3737
if !success {
@@ -46,13 +46,20 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor
4646
cPass := C.CString(password)
4747
defer C.free(unsafe.Pointer(cPass))
4848

49+
var cDomain *C.char
50+
if domain != "" {
51+
cDomain = C.CString(domain)
52+
defer C.free(unsafe.Pointer(cDomain))
53+
}
54+
4955
var handle C.uint64_t
5056
rc := C.rdp_bridge_start_windows_socket(
5157
C.uintptr_t(dupSocket),
5258
cHost,
5359
C.uint16_t(targetPort),
5460
cUser,
5561
cPass,
62+
cDomain,
5663
&handle,
5764
)
5865
if rc != C.RDP_BRIDGE_OK {
@@ -62,7 +69,7 @@ func startWithDupedSocket(dupSocket windows.Handle, targetHost string, targetPor
6269
return &Bridge{handle: uint64(handle)}, nil
6370
}
6471

65-
func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password string) (*Bridge, error) {
72+
func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16, username, password, domain string) (*Bridge, error) {
6673
listener, err := net.Listen("tcp", "127.0.0.1:0")
6774
if err != nil {
6875
return nil, fmt.Errorf("rdp bridge: loopback listen: %w", err)
@@ -97,7 +104,7 @@ func StartWithReadWriter(rw io.ReadWriter, targetHost string, targetPort uint16,
97104
return nil, fmt.Errorf("rdp bridge: dup accepted socket: %w", err)
98105
}
99106

100-
bridge, err := startWithDupedSocket(dupSocket, targetHost, targetPort, username, password)
107+
bridge, err := startWithDupedSocket(dupSocket, targetHost, targetPort, username, password, domain)
101108
if err != nil {
102109
_ = peer.Close()
103110
return nil, err
@@ -165,6 +172,7 @@ func (p *RDPProxy) HandleConnection(ctx context.Context, clientConn net.Conn) er
165172
p.config.TargetPort,
166173
p.config.InjectUsername,
167174
p.config.InjectPassword,
175+
p.config.InjectDomain,
168176
)
169177
if err != nil {
170178
return fmt.Errorf("rdp proxy: start bridge: %w", err)

packages/pam/handlers/rdp/bridge_stub.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import (
1212
// where the Rust bridge isn't compiled. All entry points return
1313
// ErrRdpUnavailable.
1414

15-
func StartWithConn(_ net.Conn, _ string, _ uint16, _, _ string) (*Bridge, error) {
15+
func StartWithConn(_ net.Conn, _ string, _ uint16, _, _, _ string) (*Bridge, error) {
1616
return nil, ErrRdpUnavailable
1717
}
1818

19-
func StartWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _ string) (*Bridge, error) {
19+
func StartWithReadWriter(_ io.ReadWriter, _ string, _ uint16, _, _, _ string) (*Bridge, error) {
2020
return nil, ErrRdpUnavailable
2121
}
2222

packages/pam/handlers/rdp/native/include/rdp_bridge.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,17 @@ extern "C" {
2020
#define RDP_BRIDGE_BAD_ARG -2
2121
#define RDP_BRIDGE_RUNTIME_ERROR -3
2222

23+
// `domain` is optional. NULL or empty string means no domain (NTLM falls back
24+
// to local-account auth). Set this for AD domain accounts so NTLM CredSSP
25+
// authenticates against the target's AD binding rather than its local SAM.
2326
#if defined(__unix__) || defined(__APPLE__)
2427
int32_t rdp_bridge_start_unix_fd(
2528
int client_fd,
2629
const char *target_host,
2730
uint16_t target_port,
2831
const char *username,
2932
const char *password,
33+
const char *domain,
3034
uint64_t *out_handle
3135
);
3236
#endif
@@ -38,6 +42,7 @@ int32_t rdp_bridge_start_windows_socket(
3842
uint16_t target_port,
3943
const char *username,
4044
const char *password,
45+
const char *domain,
4146
uint64_t *out_handle
4247
);
4348
#endif

packages/pam/handlers/rdp/native/src/bridge.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub struct TargetEndpoint {
3232
pub port: u16,
3333
pub username: String,
3434
pub password: String,
35+
/// Set for AD domain accounts; flows into NTLM CredSSP via connector config.
36+
pub domain: Option<String>,
3537
}
3638

3739
pub async fn run_mitm(
@@ -165,7 +167,11 @@ async fn run_connector_half(target: TargetEndpoint) -> Result<(ErasedStream, byt
165167
let client_addr = target_tcp.local_addr().context("connector: local_addr")?;
166168

167169
let mut target_framed = ironrdp_tokio::TokioFramed::new(target_tcp);
168-
let config = connector_config(target.username.clone(), target.password.clone());
170+
let config = connector_config(
171+
target.username.clone(),
172+
target.password.clone(),
173+
target.domain.clone(),
174+
);
169175
let mut connector = ClientConnector::new(config, client_addr);
170176

171177
let should_upgrade = ironrdp_tokio::connect_begin(&mut target_framed, &mut connector)

packages/pam/handlers/rdp/native/src/config.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use ironrdp_pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo};
99
pub const DEFAULT_WIDTH: u16 = 1920;
1010
pub const DEFAULT_HEIGHT: u16 = 1080;
1111

12-
pub fn connector_config(username: String, password: String) -> Config {
12+
pub fn connector_config(username: String, password: String, domain: Option<String>) -> Config {
1313
Config {
1414
desktop_size: DesktopSize {
1515
width: DEFAULT_WIDTH,
@@ -25,7 +25,9 @@ pub fn connector_config(username: String, password: String) -> Config {
2525
enable_credssp: true,
2626

2727
credentials: Credentials::UsernamePassword { username, password },
28-
domain: None,
28+
// Set for AD domain accounts; IronRDP forwards this in NTLM CredSSP so
29+
// the target's LSA authenticates against AD rather than the local SAM.
30+
domain,
2931

3032
// Shape-fillers: unused after CredSSP (see module doc).
3133
client_build: 0,

packages/pam/handlers/rdp/native/src/ffi.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ fn spawn_session(
5959
port: u16,
6060
username: String,
6161
password: String,
62+
domain: Option<String>,
6263
) -> anyhow::Result<u64> {
6364
client_tcp.set_nonblocking(true)?;
6465
let cancel = CancellationToken::new();
@@ -77,6 +78,7 @@ fn spawn_session(
7778
port,
7879
username,
7980
password,
81+
domain,
8082
};
8183
run_mitm(client, endpoint, cancel_for_thread).await
8284
})
@@ -91,7 +93,8 @@ fn spawn_session(
9193
/// # Safety
9294
///
9395
/// `client_fd` ownership transfers to the bridge on OK, stays with the
94-
/// caller on error. Strings must be NUL-terminated valid UTF-8.
96+
/// caller on error. Strings must be NUL-terminated valid UTF-8. `domain`
97+
/// may be NULL or empty for non-domain sessions.
9598
#[cfg(unix)]
9699
#[no_mangle]
97100
pub unsafe extern "C" fn rdp_bridge_start_unix_fd(
@@ -100,6 +103,7 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd(
100103
target_port: u16,
101104
username: *const c_char,
102105
password: *const c_char,
106+
domain: *const c_char,
103107
out_handle: *mut u64,
104108
) -> i32 {
105109
if out_handle.is_null() {
@@ -117,11 +121,13 @@ pub unsafe extern "C" fn rdp_bridge_start_unix_fd(
117121
Some(v) => v,
118122
None => return RDP_BRIDGE_BAD_ARG,
119123
};
124+
// Empty domain string is treated the same as NULL: no domain.
125+
let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty());
120126

121127
use std::os::unix::io::FromRawFd;
122128
let client_tcp = unsafe { StdTcpStream::from_raw_fd(client_fd) };
123129

124-
match spawn_session(client_tcp, host, target_port, username, password) {
130+
match spawn_session(client_tcp, host, target_port, username, password, domain) {
125131
Ok(id) => {
126132
unsafe { *out_handle = id };
127133
RDP_BRIDGE_OK
@@ -144,6 +150,7 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket(
144150
target_port: u16,
145151
username: *const c_char,
146152
password: *const c_char,
153+
domain: *const c_char,
147154
out_handle: *mut u64,
148155
) -> i32 {
149156
if out_handle.is_null() {
@@ -161,11 +168,12 @@ pub unsafe extern "C" fn rdp_bridge_start_windows_socket(
161168
Some(v) => v,
162169
None => return RDP_BRIDGE_BAD_ARG,
163170
};
171+
let domain = unsafe { c_str_to_owned(domain) }.filter(|s| !s.is_empty());
164172

165173
use std::os::windows::io::{FromRawSocket, RawSocket};
166174
let client_tcp = unsafe { StdTcpStream::from_raw_socket(client_socket as RawSocket) };
167175

168-
match spawn_session(client_tcp, host, target_port, username, password) {
176+
match spawn_session(client_tcp, host, target_port, username, password, domain) {
169177
Ok(id) => {
170178
unsafe { *out_handle = id };
171179
RDP_BRIDGE_OK

packages/pam/handlers/rdp/proxy.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ type RDPProxyConfig struct {
99
TargetPort uint16
1010
InjectUsername string
1111
InjectPassword string
12-
SessionID string
12+
// Empty for local accounts; AD domain name (e.g. "CORP.EXAMPLE.COM") for
13+
// domain-joined NTLM CredSSP. Backend session credentials populate this.
14+
InjectDomain string
15+
SessionID string
1316
// Retained for API symmetry with other PAM handlers; not yet written
1417
// through (no RDP session recording in this MVP).
1518
SessionLogger session.SessionLogger

packages/pam/pam-proxy.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,7 @@ func HandlePAMProxy(ctx context.Context, conn *tls.Conn, pamConfig *GatewayPAMCo
422422
TargetPort: uint16(credentials.Port),
423423
InjectUsername: credentials.Username,
424424
InjectPassword: credentials.Password,
425+
InjectDomain: credentials.Domain,
425426
SessionID: pamConfig.SessionId,
426427
SessionLogger: sessionLogger,
427428
}

0 commit comments

Comments
 (0)