Skip to content
Open
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
264 changes: 264 additions & 0 deletions crates/ironrdp-server/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ use ironrdp_rdpeusb::{InterfaceAlloc, server::UrbdrcControlServer, server::Urbdr
const LISTENER_BACKLOG: u32 = 1024;
const AUTO_RECONNECT_COOKIE_UPDATE_INTERVAL: Duration = Duration::from_secs(60 * 60);

/// How often the server drives [`CliprdrServer::drive_timeouts`].
///
/// The clipboard channel tracks clipboard-data locks, in-flight file contents
/// requests and locked file list snapshots against wall-clock deadlines, but it
/// has no timer of its own: the docs on `drive_timeouts` require the embedder to
/// call it periodically. `ironrdp-client` and `ironrdp-web` already do; without
/// this the server role never sends `Unlock` PDUs, never expires locks and never
/// answers abandoned file contents requests.
///
/// Two of those sweeps reach the backend with no PDU from the peer behind them:
/// a file contents request left pending past the transfer timeout is answered
/// with a synthetic error through [`on_file_contents_response`], and a locked
/// file list snapshot left inactive for that same window is dropped with
/// [`on_unlock`]. Both already run for the client and web roles; a server
/// backend starts seeing them once this timer does.
///
/// [`on_file_contents_response`]: ironrdp_cliprdr::backend::CliprdrBackend::on_file_contents_response
/// [`on_unlock`]: ironrdp_cliprdr::backend::CliprdrBackend::on_unlock
const CLIPRDR_DRIVE_TIMEOUTS_INTERVAL: Duration = Duration::from_secs(5);

/// How long a single [`ironrdp_acceptor::accept_finalize`] pass may take before
/// the connection is dropped.
///
Expand Down Expand Up @@ -1783,6 +1803,47 @@ impl RdpServer {
Ok(())
}

/// Drives the clipboard channel's time-based cleanup.
///
/// See [`CLIPRDR_DRIVE_TIMEOUTS_INTERVAL`]. A cleanup failure is logged and
/// swallowed: the sweep is best-effort maintenance, and failing it must not
/// disconnect an otherwise healthy session.
async fn drive_cliprdr_timeouts(
&mut self,
writer: &mut impl FramedWrite,
user_channel_id: u16,
) -> ServerResult<()> {
let Some(cliprdr) = self.get_svc_processor::<CliprdrServer>() else {
return Ok(());
};

let msgs = match cliprdr.drive_timeouts() {
Ok(msgs) => Vec::from(msgs),
Err(error) => {
warn!(%error, "Clipboard timeout cleanup failed");
return Ok(());
}
};

if msgs.is_empty() {
return Ok(());
}

// A configured channel the client never joined has no ID. Skip it like
// `client_accepted` does rather than end the session over it.
let Some(channel_id) = self.get_channel_id_by_type::<CliprdrServer>() else {
warn!("Clipboard channel not joined, dropping timeout cleanup messages");
return Ok(());
};
let data = server_encode_svc_messages(msgs, channel_id, user_channel_id).map_err(ServerError::encode)?;
writer
.write_all(&data)
.await
.map_err(|e| ServerError::io("write_all", e))?;

Ok(())
}
Comment on lines +1806 to +1845

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[protocol] Timer-driven sweep evicts incoming locked file lists before the peer's Unlock PDU — medium 🟠 — Wiring drive_timeouts into the server role activates its sweep, which drops an incoming locked file-list snapshot after the transfer timeout of inactivity and only notifies the local backend. MS-RDPECLIP 3.1.5.3.2 requires File Stream data covered by a received Lock Clipboard Data PDU to be stored until an Unlock Clipboard Data PDU is received, and 3.1.5.4.6 requires a File Contents Request PDU carrying that clipDataId to be serviced from the locked data. After eviction, a late request referencing the still-locked ID can only be answered with CB_RESPONSE_FAIL even though the peer never released the lock. Verified against drive_timeouts_impl (ironrdp-cliprdr/src/lib.rs:1292-1311). The deviation is bounded by the 60s default transfer_timeout and trades unconditional retention for avoiding unbounded memory growth on abandoned transfers; the outgoing-lock Unlock PDUs the same timer emits are conformant under 3.1.5.3.3, and the synthetic file-contents error stays backend-local with no wire footprint.


async fn update_auto_reconnect_cookie(
&mut self,
cookie: Option<rdp::session_info::ServerAutoReconnect>,
Expand Down Expand Up @@ -3741,6 +3802,7 @@ impl RdpServer {
let mut event_writer = writer.clone();
let mut auto_reconnect_writer = writer.clone();
let mut heartbeat_writer = writer.clone();
let mut cliprdr_writer = writer.clone();
let mut udp_tunnel_writer = writer.clone();
let udp_transport_for_events = Rc::clone(&udp_transport);
let udp_transport_for_pdus = Rc::clone(&udp_transport);
Expand Down Expand Up @@ -3949,6 +4011,23 @@ impl RdpServer {
}
};

let this = Rc::clone(&s);
let drive_cliprdr_timeouts = async move {
let mut interval = tokio::time::interval(CLIPRDR_DRIVE_TIMEOUTS_INTERVAL);
// A stalled write can hold this future past several tick deadlines;
// Burst (the default) would then fire the missed ticks back-to-back
// for a sweep that is idempotent anyway.
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await; // first tick completes immediately

loop {
interval.tick().await;
let mut this = this.lock().await;
this.drive_cliprdr_timeouts(&mut cliprdr_writer, user_channel_id)
.await?;
}
};
Comment on lines +4014 to +4029

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[skeptical] Only the sweep method is tested; the select! arm and error propagation are not — low 🟡 — The new tests call drive_cliprdr_timeouts directly; no harness reaches the active client_loop stage, so the interval wiring, the arm's propagation of any Err into the select! result, and the constant's actual use are unverified. The arm is precisely where the refined channel-ID error-handling issue manifests. Accepted on the strength of mirroring the existing refresh_auto_reconnect_cookie and send_heartbeats arms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged. This is the gap called out in the description's scope note. Testing the arm needs a harness that reaches the active stage of client_loop, and this crate doesn't have one yet (finalize_timeout.rs stops at the finalize handshake). The arm uses the same pattern as send_heartbeats and refresh_auto_reconnect_cookie, and with the fix above the error path you flagged no longer exists. Building that harness would be a good follow-up, but I'd rather keep it out of this PR.

Comment on lines +4015 to +4029

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[skeptical] The select! arm that wires the timer into client_loop is untested — low 🟡 — Both new tests call drive_cliprdr_timeouts directly, so the interval loop, Skip missed-tick behavior, mutex acquisition, and ? propagation that ends the session on write failure are never executed by a test. A regression such as dropping the arm from the select! or accidentally reverting to burst behavior would pass the suite. Mitigating context: the arm mirrors the existing send_heartbeats and refresh_auto_reconnect_cookie timers, the PR's scope note acknowledges the gap (no harness reaches the active stage of client_loop), and the direct tests do cover the driven method including the expired-lock and no-channel paths.


let this = Rc::clone(&s);
let dispatch_udp_tunnel = async move {
// Only the pass of `client_loop` that received a fresh
Expand Down Expand Up @@ -4037,6 +4116,7 @@ impl RdpServer {
state = dispatch_events => state,
state = refresh_auto_reconnect_cookie => state,
state = send_heartbeats => state,
state = drive_cliprdr_timeouts => state,
state = dispatch_udp_tunnel => state,
);

Expand Down Expand Up @@ -5846,3 +5926,187 @@ mod cliprdr_error_tests {
assert!(writer.0.is_empty(), "a refused message has nothing to put on the wire");
}
}

/// The server role must drive [`CliprdrServer::drive_timeouts`] on a timer; see
/// [`CLIPRDR_DRIVE_TIMEOUTS_INTERVAL`].
#[cfg(test)]
mod cliprdr_timeout_tests {
use core::any::TypeId;
use core::net::Ipv4Addr;

use ironrdp_cliprdr::Cliprdr;
use ironrdp_cliprdr::backend::CliprdrBackend;
use ironrdp_cliprdr::pdu::{
Capabilities, ClipboardFormat, ClipboardFormatId, ClipboardFormatName, ClipboardGeneralCapabilityFlags,
ClipboardPdu, ClipboardProtocolVersion, FileContentsRequest, FileContentsResponse, FormatDataRequest,
FormatDataResponse, FormatList, LockDataId,
};
use ironrdp_core::{Encode as _, WriteCursor, impl_as_any};

use super::*;

/// The clipboard channel dates locks from [`CliprdrBackend::now_ms`], so the
/// test owns the clock rather than sleeping.
#[derive(Debug)]
struct ClockBackend(Arc<AtomicU64>);

impl_as_any!(ClockBackend);

impl CliprdrBackend for ClockBackend {
fn temporary_directory(&self) -> &str {
"."
}

fn client_capabilities(&self) -> ClipboardGeneralCapabilityFlags {
ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA
| ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED
| ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES
}

fn on_ready(&mut self) {}
fn on_request_format_list(&mut self) {}
fn on_process_negotiated_capabilities(&mut self, _: ClipboardGeneralCapabilityFlags) {}
fn on_remote_copy(&mut self, _: &[ClipboardFormat]) {}
fn on_format_data_request(&mut self, _: FormatDataRequest) {}
fn on_format_data_response(&mut self, _: FormatDataResponse<'_>) {}
fn on_file_contents_request(&mut self, _: FileContentsRequest) {}
fn on_file_contents_response(&mut self, _: FileContentsResponse<'_>) {}
fn on_lock(&mut self, _: LockDataId) {}
fn on_unlock(&mut self, _: LockDataId) {}

fn now_ms(&self) -> u64 {
self.0.load(Ordering::SeqCst)
}
}

#[derive(Default)]
struct CapturingWriter(Vec<u8>);

impl FramedWrite for CapturingWriter {
type WriteAllFut<'write>
= core::future::Ready<std::io::Result<()>>
where
Self: 'write;

fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> Self::WriteAllFut<'a> {
self.0.extend_from_slice(buf);
core::future::ready(Ok(()))
}
}
Comment on lines +5982 to +5995

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[code-compressor] CapturingWriter test helper duplicated from cliprdr_error_tests — low 🟡 — The new cliprdr_timeout_tests module declares CapturingWriter (struct plus FramedWrite impl) byte-for-byte identical to the existing declaration in cliprdr_error_tests (head lines 5872-5885). Hoisting it into a shared cfg(test) support module would declare it once, delete the duplicate, and prevent drift if the helper changes. Test-only with zero runtime impact; optional cleanup rather than a defect.


fn encode_pdu(pdu: &ClipboardPdu<'_>) -> Vec<u8> {
let mut buf = vec![0u8; pdu.size()];
pdu.encode(&mut WriteCursor::new(&mut buf)).unwrap();
buf
}

fn capabilities_buf() -> Vec<u8> {
encode_pdu(&ClipboardPdu::Capabilities(Capabilities::new(
ClipboardProtocolVersion::V2,
ClipboardGeneralCapabilityFlags::CAN_LOCK_CLIPDATA
| ClipboardGeneralCapabilityFlags::STREAM_FILECLIP_ENABLED
| ClipboardGeneralCapabilityFlags::USE_LONG_FORMAT_NAMES,
)))
}

fn format_list_buf(formats: &[ClipboardFormat]) -> Vec<u8> {
encode_pdu(&ClipboardPdu::FormatList(
FormatList::new_unicode(formats, true).unwrap(),
))
}

fn file_format_list_buf() -> Vec<u8> {
format_list_buf(&[ClipboardFormat {
id: ClipboardFormatId(49171),
name: Some(ClipboardFormatName::new("FileGroupDescriptorW")),
}])
}

fn text_format_list_buf() -> Vec<u8> {
format_list_buf(&[ClipboardFormat {
id: ClipboardFormatId::CF_UNICODETEXT,
name: None,
}])
}

fn server_with_cliprdr(clock: &Arc<AtomicU64>) -> RdpServer {
let cliprdr: CliprdrServer = Cliprdr::with_lock_timeouts(
Box::new(ClockBackend(Arc::clone(clock))),
Duration::from_millis(100),
Duration::from_secs(60 * 60),
);

let mut server = RdpServer::builder()
.with_addr((Ipv4Addr::LOCALHOST, 0))
.with_no_security()
.with_no_input()
.with_no_display()
.build();

server.static_channels.insert(cliprdr);
// Returns the *previous* id, so `None` is the expected first attach.
server
.static_channels
.attach_channel_id(TypeId::of::<CliprdrServer>(), 1004);

server
}

/// An expired lock is released once its inactivity timeout elapses -- but
/// only because something drove the sweep.
#[tokio::test]
async fn an_expired_lock_is_released_when_the_sweep_is_driven() {
let clock = Arc::new(AtomicU64::new(0));
let mut server = server_with_cliprdr(&clock);

let cliprdr = server.get_svc_processor::<CliprdrServer>().unwrap();
cliprdr.process(&capabilities_buf()).unwrap();
// Files on the remote clipboard make the channel lock it (2.2.4.1).
let messages = cliprdr.process(&file_format_list_buf()).unwrap();
assert!(
messages.len() >= 2,
"a file FormatList must answer FormatListResponse AND send LockData, got {} message(s)",
messages.len()
);

// The remote clipboard changes: the lock expires but is deliberately
// NOT released yet, because downloads from it may still be in flight.
server
.get_svc_processor::<CliprdrServer>()
.unwrap()
.process(&text_format_list_buf())
.unwrap();

let mut writer = CapturingWriter::default();

server.drive_cliprdr_timeouts(&mut writer, 1002).await.unwrap();
assert!(
writer.0.is_empty(),
"a lock that expired 0ms ago is still inside its inactivity window"
);

clock.store(500, Ordering::SeqCst);
server.drive_cliprdr_timeouts(&mut writer, 1002).await.unwrap();
assert!(
!writer.0.is_empty(),
"past the inactivity timeout the sweep must emit an Unlock PDU; \
an empty write means the server leaks the lock for the rest of the session"
);
}

/// A session without a clipboard channel must not be an error path: the
/// timer fires for every connection.
#[tokio::test]
async fn a_session_without_a_clipboard_channel_sweeps_quietly() {
let mut server = RdpServer::builder()
.with_addr((Ipv4Addr::LOCALHOST, 0))
.with_no_security()
.with_no_input()
.with_no_display()
.build();

let mut writer = CapturingWriter::default();
server.drive_cliprdr_timeouts(&mut writer, 1002).await.unwrap();
assert!(writer.0.is_empty());
}
}
Loading