diff --git a/Cargo.lock b/Cargo.lock index 0ef3ed475..5f6451e94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2402,6 +2402,7 @@ dependencies = [ "image", "ironrdp-acceptor", "ironrdp-blocking", + "ironrdp-client", "ironrdp-cliprdr", "ironrdp-cliprdr-native", "ironrdp-connector", @@ -2411,6 +2412,7 @@ dependencies = [ "ironrdp-echo", "ironrdp-graphics", "ironrdp-input", + "ironrdp-mstsgu", "ironrdp-pdu", "ironrdp-rdpdr", "ironrdp-rdpsnd", @@ -2502,19 +2504,29 @@ version = "0.1.0" dependencies = [ "anyhow", "futures-util", - "ironrdp", + "ironrdp-cliprdr", + "ironrdp-cliprdr-native", + "ironrdp-connector", "ironrdp-core", + "ironrdp-displaycontrol", + "ironrdp-dvc", "ironrdp-dvc-com-plugin", "ironrdp-dvc-pipe-proxy", + "ironrdp-echo", + "ironrdp-graphics", "ironrdp-mstsgu", + "ironrdp-pdu", "ironrdp-rdcleanpath", + "ironrdp-rdpdr", + "ironrdp-rdpsnd", "ironrdp-rdpsnd-native", + "ironrdp-session", + "ironrdp-svc", "ironrdp-tls", "ironrdp-tokio", "smallvec", "tokio", "tokio-tungstenite", - "tokio-util", "tracing", "transport", "url", @@ -3000,9 +3012,6 @@ dependencies = [ "inquire", "ironrdp", "ironrdp-cfg", - "ironrdp-client", - "ironrdp-cliprdr-native", - "ironrdp-mstsgu", "ironrdp-propertyset", "ironrdp-rdpfile", "proc-exit", diff --git a/crates/ironrdp-client/Cargo.toml b/crates/ironrdp-client/Cargo.toml index 89eb9b97e..f4cce16c1 100644 --- a/crates/ironrdp-client/Cargo.toml +++ b/crates/ironrdp-client/Cargo.toml @@ -19,41 +19,71 @@ doctest = false test = false [features] -default = ["rustls"] -rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots", "ironrdp-mstsgu/rustls"] -native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls", "ironrdp-mstsgu/native-tls"] -qoi = ["ironrdp/qoi"] -qoiz = ["ironrdp/qoiz"] +default = [] -[dependencies] -# Protocols -ironrdp = { path = "../ironrdp", version = "0.16", features = [ - "session", - "input", - "graphics", - "dvc", - "svc", +rustls = [ + "ironrdp-tls/rustls", + "tokio-tungstenite/rustls-tls-native-roots", + "ironrdp-mstsgu?/rustls", +] + +native-tls = [ + "ironrdp-tls/native-tls", + "tokio-tungstenite/native-tls", + "ironrdp-mstsgu?/native-tls", +] + +sound = ["dep:ironrdp-rdpsnd", "dep:ironrdp-rdpsnd-native"] +clipboard = ["dep:ironrdp-cliprdr", "dep:ironrdp-cliprdr-native"] +rdpdr = ["dep:ironrdp-rdpdr"] +smartcard = ["rdpdr"] +gateway = ["dep:ironrdp-mstsgu"] +qoi = ["ironrdp-connector/qoi", "ironrdp-session/qoi"] +qoiz = ["ironrdp-connector/qoiz", "ironrdp-session/qoiz"] +dvc-pipe-proxy = ["dep:ironrdp-dvc-pipe-proxy"] +dvc-com-plugin = ["dep:ironrdp-dvc-com-plugin"] + +all = [ + "sound", + "clipboard", "rdpdr", - "rdpsnd", - "cliprdr", - "displaycontrol", - "connector", - "echo", -] } + "smartcard", + "gateway", + "dvc-pipe-proxy", + "dvc-com-plugin", +] + +[dependencies] +# Protocols (core features always on) ironrdp-core = { path = "../ironrdp-core", version = "0.2", features = ["alloc"] } -ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6" } +ironrdp-pdu = { path = "../ironrdp-pdu", version = "0.8" } +ironrdp-svc = { path = "../ironrdp-svc", version = "0.7" } +ironrdp-dvc = { path = "../ironrdp-dvc", version = "0.7" } +ironrdp-connector = { path = "../ironrdp-connector", version = "0.9" } +ironrdp-session = { path = "../ironrdp-session", version = "0.10" } +ironrdp-graphics = { path = "../ironrdp-graphics", version = "0.8" } +ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7" } +ironrdp-echo = { path = "../ironrdp-echo", version = "0.3" } ironrdp-tls = { path = "../ironrdp-tls", version = "0.2" } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.9", features = ["reqwest"] } -ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath" -ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy" +ironrdp-rdcleanpath = { path = "../ironrdp-rdcleanpath" } + +# Optional protocol crates (activated by features above) +ironrdp-cliprdr = { path = "../ironrdp-cliprdr", version = "0.6", optional = true } +ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } +ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } + +# Optional backend crates (activated by features above) +ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.6", optional = true } +ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6", optional = true } +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", optional = true } +ironrdp-dvc-pipe-proxy = { path = "../ironrdp-dvc-pipe-proxy", optional = true } # Logging tracing = { version = "0.1", features = ["log"] } # Async, futures tokio = { version = "1", features = ["macros", "net", "io-util", "sync", "rt", "time"] } -tokio-util = { version = "0.7" } tokio-tungstenite = "0.29" transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" } futures-util = { version = "0.3", features = ["sink"] } @@ -65,7 +95,7 @@ url = "2" x509-cert = { version = "0.2", default-features = false, features = ["std"] } [target.'cfg(windows)'.dependencies] -ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin" } +ironrdp-dvc-com-plugin = { path = "../ironrdp-dvc-com-plugin", optional = true } [lints] workspace = true diff --git a/crates/ironrdp-client/src/clipboard.rs b/crates/ironrdp-client/src/clipboard.rs new file mode 100644 index 000000000..cfa2c9cd1 --- /dev/null +++ b/crates/ironrdp-client/src/clipboard.rs @@ -0,0 +1,25 @@ +use ironrdp_cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; +use tokio::sync::mpsc; +use tracing::error; + +use crate::rdp::RdpInputEvent; + +/// Shim that forwards CLIPRDR events into the `RdpInputEvent` channel. +#[derive(Clone, Debug)] +pub(crate) struct ClientClipboardMessageProxy { + tx: mpsc::UnboundedSender, +} + +impl ClientClipboardMessageProxy { + pub(crate) fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } +} + +impl ClipboardMessageProxy for ClientClipboardMessageProxy { + fn send_clipboard_message(&self, message: ClipboardMessage) { + if self.tx.send(RdpInputEvent::Clipboard(message)).is_err() { + error!("Failed to send clipboard message; receiver is closed"); + } + } +} diff --git a/crates/ironrdp-client/src/config.rs b/crates/ironrdp-client/src/config.rs index 4bcd59500..2bc41d953 100644 --- a/crates/ironrdp-client/src/config.rs +++ b/crates/ironrdp-client/src/config.rs @@ -1,49 +1,107 @@ use core::fmt; use core::str::FromStr; use core::time::Duration; -#[cfg(windows)] +#[cfg(all(windows, feature = "dvc-com-plugin"))] use std::path::PathBuf; +use std::sync::Arc; use anyhow::Context as _; -use ironrdp::connector; -use ironrdp_mstsgu::GwConnectTarget; use url::Url; +// ── Extension registry ──────────────────────────────────────────────────────── + +type StaticChannelFn = Arc; +type DvcChannelFn = Arc; + +/// Private registry of user-supplied static and dynamic virtual channel factories. +/// +/// Cloneable via `Arc`; the factory closures are shared across reconnects. +#[derive(Default)] +pub(crate) struct ExtensionRegistry { + pub(crate) static_channels: Vec, + pub(crate) dvc_channels: Vec, +} + +impl Clone for ExtensionRegistry { + fn clone(&self) -> Self { + Self { + static_channels: self.static_channels.clone(), + dvc_channels: self.dvc_channels.clone(), + } + } +} + +impl fmt::Debug for ExtensionRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ExtensionRegistry") + .field("static_channels", &self.static_channels.len()) + .field("dvc_channels", &self.dvc_channels.len()) + .finish() + } +} + +// ── Public configuration types ──────────────────────────────────────────────── + /// Fully resolved client configuration. /// -/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Producing a `Config` -/// from CLI arguments, `.rdp` files, or interactive prompts is the consumer's responsibility -/// (see the `ironrdp-viewer` crate for a reference CLI front-end). -#[derive(Clone, Debug)] +/// This is the typed surface consumed by [`crate::rdp::RdpClient`]. Build it with +/// [`ConfigBuilder`]; producing a `Config` from CLI arguments, `.rdp` files, or interactive +/// prompts is the consumer's responsibility (see `ironrdp-viewer` for a reference front-end). +#[derive(Clone)] +#[expect( + clippy::partial_pub_fields, + reason = "extensions must stay crate-private because its type ExtensionRegistry is pub(crate)" +)] pub struct Config { - pub log_file: Option, - pub gw: Option, - pub kerberos_config: Option, + pub connector: ironrdp_connector::Config, pub destination: Destination, - pub connector: connector::Config, - pub clipboard_type: ClipboardType, - pub rdcleanpath: Option, + pub transport: Transport, + pub kerberos_config: Option, + pub log_file: Option, pub fake_events_interval: Option, + pub channels: ChannelConfig, - /// DVC channel <-> named pipe proxy configuration. + /// DVC channel ↔ named-pipe proxy configuration. /// - /// Each configured proxy enables IronRDP to connect to DVC channel and create a named pipe - /// server, which will be used for proxying DVC messages to/from user-defined DVC logic - /// implemented as named pipe clients (either in the same process or in a different process). + /// Each entry causes IronRDP to forward that DVC channel's traffic to/from the + /// named pipe, allowing out-of-process DVC logic. + #[cfg(feature = "dvc-pipe-proxy")] pub dvc_pipe_proxies: Vec, /// Paths to DVC client plugin DLLs to load (Windows only). /// - /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is called - /// to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. - #[cfg(windows)] + /// Each DLL is loaded via `LoadLibraryW` and its `VirtualChannelGetInstance` export is + /// called to obtain DVC plugin COM objects. Example: `C:\Windows\System32\webauthn.dll`. + #[cfg(all(windows, feature = "dvc-com-plugin"))] pub dvc_plugins: Vec, + + pub(crate) extensions: ExtensionRegistry, +} + +impl fmt::Debug for Config { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut s = f.debug_struct("Config"); + s.field("connector", &self.connector); + s.field("destination", &self.destination); + s.field("transport", &self.transport); + s.field("kerberos_config", &self.kerberos_config); + s.field("log_file", &self.log_file); + s.field("fake_events_interval", &self.fake_events_interval); + s.field("channels", &self.channels); + #[cfg(feature = "dvc-pipe-proxy")] + s.field("dvc_pipe_proxies", &self.dvc_pipe_proxies); + #[cfg(all(windows, feature = "dvc-com-plugin"))] + s.field("dvc_plugins", &self.dvc_plugins); + s.field("extensions", &self.extensions); + s.finish() + } } /// Resolved clipboard backend selection. /// /// Platform-specific details (e.g., which native clipboard backend to use) are handled -/// internally by the library when `Enable` is selected. +/// internally by the library when [`Enable`](ClipboardType::Enable) is selected. +#[cfg(feature = "clipboard")] #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ClipboardType { /// Enable clipboard redirection (use the best available backend). @@ -54,6 +112,117 @@ pub enum ClipboardType { Stub, } +/// Channel and codec runtime toggles. +/// +/// Each field is only present when the corresponding Cargo feature is enabled. +/// The defaults for all optional fields are `true` (enabled) when the feature is on. +#[derive(Clone, Debug)] +pub struct ChannelConfig { + /// Enable the RDPSND (audio) virtual channel. + #[cfg(feature = "sound")] + pub sound: bool, + + /// Clipboard redirection mode. + #[cfg(feature = "clipboard")] + pub clipboard: ClipboardType, + + /// Device-redirection (RDPDR) configuration. + #[cfg(feature = "rdpdr")] + pub rdpdr: RdpdrConfig, + + /// Enable QOI bitmap codec. + /// + /// When `false`, the QOI codec is removed from `connector.bitmap.codecs` before connecting + /// even if the `qoi` feature is compiled in. + #[cfg(feature = "qoi")] + pub qoi: bool, + + /// Enable QOIZ (QOI with zlib) bitmap codec. + #[cfg(feature = "qoiz")] + pub qoiz: bool, +} + +#[cfg_attr( + not(any(feature = "sound", feature = "clipboard", feature = "qoi", feature = "qoiz")), + expect( + clippy::derivable_impls, + reason = "fields setting non-default values are feature-gated; the impl is only trivially derivable in some feature combinations" + ) +)] +impl Default for ChannelConfig { + fn default() -> Self { + Self { + #[cfg(feature = "sound")] + sound: true, + #[cfg(feature = "clipboard")] + clipboard: ClipboardType::Enable, + #[cfg(feature = "rdpdr")] + rdpdr: RdpdrConfig::default(), + #[cfg(feature = "qoi")] + qoi: true, + #[cfg(feature = "qoiz")] + qoiz: true, + } + } +} + +/// RDPDR (device redirection) runtime configuration. +#[cfg(feature = "rdpdr")] +#[derive(Clone, Debug)] +pub struct RdpdrConfig { + /// Enable device redirection at all. + pub enabled: bool, + + /// Enable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + pub smartcard: bool, +} + +#[cfg(feature = "rdpdr")] +impl Default for RdpdrConfig { + fn default() -> Self { + Self { + enabled: true, + #[cfg(feature = "smartcard")] + smartcard: true, + } + } +} + +/// Transport selection for the RDP connection. +#[derive(Clone, Debug)] +pub enum Transport { + /// Plain TCP → TLS direct connection to the RDP server. + Direct, + + /// Connect via an RDS gateway (MS-TSGU / MSTSGU). + /// + /// The target RDP server is derived from [`Config::destination`]; the gateway + /// only needs its own endpoint and credentials. + /// + /// NOTE: the destination port is currently not forwarded to the gateway. + /// If `ironrdp-mstsgu` hardcodes port 3389, open a follow-up issue. + #[cfg(feature = "gateway")] + Gateway(GatewayConfig), + + /// Connect via an RDCleanPath proxy (WebSocket-based). + RDCleanPath(RDCleanPathConfig), +} + +/// Credentials and endpoint for an RDS gateway connection. +#[cfg(feature = "gateway")] +#[derive(Clone, Debug)] +pub struct GatewayConfig { + /// Gateway endpoint address (e.g., `"rdg.contoso.com:443"`). + pub endpoint: String, + /// Gateway username. + pub username: String, + /// Gateway password. + pub password: String, +} + +// ── Destination ─────────────────────────────────────────────────────────────── + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Destination { name: String, @@ -130,24 +299,27 @@ impl FromStr for Destination { } } -impl From for connector::ServerName { +impl From for ironrdp_connector::ServerName { fn from(value: Destination) -> Self { Self::new(value.name) } } -impl From<&Destination> for connector::ServerName { +impl From<&Destination> for ironrdp_connector::ServerName { fn from(value: &Destination) -> Self { Self::new(&value.name) } } +// ── RDCleanPath & DVC proxy ─────────────────────────────────────────────────── + #[derive(Clone, Debug)] pub struct RDCleanPathConfig { pub url: Url, pub auth_token: String, } +/// Name-to-pipe mapping for a single DVC proxy channel. #[derive(Clone, Debug)] pub struct DvcProxyInfo { pub channel_name: String, @@ -174,3 +346,163 @@ impl FromStr for DvcProxyInfo { }) } } + +// ── ConfigBuilder ───────────────────────────────────────────────────────────── + +/// Builder for [`Config`]. +/// +/// # Duplicate-channel behaviour +/// +/// * **Static channels** are keyed by the concrete processor `TypeId`; registering two factories +/// with the same concrete type silently shadows the earlier one via +/// [`ironrdp_connector::ClientConnector::attach_static_channel`]. +/// * **DVC channels** are keyed by channel name; duplicate names follow +/// [`ironrdp_dvc::DrdynvcClient`]'s overwrite semantics. +pub struct ConfigBuilder { + config: Config, +} + +impl ConfigBuilder { + pub fn new(connector: ironrdp_connector::Config, destination: Destination) -> Self { + Self { + config: Config { + connector, + destination, + transport: Transport::Direct, + kerberos_config: None, + log_file: None, + fake_events_interval: None, + channels: ChannelConfig::default(), + #[cfg(feature = "dvc-pipe-proxy")] + dvc_pipe_proxies: Vec::new(), + #[cfg(all(windows, feature = "dvc-com-plugin"))] + dvc_plugins: Vec::new(), + extensions: ExtensionRegistry::default(), + }, + } + } + + #[must_use] + pub fn with_transport(mut self, transport: Transport) -> Self { + self.config.transport = transport; + self + } + + #[must_use] + pub fn with_kerberos_config(mut self, cfg: ironrdp_connector::credssp::KerberosConfig) -> Self { + self.config.kerberos_config = Some(cfg); + self + } + + #[must_use] + pub fn with_log_file(mut self, path: impl Into) -> Self { + self.config.log_file = Some(path.into()); + self + } + + #[must_use] + pub fn with_fake_events_interval(mut self, interval: Duration) -> Self { + self.config.fake_events_interval = Some(interval); + self + } + + /// Enable or disable RDPSND (audio) playback. + #[cfg(feature = "sound")] + #[must_use] + pub fn with_sound(mut self, enabled: bool) -> Self { + self.config.channels.sound = enabled; + self + } + + /// Set the CLIPRDR (clipboard) redirection mode. + #[cfg(feature = "clipboard")] + #[must_use] + pub fn with_clipboard(mut self, mode: ClipboardType) -> Self { + self.config.channels.clipboard = mode; + self + } + + /// Enable or disable RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + #[must_use] + pub fn with_rdpdr(mut self, enabled: bool) -> Self { + self.config.channels.rdpdr.enabled = enabled; + self + } + + /// Enable or disable smart-card redirection within RDPDR. + #[cfg(feature = "smartcard")] + #[must_use] + pub fn with_smartcard(mut self, enabled: bool) -> Self { + self.config.channels.rdpdr.smartcard = enabled; + self + } + + /// Enable or disable QOI bitmap codec at runtime. + #[cfg(feature = "qoi")] + #[must_use] + pub fn with_qoi(mut self, enabled: bool) -> Self { + self.config.channels.qoi = enabled; + self + } + + /// Enable or disable QOIZ bitmap codec at runtime. + #[cfg(feature = "qoiz")] + #[must_use] + pub fn with_qoiz(mut self, enabled: bool) -> Self { + self.config.channels.qoiz = enabled; + self + } + + /// Add a DVC pipe proxy channel. + #[cfg(feature = "dvc-pipe-proxy")] + #[must_use] + pub fn with_dvc_pipe_proxy(mut self, info: DvcProxyInfo) -> Self { + self.config.dvc_pipe_proxies.push(info); + self + } + + /// Add a DVC COM plugin DLL path (Windows only). + #[cfg(all(windows, feature = "dvc-com-plugin"))] + #[must_use] + pub fn with_dvc_plugin(mut self, path: impl Into) -> Self { + self.config.dvc_plugins.push(path.into()); + self + } + + /// Register a factory for a user-defined static virtual channel. + /// + /// `factory` is called once per connection attempt to create a fresh channel instance. + /// Duplicate processor types follow `attach_static_channel` overwrite semantics. + #[must_use] + pub fn with_static_channel(mut self, factory: F) -> Self + where + F: Fn() -> P + Send + Sync + 'static, + P: ironrdp_svc::SvcClientProcessor + 'static, + { + let cb: StaticChannelFn = Arc::new(move |connector: &mut ironrdp_connector::ClientConnector| { + connector.attach_static_channel(factory()) + }); + self.config.extensions.static_channels.push(cb); + self + } + + /// Register a factory for a user-defined dynamic virtual channel. + /// + /// `factory` is called once per connection attempt to create a fresh channel instance. + /// Duplicate channel names follow `DrdynvcClient` overwrite semantics. + #[must_use] + pub fn with_dvc(mut self, factory: F) -> Self + where + F: Fn() -> P + Send + Sync + 'static, + P: ironrdp_dvc::DvcProcessor + 'static, + { + let cb: DvcChannelFn = Arc::new(move |drdynvc| drdynvc.attach_dynamic_channel(factory())); + self.config.extensions.dvc_channels.push(cb); + self + } + + pub fn build(self) -> Config { + self.config + } +} diff --git a/crates/ironrdp-client/src/lib.rs b/crates/ironrdp-client/src/lib.rs index 753d2937a..a567b8b76 100644 --- a/crates/ironrdp-client/src/lib.rs +++ b/crates/ironrdp-client/src/lib.rs @@ -10,4 +10,7 @@ pub mod config; pub mod rdp; +#[cfg(all(windows, feature = "clipboard"))] +mod clipboard; + mod ws; diff --git a/crates/ironrdp-client/src/rdp.rs b/crates/ironrdp-client/src/rdp.rs index 073ddfe50..0cb153496 100644 --- a/crates/ironrdp-client/src/rdp.rs +++ b/crates/ironrdp-client/src/rdp.rs @@ -1,37 +1,49 @@ +use core::net::SocketAddr; use core::num::NonZeroU16; use std::sync::Arc; -use ironrdp::cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; -use ironrdp::connector::connection_activation::ConnectionActivationState; -use ironrdp::connector::{ConnectionResult, ConnectorResult}; -use ironrdp::displaycontrol::client::DisplayControlClient; -use ironrdp::displaycontrol::pdu::MonitorLayoutEntry; -#[cfg(windows)] -use ironrdp::dvc::DvcProcessor as _; -use ironrdp::echo::client::EchoClient; -use ironrdp::graphics::image_processing::PixelFormat; -use ironrdp::graphics::pointer::DecodedPointer; -use ironrdp::pdu::input::fast_path::FastPathInputEvent; -use ironrdp::pdu::{PduResult, pdu_other_err}; -use ironrdp::session::image::DecodedImage; -use ironrdp::session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; -use ironrdp::svc::SvcMessage; -use ironrdp::{cliprdr, connector, rdpdr, rdpsnd, session}; +use ironrdp_connector::connection_activation::ConnectionActivationState; +use ironrdp_connector::{ConnectionResult, ConnectorResult}; use ironrdp_core::WriteBuf; -#[cfg(windows)] -use ironrdp_dvc_com_plugin::load_dvc_plugin; -use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; -use ironrdp_rdpsnd_native::cpal; +use ironrdp_displaycontrol::client::DisplayControlClient; +use ironrdp_displaycontrol::pdu::MonitorLayoutEntry; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc::DvcProcessor as _; +use ironrdp_echo::client::EchoClient; +use ironrdp_graphics::image_processing::PixelFormat; +use ironrdp_graphics::pointer::DecodedPointer; +use ironrdp_pdu::input::fast_path::FastPathInputEvent; +#[cfg(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin")))] +use ironrdp_pdu::pdu_other_err; +use ironrdp_session::image::DecodedImage; +use ironrdp_session::{ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult, fast_path}; +use ironrdp_svc::SvcMessage; use ironrdp_tokio::reqwest::ReqwestNetworkClient; use ironrdp_tokio::{FramedWrite, single_sequence_step_read, split_tokio_framed}; -use rdpdr::NoopRdpdrBackend; use smallvec::SmallVec; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpStream; use tokio::sync::mpsc; -use tracing::{debug, error, info, trace, warn}; +#[cfg(any(feature = "clipboard", all(windows, feature = "dvc-com-plugin")))] +use tracing::error; +#[cfg(feature = "clipboard")] +use tracing::warn; +use tracing::{debug, info, trace}; + +#[cfg(feature = "clipboard")] +use crate::config::ClipboardType; +#[cfg(feature = "clipboard")] +use ironrdp_cliprdr::backend::{ClipboardMessage, CliprdrBackendFactory}; +#[cfg(all(windows, feature = "dvc-com-plugin"))] +use ironrdp_dvc_com_plugin::load_dvc_plugin; +#[cfg(feature = "dvc-pipe-proxy")] +use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy; +#[cfg(feature = "sound")] +use ironrdp_rdpsnd_native::cpal; + +use crate::config::{Config, RDCleanPathConfig, Transport}; -use crate::config::{Config, RDCleanPathConfig}; +// ── Public event types ──────────────────────────────────────────────────────── #[derive(Debug)] pub enum RdpOutputEvent { @@ -40,7 +52,7 @@ pub enum RdpOutputEvent { width: NonZeroU16, height: NonZeroU16, }, - ConnectionFailure(connector::ConnectorError), + ConnectionFailure(ironrdp_connector::ConnectorError), PointerDefault, PointerHidden, PointerPosition { @@ -57,11 +69,12 @@ pub enum RdpInputEvent { width: u16, height: u16, scale_factor: u32, - /// The physical size of the display in millimeters (width, height). + /// Physical display size in millimetres (width, height). physical_size: Option<(u32, u32)>, }, FastPath(SmallVec<[FastPathInputEvent; 2]>), Close, + #[cfg(feature = "clipboard")] Clipboard(ClipboardMessage), SendDvcMessages { channel_id: u32, @@ -69,85 +82,149 @@ pub enum RdpInputEvent { }, } -impl RdpInputEvent { - pub fn create_channel() -> (mpsc::UnboundedSender, mpsc::UnboundedReceiver) { - mpsc::unbounded_channel() - } -} +// ── RdpClient ───────────────────────────────────────────────────────────────── -pub struct DvcPipeProxyFactory { - rdp_input_sender: mpsc::UnboundedSender, +pub struct RdpClient { + config: Config, + output_event_sender: mpsc::Sender, + input_event_sender: mpsc::UnboundedSender, + input_event_receiver: mpsc::UnboundedReceiver, } -impl DvcPipeProxyFactory { - pub fn new(rdp_input_sender: mpsc::UnboundedSender) -> Self { - Self { rdp_input_sender } - } - - pub fn create(&self, channel_name: String, pipe_name: String) -> DvcNamedPipeProxy { - let rdp_input_sender = self.rdp_input_sender.clone(); - - DvcNamedPipeProxy::new(&channel_name, &pipe_name, move |channel_id, messages| { - rdp_input_sender - .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send DVC messages to the event loop",))?; - - Ok(()) - }) +impl RdpClient { + pub fn new(config: Config, output_event_sender: mpsc::Sender) -> Self { + let (input_event_sender, input_event_receiver) = mpsc::unbounded_channel(); + Self { + config, + output_event_sender, + input_event_sender, + input_event_receiver, + } } - /// Get a clone of the underlying RDP input event sender. + /// Return a clone of the input-event sender for injecting keyboard, mouse, and clipboard + /// events from the GUI thread. pub fn input_sender(&self) -> mpsc::UnboundedSender { - self.rdp_input_sender.clone() + self.input_event_sender.clone() } -} -pub type WriteDvcMessageFn = Box PduResult<()> + Send + 'static>; + pub async fn run(mut self) { + // ── Clipboard initialisation (compile-time gated) ───────────────────── + // + // On Windows the WinClipboard object must outlive the entire connection loop, so we + // keep it alive via `_win_clipboard`. On non-Windows a StubClipboard backend is used + // and its ownership can be released immediately after the factory is extracted. + #[cfg(all(windows, feature = "clipboard"))] + #[expect( + clippy::collection_is_never_read, + reason = "binding owns the Windows clipboard so it stays alive for the connection's lifetime" + )] + let _win_clipboard; + + #[cfg(feature = "clipboard")] + let cliprdr_factory: Option>; + + #[cfg(feature = "clipboard")] + { + match self.config.channels.clipboard { + ClipboardType::Disable => { + cliprdr_factory = None; + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Stub => { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + #[cfg(windows)] + { + _win_clipboard = None; + } + } + ClipboardType::Enable => { + #[cfg(windows)] + { + use crate::clipboard::ClientClipboardMessageProxy; + use ironrdp_cliprdr_native::WinClipboard; + match WinClipboard::new(ClientClipboardMessageProxy::new(self.input_event_sender.clone())) { + Ok(win_cb) => { + cliprdr_factory = Some(win_cb.backend_factory()); + _win_clipboard = Some(win_cb); + } + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(ironrdp_connector::custom_err!( + "Windows clipboard initialization", + e + ))) + .await; + return; + } + } + } -pub struct RdpClient { - pub config: Config, - pub output_event_sender: mpsc::Sender, - pub input_event_receiver: mpsc::UnboundedReceiver, - pub cliprdr_factory: Option>, - pub dvc_pipe_proxy_factory: DvcPipeProxyFactory, -} + #[cfg(not(windows))] + { + use ironrdp_cliprdr_native::StubClipboard; + let stub = StubClipboard::new(); + cliprdr_factory = Some(stub.backend_factory()); + } + } + } + } -impl RdpClient { - pub async fn run(mut self) { + // Resolve the per-connection cliprdr factory reference once. `Option<&dyn …>` is `Copy`, + // so it can be threaded into every connect attempt across reconnects. + #[cfg(feature = "clipboard")] + let cliprdr_factory: CliprdrFactoryRef<'_> = cliprdr_factory.as_deref(); + #[cfg(not(feature = "clipboard"))] + let cliprdr_factory: CliprdrFactoryRef<'_> = core::marker::PhantomData; + + // ── Connection + session loop ───────────────────────────────────────── loop { - let (connection_result, framed) = if let Some(rdcleanpath) = self.config.rdcleanpath.as_ref() { - match connect_ws( - &self.config, - rdcleanpath, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self - .output_event_sender - .send(RdpOutputEvent::ConnectionFailure(e)) - .await; - break; + let (connection_result, framed) = match &self.config.transport { + Transport::Direct => { + match connect_direct(&self.config, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } - } else { - match connect( - &self.config, - self.cliprdr_factory.as_deref(), - &self.dvc_pipe_proxy_factory, - ) - .await - { - Ok(result) => result, - Err(e) => { - let _ = self - .output_event_sender - .send(RdpOutputEvent::ConnectionFailure(e)) - .await; - break; + + #[cfg(feature = "gateway")] + Transport::Gateway(gw) => { + match connect_gateway(&self.config, gw, &self.input_event_sender, cliprdr_factory).await { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } + } + } + + Transport::RDCleanPath(rdcp) => { + match connect_rdcleanpath_transport(&self.config, rdcp, &self.input_event_sender, cliprdr_factory) + .await + { + Ok(r) => r, + Err(e) => { + let _ = self + .output_event_sender + .send(RdpOutputEvent::ConnectionFailure(e)) + .await; + break; + } } } }; @@ -180,68 +257,69 @@ impl RdpClient { } } -enum RdpControlFlow { - ReconnectWithNewSize { width: u16, height: u16 }, - TerminatedGracefully(GracefulDisconnectReason), -} - -trait AsyncReadWrite: AsyncRead + AsyncWrite {} - -impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} - -type UpgradedFramed = ironrdp_tokio::TokioFramed>; - -async fn connect( +// ── Connector builder ───────────────────────────────────────────────────────── + +/// Reference to the cliprdr backend factory threaded into the connect helpers. +/// +/// Collapses to a zero-sized placeholder when the `clipboard` feature is disabled, so the +/// connect-helper signatures don't need `#[cfg]` on this parameter. +#[cfg(feature = "clipboard")] +type CliprdrFactoryRef<'a> = Option<&'a (dyn CliprdrBackendFactory + Send)>; +#[cfg(not(feature = "clipboard"))] +type CliprdrFactoryRef<'a> = core::marker::PhantomData<&'a ()>; + +/// Build a fully wired [`ironrdp_connector::ClientConnector`] with all feature-gated channels attached. +/// +/// This helper is used by all transport paths. The cliprdr backend is (re)built here, per +/// connection, from `cliprdr_factory`. +fn build_connector( config: &Config, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, -) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let dest = config.destination.to_string(); - - let (client_addr, stream) = if let Some(ref gw_config) = config.gw { - let (gw, client_addr) = ironrdp_mstsgu::GwClient::connect(gw_config, &config.connector.client_name) - .await - .map_err(|e| connector::custom_err!("GW Connect", e))?; - (client_addr, tokio_util::either::Either::Left(gw)) - } else { - let stream = TcpStream::connect(dest) - .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - let client_addr = stream - .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; - (client_addr, tokio_util::either::Either::Right(stream)) - }; - let mut framed = ironrdp_tokio::TokioFramed::new(stream); - - let mut drdynvc = ironrdp::dvc::DrdynvcClient::new() + client_addr: SocketAddr, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ironrdp_connector::ClientConnector { + // `input_sender` is only consumed by the optional DVC wirings below, and `cliprdr_factory` + // only by the optional CLIPRDR attachment; discard them explicitly when those are compiled out. + #[cfg(not(any(feature = "dvc-pipe-proxy", all(windows, feature = "dvc-com-plugin"))))] + let _ = input_sender; + #[cfg(not(feature = "clipboard"))] + let _ = cliprdr_factory; + + let mut drdynvc = ironrdp_dvc::DrdynvcClient::new() .with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))) .with_dynamic_channel(EchoClient::new()); - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { + // Attach DVC pipe proxies. + #[cfg(feature = "dvc-pipe-proxy")] + for proxy in &config.dvc_pipe_proxies { let channel_name = proxy.channel_name.clone(); let pipe_name = proxy.pipe_name.clone(); - - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); - - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); + trace!(%channel_name, %pipe_name, "Creating DVC pipe proxy"); + let sender = input_sender.clone(); + drdynvc = drdynvc.with_dynamic_channel(DvcNamedPipeProxy::new( + &channel_name, + &pipe_name, + move |channel_id, messages| { + sender + .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) + .map_err(|_| pdu_other_err!("send DVC messages to the event loop"))?; + Ok(()) + }, + )); } - // Load DVC COM plugins (Windows only) - #[cfg(windows)] + // Load DVC COM plugins (Windows + dvc-com-plugin feature). + #[cfg(all(windows, feature = "dvc-com-plugin"))] { - let sender = dvc_pipe_proxy_factory.input_sender(); - for plugin_path in config.dvc_plugins.iter() { + for plugin_path in &config.dvc_plugins { info!(dll = %plugin_path.display(), "Loading DVC COM plugin"); - - let sender_clone = sender.clone(); + let sender_clone = input_sender.clone(); match load_dvc_plugin(plugin_path, move || { let sender = sender_clone.clone(); Box::new(move |channel_id, messages| { sender .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send COM DVC messages to the event loop"))?; + .map_err(|_| pdu_other_err!("send COM DVC messages to the event loop"))?; Ok(()) }) }) { @@ -258,158 +336,229 @@ async fn connect( } } - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); + // Attach user-defined DVC channels from the extension registry. + for attach_dvc in &config.extensions.dvc_channels { + attach_dvc(&mut drdynvc); + } - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); + // Clone the connector config so we can apply runtime overrides before handing it to the + // connector. We want to set `enable_audio_playback` consistently with `channels.sound`. + let mut connector_config = config.connector.clone(); - let cliprdr = cliprdr::Cliprdr::new(backend); + // If sound is disabled at runtime (or the feature is off) ensure the connector doesn't + // advertise audio support, which would confuse the server. + #[cfg(not(feature = "sound"))] + { + connector_config.enable_audio_playback = false; + } + #[cfg(feature = "sound")] + if !config.channels.sound { + connector_config.enable_audio_playback = false; + } - connector.attach_static_channel(cliprdr); + // Honor the runtime QOI/QOIZ codec toggles. Both codecs are compiled in and advertised by + // default, but can be disabled at runtime; when disabled we drop them from the advertised + // bitmap codec list so the server won't negotiate them. + #[cfg(any(feature = "qoi", feature = "qoiz"))] + if let Some(bitmap) = connector_config.bitmap.as_mut() { + use ironrdp_pdu::rdp::capability_sets::CodecProperty; + + bitmap.codecs.0.retain(|codec| match codec.property { + #[cfg(feature = "qoi")] + CodecProperty::Qoi => config.channels.qoi, + #[cfg(feature = "qoiz")] + CodecProperty::QoiZ => config.channels.qoiz, + _ => true, + }); } - let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; + let mut connector = + ironrdp_connector::ClientConnector::new(connector_config, client_addr).with_static_channel(drdynvc); - debug!("TLS upgrade"); + // Attach RDPSND (audio). + #[cfg(feature = "sound")] + if config.channels.sound { + connector = connector.with_static_channel(ironrdp_rdpsnd::client::Rdpsnd::new(Box::new( + cpal::RdpsndBackend::new(), + ))); + } - // Ensure there is no leftover - let (initial_stream, leftover_bytes) = framed.into_inner(); + // Attach RDPDR (device redirection). + #[cfg(feature = "rdpdr")] + if config.channels.rdpdr.enabled { + #[cfg_attr( + not(feature = "smartcard"), + expect( + unused_mut, + reason = "rdpdr_channel is only reassigned when the smartcard feature is enabled" + ) + )] + let mut rdpdr_channel = + ironrdp_rdpdr::Rdpdr::new(Box::new(ironrdp_rdpdr::NoopRdpdrBackend), "IronRDP".to_owned()); + #[cfg(feature = "smartcard")] + if config.channels.rdpdr.smartcard { + rdpdr_channel = rdpdr_channel.with_smartcard(0); + } + connector = connector.with_static_channel(rdpdr_channel); + } + + // Attach CLIPRDR (clipboard redirection). The backend is built fresh per connection. + #[cfg(feature = "clipboard")] + if let Some(factory) = cliprdr_factory { + let backend = factory.build_cliprdr_backend(); + connector.attach_static_channel(ironrdp_cliprdr::Cliprdr::new(backend)); + } + + // Attach user-defined static channels from the extension registry. + for attach_sc in &config.extensions.static_channels { + attach_sc(&mut connector); + } + + connector +} + +// ── Transport-specific connect helpers ──────────────────────────────────────── + +trait AsyncReadWrite: AsyncRead + AsyncWrite {} +impl AsyncReadWrite for T where T: AsyncRead + AsyncWrite {} +type UpgradedFramed = ironrdp_tokio::TokioFramed>; - let (upgraded_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) +/// Direct TCP → TLS connection (no gateway). +async fn connect_direct( + config: &Config, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + let dest = config.destination.to_string(); + let stream = TcpStream::connect(&dest) .await - .map_err(|e| connector::custom_err!("TLS upgrade", e))?; + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; + let client_addr = stream + .local_addr() + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; + let framed = ironrdp_tokio::TokioFramed::new(stream); - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - let erased_stream: Box = Box::new(upgraded_stream); - let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + tls_handshake_and_finalize(framed, connector, config).await +} - let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert) - .ok_or_else(|| connector::general_err!("unable to extract tls server public key"))?; - let connection_result = ironrdp_tokio::connect_finalize( - upgraded, - connector, - &mut upgraded_framed, - &mut ReqwestNetworkClient::new(), - (&config.destination).into(), - server_public_key.to_owned(), - config.kerberos_config.clone(), - ) - .await?; +/// RDS gateway TCP → gateway auth → TLS connection. +#[cfg(feature = "gateway")] +async fn connect_gateway( + config: &Config, + gw: &crate::config::GatewayConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { + use ironrdp_mstsgu::GwConnectTarget; + + // Build the GwConnectTarget. `server` is the RDP target derived from `config.destination`. + // TODO: preserve the destination port; ironrdp-mstsgu may currently hard-code 3389. + let gw_target = GwConnectTarget { + gw_endpoint: gw.endpoint.clone(), + gw_user: gw.username.clone(), + gw_pass: gw.password.clone(), + server: config.destination.name().to_owned(), + }; - debug!(?connection_result); + let (gw_stream, client_addr) = ironrdp_mstsgu::GwClient::connect(&gw_target, &config.connector.client_name) + .await + .map_err(|e| ironrdp_connector::custom_err!("GW connect", e))?; - Ok((connection_result, upgraded_framed)) + let framed = ironrdp_tokio::TokioFramed::new(gw_stream); + + let connector = build_connector(config, client_addr, input_sender, cliprdr_factory); + + tls_handshake_and_finalize(framed, connector, config).await } -async fn connect_ws( +/// RDCleanPath WebSocket → RDCleanPath handshake connection. +async fn connect_rdcleanpath_transport( config: &Config, - rdcleanpath: &RDCleanPathConfig, - cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>, - dvc_pipe_proxy_factory: &DvcPipeProxyFactory, + rdcp: &RDCleanPathConfig, + input_sender: &mpsc::UnboundedSender, + cliprdr_factory: CliprdrFactoryRef<'_>, ) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> { - let hostname = rdcleanpath + let hostname = rdcp .url .host_str() - .ok_or_else(|| connector::general_err!("host missing from the URL"))?; - - let port = rdcleanpath.url.port_or_known_default().unwrap_or(443); + .ok_or_else(|| ironrdp_connector::general_err!("host missing from the URL"))?; + let port = rdcp.url.port_or_known_default().unwrap_or(443); let socket = TcpStream::connect((hostname, port)) .await - .map_err(|e| connector::custom_err!("TCP connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("TCP connect", e))?; socket .set_nodelay(true) - .map_err(|e| connector::custom_err!("set TCP_NODELAY", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("set TCP_NODELAY", e))?; let client_addr = socket .local_addr() - .map_err(|e| connector::custom_err!("get socket local address", e))?; + .map_err(|e| ironrdp_connector::custom_err!("get socket local address", e))?; - let (ws, _) = tokio_tungstenite::client_async_tls(rdcleanpath.url.as_str(), socket) + let (ws, _) = tokio_tungstenite::client_async_tls(rdcp.url.as_str(), socket) .await - .map_err(|e| connector::custom_err!("WS connect", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("WS connect", e))?; let ws = crate::ws::websocket_compat(ws); - let mut framed = ironrdp_tokio::TokioFramed::new(ws); - let mut drdynvc = ironrdp::dvc::DrdynvcClient::new() - .with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))) - .with_dynamic_channel(EchoClient::new()); + let mut connector = build_connector(config, client_addr, input_sender, cliprdr_factory); - // Instantiate all DVC proxies - for proxy in config.dvc_pipe_proxies.iter() { - let channel_name = proxy.channel_name.clone(); - let pipe_name = proxy.pipe_name.clone(); + let destination = config.destination.to_string(); + let (upgraded, server_public_key) = + rdcleanpath_handshake(&mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?; - trace!(%channel_name, %pipe_name, "Creating DVC proxy"); + let connection_result = ironrdp_tokio::connect_finalize( + upgraded, + connector, + &mut framed, + &mut ReqwestNetworkClient::new(), + (&config.destination).into(), + server_public_key, + config.kerberos_config.clone(), + ) + .await?; - drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name)); - } + let (ws, leftover_bytes) = framed.into_inner(); + let erased_stream: Box = Box::new(ws); + let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - // Load DVC COM plugins (Windows only) - #[cfg(windows)] - { - let sender = dvc_pipe_proxy_factory.input_sender(); - for plugin_path in config.dvc_plugins.iter() { - info!(dll = %plugin_path.display(), "Loading DVC COM plugin"); + Ok((connection_result, upgraded_framed)) +} - let sender_clone = sender.clone(); - match load_dvc_plugin(plugin_path, move || { - let sender = sender_clone.clone(); - Box::new(move |channel_id, messages| { - sender - .send(RdpInputEvent::SendDvcMessages { channel_id, messages }) - .map_err(|_error| pdu_other_err!("send COM DVC messages to the event loop"))?; - Ok(()) - }) - }) { - Ok(channels) => { - for channel in channels { - info!(channel_name = %channel.channel_name(), "Registered COM DVC channel"); - drdynvc = drdynvc.with_dynamic_channel(channel); - } - } - Err(e) => { - error!(dll = %plugin_path.display(), error = %e, "Failed to load DVC COM plugin"); - } - } - } - } +// ── Shared TLS handshake ────────────────────────────────────────────────────── - let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr) - .with_static_channel(drdynvc) - .with_static_channel(rdpsnd::client::Rdpsnd::new(Box::new(cpal::RdpsndBackend::new()))) - .with_static_channel(rdpdr::Rdpdr::new(Box::new(NoopRdpdrBackend {}), "IronRDP".to_owned()).with_smartcard(0)); +async fn tls_handshake_and_finalize( + mut framed: ironrdp_tokio::TokioFramed, + mut connector: ironrdp_connector::ClientConnector, + config: &Config, +) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> +where + S: AsyncRead + AsyncWrite + Unpin + Send + Sync + 'static, +{ + let should_upgrade = ironrdp_tokio::connect_begin(&mut framed, &mut connector).await?; - if let Some(builder) = cliprdr_factory { - let backend = builder.build_cliprdr_backend(); + debug!("TLS upgrade"); - let cliprdr = cliprdr::Cliprdr::new(backend); + let (initial_stream, leftover_bytes) = framed.into_inner(); - connector.attach_static_channel(cliprdr); - } + let (tls_stream, tls_cert) = ironrdp_tls::upgrade(initial_stream, config.destination.name()) + .await + .map_err(|e| ironrdp_connector::custom_err!("TLS upgrade", e))?; - let destination = config.destination.to_string(); + let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector); - let (upgraded, server_public_key) = connect_rdcleanpath( - &mut framed, - &mut connector, - destination, - rdcleanpath.auth_token.clone(), - None, - ) - .await?; + let erased_stream: Box = Box::new(tls_stream); + let mut upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); + + let server_public_key = ironrdp_tls::extract_tls_server_public_key(&tls_cert) + .ok_or_else(|| ironrdp_connector::general_err!("unable to extract tls server public key"))? + .to_owned(); let connection_result = ironrdp_tokio::connect_finalize( upgraded, connector, - &mut framed, + &mut upgraded_framed, &mut ReqwestNetworkClient::new(), (&config.destination).into(), server_public_key, @@ -417,16 +566,14 @@ async fn connect_ws( ) .await?; - let (ws, leftover_bytes) = framed.into_inner(); - let erased_stream: Box = Box::new(ws); - let upgraded_framed = ironrdp_tokio::TokioFramed::new_with_leftover(erased_stream, leftover_bytes); - Ok((connection_result, upgraded_framed)) } -async fn connect_rdcleanpath( +// ── RDCleanPath handshake ───────────────────────────────────────────────────── + +async fn rdcleanpath_handshake( framed: &mut ironrdp_tokio::Framed, - connector: &mut connector::ClientConnector, + connector: &mut ironrdp_connector::ClientConnector, destination: String, proxy_auth_token: String, pcb: Option, @@ -434,40 +581,36 @@ async fn connect_rdcleanpath( where S: ironrdp_tokio::FramedRead + FramedWrite, { - use ironrdp::connector::Sequence as _; + use ironrdp_connector::Sequence as _; use x509_cert::der::Decode as _; #[derive(Clone, Copy, Debug)] struct RDCleanPathHint; - const RDCLEANPATH_HINT: RDCleanPathHint = RDCleanPathHint; - impl ironrdp::pdu::PduHint for RDCleanPathHint { - fn find_size(&self, bytes: &[u8]) -> ironrdp::core::DecodeResult> { + impl ironrdp_pdu::PduHint for RDCleanPathHint { + fn find_size(&self, bytes: &[u8]) -> ironrdp_core::DecodeResult> { match ironrdp_rdcleanpath::RDCleanPathPdu::detect(bytes) { ironrdp_rdcleanpath::DetectionResult::Detected { total_length, .. } => Ok(Some((true, total_length))), ironrdp_rdcleanpath::DetectionResult::NotEnoughBytes => Ok(None), - ironrdp_rdcleanpath::DetectionResult::Failed => Err(ironrdp::core::other_err!( - "RDCleanPathHint", - "detection failed (invalid PDU)" - )), + ironrdp_rdcleanpath::DetectionResult::Failed => { + Err(ironrdp_core::other_err!("RDCleanPathHint", "detection failed")) + } } } } let mut buf = WriteBuf::new(); + info!("Begin RDCleanPath connection procedure"); - info!("Begin connection procedure"); - + // Send X224 + RDCleanPath request. { - // RDCleanPath request - - let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { - return Err(connector::general_err!("invalid connector state (send request)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (send request)" + )); }; - debug_assert!(connector.next_pdu_hint().is_none()); - let written = connector.step_no_input(&mut buf)?; let x224_pdu_len = written.size().expect("written size"); debug_assert_eq!(x224_pdu_len, buf.filled_len()); @@ -475,38 +618,34 @@ where let rdcleanpath_req = ironrdp_rdcleanpath::RDCleanPathPdu::new_request(x224_pdu, destination, proxy_auth_token, pcb) - .map_err(|e| connector::custom_err!("new RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("new RDCleanPath request", e))?; debug!(message = ?rdcleanpath_req, "Send RDCleanPath request"); let rdcleanpath_req = rdcleanpath_req .to_der() - .map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath request encode", e))?; framed .write_all(&rdcleanpath_req) .await - .map_err(|e| connector::custom_err!("couldn't write RDCleanPath request", e))?; + .map_err(|e| ironrdp_connector::custom_err!("couldn't write RDCleanPath request", e))?; } + // Read RDCleanPath response. { - // RDCleanPath response - let rdcleanpath_res = framed .read_by_hint(&RDCLEANPATH_HINT) .await - .map_err(|e| connector::custom_err!("read RDCleanPath request", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("read RDCleanPath response", e))?; let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res) - .map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?; - + .map_err(|e| ironrdp_connector::custom_err!("RDCleanPath response decode", e))?; debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU"); let (x224_connection_response, server_cert_chain) = match rdcleanpath_res .into_enum() - .map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))? + .map_err(|e| ironrdp_connector::custom_err!("invalid RDCleanPath PDU", e))? { ironrdp_rdcleanpath::RDCleanPath::Request { .. } => { - return Err(connector::general_err!( - "received an unexpected RDCleanPath type (request)", + return Err(ironrdp_connector::general_err!( + "received unexpected RDCleanPath type (request)" )); } ironrdp_rdcleanpath::RDCleanPath::Response { @@ -515,68 +654,70 @@ where server_addr: _, } => (x224_connection_response, server_cert_chain), ironrdp_rdcleanpath::RDCleanPath::GeneralErr(error) => { - return Err(connector::custom_err!("received an RDCleanPath error", error)); + return Err(ironrdp_connector::custom_err!("received RDCleanPath error", error)); } ironrdp_rdcleanpath::RDCleanPath::NegotiationErr { x224_connection_response, } => { - // Try to decode as X.224 Connection Confirm to extract negotiation failure details. if let Ok(x224_confirm) = ironrdp_core::decode::< - ironrdp::pdu::x224::X224, + ironrdp_pdu::x224::X224, >(&x224_connection_response) { - if let ironrdp::pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { - // Convert to negotiation failure instead of generic RDCleanPath error. - let negotiation_failure = connector::NegotiationFailure::from(code); - return Err(connector::ConnectorError::new( + if let ironrdp_pdu::nego::ConnectionConfirm::Failure { code } = x224_confirm.0 { + let negotiation_failure = ironrdp_connector::NegotiationFailure::from(code); + return Err(ironrdp_connector::ConnectorError::new( "RDP negotiation failed", - connector::ConnectorErrorKind::Negotiation(negotiation_failure), + ironrdp_connector::ConnectorErrorKind::Negotiation(negotiation_failure), )); } } - - // Fallback to generic error if we can't decode the negotiation failure. - return Err(connector::general_err!("received an RDCleanPath negotiation error")); + return Err(ironrdp_connector::general_err!( + "received RDCleanPath negotiation error" + )); } }; - let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { - return Err(connector::general_err!("invalid connector state (wait confirm)")); + let ironrdp_connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else { + return Err(ironrdp_connector::general_err!( + "invalid connector state (wait confirm)" + )); }; - debug_assert!(connector.next_pdu_hint().is_some()); buf.clear(); let written = connector.step(x224_connection_response.as_bytes(), &mut buf)?; - debug_assert!(written.is_nothing()); let server_cert = server_cert_chain .into_iter() .next() - .ok_or_else(|| connector::general_err!("server cert chain missing from rdcleanpath response"))?; + .ok_or_else(|| ironrdp_connector::general_err!("server cert chain missing from rdcleanpath response"))?; let cert = x509_cert::Certificate::from_der(server_cert.as_bytes()) - .map_err(|e| connector::custom_err!("server cert chain missing from rdcleanpath response", e))?; + .map_err(|e| ironrdp_connector::custom_err!("server cert decode", e))?; let server_public_key = cert .tbs_certificate .subject_public_key_info .subject_public_key .as_bytes() - .ok_or_else(|| connector::general_err!("subject public key BIT STRING is not aligned"))? + .ok_or_else(|| ironrdp_connector::general_err!("subject public key BIT STRING is not aligned"))? .to_owned(); let should_upgrade = ironrdp_tokio::skip_connect_begin(connector); - - // At this point, proxy established the TLS session. - let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector); Ok((upgraded, server_public_key)) } } +// ── Active session ──────────────────────────────────────────────────────────── + +enum RdpControlFlow { + ReconnectWithNewSize { width: u16, height: u16 }, + TerminatedGracefully(GracefulDisconnectReason), +} + async fn active_session( framed: UpgradedFramed, connection_result: ConnectionResult, @@ -589,22 +730,20 @@ async fn active_session( connection_result.desktop_size.width, connection_result.desktop_size.height, ); - let mut active_stage = ActiveStage::new(connection_result); - // Timer interval for driving clipboard lock timeouts (5 second interval) + // Timer interval for driving clipboard lock timeouts. let mut cleanup_interval = tokio::time::interval(core::time::Duration::from_secs(5)); let disconnect_reason = 'outer: loop { let outputs = tokio::select! { frame = reader.read_pdu() => { - let (action, payload) = frame.map_err(|e| session::custom_err!("read frame", e))?; + let (action, payload) = frame.map_err(|e| ironrdp_session::custom_err!("read frame", e))?; trace!(?action, frame_length = payload.len(), "Frame received"); - active_stage.process(&mut image, action, &payload)? } input_event = input_event_receiver.recv() => { - let input_event = input_event.ok_or_else(|| session::general_err!("GUI is stopped"))?; + let input_event = input_event.ok_or_else(|| ironrdp_session::general_err!("GUI is stopped"))?; match input_event { RdpInputEvent::Resize { width, height, scale_factor, physical_size } => { @@ -625,7 +764,7 @@ async fn active_session( let height = u16::try_from(height).expect("always in the range"); return Ok(RdpControlFlow::ReconnectWithNewSize { width, height }) } - }, + } RdpInputEvent::FastPath(events) => { trace!(?events); active_stage.process_fastpath_input(&mut image, &events)? @@ -633,28 +772,29 @@ async fn active_session( RdpInputEvent::Close => { active_stage.graceful_shutdown()? } + #[cfg(feature = "clipboard")] RdpInputEvent::Clipboard(event) => { - if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { if let Some(svc_messages) = match event { ClipboardMessage::SendInitiateCopy(formats) => { - Some(cliprdr.initiate_copy(&formats) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_copy(&formats) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFormatData(response) => { - Some(cliprdr.submit_format_data(response) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.submit_format_data(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendInitiatePaste(format) => { - Some(cliprdr.initiate_paste(format) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.initiate_paste(format) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFileContentsRequest(request) => { - Some(cliprdr.request_file_contents(request) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.request_file_contents(request) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::SendFileContentsResponse(response) => { - Some(cliprdr.submit_file_contents(response) - .map_err(|e| session::custom_err!("CLIPRDR", e))?) + Some(cliprdr_client.submit_file_contents(response) + .map_err(|e| ironrdp_session::custom_err!("CLIPRDR", e))?) } ClipboardMessage::Error(e) => { error!("Clipboard backend error: {}", e); @@ -662,29 +802,27 @@ async fn active_session( } } { let frame = active_stage.process_svc_processor_messages(svc_messages)?; - // Send the messages to the server vec![ActiveStageOutput::ResponseFrame(frame)] } else { - // No messages to send to the server Vec::new() } - } else { + } else { warn!("Clipboard event received, but Cliprdr is not available"); Vec::new() } } RdpInputEvent::SendDvcMessages { channel_id, messages } => { trace!(channel_id, ?messages, "Send DVC messages"); - let frame = active_stage.encode_dvc_messages(messages)?; vec![ActiveStageOutput::ResponseFrame(frame)] } } } _ = cleanup_interval.tick() => { - // Drive clipboard lock timeout cleanup - if let Some(cliprdr) = active_stage.get_svc_processor_mut::() { - match cliprdr.drive_timeouts() { + // Drive clipboard lock timeout cleanup. + #[cfg(feature = "clipboard")] + if let Some(cliprdr_client) = active_stage.get_svc_processor_mut::() { + match cliprdr_client.drive_timeouts() { Ok(svc_messages) => { let frame = active_stage.process_svc_processor_messages(svc_messages)?; if !frame.is_empty() { @@ -701,6 +839,8 @@ async fn active_session( } else { Vec::new() } + #[cfg(not(feature = "clipboard"))] + Vec::new() } }; @@ -709,7 +849,7 @@ async fn active_session( ActiveStageOutput::ResponseFrame(frame) => writer .write_all(&frame) .await - .map_err(|e| session::custom_err!("write response", e))?, + .map_err(|e| ironrdp_session::custom_err!("write response", e))?, ActiveStageOutput::GraphicsUpdate(_region) => { let buffer: Vec = image .data() @@ -721,58 +861,57 @@ async fn active_session( u32::from_be_bytes([0, r, g, b]) }) .collect(); - output_event_sender .send(RdpOutputEvent::Image { buffer, width: NonZeroU16::new(image.width()) - .ok_or_else(|| session::general_err!("width is zero"))?, + .ok_or_else(|| ironrdp_session::general_err!("width is zero"))?, height: NonZeroU16::new(image.height()) - .ok_or_else(|| session::general_err!("height is zero"))?, + .ok_or_else(|| ironrdp_session::general_err!("height is zero"))?, }) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerDefault => { output_event_sender .send(RdpOutputEvent::PointerDefault) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerHidden => { output_event_sender .send(RdpOutputEvent::PointerHidden) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerPosition { x, y } => { output_event_sender .send(RdpOutputEvent::PointerPosition { x, y }) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::PointerBitmap(pointer) => { output_event_sender .send(RdpOutputEvent::PointerBitmap(pointer)) .await - .map_err(|e| session::custom_err!("output_event_sender", e))?; + .map_err(|e| ironrdp_session::custom_err!("output_event_sender", e))?; } ActiveStageOutput::DeactivateAll(mut connection_activation) => { - // Execute the Deactivation-Reactivation Sequence: + // Deactivation-Reactivation Sequence: // https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-rdpbcgr/dfc234ce-481a-4674-9a5d-2a7bafb14432 - debug!("Received Server Deactivate All PDU, executing Deactivation-Reactivation Sequence"); + debug!("Executing Deactivation-Reactivation Sequence"); let mut buf = WriteBuf::new(); 'activation_seq: loop { let written = single_sequence_step_read(&mut reader, &mut *connection_activation, &mut buf) .await - .map_err(|e| session::custom_err!("read deactivation-reactivation sequence step", e))?; - + .map_err(|e| { + ironrdp_session::custom_err!("read deactivation-reactivation sequence step", e) + })?; if written.size().is_some() { writer.write_all(buf.filled()).await.map_err(|e| { - session::custom_err!("write deactivation-reactivation sequence step", e) + ironrdp_session::custom_err!("write deactivation-reactivation sequence step", e) })?; } - if let ConnectionActivationState::Finalized { io_channel_id, user_channel_id, @@ -783,9 +922,7 @@ async fn active_session( } = connection_activation.connection_activation_state() { debug!(?desktop_size, "Deactivation-Reactivation Sequence completed"); - // Update image size with the new desktop size. image = DecodedImage::new(PixelFormat::RgbA32, desktop_size.width, desktop_size.height); - // Update the active stage with the new channel IDs and pointer settings. active_stage.set_fastpath_processor( fast_path::ProcessorBuilder { io_channel_id, diff --git a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs index e60d12ade..9e262727e 100644 --- a/crates/ironrdp-testsuite-extra/tests/config_rdp.rs +++ b/crates/ironrdp-testsuite-extra/tests/config_rdp.rs @@ -1,7 +1,7 @@ use std::fs; use std::path::PathBuf; -use ironrdp_client::config::ClipboardType; +use ironrdp_client::config::{ClipboardType, Transport}; use ironrdp_viewer::config::parse_config_from; use uuid::Uuid; @@ -48,7 +48,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_zero() { &[], ); - assert!(config.gw.is_none()); + assert!(!matches!(config.transport, Transport::Gateway(_))); } #[test] @@ -58,7 +58,7 @@ fn gateway_is_disabled_when_gateway_usage_method_is_four() { &[], ); - assert!(config.gw.is_none()); + assert!(!matches!(config.transport, Transport::Gateway(_))); } #[test] @@ -68,10 +68,12 @@ fn gateway_is_enabled_with_usage_method_one_and_file_credentials() { &[], ); - let gw = config.gw.expect("gateway should be configured"); - assert_eq!(gw.gw_endpoint, "gw.example.com:443"); - assert_eq!(gw.gw_user, "gw-user"); - assert_eq!(gw.gw_pass, "gw-pass"); + let Transport::Gateway(gw) = config.transport else { + panic!("gateway should be configured"); + }; + assert_eq!(gw.endpoint, "gw.example.com:443"); + assert_eq!(gw.username, "gw-user"); + assert_eq!(gw.password, "gw-pass"); } #[test] @@ -103,7 +105,7 @@ fn redirectclipboard_zero_disables_clipboard_for_default_mode() { &[], ); - assert!(matches!(config.clipboard_type, ClipboardType::Disable)); + assert!(matches!(config.channels.clipboard, ClipboardType::Disable)); } #[test] diff --git a/crates/ironrdp-tls/src/native_tls.rs b/crates/ironrdp-tls/src/native_tls.rs index f3b7d0d0e..319fdc749 100644 --- a/crates/ironrdp-tls/src/native_tls.rs +++ b/crates/ironrdp-tls/src/native_tls.rs @@ -14,12 +14,9 @@ where .use_sni(false) .build() .map(tokio_native_tls::TlsConnector::from) - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)?; - connector - .connect(server_name, stream) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? + connector.connect(server_name, stream).await.map_err(io::Error::other)? }; tls_stream.flush().await?; @@ -30,9 +27,9 @@ where let cert = tls_stream .get_ref() .peer_certificate() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))? - .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "peer certificate is missing"))?; - let cert = cert.to_der().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .map_err(io::Error::other)? + .ok_or_else(|| io::Error::other("peer certificate is missing"))?; + let cert = cert.to_der().map_err(io::Error::other)?; x509_cert::Certificate::from_der(&cert).map_err(io::Error::other)? }; diff --git a/crates/ironrdp-viewer/Cargo.toml b/crates/ironrdp-viewer/Cargo.toml index 20ffbc258..18ac91b17 100644 --- a/crates/ironrdp-viewer/Cargo.toml +++ b/crates/ironrdp-viewer/Cargo.toml @@ -25,17 +25,14 @@ test = false [features] default = ["rustls"] -rustls = ["ironrdp-client/rustls"] -native-tls = ["ironrdp-client/native-tls"] -qoi = ["ironrdp-client/qoi"] -qoiz = ["ironrdp-client/qoiz"] +rustls = ["ironrdp/rustls"] +native-tls = ["ironrdp/native-tls"] +qoi = ["ironrdp/qoi"] +qoiz = ["ironrdp/qoiz"] [dependencies] -ironrdp = { path = "../ironrdp", version = "0.16", features = ["input", "pdu"] } -ironrdp-client = { path = "../ironrdp-client", version = "0.1", default-features = false } -ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.6" } +ironrdp = { path = "../ironrdp", features = ["connector", "cliprdr", "input", "pdu", "client", "client-all"] } ironrdp-cfg = { path = "../ironrdp-cfg" } -ironrdp-mstsgu = { path = "../ironrdp-mstsgu" } ironrdp-propertyset = { path = "../ironrdp-propertyset" } ironrdp-rdpfile = { path = "../ironrdp-rdpfile" } diff --git a/crates/ironrdp-viewer/src/app.rs b/crates/ironrdp-viewer/src/app.rs index 952e79d09..a19703a01 100644 --- a/crates/ironrdp-viewer/src/app.rs +++ b/crates/ironrdp-viewer/src/app.rs @@ -6,10 +6,10 @@ use std::sync::Arc; use std::time::Instant; use anyhow::Context as _; +use ironrdp::client::rdp::{RdpInputEvent, RdpOutputEvent}; use ironrdp::pdu::input::MousePdu; use ironrdp::pdu::input::fast_path::FastPathInputEvent; use ironrdp::pdu::input::mouse::PointerFlags; -use ironrdp_client::rdp::{RdpInputEvent, RdpOutputEvent}; use raw_window_handle::{DisplayHandle, HasDisplayHandle as _}; use smallvec::SmallVec; use tokio::sync::mpsc; diff --git a/crates/ironrdp-viewer/src/clipboard.rs b/crates/ironrdp-viewer/src/clipboard.rs index 9b2855844..cc304c0bc 100644 --- a/crates/ironrdp-viewer/src/clipboard.rs +++ b/crates/ironrdp-viewer/src/clipboard.rs @@ -1,5 +1,5 @@ use ironrdp::cliprdr::backend::{ClipboardMessage, ClipboardMessageProxy}; -use ironrdp_client::rdp::RdpInputEvent; +use ironrdp::client::rdp::RdpInputEvent; use tokio::sync::mpsc; use tracing::error; diff --git a/crates/ironrdp-viewer/src/config.rs b/crates/ironrdp-viewer/src/config.rs index 009f3b35e..73db59967 100644 --- a/crates/ironrdp-viewer/src/config.rs +++ b/crates/ironrdp-viewer/src/config.rs @@ -7,13 +7,13 @@ use std::path::PathBuf; use anyhow::Context as _; use clap::Parser; use clap::clap_derive::ValueEnum; +use ironrdp::client::config::{ + ClipboardType as ResolvedClipboardType, Config, ConfigBuilder, Destination, DvcProxyInfo, GatewayConfig, + RDCleanPathConfig, Transport, +}; use ironrdp::connector::{self, Credentials}; use ironrdp::pdu::rdp::capability_sets::{MajorPlatformType, client_codecs_capabilities}; use ironrdp::pdu::rdp::client_info::{PerformanceFlags, TimezoneInfo}; -use ironrdp_client::config::{ - ClipboardType as ResolvedClipboardType, Config, Destination, DvcProxyInfo, RDCleanPathConfig, -}; -use ironrdp_mstsgu::GwConnectTarget; use tap::prelude::*; use url::Url; @@ -239,7 +239,7 @@ struct Args { /// Automatically logon to the server by passing the INFO_AUTOLOGON flag /// /// This flag is ignored if CredSSP authentication is used. - /// You can use `--no-credssp` to ensure it’s not. + /// You can use `--no-credssp` to ensure it's not. #[clap(long)] autologon: bool, @@ -252,7 +252,7 @@ struct Args { /// Disable TLS + Network Level Authentication (NLA) using CredSSP /// /// NLA is used to authenticates RDP clients and servers before sending credentials over the network. - /// It’s not recommended to disable this. + /// It's not recommended to disable this. #[clap(long, alias = "no-nla")] no_credssp: bool, @@ -420,25 +420,24 @@ impl PartialConfig { }) .map_or(has_gateway_host, ironrdp_cfg::GatewayUsageMethod::is_gateway_required); - let mut gw: Option = + let mut gw_config: Option = use_gateway .then(|| properties.gateway_hostname()) .flatten() - .map(|gw_addr| GwConnectTarget { - gw_endpoint: gw_addr.to_owned(), - gw_user: String::new(), - gw_pass: String::new(), - server: String::new(), // TODO: non-standard port? also dont use here? + .map(|gw_addr| GatewayConfig { + endpoint: gw_addr.to_owned(), + username: String::new(), + password: String::new(), }); - if let Some(ref mut gw) = gw { + if let Some(ref mut gw) = gw_config { if let Ok(Some(gateway_credentials_source)) = properties.gateway_credentials_source() { // All known credential sources fall through to username/password prompts. // The value is available for future differentiation if needed. let _ = gateway_credentials_source; } - gw.gw_user = if let Some(gw_user) = properties.gateway_username() { + gw.username = if let Some(gw_user) = properties.gateway_username() { gw_user.to_owned() } else { inquire::Text::new("Gateway username:") @@ -446,7 +445,7 @@ impl PartialConfig { .context("Username prompt")? }; - gw.gw_pass = if let Some(gw_pass) = properties.gateway_password() { + gw.password = if let Some(gw_pass) = properties.gateway_password() { gw_pass.to_owned() } else { inquire::Password::new("Gateway password:") @@ -484,10 +483,6 @@ impl PartialConfig { .pipe(Destination::new)? }; - if let Some(ref mut gw) = gw { - gw.server = destination.name().to_owned(); // TODO - } - let username = if let Some(username) = properties.username() { username.to_owned() } else { @@ -640,31 +635,41 @@ impl PartialConfig { work_dir: properties.shell_working_directory().unwrap_or_default().to_owned(), }; - Ok(Config { - log_file: self.log_file, - gw, - kerberos_config, - destination, - connector, - clipboard_type, - rdcleanpath: self.rdcleanpath, - fake_events_interval, - dvc_pipe_proxies: self.dvc_pipe_proxies, - #[cfg(windows)] - dvc_plugins: self.dvc_plugins, - }) - } -} + // Determine the transport. RDCleanPath takes precedence over gateway. + let transport = if let Some(rdcp) = self.rdcleanpath { + Transport::RDCleanPath(rdcp) + } else if let Some(gw) = gw_config { + Transport::Gateway(gw) + } else { + Transport::Direct + }; -fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { - if !redirect_clipboard { - return ResolvedClipboardType::Disable; - } + let mut builder = ConfigBuilder::new(connector, destination) + .with_transport(transport) + .with_clipboard(clipboard_type); - match cli { - ClipboardType::Enable => ResolvedClipboardType::Enable, - ClipboardType::Disable => ResolvedClipboardType::Disable, - ClipboardType::Stub => ResolvedClipboardType::Stub, + if let Some(kerberos_config) = kerberos_config { + builder = builder.with_kerberos_config(kerberos_config); + } + + if let Some(log_file) = self.log_file { + builder = builder.with_log_file(log_file); + } + + if let Some(fake_events_interval) = fake_events_interval { + builder = builder.with_fake_events_interval(fake_events_interval); + } + + for proxy in self.dvc_pipe_proxies { + builder = builder.with_dvc_pipe_proxy(proxy); + } + + #[cfg(windows)] + for plugin in self.dvc_plugins { + builder = builder.with_dvc_plugin(plugin); + } + + Ok(builder.build()) } } @@ -680,6 +685,18 @@ where PartialConfig::parse_from(args)?.into_config() } +fn resolve_clipboard_type(cli: ClipboardType, redirect_clipboard: bool) -> ResolvedClipboardType { + if !redirect_clipboard { + return ResolvedClipboardType::Disable; + } + + match cli { + ClipboardType::Enable => ResolvedClipboardType::Enable, + ClipboardType::Disable => ResolvedClipboardType::Disable, + ClipboardType::Stub => ResolvedClipboardType::Stub, + } +} + fn normalize_kdc_proxy_url_from_name(name: &str) -> String { if name.starts_with("http://") || name.starts_with("https://") { name.to_owned() diff --git a/crates/ironrdp-viewer/src/lib.rs b/crates/ironrdp-viewer/src/lib.rs index 69402deb0..9d1f6d63f 100644 --- a/crates/ironrdp-viewer/src/lib.rs +++ b/crates/ironrdp-viewer/src/lib.rs @@ -10,5 +10,4 @@ #![allow(clippy::cast_sign_loss)] pub mod app; -pub mod clipboard; pub mod config; diff --git a/crates/ironrdp-viewer/src/main.rs b/crates/ironrdp-viewer/src/main.rs index 157ddb41f..f62400be2 100644 --- a/crates/ironrdp-viewer/src/main.rs +++ b/crates/ironrdp-viewer/src/main.rs @@ -1,8 +1,7 @@ #![allow(unused_crate_dependencies)] // false positives because there is both a library and a binary use anyhow::Context as _; -use ironrdp_client::config::ClipboardType; -use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent}; +use ironrdp::client::rdp::{RdpClient, RdpOutputEvent}; use ironrdp_viewer::app::App; use ironrdp_viewer::config::PartialConfig; use tokio::runtime; @@ -27,16 +26,20 @@ fn main() -> anyhow::Result<()> { debug!("Initialize App"); let event_loop = EventLoop::::with_user_event().build()?; let event_loop_proxy = event_loop.create_proxy(); - let (input_event_sender, input_event_receiver) = RdpInputEvent::create_channel(); let (output_event_sender, mut output_event_receiver) = mpsc::channel::(64); let initial_window_size = PhysicalSize::new( u32::from(config.connector.desktop_size.width), u32::from(config.connector.desktop_size.height), ); + let fake_events_interval = config.fake_events_interval; + + let client = RdpClient::new(config, output_event_sender); + let input_event_sender = client.input_sender(); + let mut app = App::new( &event_loop, &input_event_sender, - config.fake_events_interval, + fake_events_interval, initial_window_size, ) .context("unable to initialize App")?; @@ -46,54 +49,6 @@ fn main() -> anyhow::Result<()> { .build() .context("unable to create tokio runtime")?; - // NOTE: we need to keep `win_clipboard` alive, otherwise it will be dropped before IronRDP - // starts and clipboard functionality will not be available. - #[cfg(windows)] - let _win_clipboard; - - let cliprdr_factory = match config.clipboard_type { - ClipboardType::Stub => { - use ironrdp_cliprdr_native::StubClipboard; - - let cliprdr = StubClipboard::new(); - let factory = cliprdr.backend_factory(); - Some(factory) - } - ClipboardType::Enable => { - #[cfg(windows)] - { - use ironrdp_cliprdr_native::WinClipboard; - use ironrdp_viewer::clipboard::ClientClipboardMessageProxy; - - let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?; - - let factory = cliprdr.backend_factory(); - _win_clipboard = cliprdr; - Some(factory) - } - #[cfg(not(windows))] - { - // No native clipboard backend available on this platform; fall back to stub. - use ironrdp_cliprdr_native::StubClipboard; - - let cliprdr = StubClipboard::new(); - let factory = cliprdr.backend_factory(); - Some(factory) - } - } - ClipboardType::Disable => None, - }; - - let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender); - - let client = RdpClient { - config, - output_event_sender, - input_event_receiver, - cliprdr_factory, - dvc_pipe_proxy_factory, - }; - // Forward output events from the library's mpsc channel to winit's `EventLoopProxy`. // // The library is winit-agnostic: it just emits `RdpOutputEvent`s on a plain @@ -114,6 +69,7 @@ fn main() -> anyhow::Result<()> { debug!("Run App"); event_loop.run_app(&mut app)?; + Ok(()) } diff --git a/crates/ironrdp/Cargo.toml b/crates/ironrdp/Cargo.toml index d557efec7..856f7827a 100644 --- a/crates/ironrdp/Cargo.toml +++ b/crates/ironrdp/Cargo.toml @@ -33,8 +33,23 @@ rdpdr = ["dep:ironrdp-rdpdr"] rdpsnd = ["dep:ironrdp-rdpsnd"] displaycontrol = ["dep:ironrdp-displaycontrol"] echo = ["dep:ironrdp-echo"] -qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi"] -qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz"] +mstsgu = ["dep:ironrdp-mstsgu"] +client = ["dep:ironrdp-client"] +# TLS backends for the client (exactly one is required when the client is enabled). +rustls = ["ironrdp-client?/rustls", "ironrdp-mstsgu?/rustls"] +native-tls = ["ironrdp-client?/native-tls", "ironrdp-mstsgu?/native-tls"] +# Optional client subsystems, forwarded so consumers can opt in without naming `ironrdp-client`. +client-sound = ["ironrdp-client?/sound"] +client-clipboard = ["ironrdp-client?/clipboard"] +client-rdpdr = ["ironrdp-client?/rdpdr"] +client-smartcard = ["ironrdp-client?/smartcard"] +client-gateway = ["ironrdp-client?/gateway"] +client-dvc-pipe-proxy = ["ironrdp-client?/dvc-pipe-proxy"] +client-dvc-com-plugin = ["ironrdp-client?/dvc-com-plugin"] +client-all = ["ironrdp-client?/all"] +qoi = ["ironrdp-server?/qoi", "ironrdp-pdu?/qoi", "ironrdp-connector?/qoi", "ironrdp-session?/qoi", "ironrdp-client?/qoi"] +qoiz = ["ironrdp-server?/qoiz", "ironrdp-pdu?/qoiz", "ironrdp-connector?/qoiz", "ironrdp-session?/qoiz", "ironrdp-client?/qoiz"] + # Internal (PRIVATE!) features used to aid testing. # Don't rely on these whatsoever. They may disappear at any time. __bench = ["ironrdp-server/__bench"] @@ -55,6 +70,8 @@ ironrdp-rdpdr = { path = "../ironrdp-rdpdr", version = "0.6", optional = true } ironrdp-rdpsnd = { path = "../ironrdp-rdpsnd", version = "0.8", optional = true } # public ironrdp-displaycontrol = { path = "../ironrdp-displaycontrol", version = "0.7", optional = true } # public ironrdp-echo = { path = "../ironrdp-echo", version = "0.3", optional = true } # public +ironrdp-mstsgu = { path = "../ironrdp-mstsgu", version = "0.0.1", optional = true } # public +ironrdp-client = { path = "../ironrdp-client", version = "0.1", optional = true } # public [dev-dependencies] ironrdp-blocking = { path = "../ironrdp-blocking", version = "0.9" } diff --git a/crates/ironrdp/src/lib.rs b/crates/ironrdp/src/lib.rs index 3ef760f6a..1ca6ae70e 100644 --- a/crates/ironrdp/src/lib.rs +++ b/crates/ironrdp/src/lib.rs @@ -16,6 +16,10 @@ pub use ironrdp_acceptor as acceptor; #[doc(inline)] pub use ironrdp_cliprdr as cliprdr; +#[cfg(feature = "client")] +#[doc(inline)] +pub use ironrdp_client as client; + #[cfg(feature = "connector")] #[doc(inline)] pub use ironrdp_connector as connector; @@ -44,6 +48,10 @@ pub use ironrdp_graphics as graphics; #[doc(inline)] pub use ironrdp_input as input; +#[cfg(feature = "mstsgu")] +#[doc(inline)] +pub use ironrdp_mstsgu as mstsgu; + #[cfg(feature = "pdu")] #[doc(inline)] pub use ironrdp_pdu as pdu; diff --git a/xtask/src/features.rs b/xtask/src/features.rs index b90916392..6f1e051d8 100644 --- a/xtask/src/features.rs +++ b/xtask/src/features.rs @@ -151,17 +151,34 @@ const CASES: &[FeatureCheckCase] = &[ extra_args: &[], }, }, - // `ironrdp-tls`, `ironrdp-client`, `ironrdp-mstsgu` are intentionally - // outside this initial case set. The `exactly-one-of` TLS-backend - // constraint on `ironrdp-tls` needs `--mutually-exclusive-features`, - // `--at-least-one-of`, and `--exclude-no-default-features` on the - // cargo-hack invocation (cargo-hack does not honor - // `package.metadata.cargo-hack`), and the powerset surfaces a latent - // bug in `extract_tls_server_public_key` that uses `x509_cert::*` - // unconditionally instead of gating on `rustls | native-tls`. Both - // are tractable but out of scope for this gate's initial landing. - // The regular `Checks` job already exercises all three crates with - // their default features. + // `ironrdp-client` has a pair of mutually-exclusive, at-least-one-of TLS + // backends (`rustls` / `native-tls`). cargo-hack does not honor + // `package.metadata.cargo-hack`, so the constraint is expressed inline via + // `--mutually-exclusive-features`, `--at-least-one-of`, and + // `--exclude-no-default-features` (building with no TLS backend cannot + // compile, since `ironrdp-tls` requires one). + FeatureCheckCase { + name: "workspace/powerset-client", + invocation: Invocation::CargoHack { + packages: &["ironrdp-client"], + depth: 2, + extra_args: &[ + "--mutually-exclusive-features", + "rustls,native-tls", + "--at-least-one-of", + "rustls,native-tls", + "--exclude-no-default-features", + ], + }, + }, + // FIXME: `ironrdp-tls` and `ironrdp-mstsgu` are intentionally outside this initial + // case set. The `exactly-one-of` TLS-backend constraint on `ironrdp-tls` + // also needs the inline cargo-hack flags shown above, and the powerset + // surfaces a latent bug in `extract_tls_server_public_key` that uses + // `x509_cert::*` unconditionally instead of gating on `rustls | native-tls`. + // Both are tractable but out of scope for this gate's initial landing. + // The regular `Checks` job already exercises both crates with their default + // features. ]; /// Run every case sequentially. Mirrors what a contributor gets locally with