Skip to content

Commit f390d98

Browse files
feat(dgw): route KDC traffic through agent tunnel (DGW-384)
When an agent advertises the KDC's subnet or DNS domain, route Kerberos traffic through the QUIC tunnel just like every other proxy path. This closes the last gap left after the transparent routing PR (#1741): - `/jet/KdcProxy` HTTP endpoint — `send_krb_message` now consults the routing pipeline before falling back to direct TCP. The HTTP handler has no parent association, so it mints a fresh session_id purely for agent-side log correlation. - RDP CredSSP/NLA — `rdp_proxy.rs::send_network_request` previously hard-coded `None` for the agent handle. Plumb `agent_tunnel_handle` and `session_id` from `RdpProxy` down through `perform_credssp_with_*` → `resolve_*_generator` → `send_network_request`. The same change reaches the credential-injection clean path (`rd_clean_path.rs`). `session_id` here is `session_info.id` / `claims.jet_aid` so the agent log ties KDC sub-traffic to its parent RDP session. Stack: based on #1741. Picks up `agent_tunnel::routing::try_route`. `send_krb_message` signature gains `(agent_tunnel_handle, session_id)` in that order — required `Uuid`, no `Option<>` — so the call site is honest about which UUID it's logging. The UDP scheme guard (KDC over UDP keeps going direct because the agent protocol only carries TCP) and the 64 KiB `MAX_KDC_REPLY_MESSAGE_LEN` DoS cap (and the matching generic `read_kdc_reply_message`) come along since they live in the same file and serve the same end.
1 parent 9340a82 commit f390d98

5 files changed

Lines changed: 140 additions & 18 deletions

File tree

crates/agent-tunnel/src/routing.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
//! Shared routing pipeline for agent tunnel.
22
//!
33
//! Consumed by the upstream connection paths (forwarding, RDP clean path,
4-
//! generic client) to ensure consistent routing behavior and error messages.
4+
//! generic client) and by the KDC proxy (HTTP endpoint plus the CredSSP/NLA
5+
//! sub-flow inside `rdp_proxy.rs`) to ensure consistent routing behavior and
6+
//! error messages.
57
68
use std::net::IpAddr;
79
use std::sync::Arc;

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use kdc::handle_kdc_proxy_message;
99
use picky_krb::messages::KdcProxyMessage;
1010
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1111
use tokio::net::{TcpStream, UdpSocket};
12+
use uuid::Uuid;
1213

1314
use crate::DgwState;
1415
use crate::http::{HttpError, HttpErrorBuilder};
@@ -25,6 +26,7 @@ async fn kdc_proxy(
2526
token_cache,
2627
jrl,
2728
recordings,
29+
agent_tunnel_handle,
2830
..
2931
}): State<DgwState>,
3032
extract::Path(token): extract::Path<String>,
@@ -105,7 +107,19 @@ async fn kdc_proxy(
105107
&claims.krb_kdc
106108
};
107109

108-
let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
110+
// The HTTP /jet/KdcProxy endpoint stands on its own — its token does not carry
111+
// a parent association — so we mint a fresh session_id purely for log/agent
112+
// correlation. The RDP CredSSP/NLA caller (rdp_proxy.rs::send_network_request)
113+
// passes `claims.jet_aid` instead so KDC sub-traffic correlates with its RDP session.
114+
let session_id = Uuid::new_v4();
115+
116+
let kdc_reply_message = send_krb_message(
117+
kdc_addr,
118+
&kdc_proxy_message.kerb_message.0.0,
119+
agent_tunnel_handle.as_deref(),
120+
session_id,
121+
)
122+
.await?;
109123

110124
let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message)
111125
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;
@@ -115,11 +129,33 @@ async fn kdc_proxy(
115129
kdc_reply_message.to_vec().map_err(HttpError::internal().err())
116130
}
117131

118-
async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result<Vec<u8>> {
119-
let len = connection.read_u32().await?;
120-
let mut buf = vec![0; (len + 4).try_into().expect("u32-to-usize")];
121-
buf[0..4].copy_from_slice(&(len.to_be_bytes()));
122-
connection.read_exact(&mut buf[4..]).await?;
132+
/// Hard ceiling on the announced length of a TCP-framed KDC reply.
133+
///
134+
/// The KDC TCP transport prefixes its message with a 4-byte big-endian length.
135+
/// A misbehaving (or malicious) peer can claim up to `u32::MAX` bytes, which
136+
/// without a cap would have us pre-allocate ~4 GiB on a single reply. 64 KiB
137+
/// is well above any realistic Kerberos reply size while keeping the worst
138+
/// case bounded.
139+
const MAX_KDC_REPLY_MESSAGE_LEN: u32 = 64 * 1024;
140+
141+
async fn read_kdc_reply_message<R: AsyncReadExt + Unpin>(reader: &mut R) -> io::Result<Vec<u8>> {
142+
let len = reader.read_u32().await?;
143+
144+
if len > MAX_KDC_REPLY_MESSAGE_LEN {
145+
return Err(io::Error::new(
146+
io::ErrorKind::InvalidData,
147+
format!("KDC reply too large: announced {len} bytes, maximum is {MAX_KDC_REPLY_MESSAGE_LEN}"),
148+
));
149+
}
150+
151+
let total_len = len
152+
.checked_add(4)
153+
.and_then(|n| usize::try_from(n).ok())
154+
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "KDC reply length prefix overflowed"))?;
155+
156+
let mut buf = vec![0; total_len];
157+
buf[0..4].copy_from_slice(&len.to_be_bytes());
158+
reader.read_exact(&mut buf[4..]).await?;
123159
Ok(buf)
124160
}
125161

@@ -148,7 +184,60 @@ fn unable_to_reach_kdc_server_err(error: io::Error) -> HttpError {
148184
}
149185

150186
/// Sends the Kerberos message to the specified KDC address.
151-
pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result<Vec<u8>, HttpError> {
187+
///
188+
/// Uses the same routing pipeline as connection forwarding:
189+
/// if an agent claims the KDC's domain/subnet, traffic goes through the tunnel.
190+
/// Falls back to direct connect when no agent matches.
191+
///
192+
/// `session_id` is forwarded to the agent as the QUIC stream's session ID for
193+
/// log correlation. Callers that have a parent association (RDP CredSSP) should
194+
/// pass the parent's `jet_aid`; callers with no parent (the HTTP `/jet/KdcProxy`
195+
/// endpoint) should mint a fresh `Uuid::new_v4()`.
196+
pub async fn send_krb_message(
197+
kdc_addr: &TargetAddr,
198+
message: &[u8],
199+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
200+
session_id: Uuid,
201+
) -> Result<Vec<u8>, HttpError> {
202+
// Route through agent tunnel using the SAME pipeline as connection forwarding,
203+
// but only for `tcp` KDC targets. The agent tunnel currently has a single
204+
// `ConnectRequest::tcp` shape, so a `udp://` KDC routed this way would be
205+
// delivered to the agent as a TCP target — wrong protocol semantics that can
206+
// silently break UDP Kerberos deployments. Fall through to the direct path
207+
// (which honors the scheme) until an explicit UDP tunnel hop exists.
208+
//
209+
// `as_addr()` returns `host:port` (with IPv6 brackets), which is what the agent
210+
// tunnel target parser expects — unlike `to_string()` which includes the scheme.
211+
let kdc_target = kdc_addr.as_addr();
212+
let tunnel_handle = if kdc_addr.scheme().eq_ignore_ascii_case("tcp") {
213+
agent_tunnel_handle
214+
} else {
215+
None
216+
};
217+
218+
let route_target = match kdc_addr.host_ip() {
219+
Some(ip) => agent_tunnel::routing::RouteTarget::ip(ip),
220+
None => agent_tunnel::routing::RouteTarget::hostname(kdc_addr.host()),
221+
};
222+
223+
if let Some((mut stream, _agent)) =
224+
agent_tunnel::routing::try_route(tunnel_handle, None, &route_target, session_id, kdc_target)
225+
.await
226+
.map_err(|e| HttpError::bad_gateway().build(format!("KDC routing through agent tunnel failed: {e:#}")))?
227+
{
228+
stream.write_all(message).await.map_err(
229+
HttpError::bad_gateway()
230+
.with_msg("unable to send KDC message through agent tunnel")
231+
.err(),
232+
)?;
233+
234+
return read_kdc_reply_message(&mut stream).await.map_err(
235+
HttpError::bad_gateway()
236+
.with_msg("unable to read KDC reply through agent tunnel")
237+
.err(),
238+
);
239+
}
240+
152241
let protocol = kdc_addr.scheme();
153242

154243
debug!("Connecting to KDC server located at {kdc_addr} using protocol {protocol}...");

devolutions-gateway/src/generic_client.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ where
170170
.client_stream_leftover_bytes(leftover_bytes)
171171
.server_dns_name(selected_target.host().to_owned())
172172
.disconnect_interest(disconnect_interest)
173+
.agent_tunnel_handle(agent_tunnel_handle)
173174
.build()
174175
.run()
175176
.await

devolutions-gateway/src/rd_clean_path.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,8 @@ async fn handle_with_credential_injection(
469469
client_security_protocol,
470470
&credential_mapping.proxy,
471471
krb_server_config,
472+
agent_tunnel_handle.as_deref(),
473+
claims.jet_aid,
472474
);
473475

474476
let krb_client_config = if conf.debug.enable_unstable
@@ -492,6 +494,8 @@ async fn handle_with_credential_injection(
492494
server_security_protocol,
493495
&credential_mapping.target,
494496
krb_client_config,
497+
agent_tunnel_handle.as_deref(),
498+
claims.jet_aid,
495499
);
496500

497501
let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut);

devolutions-gateway/src/rdp_proxy.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use ironrdp_pdu::{mcs, nego, x224};
1010
use secrecy::ExposeSecret as _;
1111
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
1212
use typed_builder::TypedBuilder;
13+
use uuid::Uuid;
1314

1415
use crate::api::kdc_proxy::send_krb_message;
1516
use crate::config::Conf;
@@ -33,6 +34,8 @@ pub struct RdpProxy<C, S> {
3334
subscriber_tx: SubscriberSender,
3435
server_dns_name: String,
3536
disconnect_interest: Option<DisconnectInterest>,
37+
#[builder(default)]
38+
agent_tunnel_handle: Option<Arc<agent_tunnel::AgentTunnelHandle>>,
3639
}
3740

3841
impl<A, B> RdpProxy<A, B>
@@ -64,8 +67,12 @@ where
6467
subscriber_tx,
6568
server_dns_name,
6669
disconnect_interest,
70+
agent_tunnel_handle,
6771
} = proxy;
6872

73+
// session_id used for KDC-via-tunnel correlation (see send_krb_message).
74+
let session_id = session_info.id;
75+
6976
let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?;
7077
let gateway_hostname = conf.hostname.clone();
7178

@@ -163,6 +170,8 @@ where
163170
handshake_result.client_security_protocol,
164171
&credential_mapping.proxy,
165172
krb_server_config,
173+
agent_tunnel_handle.as_deref(),
174+
session_id,
166175
);
167176

168177
let krb_client_config = if conf.debug.enable_unstable
@@ -186,6 +195,8 @@ where
186195
handshake_result.server_security_protocol,
187196
&credential_mapping.target,
188197
krb_client_config,
198+
agent_tunnel_handle.as_deref(),
199+
session_id,
189200
);
190201

191202
let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut);
@@ -393,6 +404,7 @@ where
393404
handshake_result
394405
}
395406

407+
#[expect(clippy::too_many_arguments)]
396408
#[instrument(name = "server_credssp", level = "debug", ret, skip_all)]
397409
pub(crate) async fn perform_credssp_with_server<S>(
398410
framed: &mut ironrdp_tokio::Framed<S>,
@@ -401,6 +413,8 @@ pub(crate) async fn perform_credssp_with_server<S>(
401413
security_protocol: nego::SecurityProtocol,
402414
credentials: &crate::credential::AppCredential,
403415
kerberos_config: Option<ironrdp_connector::credssp::KerberosConfig>,
416+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
417+
session_id: Uuid,
404418
) -> anyhow::Result<()>
405419
where
406420
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
@@ -433,7 +447,7 @@ where
433447
loop {
434448
let client_state = {
435449
let mut generator = sequence.process_ts_request(ts_request);
436-
resolve_client_generator(&mut generator).await?
450+
resolve_client_generator(&mut generator, agent_tunnel_handle, session_id).await?
437451
}; // drop generator
438452

439453
buf.clear();
@@ -465,13 +479,15 @@ where
465479

466480
async fn resolve_server_generator(
467481
generator: &mut CredsspServerProcessGenerator<'_>,
482+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
483+
session_id: Uuid,
468484
) -> Result<sspi::credssp::ServerState, sspi::credssp::ServerError> {
469485
let mut state = generator.start();
470486

471487
loop {
472488
match state {
473489
GeneratorState::Suspended(request) => {
474-
let response = send_network_request(&request)
490+
let response = send_network_request(&request, agent_tunnel_handle, session_id)
475491
.await
476492
.map_err(|err| sspi::credssp::ServerError {
477493
ts_request: None,
@@ -489,13 +505,15 @@ async fn resolve_server_generator(
489505

490506
async fn resolve_client_generator(
491507
generator: &mut CredsspClientProcessGenerator<'_>,
508+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
509+
session_id: Uuid,
492510
) -> anyhow::Result<sspi::credssp::ClientState> {
493511
let mut state = generator.start();
494512

495513
loop {
496514
match state {
497515
GeneratorState::Suspended(request) => {
498-
let response = send_network_request(&request).await?;
516+
let response = send_network_request(&request, agent_tunnel_handle, session_id).await?;
499517
state = generator.resume(Ok(response));
500518
}
501519
GeneratorState::Completed(client_state) => {
@@ -507,6 +525,7 @@ async fn resolve_client_generator(
507525
}
508526
}
509527

528+
#[expect(clippy::too_many_arguments)]
510529
#[instrument(name = "client_credssp", level = "debug", ret, skip_all)]
511530
pub(crate) async fn perform_credssp_with_client<S>(
512531
framed: &mut ironrdp_tokio::Framed<S>,
@@ -515,6 +534,8 @@ pub(crate) async fn perform_credssp_with_client<S>(
515534
security_protocol: nego::SecurityProtocol,
516535
credentials: &crate::credential::AppCredential,
517536
kerberos_server_config: Option<sspi::KerberosServerConfig>,
537+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
538+
session_id: Uuid,
518539
) -> anyhow::Result<()>
519540
where
520541
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
@@ -535,6 +556,8 @@ where
535556
gateway_public_key,
536557
credentials,
537558
kerberos_server_config,
559+
agent_tunnel_handle,
560+
session_id,
538561
)
539562
.await;
540563

@@ -555,13 +578,16 @@ where
555578

556579
return result;
557580

581+
#[expect(clippy::too_many_arguments)]
558582
async fn credssp_loop<S>(
559583
framed: &mut ironrdp_tokio::Framed<S>,
560584
buf: &mut ironrdp_pdu::WriteBuf,
561585
client_computer_name: ironrdp_connector::ServerName,
562586
public_key: Vec<u8>,
563587
credentials: &crate::credential::AppCredential,
564588
kerberos_server_config: Option<sspi::KerberosServerConfig>,
589+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
590+
session_id: Uuid,
565591
) -> anyhow::Result<()>
566592
where
567593
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
@@ -603,7 +629,7 @@ where
603629

604630
let result = {
605631
let mut generator = sequence.process_ts_request(ts_request);
606-
resolve_server_generator(&mut generator).await
632+
resolve_server_generator(&mut generator, agent_tunnel_handle, session_id).await
607633
}; // drop generator
608634

609635
buf.clear();
@@ -634,14 +660,14 @@ where
634660
Ok(())
635661
}
636662

637-
async fn send_network_request(request: &NetworkRequest) -> anyhow::Result<Vec<u8>> {
663+
async fn send_network_request(
664+
request: &NetworkRequest,
665+
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
666+
session_id: Uuid,
667+
) -> anyhow::Result<Vec<u8>> {
638668
let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?;
639669

640-
// TODO(DGW-384): plumb `agent_tunnel_handle` through `RdpProxy` so
641-
// CredSSP-originated Kerberos requests can traverse the agent tunnel.
642-
// Currently these go direct from the gateway host, bypassing the
643-
// routing pipeline used by every other proxy path.
644-
send_krb_message(&target_addr, &request.data)
670+
send_krb_message(&target_addr, &request.data, agent_tunnel_handle, session_id)
645671
.await
646672
.map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err))
647673
}

0 commit comments

Comments
 (0)