From b38a0ee000d6013c7955308799a0df4f3000b9ef Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 25 Sep 2026 23:46:33 -0600 Subject: [PATCH 1/3] feat(client): options for the graphics pipeline and the RDP-UDP version The client registers the graphics pipeline channel but always told the server it does not support it (`support_dyn_vc_gfx_protocol: false`), so a server kept graphics on the legacy bitmap path. That path travels on the main connection and cannot move onto a reliable UDP tunnel: measured against Windows, input, pointer and video channels moved to the tunnel while the desktop kept painting over TCP. The UDP handshake also always offered version 3. `ConnectionConfig`'s `offer_version` already lets a caller offer MS-RDPEUDP (version 1 or 2) outright, which is what the Windows hosts measured settle on, but the client gave no way to set it. ConfigBuilder gains: - `with_graphics_pipeline(bool)`, off by default, which advertises the graphics pipeline to the server. - `with_udp_offer_version(UdpVersion)`, version 3 by default, which sets the highest RDP-UDP version the SYN offers. `UdpVersion` is re-exported from `config`. The viewer exposes both as `--egfx` and `--udp-offer`, next to a new `--udp` switch for the existing `with_udp_transport`. Each also reads an environment variable (`IRONRDP_EGFX`, `IRONRDP_UDP_OFFER`, `IRONRDP_UDP`). --- crates/ironrdp-client/src/config.rs | 44 ++++++++++++++++++++++++++++- crates/ironrdp-client/src/rdp.rs | 7 ++++- crates/ironrdp-viewer/src/cli.rs | 26 +++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 4c79ab8ebe..900c849305 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -11,6 +11,9 @@ use ironrdp_propertyset::PropertySet; use ironrdp_rail::pdu::ExecutePdu; use url::Url; +/// RDP-UDP protocol version, as offered with [`ConfigBuilder::with_udp_offer_version`]. +#[cfg(feature = "udp")] +pub use ironrdp_rdpeudp::pdu::UdpVersion; #[cfg(feature = "vmconnect")] pub use ironrdp_vmconnect::Mode as VmConnectMode; @@ -93,6 +96,8 @@ pub struct Config { pub(crate) transport: Transport, #[cfg(feature = "udp")] pub(crate) udp_transport_enabled: bool, + #[cfg(feature = "udp")] + pub(crate) udp_offer_version: UdpVersion, pub(crate) certificate_validation: ironrdp_tls::CertificateValidation, pub(crate) certificate_validation_callback: Option, @@ -164,6 +169,12 @@ impl Config { self.udp_transport_enabled } + /// Highest RDP-UDP protocol version offered in the SYN of the UDP handshake. + #[cfg(feature = "udp")] + pub fn udp_offer_version(&self) -> UdpVersion { + self.udp_offer_version + } + /// TLS peer-certificate validation policy. pub fn certificate_validation(&self) -> ironrdp_tls::CertificateValidation { self.certificate_validation @@ -273,6 +284,8 @@ impl fmt::Debug for Config { s.field("transport", &self.transport); #[cfg(feature = "udp")] s.field("udp_transport_enabled", &self.udp_transport_enabled); + #[cfg(feature = "udp")] + s.field("udp_offer_version", &self.udp_offer_version); s.field("certificate_validation", &self.certificate_validation); s.field( "certificate_validation_callback", @@ -784,6 +797,9 @@ pub struct ConfigBuilder { transport: TransportKind, #[cfg(feature = "udp")] udp_transport_enabled: bool, + #[cfg(feature = "udp")] + udp_offer_version: Option, + graphics_pipeline: bool, #[cfg(feature = "vmconnect")] vm_id: Option, #[cfg(feature = "vmconnect")] @@ -1281,6 +1297,30 @@ impl ConfigBuilder { self } + /// Selects the highest RDP-UDP protocol version the UDP handshake offers. + /// + /// The default, [`UdpVersion::V3`], offers MS-RDPEUDP2 and lets the server settle on a + /// lower version. Offering [`UdpVersion::V2`] or [`UdpVersion::V1`] asks for MS-RDPEUDP + /// outright, which is what Windows servers that do not implement version 3 answer. + #[cfg(feature = "udp")] + #[must_use] + pub fn with_udp_offer_version(mut self, version: UdpVersion) -> Self { + self.udp_offer_version = Some(version); + self + } + + /// Advertises the graphics pipeline ([MS-RDPEGFX]) to the server. Off by default. + /// + /// Without it, the server keeps graphics on the legacy bitmap path, which travels on the + /// main connection and cannot move to a reliable UDP tunnel. + /// + /// [MS-RDPEGFX]: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpegfx/ + #[must_use] + pub fn with_graphics_pipeline(mut self, enabled: bool) -> Self { + self.graphics_pipeline = enabled; + self + } + /// Connect to a Hyper-V VM console by VM GUID. Destination must use port /// [`ironrdp_vmconnect::PORT`] (2179) unless the caller explicitly selects another port. /// A destination with no explicit port defaults to 2179 instead of the ordinary RDP port. @@ -1947,7 +1987,7 @@ impl ConfigBuilder { request_data: None, pointer_software_rendering: self.pointer_software_rendering.unwrap_or(false), multitransport_flags: None, - support_dyn_vc_gfx_protocol: false, + support_dyn_vc_gfx_protocol: self.graphics_pipeline, compression_type, performance_flags: self.performance_flags.unwrap_or_default(), timezone_info: TimezoneInfo::default(), @@ -1982,6 +2022,8 @@ impl ConfigBuilder { transport, #[cfg(feature = "udp")] udp_transport_enabled: self.udp_transport_enabled, + #[cfg(feature = "udp")] + udp_offer_version: self.udp_offer_version.unwrap_or(UdpVersion::V3), certificate_validation, certificate_validation_callback: self.certificate_validation_callback, #[cfg(feature = "vmconnect")] diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index a67d49c9af..2d38a705ba 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -2014,6 +2014,7 @@ struct UdpTunnel { struct UdpBootstrapConfig { peer: SocketAddr, server_name: String, + offer_version: ironrdp_rdpeudp::pdu::UdpVersion, tls: ironrdp_rdpeudp_tokio::UdpTlsConfig, } @@ -2034,7 +2035,10 @@ async fn bootstrap_udp_transport( .connect( config.peer, config.server_name, - ironrdp_rdpeudp::ConnectionConfig::default(), + ironrdp_rdpeudp::ConnectionConfig { + offer_version: config.offer_version, + ..ironrdp_rdpeudp::ConnectionConfig::default() + }, config.tls, ) .await @@ -2450,6 +2454,7 @@ where let udp_config = UdpBootstrapConfig { peer: udp_peer, server_name: config.destination.name().to_owned(), + offer_version: config.udp_offer_version, tls: ironrdp_rdpeudp_tokio::UdpTlsConfig { certificate_validation: config.certificate_validation, certificate_validation_callback: config.certificate_validation_callback.clone(), diff --git a/crates/ironrdp-viewer/src/cli.rs b/crates/ironrdp-viewer/src/cli.rs index 85b501df5a..ced0dfa28c 100644 --- a/crates/ironrdp-viewer/src/cli.rs +++ b/crates/ironrdp-viewer/src/cli.rs @@ -128,6 +128,18 @@ struct Args { #[clap(long, value_enum, default_value_t = KeyboardType::IbmEnhanced)] keyboard_type: KeyboardType, + /// Enable reliable RDP-UDP for direct connections, falling back to TCP + #[clap(long, env = "IRONRDP_UDP", value_parser = clap::builder::BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")] + udp: Option, + + /// Highest RDP-UDP protocol version to offer (1, 2 or 3) + #[clap(long, env = "IRONRDP_UDP_OFFER", value_parser = clap::value_parser!(u16).range(1..=3))] + udp_offer: Option, + + /// Advertise the graphics pipeline (MS-RDPEGFX) + #[clap(long, env = "IRONRDP_EGFX", value_parser = clap::builder::BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")] + egfx: Option, + /// The keyboard subtype (an original equipment manufacturer-dependent value) #[clap(long, default_value_t = 0)] keyboard_subtype: u32, @@ -411,6 +423,20 @@ fn apply_cli_to_builder( if let Some(scale) = args.scale_desktop { builder = builder.with_desktop_scale_factor(scale); } + if let Some(enabled) = args.udp { + builder = builder.with_udp_transport(enabled); + } + if let Some(version) = args.udp_offer { + use ironrdp::client::config::UdpVersion; + builder = builder.with_udp_offer_version(match version { + 1 => UdpVersion::V1, + 2 => UdpVersion::V2, + _ => UdpVersion::V3, + }); + } + if let Some(enabled) = args.egfx { + builder = builder.with_graphics_pipeline(enabled); + } if let Some(width) = args.desktop_width { builder = builder.with_desktop_width(width); } From 2f328b391d6a86bb9f5f7cc56cee3c9f57f7115b Mon Sep 17 00:00:00 2001 From: AKolenda Date: Mon, 28 Sep 2026 13:21:23 -0600 Subject: [PATCH 2/3] test(client): cover the graphics pipeline and UDP offer options Tests the builder defaults and the viewer flags in the testsuite, where CI runs them. The viewer now rejects --udp-offer without --udp, since the offered version is only read by the UDP handshake and would otherwise be ignored without a word. --- .../tests/client/config.rs | 61 ++++++++++++++++++- crates/ironrdp-viewer/src/cli.rs | 7 ++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/crates/ironrdp-testsuite-extra/tests/client/config.rs b/crates/ironrdp-testsuite-extra/tests/client/config.rs index 147c5a4dde..4baea2a033 100644 --- a/crates/ironrdp-testsuite-extra/tests/client/config.rs +++ b/crates/ironrdp-testsuite-extra/tests/client/config.rs @@ -6,7 +6,9 @@ use std::sync::Arc; #[cfg(windows)] use ironrdp_cfg::GatewayCredentialsSource; use ironrdp_cfg::PropertySetExt as _; -use ironrdp_client::config::{AudioQualityMode, ClipboardType, ConfigBuilder, Destination, Transport, VmConnectMode}; +use ironrdp_client::config::{ + AudioQualityMode, ClipboardType, ConfigBuilder, Destination, Transport, UdpVersion, VmConnectMode, +}; #[cfg(windows)] use ironrdp_client::config::{MissingField, TransportKind}; use ironrdp_pdu::gcc::{ClientMonitorData, Monitor, MonitorFlags}; @@ -681,6 +683,63 @@ fn reliable_udp_is_opt_in() { assert!(config.udp_transport_enabled()); } +#[test] +fn udp_offer_defaults_to_version_3_and_can_be_lowered() { + let config = complete_builder().build().expect("default config"); + assert_eq!(config.udp_offer_version(), UdpVersion::V3); + + for version in [UdpVersion::V1, UdpVersion::V2, UdpVersion::V3] { + let config = complete_builder() + .with_udp_offer_version(version) + .build() + .expect("config with a UDP offer version"); + assert_eq!(config.udp_offer_version(), version); + } +} + +#[test] +fn graphics_pipeline_is_opt_in() { + let config = complete_builder().build().expect("default config"); + assert!(!config.connector().support_dyn_vc_gfx_protocol); + + let config = complete_builder() + .with_graphics_pipeline(true) + .build() + .expect("graphics pipeline config"); + assert!(config.connector().support_dyn_vc_gfx_protocol); +} + +#[test] +fn udp_and_graphics_pipeline_cli_flags_reach_the_config() { + let rdp = "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\n"; + + let config = parse_config_from_rdp(rdp, &[]); + assert!(!config.udp_transport_enabled()); + assert_eq!(config.udp_offer_version(), UdpVersion::V3); + assert!(!config.connector().support_dyn_vc_gfx_protocol); + + let config = parse_config_from_rdp(rdp, &["--udp", "--udp-offer", "2", "--egfx"]); + assert!(config.udp_transport_enabled()); + assert_eq!(config.udp_offer_version(), UdpVersion::V2); + assert!(config.connector().support_dyn_vc_gfx_protocol); + + let config = parse_config_from_rdp(rdp, &["--udp", "--udp-offer", "1"]); + assert_eq!(config.udp_offer_version(), UdpVersion::V1); +} + +#[test] +fn udp_offer_without_udp_is_rejected() { + let rdp = "full address:s:rdp.example.com\nusername:s:test-user\nClearTextPassword:s:test-pass\n"; + + let error = parse_config_from_rdp_result(rdp, &["--udp-offer", "2"]) + .expect_err("--udp-offer is only used with --udp, so it must not be accepted alone"); + assert!(error.to_string().contains("requires --udp"), "{error:?}"); + + // Turning UDP off explicitly leaves nothing to be surprised by. + let config = parse_config_from_rdp(rdp, &["--udp=false", "--udp-offer", "2"]); + assert!(!config.udp_transport_enabled()); +} + #[test] fn remote_application_mode_requires_remote_programs_support() { let error = complete_builder() diff --git a/crates/ironrdp-viewer/src/cli.rs b/crates/ironrdp-viewer/src/cli.rs index ced0dfa28c..527bff9b2e 100644 --- a/crates/ironrdp-viewer/src/cli.rs +++ b/crates/ironrdp-viewer/src/cli.rs @@ -132,7 +132,7 @@ struct Args { #[clap(long, env = "IRONRDP_UDP", value_parser = clap::builder::BoolishValueParser::new(), num_args = 0..=1, default_missing_value = "true")] udp: Option, - /// Highest RDP-UDP protocol version to offer (1, 2 or 3) + /// Highest RDP-UDP protocol version to offer (1, 2 or 3). Requires --udp #[clap(long, env = "IRONRDP_UDP_OFFER", value_parser = clap::value_parser!(u16).range(1..=3))] udp_offer: Option, @@ -333,6 +333,11 @@ impl ViewerConfig { }); } + // Only the UDP handshake reads the offered version, so without `--udp` it would do nothing. + if args.udp_offer.is_some() && args.udp.is_none() { + anyhow::bail!("--udp-offer (IRONRDP_UDP_OFFER) requires --udp (IRONRDP_UDP)"); + } + // The library overlays everything expressible as a `.rdp` property: destination, credentials, // transport, channels, desktop size, audio, DVC proxies, etc. let builder = ConfigBuilder::from_property_set(&properties)?; From bbc3b6f0bd4bbc903002a51e8fcb605ad440b60b Mon Sep 17 00:00:00 2001 From: AKolenda Date: Fri, 2 Oct 2026 00:10:46 -0600 Subject: [PATCH 3/3] refactor(viewer): validate UDP offer dependency with clap --- .../tests/client/config.rs | 17 ++++++++++++++++- crates/ironrdp-viewer/src/cli.rs | 14 ++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/ironrdp-testsuite-extra/tests/client/config.rs b/crates/ironrdp-testsuite-extra/tests/client/config.rs index 4baea2a033..46f99000bb 100644 --- a/crates/ironrdp-testsuite-extra/tests/client/config.rs +++ b/crates/ironrdp-testsuite-extra/tests/client/config.rs @@ -733,13 +733,28 @@ fn udp_offer_without_udp_is_rejected() { let error = parse_config_from_rdp_result(rdp, &["--udp-offer", "2"]) .expect_err("--udp-offer is only used with --udp, so it must not be accepted alone"); - assert!(error.to_string().contains("requires --udp"), "{error:?}"); + assert!(error.to_string().contains("required arguments"), "{error:?}"); + assert!(error.to_string().contains("--udp"), "{error:?}"); // Turning UDP off explicitly leaves nothing to be surprised by. let config = parse_config_from_rdp(rdp, &["--udp=false", "--udp-offer", "2"]); assert!(!config.udp_transport_enabled()); } +#[test] +fn udp_offer_requires_udp_in_rpc_mode() { + let error = ironrdp_viewer::cli::ViewerConfig::parse_from(["ironrdp-viewer", "--rpc", "--udp-offer", "2"]) + .err() + .expect("clap must validate UDP arguments before entering RPC mode"); + assert!(error.to_string().contains("required arguments"), "{error:?}"); + assert!(error.to_string().contains("--udp"), "{error:?}"); + + assert!( + ironrdp_viewer::cli::ViewerConfig::parse_from(["ironrdp-viewer", "--rpc", "--udp=false", "--udp-offer", "2",]) + .is_ok() + ); +} + #[test] fn remote_application_mode_requires_remote_programs_support() { let error = complete_builder() diff --git a/crates/ironrdp-viewer/src/cli.rs b/crates/ironrdp-viewer/src/cli.rs index 527bff9b2e..3d8196f69f 100644 --- a/crates/ironrdp-viewer/src/cli.rs +++ b/crates/ironrdp-viewer/src/cli.rs @@ -133,7 +133,7 @@ struct Args { udp: Option, /// Highest RDP-UDP protocol version to offer (1, 2 or 3). Requires --udp - #[clap(long, env = "IRONRDP_UDP_OFFER", value_parser = clap::value_parser!(u16).range(1..=3))] + #[clap(long, env = "IRONRDP_UDP_OFFER", requires = "udp", value_parser = clap::value_parser!(u16).range(1..=3))] udp_offer: Option, /// Advertise the graphics pipeline (MS-RDPEGFX) @@ -294,16 +294,19 @@ pub struct ViewerConfig { impl ViewerConfig { pub fn parse_args() -> anyhow::Result { - Self::parse_from(std::env::args_os()) + Self::from_args(Args::parse()) } + /// Parses arguments without exiting the process on a command-line error. pub fn parse_from(args: I) -> anyhow::Result where I: IntoIterator, T: Into + Clone, { - let args = Args::parse_from(args); + Self::from_args(Args::try_parse_from(args)?) + } + fn from_args(args: Args) -> anyhow::Result { let mut properties = ironrdp_propertyset::PropertySet::new(); if let Some(rdp_file) = &args.rdp_file { @@ -333,11 +336,6 @@ impl ViewerConfig { }); } - // Only the UDP handshake reads the offered version, so without `--udp` it would do nothing. - if args.udp_offer.is_some() && args.udp.is_none() { - anyhow::bail!("--udp-offer (IRONRDP_UDP_OFFER) requires --udp (IRONRDP_UDP)"); - } - // The library overlays everything expressible as a `.rdp` property: destination, credentials, // transport, channels, desktop size, audio, DVC proxies, etc. let builder = ConfigBuilder::from_property_set(&properties)?;