Skip to content

Commit 033206e

Browse files
committed
feat(dgw): proxy-based credential injection for RDP
Issue: ARC-277
1 parent ef63299 commit 033206e

File tree

11 files changed

+914
-143
lines changed

11 files changed

+914
-143
lines changed

Cargo.lock

Lines changed: 211 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

devolutions-gateway/Cargo.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" }
2626
devolutions-log = { path = "../crates/devolutions-log" }
2727
job-queue = { path = "../crates/job-queue" }
2828
job-queue-libsql = { path = "../crates/job-queue-libsql" }
29-
ironrdp-pdu = { version = "0.1", git = "https://github.com/Devolutions/IronRDP", rev = "7c268d863048d0a9182b3f7bf778668de8db4ccf", features = ["std"] }
30-
ironrdp-core = { version = "0.1", git = "https://github.com/Devolutions/IronRDP", rev = "7c268d863048d0a9182b3f7bf778668de8db4ccf", features = ["std"] }
31-
ironrdp-rdcleanpath = { version = "0.1", git = "https://github.com/Devolutions/IronRDP", rev = "7c268d863048d0a9182b3f7bf778668de8db4ccf" }
29+
ironrdp-pdu = { version = "0.5", features = ["std"] }
30+
ironrdp-core = { version = "0.1", features = ["std"] }
31+
ironrdp-rdcleanpath = "0.1"
32+
ironrdp-tokio = { version = "0.4", default-features = false }
33+
ironrdp-connector = { version = "0.5" }
34+
ironrdp-acceptor = { version = "0.5" }
3235
ceviche = "0.6.1"
3336
picky-krb = "0.9"
3437
network-scanner = { version = "0.0.0", path = "../crates/network-scanner" }
@@ -65,6 +68,7 @@ picky = { version = "7.0.0-rc.10", default-features = false, features = ["jose",
6568
zeroize = { version = "1.8", features = ["derive"] }
6669
multibase = "0.9"
6770
argon2 = { version = "0.5", features = ["std"] }
71+
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
6872

6973
# Logging
7074
tracing = "0.1"

devolutions-gateway/src/config.rs

Lines changed: 52 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -152,63 +152,59 @@ impl Conf {
152152
.any(|l| matches!(l.internal_url.scheme(), "https" | "wss"));
153153

154154
let tls = match conf_file.tls_certificate_source.unwrap_or_default() {
155-
_ if !requires_tls => {
156-
trace!("Not configured to use HTTPS, ignoring TLS configuration");
157-
None
158-
}
159-
dto::CertSource::External => {
160-
let certificate_path = conf_file
161-
.tls_certificate_file
162-
.as_ref()
163-
.context("TLS usage implied, but TLS certificate file is missing")?;
164-
165-
let (certificates, private_key) = match certificate_path.extension() {
166-
Some("pfx" | "p12") => read_pfx_file(certificate_path, conf_file.tls_private_key_password.as_ref())
167-
.context("read PFX/PKCS12 file")?,
168-
None | Some(_) => {
169-
let certificates =
170-
read_rustls_certificate_file(certificate_path).context("read TLS certificate")?;
171-
172-
let private_key = conf_file
173-
.tls_private_key_file
174-
.as_ref()
175-
.context("TLS private key file is missing")?
176-
.pipe_deref(read_rustls_priv_key_file)
177-
.context("read TLS private key")?;
178-
179-
(certificates, private_key)
180-
}
181-
};
182-
183-
let cert_source = crate::tls::CertificateSource::External {
184-
certificates,
185-
private_key,
186-
};
187-
188-
Tls::init(cert_source).context("failed to init TLS config")?.pipe(Some)
189-
}
190-
dto::CertSource::System => {
191-
let cert_subject_name = conf_file
192-
.tls_certificate_subject_name
193-
.clone()
194-
.context("TLS usage implied, but TLS certificate subject name is missing")?;
195-
196-
let store_location = conf_file.tls_certificate_store_location.unwrap_or_default();
197-
198-
let store_name = conf_file
199-
.tls_certificate_store_name
200-
.clone()
201-
.unwrap_or_else(|| String::from("My"));
155+
dto::CertSource::External => match conf_file.tls_certificate_file.as_ref() {
156+
None if requires_tls => anyhow::bail!("TLS usage implied, but TLS certificate file is missing"),
157+
None => None,
158+
Some(certificate_path) => {
159+
let (certificates, private_key) = match certificate_path.extension() {
160+
Some("pfx" | "p12") => {
161+
read_pfx_file(certificate_path, conf_file.tls_private_key_password.as_ref())
162+
.context("read PFX/PKCS12 file")?
163+
}
164+
None | Some(_) => {
165+
let certificates =
166+
read_rustls_certificate_file(certificate_path).context("read TLS certificate")?;
167+
168+
let private_key = conf_file
169+
.tls_private_key_file
170+
.as_ref()
171+
.context("TLS private key file is missing")?
172+
.pipe_deref(read_rustls_priv_key_file)
173+
.context("read TLS private key")?;
174+
175+
(certificates, private_key)
176+
}
177+
};
202178

203-
let cert_source = crate::tls::CertificateSource::SystemStore {
204-
machine_hostname: hostname.clone(),
205-
cert_subject_name,
206-
store_location,
207-
store_name,
208-
};
179+
let cert_source = crate::tls::CertificateSource::External {
180+
certificates,
181+
private_key,
182+
};
209183

210-
Tls::init(cert_source).context("failed to init TLS config")?.pipe(Some)
211-
}
184+
Tls::init(cert_source).context("failed to init TLS config")?.pipe(Some)
185+
}
186+
},
187+
dto::CertSource::System => match conf_file.tls_certificate_subject_name.clone() {
188+
None if requires_tls => anyhow::bail!("TLS usage implied, but TLS certificate subject name is missing"),
189+
None => None,
190+
Some(cert_subject_name) => {
191+
let store_location = conf_file.tls_certificate_store_location.unwrap_or_default();
192+
193+
let store_name = conf_file
194+
.tls_certificate_store_name
195+
.clone()
196+
.unwrap_or_else(|| String::from("My"));
197+
198+
let cert_source = crate::tls::CertificateSource::SystemStore {
199+
machine_hostname: hostname.clone(),
200+
cert_subject_name,
201+
store_location,
202+
store_name,
203+
};
204+
205+
Tls::init(cert_source).context("failed to init TLS config")?.pipe(Some)
206+
}
207+
},
212208
};
213209

214210
// Sanity check
@@ -1089,7 +1085,7 @@ pub mod dto {
10891085
match self {
10901086
VerbosityProfile::Default => "info",
10911087
VerbosityProfile::Debug => {
1092-
"info,devolutions_gateway=debug,devolutions_gateway::api=trace,jmux_proxy=debug,tower_http=trace,job_queue=trace,job_queue_libsql=trace"
1088+
"info,devolutions_gateway=debug,devolutions_gateway::api=trace,jmux_proxy=debug,tower_http=trace,job_queue=trace,job_queue_libsql=trace,devolutions_gateway::rdp_proxy=trace"
10931089
}
10941090
VerbosityProfile::Tls => {
10951091
"info,devolutions_gateway=debug,devolutions_gateway::tls=trace,rustls=trace,tokio_rustls=debug"

devolutions-gateway/src/credential.rs

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
use core::fmt;
22
use std::collections::HashMap;
3-
use std::str::FromStr;
43
use std::sync::Arc;
54

65
use anyhow::Context;
@@ -47,10 +46,6 @@ impl CredentialStoreHandle {
4746
pub fn get(&self, token_id: Uuid) -> Option<ArcCredentialEntry> {
4847
self.0.lock().get(token_id)
4948
}
50-
51-
pub fn remove(&self, token_id: Uuid) -> Option<ArcCredentialEntry> {
52-
self.0.lock().remove(token_id)
53-
}
5449
}
5550

5651
#[derive(Debug)]
@@ -80,15 +75,7 @@ impl CredentialStore {
8075
mapping: Option<AppCredentialMapping>,
8176
time_to_live: time::Duration,
8277
) -> anyhow::Result<Option<ArcCredentialEntry>> {
83-
use picky::jose::jws::RawJws;
84-
85-
let jws = RawJws::decode(&token)
86-
.context("failed to parse the provided JWS")?
87-
.discard_signature();
88-
let payload = serde_json::from_slice::<serde_json::Value>(&jws.payload).context("parse JWS payload")?;
89-
let jti = payload.get("jti").context("jti is missing from the token")?;
90-
let jti = jti.as_str().context("jti value is malformed")?;
91-
let jti = Uuid::from_str(jti).context("jti is not a valid UUID string")?;
78+
let jti = crate::token::extract_jti(&token).context("failed to extract token ID")?;
9279

9380
let entry = CredentialEntry {
9481
token,
@@ -104,10 +91,6 @@ impl CredentialStore {
10491
fn get(&self, token_id: Uuid) -> Option<ArcCredentialEntry> {
10592
self.entries.get(&token_id).map(Arc::clone)
10693
}
107-
108-
fn remove(&mut self, token_id: Uuid) -> Option<ArcCredentialEntry> {
109-
self.entries.remove(&token_id)
110-
}
11194
}
11295

11396
#[derive(PartialEq, Eq, Clone, zeroize::Zeroize)]

devolutions-gateway/src/generic_client.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ use tracing::field;
77
use typed_builder::TypedBuilder;
88

99
use crate::config::Conf;
10+
use crate::credential::CredentialStoreHandle;
1011
use crate::proxy::Proxy;
1112
use crate::rdp_pcb::{extract_association_claims, read_pcb};
1213
use crate::recording::ActiveRecordings;
1314
use crate::session::{ConnectionModeDetails, SessionInfo, SessionMessageSender};
1415
use crate::subscriber::SubscriberSender;
15-
use crate::token::{ConnectionMode, CurrentJrl, RecordingPolicy, TokenCache};
16+
use crate::token::{self, ConnectionMode, CurrentJrl, RecordingPolicy, TokenCache};
1617
use crate::utils;
1718

1819
#[derive(TypedBuilder)]
@@ -25,11 +26,12 @@ pub struct GenericClient<S> {
2526
sessions: SessionMessageSender,
2627
subscriber_tx: SubscriberSender,
2728
active_recordings: Arc<ActiveRecordings>,
29+
credential_store: CredentialStoreHandle,
2830
}
2931

3032
impl<S> GenericClient<S>
3133
where
32-
S: AsyncWrite + AsyncRead + Unpin,
34+
S: AsyncWrite + AsyncRead + Unpin + Send + Sync,
3335
{
3436
#[instrument(
3537
"generic_client",
@@ -46,14 +48,15 @@ where
4648
sessions,
4749
subscriber_tx,
4850
active_recordings,
51+
credential_store,
4952
} = self;
5053

5154
let span = tracing::Span::current();
5255

5356
let timeout = tokio::time::sleep(tokio::time::Duration::from_secs(10));
5457
let read_pcb_fut = read_pcb(&mut client_stream);
5558

56-
let (pdu, mut leftover_bytes) = tokio::select! {
59+
let (pcb, mut leftover_bytes) = tokio::select! {
5760
() = timeout => {
5861
info!("Timed out at preconnection blob reception");
5962
return Ok(())
@@ -69,8 +72,14 @@ where
6972
}
7073
};
7174

75+
let token = pcb.v2_payload.as_deref().context("V2 payload missing from RDP PCB")?;
76+
77+
if conf.debug.dump_tokens {
78+
debug!(token, "**DEBUG OPTION**");
79+
}
80+
7281
let source_ip = client_addr.ip();
73-
let claims = extract_association_claims(&pdu, source_ip, &conf, &token_cache, &jrl, &active_recordings)?;
82+
let claims = extract_association_claims(token, source_ip, &conf, &token_cache, &jrl, &active_recordings)?;
7483

7584
span.record("session_id", claims.jet_aid.to_string())
7685
.record("protocol", claims.jet_ap.to_string());
@@ -93,12 +102,7 @@ where
93102
trace!(%selected_target, "Connected");
94103
span.record("target", selected_target.to_string());
95104

96-
info!("TCP forwarding");
97-
98-
server_stream
99-
.write_buf(&mut leftover_bytes)
100-
.await
101-
.context("failed to write leftover bytes")?;
105+
let is_rdp = claims.jet_ap == token::ApplicationProtocol::Known(token::Protocol::Rdp);
102106

103107
let info = SessionInfo::builder()
104108
.association_id(claims.jet_aid)
@@ -111,6 +115,44 @@ where
111115
.filtering_policy(claims.jet_flt)
112116
.build();
113117

118+
// We support proxy-based credential injection for RDP.
119+
// If a credential mapping has been pushed, we automatically switch to this mode.
120+
// Otherwise, we continue the generic procedure.
121+
if is_rdp {
122+
let token_id = token::extract_jti(token).context("failed to extract jti claim from token")?;
123+
124+
if let Some(entry) = credential_store.get(token_id) {
125+
anyhow::ensure!(token == entry.token, "token mismatch");
126+
127+
// NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy.
128+
if entry.mapping.is_some() {
129+
return crate::rdp_proxy::RdpProxy::builder()
130+
.conf(conf)
131+
.session_info(info)
132+
.client_addr(client_addr)
133+
.client_stream(client_stream)
134+
.server_addr(server_addr)
135+
.server_stream(server_stream)
136+
.sessions(sessions)
137+
.subscriber_tx(subscriber_tx)
138+
.credential_entry(entry)
139+
.client_stream_leftover_bytes(leftover_bytes)
140+
.server_dns_name(selected_target.host().to_owned())
141+
.build()
142+
.run()
143+
.await
144+
.context("encountered a failure during RDP proxying (credential injection)");
145+
}
146+
}
147+
}
148+
149+
info!("TCP forwarding");
150+
151+
server_stream
152+
.write_buf(&mut leftover_bytes)
153+
.await
154+
.context("failed to write leftover bytes")?;
155+
114156
Proxy::builder()
115157
.conf(conf)
116158
.session_info(info)

devolutions-gateway/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod plugin_manager;
3030
pub mod proxy;
3131
pub mod rd_clean_path;
3232
pub mod rdp_pcb;
33+
pub mod rdp_proxy;
3334
pub mod recording;
3435
pub mod session;
3536
pub mod streaming;

devolutions-gateway/src/listener.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ async fn handle_tcp_peer(stream: TcpStream, state: DgwState, peer_addr: SocketAd
155155
.sessions(state.sessions)
156156
.subscriber_tx(state.subscriber_tx)
157157
.active_recordings(state.recordings.active_recordings)
158+
.credential_store(state.credential_store)
158159
.build()
159160
.serve()
160161
.await?;

devolutions-gateway/src/ngrok.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ async fn run_tcp_tunnel(mut tunnel: ngrok::tunnel::TcpTunnel, state: DgwState) {
233233
.sessions(state.sessions)
234234
.subscriber_tx(state.subscriber_tx)
235235
.active_recordings(state.recordings.active_recordings)
236+
.credential_store(state.credential_store)
236237
.build()
237238
.serve()
238239
.await

devolutions-gateway/src/rdp_pcb.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,13 @@ use crate::recording::ActiveRecordings;
1111
use crate::token::{AccessTokenClaims, AssociationTokenClaims, CurrentJrl, TokenCache, TokenValidator};
1212

1313
pub fn extract_association_claims(
14-
pcb: &PreconnectionBlob,
14+
token: &str,
1515
source_ip: IpAddr,
1616
conf: &Conf,
1717
token_cache: &TokenCache,
1818
jrl: &CurrentJrl,
1919
active_recordings: &ActiveRecordings,
2020
) -> anyhow::Result<AssociationTokenClaims> {
21-
let token = pcb.v2_payload.as_deref().context("V2 payload missing from RDP PCB")?;
22-
23-
if conf.debug.dump_tokens {
24-
debug!(token, "**DEBUG OPTION**");
25-
}
26-
2721
let delegation_key = conf.delegation_private_key.as_ref();
2822

2923
let claims = if conf.debug.disable_token_validation {

0 commit comments

Comments
 (0)