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-testsuite-extra/tests/client/config.rs b/crates/ironrdp-testsuite-extra/tests/client/config.rs index 147c5a4dde..46f99000bb 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,78 @@ 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("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 85b501df5a..3d8196f69f 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). Requires --udp + #[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) + #[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, @@ -282,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 { @@ -411,6 +426,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); }