Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 13 additions & 1 deletion crates/ironrdp-server/src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ use super::display::{DesktopSize, RdpServerDisplay};
#[cfg(feature = "egfx")]
use super::gfx::GfxServerFactory;
use super::handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler};
use super::server::{ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity};
use super::server::{
ConnectionBinder, ConnectionHandler, CredentialValidator, RdpServer, RdpServerOptions, RdpServerSecurity,
};
use crate::{DisplayUpdate, RdpServerDisplayUpdates, SoundServerFactory};

pub struct WantsAddr {}
Expand All @@ -38,6 +40,7 @@ pub struct BuilderDone {
sound_factory: Option<Box<dyn SoundServerFactory>>,
connection_handler: Option<Box<dyn ConnectionHandler>>,
credential_validator: Option<Arc<dyn CredentialValidator>>,
connection_binder: Option<Arc<dyn ConnectionBinder>>,
#[cfg(feature = "egfx")]
gfx_factory: Option<Box<dyn GfxServerFactory>>,
display_suppressed: Option<Arc<AtomicBool>>,
Expand Down Expand Up @@ -137,6 +140,7 @@ impl RdpServerBuilder<WantsDisplay> {
cliprdr_factory: None,
connection_handler: None,
credential_validator: None,
connection_binder: None,
codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"),
max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE,
#[cfg(feature = "egfx")]
Expand All @@ -159,6 +163,7 @@ impl RdpServerBuilder<WantsDisplay> {
cliprdr_factory: None,
connection_handler: None,
credential_validator: None,
connection_binder: None,
codecs: server_codecs_capabilities(&[]).expect("can't panic for &[]"),
max_request_size: RdpServerOptions::DEFAULT_MAX_REQUEST_SIZE,
#[cfg(feature = "egfx")]
Expand Down Expand Up @@ -278,6 +283,12 @@ impl RdpServerBuilder<BuilderDone> {
self
}

/// Set a binder that replaces display/input handlers after credentials are accepted.
pub fn with_connection_binder(mut self, binder: Option<Arc<dyn ConnectionBinder>>) -> Self {
self.state.connection_binder = binder;
self
}

/// Inject a shared NetworkAutoDetect RTT handle (milliseconds, `u32::MAX`
/// until the first measurement). The server writes the latest measured RTT
/// to the same instance the backend reads. When not called, the server
Expand Down Expand Up @@ -309,6 +320,7 @@ impl RdpServerBuilder<BuilderDone> {
self.state.autodetect_rtt,
);
server.set_credential_validator(self.state.credential_validator);
server.set_connection_binder(self.state.connection_binder);
server
}
}
Expand Down
6 changes: 3 additions & 3 deletions crates/ironrdp-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ pub use handler::{KeyboardEvent, MouseEvent, RdpServerInputHandler};
#[cfg(feature = "helper")]
pub use helper::TlsIdentityCtx;
pub use server::{
ConnectionHandler, CredentialDecision, CredentialValidationError, CredentialValidator, Credentials,
ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions, RdpServerSecurity, ServerEvent,
ServerEventSender, TransportTls,
BoundConnection, ConnectionBinder, ConnectionHandler, CredentialDecision, CredentialValidationError,
CredentialValidator, Credentials, ExactMatchCredentialValidator, PostConnectionAction, RdpServer, RdpServerOptions,
RdpServerSecurity, ServerEvent, ServerEventSender, TransportTls,
};
pub use sound::{RdpsndServerHandler, RdpsndServerMessage, SoundServerFactory};

Expand Down
209 changes: 188 additions & 21 deletions crates/ironrdp-server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ pub trait CredentialValidator: Send + Sync {
async fn validate(&self, credentials: &Credentials) -> Result<CredentialDecision, CredentialValidationError>;
}

/// Display/input objects bound after a credential validator accepts a client.
///
/// Servers with per-user desktop/session isolation can start with placeholder
/// display and input handlers, validate the client's credentials, and then
/// replace those placeholders with handlers attached to the authenticated
/// user's session before the RDP client loop starts.
pub struct BoundConnection {
pub display: Box<dyn RdpServerDisplay>,
pub input: Box<dyn RdpServerInputHandler>,
}

/// Async post-auth connection binder.
///
/// This hook runs after [`CredentialValidator`] accepts credentials and before
/// static channels, display updates, or input dispatch begin. It lets a server
/// bind display/input resources to the authenticated identity without creating
/// per-user resources before authentication.
#[async_trait::async_trait]
pub trait ConnectionBinder: Send + Sync {
async fn bind_connection(&self, credentials: &Credentials) -> Result<BoundConnection>;
}

/// A built-in [`CredentialValidator`] that accepts exactly one fixed set of credentials.
///
/// This is the validation-policy equivalent of the acceptor's pre-loaded
Expand Down Expand Up @@ -448,6 +470,7 @@ pub struct RdpServer {
ev_receiver: Arc<Mutex<mpsc::UnboundedReceiver<ServerEvent>>>,
creds: Option<Credentials>,
credential_validator: Option<Arc<dyn CredentialValidator>>,
connection_binder: Option<Arc<dyn ConnectionBinder>>,
local_addr: Option<SocketAddr>,
autodetect: Option<AutoDetectManager>,
connection_handler: Option<Box<dyn ConnectionHandler>>,
Expand Down Expand Up @@ -546,6 +569,7 @@ impl RdpServer {
ev_receiver: Arc::new(Mutex::new(ev_receiver)),
creds: None,
credential_validator: None,
connection_binder: None,
local_addr: None,
autodetect: None,
connection_handler,
Expand Down Expand Up @@ -582,6 +606,15 @@ impl RdpServer {
self.credential_validator = validator;
}

/// Set or clear a post-auth connection binder.
///
/// When set, the binder is called after credentials have been validated.
/// The returned display/input handlers replace the server defaults for the
/// accepted connection.
pub fn set_connection_binder(&mut self, binder: Option<Arc<dyn ConnectionBinder>>) {
self.connection_binder = binder;
}

pub fn event_sender(&self) -> &mpsc::UnboundedSender<ServerEvent> {
&self.ev_sender
}
Expand Down Expand Up @@ -1328,6 +1361,7 @@ impl RdpServer {
reader: &mut Framed<R>,
writer: &mut Framed<W>,
result: AcceptorResult,
authenticated_credentials_cache: &mut Option<Credentials>,
) -> Result<RunState>
where
R: FramedRead,
Expand All @@ -1339,26 +1373,28 @@ impl RdpServer {
// async server layer, rather than in the sans-I/O acceptor, because real validators
// (PAM/LDAP/DB) are I/O-bound. On rejection, deny with a ServerSetErrorInfoPdu before
// closing, matching the acceptor's exact-match denial path.
if let Some(validator) = self.credential_validator.clone() {
if let Some(creds) = &result.credentials {
match validator.validate(creds).await {
Ok(CredentialDecision::Accept) => {
debug!("Credential validation accepted");
}
Ok(CredentialDecision::Reject) => {
warn!("Credential validation rejected");
send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?;
bail!("credential validation rejected");
}
Err(e) => {
error!(error = %e, "Credential validator backend error");
send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?;
bail!("credential validation backend error");
}
}
} else {
debug!("Skipping credential validation (no credentials in AcceptorResult)");
}
let authenticated_credentials = resolve_authenticated_credentials(
self.credential_validator.clone(),
result.credentials.as_ref(),
result.reactivation,
authenticated_credentials_cache,
)
.await?;
Comment on lines +1376 to +1382

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on the canonical rcarmo/IronRDP:wrdp branch in b51bd0a68fb26bfc179571f59ea137eccc850fce and retained at 3b05f6fc70d91c2529df8454ce73a292c05195e6: credential rejection/backend-error paths now send the denial PDU before returning the error.


if let Some(binder) = self.connection_binder.clone() {
let Some(credentials) = authenticated_credentials.as_ref() else {
warn!("Connection binder configured but no authenticated credentials are available");
send_access_denied(result.io_channel_id, result.user_channel_id, writer).await?;
bail!("no authenticated credentials for connection binding");
};

let bound = binder
.bind_connection(credentials)
.await
.context("connection binder failed")?;
*self.display.lock().await = bound.display;
*self.handler.lock().await = bound.input;
debug!("Connection binder installed display/input handlers");
}
Comment on lines +1384 to 1398

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on the canonical rcarmo/IronRDP:wrdp branch in 3b05f6fc70d91c2529df8454ce73a292c05195e6: the binder block is gated on !result.reactivation, avoiding repeated per-user session lookup/setup and handler replacement during resize/reactivation.


if !result.input_events.is_empty() {
Expand Down Expand Up @@ -1690,14 +1726,19 @@ impl RdpServer {
where
S: AsyncRead + AsyncWrite + Sync + Send + Unpin,
{
let mut authenticated_credentials_cache = None;

loop {
let (new_framed, result) = ironrdp_acceptor::accept_finalize(framed, &mut acceptor)
.await
.context("failed to accept client during finalize")?;

let (mut reader, mut writer) = split_tokio_framed(new_framed);

match self.client_accepted(&mut reader, &mut writer, result).await? {
match self
.client_accepted(&mut reader, &mut writer, result, &mut authenticated_credentials_cache)
.await?
{
RunState::Continue => {
unreachable!();
}
Expand Down Expand Up @@ -1728,6 +1769,51 @@ impl RdpServer {
}
}

async fn resolve_authenticated_credentials(
credential_validator: Option<Arc<dyn CredentialValidator>>,
result_credentials: Option<&Credentials>,
reactivation: bool,
authenticated_credentials_cache: &mut Option<Credentials>,
) -> Result<Option<Credentials>> {
if let Some(validator) = credential_validator {
if let Some(creds) = result_credentials {
match validator.validate(creds).await {
Ok(CredentialDecision::Accept) => {
debug!("Credential validation accepted");
*authenticated_credentials_cache = Some(creds.clone());
Ok(Some(creds.clone()))
}
Ok(CredentialDecision::Reject) => {
warn!("Credential validation rejected");
bail!("credential validation rejected");
}
Err(e) => {
error!(error = %e, "Credential validator backend error");
bail!("credential validation backend error");
}
}
} else if reactivation {
let credentials = authenticated_credentials_cache.clone();
if credentials.is_some() {
debug!("Reusing cached authenticated credentials for reactivation");
} else {
debug!("Skipping credential validation for reactivation without cached credentials");
}
Ok(credentials)
} else {
debug!("Skipping credential validation (no credentials in AcceptorResult)");
Ok(None)
}
} else if let Some(creds) = result_credentials {
*authenticated_credentials_cache = Some(creds.clone());
Ok(Some(creds.clone()))
} else if reactivation {
Ok(authenticated_credentials_cache.clone())
} else {
Ok(None)
}
}

/// Encode a server-initiated Share Data PDU for the IO channel.
///
/// `share_id` is hard-coded to 0, matching the existing convention in
Expand Down Expand Up @@ -1842,3 +1928,84 @@ impl<'a, W: FramedWrite> SharedWriter<'a, W> {
}
}
}

#[cfg(test)]
mod wrdp_reactivation_tests {
use super::*;

struct AllowUserValidator(&'static str);

#[async_trait::async_trait]
impl CredentialValidator for AllowUserValidator {
async fn validate(&self, credentials: &Credentials) -> Result<CredentialDecision, CredentialValidationError> {
if credentials.username == self.0 {
Ok(CredentialDecision::Accept)
} else {
Ok(CredentialDecision::Reject)
}
}
}

fn creds(username: &str) -> Credentials {
Credentials {
username: username.to_owned(),
password: "secret".to_owned(),
domain: None,
}
}

#[tokio::test]
async fn reactivation_without_credentials_reuses_same_connection_validated_identity() {
let validator = Arc::new(AllowUserValidator("alice"));
let mut per_connection_cache = None;

let first = resolve_authenticated_credentials(
Some(validator.clone()),
Some(&creds("alice")),
false,
&mut per_connection_cache,
)
.await
.expect("initial validation should succeed")
.expect("initial validation should produce credentials");
assert_eq!(first.username, "alice");

let reactivated = resolve_authenticated_credentials(
Some(validator),
None,
true,
&mut per_connection_cache,
)
.await
.expect("reactivation should reuse same-connection cache")
.expect("reactivation should have cached credentials");
assert_eq!(reactivated.username, "alice");
}

#[tokio::test]
async fn reactivation_without_credentials_cannot_use_previous_tcp_connection_cache() {
let validator = Arc::new(AllowUserValidator("alice"));
let mut first_connection_cache = None;
resolve_authenticated_credentials(
Some(validator.clone()),
Some(&creds("alice")),
false,
&mut first_connection_cache,
)
.await
.expect("initial validation should succeed");
assert!(first_connection_cache.is_some());

let mut second_connection_cache = None;
let reactivated = resolve_authenticated_credentials(
Some(validator),
None,
true,
&mut second_connection_cache,
)
.await
.expect("missing same-connection cache is not a backend error");
assert!(reactivated.is_none());
assert!(second_connection_cache.is_none());
}
}
14 changes: 14 additions & 0 deletions docs/wrdp/auth-delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Post-auth connection binding for multi-user servers

`wrdp` follows the same multi-user architecture model as `xrdp-sesman`: a
single public RDP listener authenticates the client first, then delegates the
connection to a per-user desktop/session stack.

That model needs a server hook that runs after credentials have been accepted
but before display updates and input dispatch begin. The hook lets a server
start or locate the authenticated user's session and then replace placeholder
handlers with display/input handlers bound to that session.

The `ConnectionBinder` API keeps protocol ownership inside IronRDP while
allowing downstream servers to keep user/session lifecycle code outside the RDP
state machine.
10 changes: 10 additions & 0 deletions docs/wrdp/reactivation-credential-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Reactivation credential cache scope

During Deactivation-Reactivation some clients do not send a second credentials
PDU. A server that binds display/input handlers after authentication still needs
the identity accepted earlier on the same TCP connection.

The cache introduced here is deliberately scoped to `accept_finalize()`, i.e. to
one TCP connection. It allows same-connection reactivation to reuse the validated
identity but prevents a new TCP connection from inheriting credentials accepted
on a previous connection.