Skip to content

feat(dvc): add DVC named pipe proxy support #791

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ironrdp-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.3" }
ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.5", features = ["reqwest"] }
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy"

# Windowing and rendering
winit = { version = "0.30", features = ["rwh_06"] }
Expand Down
43 changes: 43 additions & 0 deletions crates/ironrdp-client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub struct Config {
pub connector: connector::Config,
pub clipboard_type: ClipboardType,
pub rdcleanpath: Option<RDCleanPathConfig>,

/// 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).
pub dvc_pipe_proxies: Vec<DvcProxyInfo>,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
Expand Down Expand Up @@ -137,6 +144,33 @@ pub struct RDCleanPathConfig {
pub auth_token: String,
}

#[derive(Clone, Debug)]
pub struct DvcProxyInfo {
pub channel_name: String,
pub pipe_name: String,
}

impl FromStr for DvcProxyInfo {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut parts = s.split('=');
let channel_name = parts
.next()
.ok_or_else(|| anyhow::anyhow!("missing DVC channel name"))?
.to_owned();
let pipe_name = parts
.next()
.ok_or_else(|| anyhow::anyhow!("missing DVC proxy pipe name"))?
.to_owned();

Ok(Self {
channel_name,
pipe_name,
})
}
}

/// Devolutions IronRDP client
#[derive(Parser, Debug)]
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
Expand Down Expand Up @@ -238,6 +272,14 @@ struct Args {
/// The bitmap codecs to use (remotefx:on, ...)
#[clap(long, value_parser, num_args = 1.., value_delimiter = ',')]
codecs: Vec<String>,

/// Add DVC channel named pipe proxy.
/// the format is <name>=<pipe>
/// e.g. `ChannelName=PipeName` where `ChannelName` is the name of the channel,
/// and `PipeName` is the name of the named pipe to connect to (without OS-specific prefix),
/// e.g. PipeName will automatically be prefixed with `\\.\pipe\` on Windows.
#[clap(long, value_parser)]
dvc_proxy: Vec<DvcProxyInfo>,
}

impl Config {
Expand Down Expand Up @@ -357,6 +399,7 @@ impl Config {
connector,
clipboard_type,
rdcleanpath,
dvc_pipe_proxies: args.dvc_proxy,
})
}
}
7 changes: 5 additions & 2 deletions crates/ironrdp-client/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ extern crate tracing;
use anyhow::Context as _;
use ironrdp_client::app::App;
use ironrdp_client::config::{ClipboardType, Config};
use ironrdp_client::rdp::{RdpClient, RdpInputEvent, RdpOutputEvent};
use ironrdp_client::rdp::{DvcPipeProxyFactory, RdpClient, RdpInputEvent, RdpOutputEvent};
use tokio::runtime;
use winit::event_loop::EventLoop;

Expand Down Expand Up @@ -50,7 +50,7 @@ fn main() -> anyhow::Result<()> {
use ironrdp_client::clipboard::ClientClipboardMessageProxy;
use ironrdp_cliprdr_native::WinClipboard;

let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender))?;
let cliprdr = WinClipboard::new(ClientClipboardMessageProxy::new(input_event_sender.clone()))?;

let factory = cliprdr.backend_factory();
_win_clipboard = cliprdr;
Expand All @@ -59,11 +59,14 @@ fn main() -> anyhow::Result<()> {
_ => None,
};

let dvc_pipe_proxy_factory = DvcPipeProxyFactory::new(input_event_sender);

let client = RdpClient {
config,
event_loop_proxy,
input_event_receiver,
cliprdr_factory,
dvc_pipe_proxy_factory,
};

debug!("Start RDP thread");
Expand Down
91 changes: 83 additions & 8 deletions crates/ironrdp-client/src/rdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ use ironrdp::displaycontrol::pdu::MonitorLayoutEntry;
use ironrdp::graphics::image_processing::PixelFormat;
use ironrdp::graphics::pointer::DecodedPointer;
use ironrdp::pdu::input::fast_path::FastPathInputEvent;
use ironrdp::pdu::{pdu_other_err, PduResult};
use ironrdp::session::image::DecodedImage;
use ironrdp::session::{fast_path, ActiveStage, ActiveStageOutput, GracefulDisconnectReason, SessionResult};
use ironrdp::svc::SvcMessage;
use ironrdp::{cliprdr, connector, rdpdr, rdpsnd, session};
use ironrdp_core::WriteBuf;
use ironrdp_rdpsnd_native::cpal;
Expand All @@ -23,6 +25,7 @@ use tokio::sync::mpsc;
use winit::event_loop::EventLoopProxy;

use crate::config::{Config, RDCleanPathConfig};
use ironrdp_dvc_pipe_proxy::DvcNamedPipeProxy;

#[derive(Debug)]
pub enum RdpOutputEvent {
Expand All @@ -47,6 +50,10 @@ pub enum RdpInputEvent {
FastPath(SmallVec<[FastPathInputEvent; 2]>),
Close,
Clipboard(ClipboardMessage),
SendDvcMessages {
channel_id: u32,
messages: Vec<SvcMessage>,
},
}

impl RdpInputEvent {
Expand All @@ -55,26 +62,64 @@ impl RdpInputEvent {
}
}

pub struct DvcPipeProxyFactory {
rdp_input_sender: mpsc::UnboundedSender<RdpInputEvent>,
Copy link
Member

Choose a reason for hiding this comment

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

note: It’s typically not ideal to use unbounded channels because there is no backpressure. You may consider a bounded channel and using async send or blocking_send instead. The sender will have to wait if the queue is full, creating (typically) necessary backpressure when the target system is overloaded.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am re-using the already existing RdpInputEvent channel, which is tokio::mpsc::Unbounded*, I agree that we need to change that in follow-up

}

impl DvcPipeProxyFactory {
pub fn new(rdp_input_sender: mpsc::UnboundedSender<RdpInputEvent>) -> 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(())
})
}
}

pub type WriteDvcMessageFn = Box<dyn Fn(u32, SvcMessage) -> PduResult<()> + Send + 'static>;

pub struct RdpClient {
pub config: Config,
pub event_loop_proxy: EventLoopProxy<RdpOutputEvent>,
pub input_event_receiver: mpsc::UnboundedReceiver<RdpInputEvent>,
pub cliprdr_factory: Option<Box<dyn CliprdrBackendFactory + Send>>,
pub dvc_pipe_proxy_factory: DvcPipeProxyFactory,
}

impl RdpClient {
pub async fn run(mut self) {
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()).await {
match connect_ws(
&self.config,
rdcleanpath,
self.cliprdr_factory.as_deref(),
&self.dvc_pipe_proxy_factory,
)
.await
{
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
break;
}
}
} else {
match connect(&self.config, self.cliprdr_factory.as_deref()).await {
match connect(
&self.config,
self.cliprdr_factory.as_deref(),
&self.dvc_pipe_proxy_factory,
)
.await
{
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
Expand Down Expand Up @@ -122,6 +167,7 @@ type UpgradedFramed = ironrdp_tokio::TokioFramed<Box<dyn AsyncReadWrite + Unpin
async fn connect(
config: &Config,
cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>,
dvc_pipe_proxy_factory: &DvcPipeProxyFactory,
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let dest = format!("{}:{}", config.destination.name(), config.destination.port());

Expand All @@ -135,10 +181,21 @@ async fn connect(

let mut framed = ironrdp_tokio::TokioFramed::new(socket);

let mut drdynvc =
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));

// 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();

trace!(%channel_name, %pipe_name, "Creating DVC proxy");

drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name));
}

let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr)
.with_static_channel(
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))),
)
.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));

Expand Down Expand Up @@ -186,6 +243,7 @@ async fn connect_ws(
config: &Config,
rdcleanpath: &RDCleanPathConfig,
cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>,
dvc_pipe_proxy_factory: &DvcPipeProxyFactory,
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let hostname = rdcleanpath
.url
Expand Down Expand Up @@ -214,10 +272,21 @@ async fn connect_ws(

let mut framed = ironrdp_tokio::TokioFramed::new(ws);

let mut drdynvc =
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));

// 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();

trace!(%channel_name, %pipe_name, "Creating DVC proxy");

drdynvc = drdynvc.with_dynamic_channel(dvc_pipe_proxy_factory.create(channel_name, pipe_name));
}

let mut connector = connector::ClientConnector::new(config.connector.clone(), client_addr)
.with_static_channel(
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))),
)
.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));

Expand Down Expand Up @@ -468,6 +537,12 @@ async fn active_session(
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)]
}
}
}
};
Expand Down
6 changes: 6 additions & 0 deletions crates/ironrdp-dvc-pipe-proxy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
40 changes: 40 additions & 0 deletions crates/ironrdp-dvc-pipe-proxy/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[package]
name = "ironrdp-dvc-pipe-proxy"
version = "0.1.0"
readme = "README.md"
description = "DVC named pipe proxy for IronRDP"
edition.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
authors.workspace = true
keywords.workspace = true
categories.workspace = true

[lib]
doctest = false
test = false

[dependencies]
ironrdp-core.path = "../ironrdp-core"
ironrdp-dvc.path = "../ironrdp-dvc"
ironrdp-pdu.path = "../ironrdp-pdu"
ironrdp-svc.path = "../ironrdp-svc"

tracing = { version = "0.1", features = ["log"] }


[target.'cfg(windows)'.dependencies]
widestring = "1"
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_System_Threading",
"Win32_Storage_FileSystem",
"Win32_System_Pipes",
"Win32_System_IO",
] }


[lints]
workspace = true
1 change: 1 addition & 0 deletions crates/ironrdp-dvc-pipe-proxy/LICENSE-APACHE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
../../LICENSE-APACHE
1 change: 1 addition & 0 deletions crates/ironrdp-dvc-pipe-proxy/LICENSE-MIT
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
../../LICENSE-MIT
15 changes: 15 additions & 0 deletions crates/ironrdp-dvc-pipe-proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# IronRDP DVC pipe proxy

This crate provides a Device Virtual Channel (DVC) handler for IronRDP, enabling proxying of RDP DVC
traffic over a named pipe.

It was originally designed to simplify custom DVC integration within Devolutions Remote Desktop
Manager (RDM). By implementing a thin pipe proxy for target RDP clients (such as IronRDP, FreeRDP,
mstsc, etc.), the main client logic can be centralized and reused across all supported clients via a
named pipe.

This approach allows you to implement your DVC logic in one place, making it easier to support
multiple RDP clients without duplicating code.

Additionally, this crate can be used for other scenarios, such as testing your own custom DVC
channel client, without needing to patch or rebuild IronRDP itself.
12 changes: 12 additions & 0 deletions crates/ironrdp-dvc-pipe-proxy/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#![doc = include_str!("../README.md")]
#![doc(html_logo_url = "https://cdnweb.devolutions.net/images/projects/devolutions/logos/devolutions-icon-shadow.svg")]

#[macro_use]
extern crate tracing;

#[cfg(target_os = "windows")]
mod windows;

mod platform;

pub use platform::DvcNamedPipeProxy;
Loading
Loading