feat(client): add support for RDCleanPath (#745)

Issue: ARC-309
This commit is contained in:
Benoît Cortier
2025-04-14 13:08:44 +00:00
committed by GitHub
parent 5e6746c1b6
commit cc3dbf124f
7 changed files with 446 additions and 73 deletions
Generated
+159 -45
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -25,8 +25,8 @@ test = false
[features]
default = ["rustls"]
rustls = ["ironrdp-tls/rustls"]
native-tls = ["ironrdp-tls/native-tls"]
rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots"]
native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls"]
[dependencies]
# Protocols
@@ -42,15 +42,12 @@ ironrdp = { path = "../ironrdp", version = "0.9", features = [
"displaycontrol",
"connector",
] }
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = [
"alloc",
] }
ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"] }
ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.2" }
ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.2" }
ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.3", features = [
"reqwest",
] }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.3", features = ["reqwest"] }
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
# Windowing and rendering
winit = { version = "0.30", features = ["rwh_06"] }
@@ -67,6 +64,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Async, futures
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = "0.26"
transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" }
futures-util = { version = "0.3", features = ["sink"] }
# Utils
whoami = "1.6"
@@ -76,6 +76,7 @@ tap = "1"
semver = "1"
raw-window-handle = "0.6"
uuid = { version = "1.16" }
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = ["Win32_Foundation"] }
+21 -9
View File
@@ -1,6 +1,5 @@
use core::num::ParseIntError;
use core::str::FromStr;
use std::io;
use anyhow::Context as _;
use clap::clap_derive::ValueEnum;
@@ -19,6 +18,7 @@ pub struct Config {
pub destination: Destination,
pub connector: connector::Config,
pub clipboard_type: ClipboardType,
pub rdcleanpath: Option<RDCleanPathConfig>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
@@ -107,14 +107,6 @@ impl Destination {
pub fn port(&self) -> u16 {
self.port
}
pub fn lookup_addr(&self) -> io::Result<std::net::SocketAddr> {
use std::net::ToSocketAddrs as _;
let sockaddr = (self.name.as_str(), self.port).to_socket_addrs()?.next().unwrap();
Ok(sockaddr)
}
}
impl FromStr for Destination {
@@ -137,6 +129,12 @@ impl From<&Destination> for connector::ServerName {
}
}
#[derive(Clone, Debug)]
pub struct RDCleanPathConfig {
pub url: String,
pub auth_token: String,
}
/// Devolutions IronRDP client
#[derive(Parser, Debug)]
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
@@ -161,6 +159,14 @@ struct Args {
#[clap(short, long, value_parser)]
password: Option<String>,
/// Proxy URL to connect to for the RDCleanPath
#[clap(long, requires("rdcleanpath_token"))]
rdcleanpath_url: Option<String>,
/// Authentication token to insert in the RDCleanPath packet
#[clap(long, requires("rdcleanpath_url"))]
rdcleanpath_token: Option<String>,
/// The keyboard type
#[clap(long, value_enum, value_parser, default_value_t = KeyboardType::IbmEnhanced)]
keyboard_type: KeyboardType,
@@ -326,11 +332,17 @@ impl Config {
performance_flags: PerformanceFlags::default(),
};
let rdcleanpath = args
.rdcleanpath_url
.zip(args.rdcleanpath_token)
.map(|(url, auth_token)| RDCleanPathConfig { url, auth_token });
Ok(Self {
log_file: args.log_file,
destination,
connector,
clipboard_type,
rdcleanpath,
})
}
}
+2
View File
@@ -16,3 +16,5 @@ pub mod app;
pub mod clipboard;
pub mod config;
pub mod rdp;
mod ws;
+220 -10
View File
@@ -14,11 +14,12 @@ use ironrdp_tokio::reqwest::ReqwestNetworkClient;
use ironrdp_tokio::{single_sequence_step_read, split_tokio_framed, FramedWrite};
use rdpdr::NoopRdpdrBackend;
use smallvec::SmallVec;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use winit::event_loop::EventLoopProxy;
use crate::config::Config;
use crate::config::{Config, RDCleanPathConfig};
#[derive(Debug)]
pub enum RdpOutputEvent {
@@ -60,11 +61,21 @@ pub struct RdpClient {
impl RdpClient {
pub async fn run(mut self) {
loop {
let (connection_result, framed) = match connect(&self.config, self.cliprdr_factory.as_deref()).await {
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
break;
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 {
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 {
Ok(result) => result,
Err(e) => {
let _ = self.event_loop_proxy.send_event(RdpOutputEvent::ConnectionFailure(e));
break;
}
}
};
@@ -98,7 +109,11 @@ enum RdpControlFlow {
TerminatedGracefully(GracefulDisconnectReason),
}
type UpgradedFramed = ironrdp_tokio::TokioFramed<ironrdp_tls::TlsStream<TcpStream>>;
trait AsyncReadWrite: AsyncRead + AsyncWrite {}
impl<T> AsyncReadWrite for T where T: AsyncRead + AsyncWrite {}
type UpgradedFramed = ironrdp_tokio::TokioFramed<Box<dyn AsyncReadWrite + Unpin + Send + Sync>>;
async fn connect(
config: &Config,
@@ -145,16 +160,16 @@ async fn connect(
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, &mut connector);
let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(upgraded_stream);
let erased_stream = Box::new(upgraded_stream) as Box<dyn AsyncReadWrite + Unpin + Send + Sync>;
let mut upgraded_framed = ironrdp_tokio::TokioFramed::new(erased_stream);
let mut network_client = ReqwestNetworkClient::new();
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
&mut upgraded_framed,
connector,
(&config.destination).into(),
server_public_key,
Some(&mut network_client),
Some(&mut ReqwestNetworkClient::new()),
None,
)
.await?;
@@ -164,6 +179,201 @@ async fn connect(
Ok((connection_result, upgraded_framed))
}
async fn connect_ws(
config: &Config,
rdcleanpath: &RDCleanPathConfig,
cliprdr_factory: Option<&(dyn CliprdrBackendFactory + Send)>,
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let (ws, _) = tokio_tungstenite::connect_async(&rdcleanpath.url)
.await
.map_err(|e| connector::custom_err!("WS connect", e))?;
let ws = crate::ws::websocket_compat(ws);
let mut framed = ironrdp_tokio::TokioFramed::new(ws);
let mut connector = connector::ClientConnector::new(config.connector.clone())
.with_static_channel(
ironrdp::dvc::DrdynvcClient::new().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new()))),
)
.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));
if let Some(builder) = cliprdr_factory {
let backend = builder.build_cliprdr_backend();
let cliprdr = cliprdr::Cliprdr::new(backend);
connector.attach_static_channel(cliprdr);
}
let destination = format!("{}:{}", config.destination.name(), config.destination.port());
let (upgraded, server_public_key) = connect_rdcleanpath(
&mut framed,
&mut connector,
destination,
rdcleanpath.auth_token.clone(),
None,
)
.await?;
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
&mut framed,
connector,
(&config.destination).into(),
server_public_key,
Some(&mut ReqwestNetworkClient::new()),
None,
)
.await?;
let ws = framed.into_inner_no_leftover();
let erased_stream = Box::new(ws) as Box<dyn AsyncReadWrite + Unpin + Send + Sync>;
let upgraded_framed = ironrdp_tokio::TokioFramed::new(erased_stream);
Ok((connection_result, upgraded_framed))
}
async fn connect_rdcleanpath<S>(
framed: &mut ironrdp_tokio::Framed<S>,
connector: &mut connector::ClientConnector,
destination: String,
proxy_auth_token: String,
pcb: Option<String>,
) -> ConnectorResult<(ironrdp_tokio::Upgraded, Vec<u8>)>
where
S: ironrdp_tokio::FramedRead + FramedWrite,
{
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<Option<(bool, usize)>> {
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)"
)),
}
}
}
let mut buf = WriteBuf::new();
info!("Begin connection procedure");
{
// RDCleanPath request
let connector::ClientConnectorState::ConnectionInitiationSendRequest = connector.state else {
return Err(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());
let x224_pdu = buf.filled().to_vec();
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))?;
debug!(message = ?rdcleanpath_req, "Send RDCleanPath request");
let rdcleanpath_req = rdcleanpath_req
.to_der()
.map_err(|e| connector::custom_err!("RDCleanPath request encode", e))?;
framed
.write_all(&rdcleanpath_req)
.await
.map_err(|e| connector::custom_err!("couldnt write RDCleanPath request", e))?;
}
{
// RDCleanPath response
let rdcleanpath_res = framed
.read_by_hint(&RDCLEANPATH_HINT)
.await
.map_err(|e| connector::custom_err!("read RDCleanPath request", e))?;
let rdcleanpath_res = ironrdp_rdcleanpath::RDCleanPathPdu::from_der(&rdcleanpath_res)
.map_err(|e| connector::custom_err!("RDCleanPath response decode", e))?;
debug!(message = ?rdcleanpath_res, "Received RDCleanPath PDU");
let (x224_connection_response, server_cert_chain, server_addr) = match rdcleanpath_res
.into_enum()
.map_err(|e| connector::custom_err!("invalid RDCleanPath PDU", e))?
{
ironrdp_rdcleanpath::RDCleanPath::Request { .. } => {
return Err(connector::general_err!(
"received an unexpected RDCleanPath type (request)",
));
}
ironrdp_rdcleanpath::RDCleanPath::Response {
x224_connection_response,
server_cert_chain,
server_addr,
} => (x224_connection_response, server_cert_chain, server_addr),
ironrdp_rdcleanpath::RDCleanPath::Err(error) => {
return Err(connector::custom_err!("received an RDCleanPath error", error));
}
};
let server_addr = server_addr
.parse()
.map_err(|e| connector::custom_err!("failed to parse server address sent by proxy", e))?;
connector.attach_server_addr(server_addr);
let connector::ClientConnectorState::ConnectionInitiationWaitConfirm { .. } = connector.state else {
return Err(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"))?;
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))?;
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"))?
.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))
}
}
async fn active_session(
framed: UpgradedFramed,
connection_result: ConnectionResult,
+34
View File
@@ -0,0 +1,34 @@
use futures_util::{Sink, SinkExt as _, Stream, StreamExt as _};
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_tungstenite::tungstenite;
pub(crate) fn websocket_compat<S>(stream: S) -> impl AsyncRead + AsyncWrite + Unpin + Send + 'static
where
S: Stream<Item = Result<tungstenite::Message, tungstenite::Error>>
+ Sink<tungstenite::Message, Error = tungstenite::Error>
+ Unpin
+ Send
+ 'static,
{
let compat = stream
.filter_map(|item| {
let mapped = item
.map(|msg| match msg {
tungstenite::Message::Text(s) => Some(transport::WsReadMsg::Payload(tungstenite::Bytes::from(s))),
tungstenite::Message::Binary(data) => Some(transport::WsReadMsg::Payload(data)),
tungstenite::Message::Ping(_) | tungstenite::Message::Pong(_) => None,
tungstenite::Message::Close(_) => Some(transport::WsReadMsg::Close),
tungstenite::Message::Frame(_) => unreachable!("raw frames are never returned when reading"),
})
.transpose();
core::future::ready(mapped)
})
.with(|item| {
core::future::ready(Ok::<_, tungstenite::Error>(tungstenite::Message::Binary(
tungstenite::Bytes::from(item),
)))
});
transport::WsStream::new(compat)
}
+1 -1
View File
@@ -1090,7 +1090,7 @@ where
let should_upgrade = ironrdp_futures::skip_connect_begin(connector);
// At this point, proxy established the TLS session
// At this point, proxy established the TLS session.
let upgraded = ironrdp_futures::mark_as_upgraded(should_upgrade, connector);