feat: add MS-TSGU (Microsoft RD Gateway) support (#913)

This adds a working state to connect with the ironrdp-client CLI against
a server behind a microsoft remote desktop gateway.
During my testing this was robust enough to work with sessions for more
than 30 minutes.

CLI Flags and prompts are implemented and can be mixed, so the following
would prompt only for 2 passwords:
> ironrdp-client --gw-user username@domain --gw-endpoint rdp.gw.host:443
-u username@domain rdp.internal.host

[MS-TSGU]
https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsgu/0007d661-a86d-4e8f-89f7-7f77f8824188
* This implements a MVP (in terms of recentness) state needed to connect
through microsoft rdp gateway.
* This only supports the HTTPS protocol with Websocket (and not the
legacy HTTP, HTTP-RPC or UDP protocols).
* This does not implement reconnection/reauthentication.
* This only supports basic auth.

Mostly looking for rough initial feedback (e.g. in terms of if there are
parts that dont align with projects architecture or other areas needing
major rework) as well as if the implemented scope would be considered
complete enough to land this in the first place.
This commit is contained in:
Steffen Butzer
2025-08-18 06:11:24 -04:00
committed by GitHub
parent cd184d30bd
commit 7d28ef83a6
7 changed files with 1240 additions and 49 deletions
Generated
+121 -34
View File
@@ -1510,7 +1510,7 @@ dependencies = [
"embed-resource",
"ironrdp",
"ironrdp-cliprdr-native",
"ironrdp-core",
"ironrdp-core 0.1.5",
"sspi",
"thiserror 2.0.14",
"tracing",
@@ -2332,7 +2332,7 @@ dependencies = [
"ironrdp-cliprdr",
"ironrdp-cliprdr-native",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-graphics",
@@ -2359,7 +2359,7 @@ version = "0.6.0"
dependencies = [
"ironrdp-async",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
@@ -2370,7 +2370,7 @@ name = "ironrdp-ainput"
version = "0.3.0"
dependencies = [
"bitflags 2.9.1",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-dvc",
"num-derive",
"num-traits",
@@ -2382,7 +2382,7 @@ version = "0.6.0"
dependencies = [
"bytes",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"tracing",
]
@@ -2403,7 +2403,7 @@ version = "0.6.0"
dependencies = [
"bytes",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"tracing",
]
@@ -2426,13 +2426,14 @@ dependencies = [
"ironrdp",
"ironrdp-cfg",
"ironrdp-cliprdr-native",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-dvc-pipe-proxy",
"ironrdp-mstsgu",
"ironrdp-propertyset",
"ironrdp-rdcleanpath",
"ironrdp-rdpfile",
"ironrdp-rdpsnd-native",
"ironrdp-tls",
"ironrdp-tls 0.1.3",
"ironrdp-tokio",
"proc-exit",
"raw-window-handle",
@@ -2441,7 +2442,8 @@ dependencies = [
"softbuffer",
"tap",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.27.0",
"tokio-util",
"tracing",
"tracing-subscriber",
"transport",
@@ -2458,7 +2460,7 @@ name = "ironrdp-cliprdr"
version = "0.3.0"
dependencies = [
"bitflags 2.9.1",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
@@ -2468,7 +2470,7 @@ dependencies = [
name = "ironrdp-cliprdr-format"
version = "0.1.3"
dependencies = [
"ironrdp-core",
"ironrdp-core 0.1.5",
"png",
]
@@ -2477,7 +2479,7 @@ name = "ironrdp-cliprdr-native"
version = "0.3.0"
dependencies = [
"ironrdp-cliprdr",
"ironrdp-core",
"ironrdp-core 0.1.5",
"tracing",
"windows 0.61.3",
]
@@ -2487,8 +2489,8 @@ name = "ironrdp-connector"
version = "0.6.0"
dependencies = [
"arbitrary",
"ironrdp-core",
"ironrdp-error",
"ironrdp-core 0.1.5",
"ironrdp-error 0.1.3",
"ironrdp-pdu",
"ironrdp-svc",
"picky",
@@ -2504,14 +2506,23 @@ dependencies = [
name = "ironrdp-core"
version = "0.1.5"
dependencies = [
"ironrdp-error",
"ironrdp-error 0.1.3",
]
[[package]]
name = "ironrdp-core"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2db60a59716a84d09040d29c9e75e81545842510fccb0934c09b28e78b46680"
dependencies = [
"ironrdp-error 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "ironrdp-displaycontrol"
version = "0.3.0"
dependencies = [
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-dvc",
"ironrdp-pdu",
"ironrdp-svc",
@@ -2522,7 +2533,7 @@ dependencies = [
name = "ironrdp-dvc"
version = "0.3.1"
dependencies = [
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"ironrdp-svc",
"slab",
@@ -2534,7 +2545,7 @@ name = "ironrdp-dvc-pipe-proxy"
version = "0.1.0"
dependencies = [
"async-trait",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-dvc",
"ironrdp-pdu",
"ironrdp-svc",
@@ -2546,6 +2557,12 @@ dependencies = [
name = "ironrdp-error"
version = "0.1.3"
[[package]]
name = "ironrdp-error"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a9d7794e854eef2f13fdf79c8502bcc567a75a15fd0522885f37739386a4cef"
[[package]]
name = "ironrdp-futures"
version = "0.4.0"
@@ -2562,7 +2579,7 @@ dependencies = [
"arbitrary",
"ironrdp-cliprdr",
"ironrdp-cliprdr-format",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-displaycontrol",
"ironrdp-graphics",
"ironrdp-pdu",
@@ -2582,7 +2599,7 @@ dependencies = [
"bytemuck",
"byteorder",
"expect-test",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"lazy_static",
"num-derive",
@@ -2599,6 +2616,26 @@ dependencies = [
"smallvec",
]
[[package]]
name = "ironrdp-mstsgu"
version = "0.0.1"
dependencies = [
"base64",
"bitflags 2.9.1",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
"ironrdp-core 0.1.5 (registry+https://github.com/rust-lang/crates.io-index)",
"ironrdp-error 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
"ironrdp-tls 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
"log",
"tokio",
"tokio-tungstenite 0.26.2",
"tokio-util",
"uuid",
]
[[package]]
name = "ironrdp-pdu"
version = "0.5.0"
@@ -2608,8 +2645,8 @@ dependencies = [
"byteorder",
"der-parser",
"expect-test",
"ironrdp-core",
"ironrdp-error",
"ironrdp-core 0.1.5",
"ironrdp-error 0.1.3",
"lazy_static",
"md-5",
"num-bigint",
@@ -2646,8 +2683,8 @@ name = "ironrdp-rdpdr"
version = "0.3.0"
dependencies = [
"bitflags 2.9.1",
"ironrdp-core",
"ironrdp-error",
"ironrdp-core 0.1.5",
"ironrdp-error 0.1.3",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
@@ -2657,7 +2694,7 @@ dependencies = [
name = "ironrdp-rdpdr-native"
version = "0.3.0"
dependencies = [
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"ironrdp-rdpdr",
"ironrdp-svc",
@@ -2677,7 +2714,7 @@ name = "ironrdp-rdpsnd"
version = "0.5.0"
dependencies = [
"bitflags 2.9.1",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
@@ -2707,7 +2744,7 @@ dependencies = [
"ironrdp-ainput",
"ironrdp-async",
"ironrdp-cliprdr",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-graphics",
@@ -2731,10 +2768,10 @@ name = "ironrdp-session"
version = "0.5.0"
dependencies = [
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-error",
"ironrdp-error 0.1.3",
"ironrdp-graphics",
"ironrdp-pdu",
"ironrdp-svc",
@@ -2752,7 +2789,7 @@ name = "ironrdp-svc"
version = "0.4.1"
dependencies = [
"bitflags 2.9.1",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-pdu",
]
@@ -2767,7 +2804,7 @@ dependencies = [
"ironrdp-cliprdr",
"ironrdp-cliprdr-format",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-displaycontrol",
"ironrdp-dvc",
"ironrdp-fuzzing",
@@ -2796,7 +2833,7 @@ dependencies = [
"async-trait",
"ironrdp",
"ironrdp-async",
"ironrdp-tls",
"ironrdp-tls 0.1.3",
"ironrdp-tokio",
"semver",
"tokio",
@@ -2814,6 +2851,18 @@ dependencies = [
"x509-cert",
]
[[package]]
name = "ironrdp-tls"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc807c143533f41e19bf323e8c7f78995953ce8427d37220dde53906cbd48a3"
dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
"x509-cert",
]
[[package]]
name = "ironrdp-tokio"
version = "0.6.0"
@@ -2843,7 +2892,7 @@ dependencies = [
"iron-remote-desktop",
"ironrdp",
"ironrdp-cliprdr-format",
"ironrdp-core",
"ironrdp-core 0.1.5",
"ironrdp-futures",
"ironrdp-propertyset",
"ironrdp-rdcleanpath",
@@ -5474,6 +5523,24 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
dependencies = [
"futures-util",
"log",
"native-tls",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tungstenite 0.26.2",
]
[[package]]
name = "tokio-tungstenite"
version = "0.27.0"
@@ -5489,7 +5556,7 @@ dependencies = [
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tungstenite",
"tungstenite 0.27.0",
]
[[package]]
@@ -5709,6 +5776,26 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"native-tls",
"rand 0.9.2",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.12",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.27.0"
+4 -2
View File
@@ -25,8 +25,8 @@ test = false
[features]
default = ["rustls"]
rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots"]
native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls"]
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"]
@@ -48,6 +48,7 @@ ironrdp-core = { path = "../ironrdp-core", version = "0.1", features = ["alloc"]
ironrdp-cliprdr-native = { path = "../ironrdp-cliprdr-native", version = "0.3" }
ironrdp-rdpsnd-native = { path = "../ironrdp-rdpsnd-native", version = "0.3" }
ironrdp-tls = { path = "../ironrdp-tls", version = "0.1" }
ironrdp-mstsgu = { path = "../ironrdp-mstsgu" }
ironrdp-tokio = { path = "../ironrdp-tokio", version = "0.6", features = ["reqwest"] }
ironrdp-rdcleanpath.path = "../ironrdp-rdcleanpath"
ironrdp-dvc-pipe-proxy.path = "../ironrdp-dvc-pipe-proxy"
@@ -70,6 +71,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Async, futures
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7" }
tokio-tungstenite = "0.27"
transport = { git = "https://github.com/Devolutions/devolutions-gateway", rev = "06e91dfe82751a6502eaf74b6a99663f06f0236d" }
futures-util = { version = "0.3", features = ["sink"] }
+47 -4
View File
@@ -10,6 +10,7 @@ use clap::Parser;
use ironrdp::connector::{self, Credentials};
use ironrdp::pdu::rdp::capability_sets::{client_codecs_capabilities, MajorPlatformType};
use ironrdp::pdu::rdp::client_info::PerformanceFlags;
use ironrdp_mstsgu::GwConnectTarget;
use tap::prelude::*;
use url::Url;
@@ -19,6 +20,7 @@ const DEFAULT_HEIGHT: u16 = 1080;
#[derive(Clone, Debug)]
pub struct Config {
pub log_file: Option<String>,
pub gw: Option<GwConnectTarget>,
pub destination: Destination,
pub connector: connector::Config,
pub clipboard_type: ClipboardType,
@@ -178,6 +180,17 @@ impl FromStr for DvcProxyInfo {
#[clap(author = "Devolutions", about = "Devolutions-IronRDP client")]
#[clap(version, long_about = None)]
struct Args {
/// A file with IronRDP client logs
#[clap(short, long, value_parser)]
log_file: Option<String>,
#[clap(long, value_parser)]
gw_endpoint: Option<String>,
#[clap(long, value_parser)]
gw_user: Option<String>,
#[clap(long, value_parser)]
gw_pass: Option<String>,
/// An address on which the client will connect.
destination: Option<Destination>,
@@ -185,10 +198,6 @@ struct Args {
#[clap(long)]
rdp_file: Option<PathBuf>,
/// A file with IronRDP client logs
#[clap(short, long)]
log_file: Option<String>,
/// A target RDP server user name
#[clap(short, long)]
username: Option<String>,
@@ -310,6 +319,35 @@ impl Config {
}
}
let mut gw: Option<GwConnectTarget> = None;
if let Some(gw_addr) = args.gw_endpoint {
gw = Some(GwConnectTarget {
gw_endpoint: gw_addr,
gw_user: String::new(),
gw_pass: String::new(),
server: String::new(), // TODO non-standard port? also dont use here?
});
}
if let Some(ref mut gw) = gw {
gw.gw_user = if let Some(gw_user) = args.gw_user {
gw_user
} else {
inquire::Text::new("Gateway username:")
.prompt()
.context("Username prompt")?
};
gw.gw_pass = if let Some(gw_pass) = args.gw_pass {
gw_pass
} else {
inquire::Password::new("Gateway password:")
.without_confirmation()
.prompt()
.context("Password prompt")?
};
};
let destination = if let Some(destination) = args.destination {
destination
} else if let Some(destination) = properties.full_address() {
@@ -326,6 +364,10 @@ impl Config {
.pipe(Destination::new)?
};
if let Some(ref mut gw) = gw {
gw.server = destination.name.clone(); // TODO
}
let username = if let Some(username) = args.username {
username
} else if let Some(username) = properties.username() {
@@ -430,6 +472,7 @@ impl Config {
Ok(Self {
log_file: args.log_file,
gw,
destination,
connector,
clipboard_type,
+15 -9
View File
@@ -171,15 +171,21 @@ async fn connect(
) -> ConnectorResult<(ConnectionResult, UpgradedFramed)> {
let dest = format!("{}:{}", config.destination.name(), config.destination.port());
let socket = TcpStream::connect(dest)
.await
.map_err(|e| connector::custom_err!("TCP connect", e))?;
let client_addr = socket
.local_addr()
.map_err(|e| connector::custom_err!("get socket local address", e))?;
let mut framed = ironrdp_tokio::TokioFramed::new(socket);
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().with_dynamic_channel(DisplayControlClient::new(|_| Ok(Vec::new())));
+40
View File
@@ -0,0 +1,40 @@
[package]
name = "ironrdp-mstsgu"
version = "0.0.1"
readme = "README.md"
description = "Terminal Services Gateway Server Protocol"
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
[features]
default = []
rustls = ["ironrdp-tls/rustls", "tokio-tungstenite/rustls-tls-native-roots"]
native-tls = ["ironrdp-tls/native-tls", "tokio-tungstenite/native-tls"]
[dependencies]
bitflags = "2.9"
ironrdp-core = { version = "0.1", features = ["std"] }
ironrdp-error = { version = "0.1" }
tokio = { version = "1.43", features = ["macros", "rt"] }
tokio-util = { version = "0.7" }
tokio-tungstenite = { version = "0.26" }
ironrdp-tls = { "version" = "0.1.3" }
hyper = { version = "1.6", features = ["client", "http1"] }
hyper-util = { version = "0.1", features = ["tokio"] }
http-body-util = { version = "0.1" }
futures-util = "0.3"
log = "0.4"
base64 = "0.22"
uuid = { version = "1.16.0", features = ["v4"] }
[lints]
workspace = true
+466
View File
@@ -0,0 +1,466 @@
//! [MS-TSGU] https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-tsgu/0007d661-a86d-4e8f-89f7-7f77f8824188
//! * This implements a MVP (in terms of recentness) state needed to connect through microsoft rdp gateway.
//! * This only supports the HTTPS protocol with Websocket (and not the legacy HTTP, HTTP-RPC or UDP protocols).
//! * This does not implement reconnection/reauthentication.
//! * This only supports basic auth.
use core::pin::Pin;
use core::time::Duration;
use core::{fmt, fmt::Display, task::Poll};
use std::io;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use futures_util::{
stream::{SplitSink, SplitStream},
FutureExt as _, SinkExt as _, StreamExt as _,
};
use hyper::body::Bytes;
use ironrdp_core::{Decode as _, Encode, ReadCursor, WriteCursor};
use ironrdp_tls::TlsStream;
use log::{error, warn};
use tokio::{
io::{AsyncRead, AsyncWrite},
net::TcpStream,
sync::oneshot,
};
use tokio_tungstenite::{
tungstenite::{
handshake::client::generate_key,
http::{self},
protocol::Role,
Message,
},
WebSocketStream,
};
mod proto;
use proto::*;
use tokio_util::sync::PollSender;
#[derive(Clone, Debug)]
pub struct GwConnectTarget {
pub gw_endpoint: String,
pub gw_user: String,
pub gw_pass: String,
pub server: String,
}
type Error = ironrdp_error::Error<GwErrorKind>;
#[derive(Debug)]
#[non_exhaustive]
pub enum GwErrorKind {
InvalidGwTarget,
Connect,
PacketEOF,
UnsupportedFeature,
Custom,
Decode,
}
trait GwErrorExt {
fn custom<E>(context: &'static str, e: E) -> Self
where
E: core::error::Error + Sync + Send + 'static;
}
impl GwErrorExt for ironrdp_error::Error<GwErrorKind> {
fn custom<E>(context: &'static str, e: E) -> Self
where
E: core::error::Error + Sync + Send + 'static,
{
Self::new(context, GwErrorKind::Custom).with_source(e)
}
}
impl Display for GwErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let x = match self {
GwErrorKind::InvalidGwTarget => "Invalid GW Target",
GwErrorKind::Connect => "Connection error",
GwErrorKind::PacketEOF => "PacketEOF",
GwErrorKind::UnsupportedFeature => "Unsupported feature",
GwErrorKind::Custom => "Custom",
GwErrorKind::Decode => "Decode",
};
f.write_str(x)
}
}
impl core::error::Error for GwErrorKind {}
/// Creates a `ConnectorError` with `Custom` kind and a source error attached to it
#[macro_export]
macro_rules! custom_err {
( $context:expr, $source:expr $(,)? ) => {{
<$crate::Error as $crate::GwErrorExt>::custom($context, $source)
}};
}
struct GwConn {
client_name: String,
target: GwConnectTarget,
ws_sink: SplitSink<WebSocketStream<TlsStream<TcpStream>>, Message>,
ws_stream: SplitStream<WebSocketStream<TlsStream<TcpStream>>>,
}
pub struct GwClient {
work: tokio::task::JoinHandle<Result<(), Error>>,
rx: tokio::sync::mpsc::Receiver<Bytes>,
rx_bufs: Vec<Bytes>,
tx: PollSender<Bytes>,
}
impl Drop for GwClient {
fn drop(&mut self) {
self.work.abort();
}
}
impl GwClient {
pub async fn connect(
target: &GwConnectTarget,
client_name: &str,
) -> Result<(GwClient, core::net::SocketAddr), Error> {
let gw_host = target
.gw_endpoint
.split(":")
.nth(0)
.ok_or_else(|| Error::new("Connect", GwErrorKind::InvalidGwTarget))?;
let stream = TcpStream::connect(&target.gw_endpoint)
.await
.map_err(|e| custom_err!("TCP connect", e))?;
let client_addr = stream
.local_addr()
.map_err(|e| custom_err!("get socket local address", e))?;
let (stream, _) = ironrdp_tls::upgrade(stream, gw_host)
.await
.map_err(|e| custom_err!("TLS connect", e))?;
let auth_val: String = STANDARD.encode(format!("{}:{}", target.gw_user, target.gw_pass));
let req = http::Request::builder()
.method("RDG_OUT_DATA")
.header(hyper::header::HOST, gw_host)
.header("Rdg-Connection-Id", format!("{{{}}}", uuid::Uuid::new_v4()))
.uri("/remoteDesktopGateway/")
.header(hyper::header::AUTHORIZATION, format!("Basic {auth_val}"))
.header(hyper::header::CONNECTION, "Upgrade")
.header(hyper::header::UPGRADE, "websocket")
.header(hyper::header::SEC_WEBSOCKET_VERSION, "13")
.header(hyper::header::SEC_WEBSOCKET_KEY, generate_key())
.body(http_body_util::Empty::<Bytes>::new())
.expect("Failed to build request");
let stream = hyper_util::rt::tokio::TokioIo::new(stream);
let (mut sender, mut conn) = hyper::client::conn::http1::handshake(stream)
.await
.map_err(|e| custom_err!("H1 Handshake", e))?;
let (tx, rx) = oneshot::channel();
let jh = tokio::task::spawn(async move {
tokio::select! {
Err(e) = &mut conn => error!("Handshake error: {:?}", e),
_ = rx => (),
}
conn.into_parts()
});
let resp = sender
.send_request(req)
.await
.map_err(|e| custom_err!("WS Upgrade Send error", e))?;
if resp.status() != http::StatusCode::SWITCHING_PROTOCOLS {
return Err(Error::new("WS Upgrade", GwErrorKind::Connect));
}
let _ = tx.send(()); // TODO: Not needed since it doesnt keep alive conn?
let stream = jh.await.map_err(|e| custom_err!("WS join", e))?.io.into_inner();
Self::connect_ws(target.clone(), client_name, stream)
.await
.map(|x| (x, client_addr))
}
async fn connect_ws(
target: GwConnectTarget,
client_name: &str,
tls_stream: TlsStream<TcpStream>,
) -> Result<GwClient, Error> {
let ws_stream: WebSocketStream<_> = WebSocketStream::from_raw_socket(tls_stream, Role::Client, None).await;
let (ws_sink, ws_stream) = ws_stream.split();
let mut gw = GwConn {
client_name: client_name.to_owned(),
target,
ws_sink,
ws_stream,
};
gw.handshake().await?;
gw.tunnel().await?;
gw.tunnel_auth().await?;
gw.channel().await?;
let (in_tx, in_rx) = tokio::sync::mpsc::channel(4);
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<Bytes>(4);
let work = tokio::spawn(async move {
let iv = Duration::from_secs(15 * 60);
let mut keepalive_interval: tokio::time::Interval =
tokio::time::interval_at(tokio::time::Instant::now() + iv, iv);
loop {
let mut wsbuf = [0u8; 8192];
tokio::select!(
_ = keepalive_interval.tick() => {
let pos = {
let mut cur = WriteCursor::new(&mut wsbuf);
KeepalivePkt.encode(&mut cur).map_err(|e| custom_err!("PktEncode", e))?;
cur.pos()
};
gw.ws_sink.send(Message::Binary(Bytes::copy_from_slice(&wsbuf[..pos]))).await.map_err(|e| custom_err!("ws send", e))?;
},
next = gw.ws_stream.next() => {
let tmp = next.ok_or_else(|| Error::new("WS Stream Dead", GwErrorKind::Connect))?;
let msg = tmp.map_err(|e| custom_err!("Stream", e))?.into_data();
let mut cur = ReadCursor::new(&msg);
let hdr = PktHdr::decode(&mut cur).map_err(|e| custom_err!("Header Decode", e))?;
assert!(cur.len() >= hdr.length as usize - hdr.size());
match hdr.ty {
PktTy::Keepalive => {
continue;
},
PktTy::Data => {
let p = DataPkt::decode(&mut cur).map_err(|e| custom_err!("PktDecode", e))?;
in_tx.send(Bytes::from(p.data.to_vec())).await.map_err(|e| custom_err!("in_tx dead", e))?;
},
x => {
warn!("Unhandled gw packet type {x:?}");
}
}
},
next = out_rx.recv() => {
let next = next.ok_or_else(|| Error::new("WS Sink Dead", GwErrorKind::Connect))?;
let pkt = DataPkt { data: &next };
let pos = {
let mut cur = WriteCursor::new(&mut wsbuf);
pkt.encode(&mut cur).map_err(|e| custom_err!("PktEncode", e))?;
cur.pos()
};
gw.ws_sink.send(Message::Binary(Bytes::copy_from_slice(&wsbuf[..pos]))).await.map_err(|e| custom_err!("ws send", e))?;
}
);
}
});
Ok(GwClient {
work,
rx: in_rx,
rx_bufs: vec![],
tx: PollSender::new(out_tx),
})
}
}
impl GwConn {
async fn send_packet<E: Encode>(&mut self, payload: &E) -> Result<(), Error> {
let mut buf = [0u8; 4096];
let pos = {
let mut cur = WriteCursor::new(&mut buf);
payload.encode(&mut cur).unwrap();
cur.pos()
};
self.ws_sink
.send(Message::Binary(Bytes::copy_from_slice(&buf[..pos])))
.await
.map_err(|e| custom_err!("WS Send error", e))?;
Ok(())
}
async fn read_packet(&mut self) -> Result<(PktHdr, Bytes), Error> {
let mut msg = self
.ws_stream
.next()
.await
.ok_or_else(|| Error::new("Stream closed", GwErrorKind::Connect))?
.map_err(|e| custom_err!("WS err", e))?
.into_data();
let mut cur = ReadCursor::new(&msg);
let hdr = PktHdr::decode(&mut cur).map_err(|_| Error::new("PktHdr", GwErrorKind::Decode))?;
if cur.len() != hdr.length as usize - hdr.size() {
return Err(Error::new("read_packet", GwErrorKind::PacketEOF));
}
Ok((hdr, msg.split_off(cur.pos())))
}
async fn handshake(&mut self) -> Result<(), Error> {
// For NTLM we would include extended_auth: NTLM_SSPI in this handshake req here.
let hs = HandshakeReqPkt {
ver_major: 1,
ver_minor: 0,
..HandshakeReqPkt::default()
};
self.send_packet(&hs).await?;
let (_hdr, bytes) = self.read_packet().await?;
let mut cur = ReadCursor::new(&bytes);
let resp = HandshakeRespPkt::decode(&mut cur).map_err(|_| Error::new("Handshake", GwErrorKind::Decode))?;
if resp.error_code != 0 || resp.ver_major != 1 || resp.ver_minor != 0 || resp.server_version != 0 {
return Err(Error::new("Handshake", GwErrorKind::Connect));
}
Ok(())
}
async fn tunnel(&mut self) -> Result<(), Error> {
let req = TunnelReqPkt {
// Havent seen any server working without this.
caps: HttpCapsTy::MessagingConsentSign as u32,
fields_present: 0,
..TunnelReqPkt::default()
};
self.send_packet(&req).await?;
let (_hdr, bytes) = self.read_packet().await?;
let mut cur = ReadCursor::new(&bytes);
let resp = TunnelRespPkt::decode(&mut cur).map_err(|_| Error::new("TunnelDecode", GwErrorKind::Decode))?;
if resp.status_code != 0 {
return Err(Error::new("Tunnel", GwErrorKind::Connect));
}
assert!(cur.eof());
if !resp.consent_msg.is_empty() {
return Err(Error::new(
"Received consent message but showing it not implemented",
GwErrorKind::UnsupportedFeature,
));
}
Ok(())
}
async fn tunnel_auth(&mut self) -> Result<(), Error> {
let req = TunnelAuthPkt {
fields_present: 0,
client_name: self.client_name.clone(),
};
self.send_packet(&req).await?;
let (_hdr, bytes) = self.read_packet().await?;
let mut cur = ReadCursor::new(&bytes);
let resp: TunnelAuthRespPkt =
TunnelAuthRespPkt::decode(&mut cur).map_err(|_| Error::new("TunnelAuth", GwErrorKind::Decode))?;
if resp.error_code != 0 {
return Err(Error::new("TunnelAuth", GwErrorKind::Connect));
}
Ok(())
}
async fn channel(&mut self) -> Result<ChannelResp, Error> {
let req = ChannelPkt {
resources: vec![self.target.server.clone()],
port: 3389,
protocol: 3,
};
self.send_packet(&req).await?;
let (hdr, bytes) = self.read_packet().await?;
assert!(hdr.ty == PktTy::ChannelResp);
let mut cur: ReadCursor<'_> = ReadCursor::new(&bytes);
let resp: ChannelResp =
ChannelResp::decode(&mut cur).map_err(|_| Error::new("ChannelResp", GwErrorKind::Decode))?;
if resp.error_code != 0 {
return Err(Error::new("ChannelCreate", GwErrorKind::Connect));
}
assert!(cur.eof());
Ok(resp)
}
}
impl AsyncRead for GwClient {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut core::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<io::Result<()>> {
// Propagate error or premature exit (?)
match self.work.poll_unpin(cx) {
Poll::Ready(Err(e)) => return Poll::Ready(Err(io::Error::other(e))),
Poll::Ready(Ok(Err(e))) => return Poll::Ready(Err(io::Error::other(e))),
Poll::Ready(_) => return Poll::Ready(Err(io::Error::other("Premature Work Task end?"))),
_ => (),
}
// Get new bufs
if let Poll::Ready(Some(new_buf)) = self.rx.poll_recv(cx) {
self.rx_bufs.push(new_buf);
}
// Read from all queued bufs
let mut n = 0;
self.rx_bufs.retain_mut(|rx_buf| {
let rem = buf.remaining();
if rem == 0 {
return true;
}
let max = core::cmp::min(rem, rx_buf.len());
buf.put_slice(&rx_buf[..max]);
n += max;
let _ = rx_buf.split_to(max);
!rx_buf.is_empty()
});
if n > 0 {
Poll::Ready(Ok(()))
} else {
Poll::Pending
}
}
}
impl AsyncWrite for GwClient {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut core::task::Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
// Propagate error or premature exit (?)
match self.work.poll_unpin(cx) {
Poll::Ready(Err(e)) => return Poll::Ready(Err(io::Error::other(e))),
Poll::Ready(Ok(Err(e))) => return Poll::Ready(Err(io::Error::other(e))),
Poll::Ready(_) => return Poll::Ready(Err(io::Error::other("Premature Work Task end?"))),
Poll::Pending => (),
}
match self.tx.poll_reserve(cx) {
Poll::Ready(Ok(())) => {
if self.tx.send_item(Bytes::from(buf.to_vec())).is_err() {
return Poll::Ready(Err(io::Error::other("Sender closed")));
}
return Poll::Ready(Ok(buf.len()));
}
Poll::Ready(Err(err)) => {
return Poll::Ready(Err(io::Error::other(err)));
}
Poll::Pending => (),
}
Poll::Pending
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut core::task::Context<'_>) -> Poll<Result<(), io::Error>> {
// TODO: call flush on the backing sink (e.g. websocket, but atleast for that backend doesnt seem to matter)?
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut core::task::Context<'_>) -> Poll<Result<(), io::Error>> {
Poll::Ready(Ok(()))
}
}
File diff suppressed because it is too large Load Diff