From f390d9865daa2bb26d7b08bc42532bfe1f0ac1e4 Mon Sep 17 00:00:00 2001 From: irving ou Date: Tue, 12 May 2026 14:05:25 -0400 Subject: [PATCH] feat(dgw): route KDC traffic through agent tunnel (DGW-384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/agent-tunnel/src/routing.rs | 4 +- devolutions-gateway/src/api/kdc_proxy.rs | 103 ++++++++++++++++++++-- devolutions-gateway/src/generic_client.rs | 1 + devolutions-gateway/src/rd_clean_path.rs | 4 + devolutions-gateway/src/rdp_proxy.rs | 46 +++++++--- 5 files changed, 140 insertions(+), 18 deletions(-) diff --git a/crates/agent-tunnel/src/routing.rs b/crates/agent-tunnel/src/routing.rs index b205fd2ae..85404ee9e 100644 --- a/crates/agent-tunnel/src/routing.rs +++ b/crates/agent-tunnel/src/routing.rs @@ -1,7 +1,9 @@ //! Shared routing pipeline for agent tunnel. //! //! Consumed by the upstream connection paths (forwarding, RDP clean path, -//! generic client) to ensure consistent routing behavior and error messages. +//! generic client) and by the KDC proxy (HTTP endpoint plus the CredSSP/NLA +//! sub-flow inside `rdp_proxy.rs`) to ensure consistent routing behavior and +//! error messages. use std::net::IpAddr; use std::sync::Arc; diff --git a/devolutions-gateway/src/api/kdc_proxy.rs b/devolutions-gateway/src/api/kdc_proxy.rs index 8847009d0..044f311c5 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -9,6 +9,7 @@ use kdc::handle_kdc_proxy_message; use picky_krb::messages::KdcProxyMessage; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; +use uuid::Uuid; use crate::DgwState; use crate::http::{HttpError, HttpErrorBuilder}; @@ -25,6 +26,7 @@ async fn kdc_proxy( token_cache, jrl, recordings, + agent_tunnel_handle, .. }): State, extract::Path(token): extract::Path, @@ -105,7 +107,19 @@ async fn kdc_proxy( &claims.krb_kdc }; - let kdc_reply_message = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; + // The HTTP /jet/KdcProxy endpoint stands on its own — its token does not carry + // a parent association — so we mint a fresh session_id purely for log/agent + // correlation. The RDP CredSSP/NLA caller (rdp_proxy.rs::send_network_request) + // passes `claims.jet_aid` instead so KDC sub-traffic correlates with its RDP session. + let session_id = Uuid::new_v4(); + + let kdc_reply_message = send_krb_message( + kdc_addr, + &kdc_proxy_message.kerb_message.0.0, + agent_tunnel_handle.as_deref(), + session_id, + ) + .await?; let kdc_reply_message = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_message) .map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?; @@ -115,11 +129,33 @@ async fn kdc_proxy( kdc_reply_message.to_vec().map_err(HttpError::internal().err()) } -async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result> { - let len = connection.read_u32().await?; - let mut buf = vec![0; (len + 4).try_into().expect("u32-to-usize")]; - buf[0..4].copy_from_slice(&(len.to_be_bytes())); - connection.read_exact(&mut buf[4..]).await?; +/// Hard ceiling on the announced length of a TCP-framed KDC reply. +/// +/// The KDC TCP transport prefixes its message with a 4-byte big-endian length. +/// A misbehaving (or malicious) peer can claim up to `u32::MAX` bytes, which +/// without a cap would have us pre-allocate ~4 GiB on a single reply. 64 KiB +/// is well above any realistic Kerberos reply size while keeping the worst +/// case bounded. +const MAX_KDC_REPLY_MESSAGE_LEN: u32 = 64 * 1024; + +async fn read_kdc_reply_message(reader: &mut R) -> io::Result> { + let len = reader.read_u32().await?; + + if len > MAX_KDC_REPLY_MESSAGE_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("KDC reply too large: announced {len} bytes, maximum is {MAX_KDC_REPLY_MESSAGE_LEN}"), + )); + } + + let total_len = len + .checked_add(4) + .and_then(|n| usize::try_from(n).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "KDC reply length prefix overflowed"))?; + + let mut buf = vec![0; total_len]; + buf[0..4].copy_from_slice(&len.to_be_bytes()); + reader.read_exact(&mut buf[4..]).await?; Ok(buf) } @@ -148,7 +184,60 @@ fn unable_to_reach_kdc_server_err(error: io::Error) -> HttpError { } /// Sends the Kerberos message to the specified KDC address. -pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result, HttpError> { +/// +/// Uses the same routing pipeline as connection forwarding: +/// if an agent claims the KDC's domain/subnet, traffic goes through the tunnel. +/// Falls back to direct connect when no agent matches. +/// +/// `session_id` is forwarded to the agent as the QUIC stream's session ID for +/// log correlation. Callers that have a parent association (RDP CredSSP) should +/// pass the parent's `jet_aid`; callers with no parent (the HTTP `/jet/KdcProxy` +/// endpoint) should mint a fresh `Uuid::new_v4()`. +pub async fn send_krb_message( + kdc_addr: &TargetAddr, + message: &[u8], + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, +) -> Result, HttpError> { + // Route through agent tunnel using the SAME pipeline as connection forwarding, + // but only for `tcp` KDC targets. The agent tunnel currently has a single + // `ConnectRequest::tcp` shape, so a `udp://` KDC routed this way would be + // delivered to the agent as a TCP target — wrong protocol semantics that can + // silently break UDP Kerberos deployments. Fall through to the direct path + // (which honors the scheme) until an explicit UDP tunnel hop exists. + // + // `as_addr()` returns `host:port` (with IPv6 brackets), which is what the agent + // tunnel target parser expects — unlike `to_string()` which includes the scheme. + let kdc_target = kdc_addr.as_addr(); + let tunnel_handle = if kdc_addr.scheme().eq_ignore_ascii_case("tcp") { + agent_tunnel_handle + } else { + None + }; + + let route_target = match kdc_addr.host_ip() { + Some(ip) => agent_tunnel::routing::RouteTarget::ip(ip), + None => agent_tunnel::routing::RouteTarget::hostname(kdc_addr.host()), + }; + + if let Some((mut stream, _agent)) = + agent_tunnel::routing::try_route(tunnel_handle, None, &route_target, session_id, kdc_target) + .await + .map_err(|e| HttpError::bad_gateway().build(format!("KDC routing through agent tunnel failed: {e:#}")))? + { + stream.write_all(message).await.map_err( + HttpError::bad_gateway() + .with_msg("unable to send KDC message through agent tunnel") + .err(), + )?; + + return read_kdc_reply_message(&mut stream).await.map_err( + HttpError::bad_gateway() + .with_msg("unable to read KDC reply through agent tunnel") + .err(), + ); + } + let protocol = kdc_addr.scheme(); debug!("Connecting to KDC server located at {kdc_addr} using protocol {protocol}..."); diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index 7b5e6c47b..acf674809 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -170,6 +170,7 @@ where .client_stream_leftover_bytes(leftover_bytes) .server_dns_name(selected_target.host().to_owned()) .disconnect_interest(disconnect_interest) + .agent_tunnel_handle(agent_tunnel_handle) .build() .run() .await diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index 41a118a02..d402523dd 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -469,6 +469,8 @@ async fn handle_with_credential_injection( client_security_protocol, &credential_mapping.proxy, krb_server_config, + agent_tunnel_handle.as_deref(), + claims.jet_aid, ); let krb_client_config = if conf.debug.enable_unstable @@ -492,6 +494,8 @@ async fn handle_with_credential_injection( server_security_protocol, &credential_mapping.target, krb_client_config, + agent_tunnel_handle.as_deref(), + claims.jet_aid, ); let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); diff --git a/devolutions-gateway/src/rdp_proxy.rs b/devolutions-gateway/src/rdp_proxy.rs index af7d5f090..dcbd81b2c 100644 --- a/devolutions-gateway/src/rdp_proxy.rs +++ b/devolutions-gateway/src/rdp_proxy.rs @@ -10,6 +10,7 @@ use ironrdp_pdu::{mcs, nego, x224}; use secrecy::ExposeSecret as _; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use typed_builder::TypedBuilder; +use uuid::Uuid; use crate::api::kdc_proxy::send_krb_message; use crate::config::Conf; @@ -33,6 +34,8 @@ pub struct RdpProxy { subscriber_tx: SubscriberSender, server_dns_name: String, disconnect_interest: Option, + #[builder(default)] + agent_tunnel_handle: Option>, } impl RdpProxy @@ -64,8 +67,12 @@ where subscriber_tx, server_dns_name, disconnect_interest, + agent_tunnel_handle, } = proxy; + // session_id used for KDC-via-tunnel correlation (see send_krb_message). + let session_id = session_info.id; + let tls_conf = conf.credssp_tls.get().context("CredSSP TLS configuration")?; let gateway_hostname = conf.hostname.clone(); @@ -163,6 +170,8 @@ where handshake_result.client_security_protocol, &credential_mapping.proxy, krb_server_config, + agent_tunnel_handle.as_deref(), + session_id, ); let krb_client_config = if conf.debug.enable_unstable @@ -186,6 +195,8 @@ where handshake_result.server_security_protocol, &credential_mapping.target, krb_client_config, + agent_tunnel_handle.as_deref(), + session_id, ); let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); @@ -393,6 +404,7 @@ where handshake_result } +#[expect(clippy::too_many_arguments)] #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] pub(crate) async fn perform_credssp_with_server( framed: &mut ironrdp_tokio::Framed, @@ -401,6 +413,8 @@ pub(crate) async fn perform_credssp_with_server( security_protocol: nego::SecurityProtocol, credentials: &crate::credential::AppCredential, kerberos_config: Option, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -433,7 +447,7 @@ where loop { let client_state = { let mut generator = sequence.process_ts_request(ts_request); - resolve_client_generator(&mut generator).await? + resolve_client_generator(&mut generator, agent_tunnel_handle, session_id).await? }; // drop generator buf.clear(); @@ -465,13 +479,15 @@ where async fn resolve_server_generator( generator: &mut CredsspServerProcessGenerator<'_>, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, ) -> Result { let mut state = generator.start(); loop { match state { GeneratorState::Suspended(request) => { - let response = send_network_request(&request) + let response = send_network_request(&request, agent_tunnel_handle, session_id) .await .map_err(|err| sspi::credssp::ServerError { ts_request: None, @@ -489,13 +505,15 @@ async fn resolve_server_generator( async fn resolve_client_generator( generator: &mut CredsspClientProcessGenerator<'_>, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, ) -> anyhow::Result { let mut state = generator.start(); loop { match state { GeneratorState::Suspended(request) => { - let response = send_network_request(&request).await?; + let response = send_network_request(&request, agent_tunnel_handle, session_id).await?; state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { @@ -507,6 +525,7 @@ async fn resolve_client_generator( } } +#[expect(clippy::too_many_arguments)] #[instrument(name = "client_credssp", level = "debug", ret, skip_all)] pub(crate) async fn perform_credssp_with_client( framed: &mut ironrdp_tokio::Framed, @@ -515,6 +534,8 @@ pub(crate) async fn perform_credssp_with_client( security_protocol: nego::SecurityProtocol, credentials: &crate::credential::AppCredential, kerberos_server_config: Option, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -535,6 +556,8 @@ where gateway_public_key, credentials, kerberos_server_config, + agent_tunnel_handle, + session_id, ) .await; @@ -555,6 +578,7 @@ where return result; + #[expect(clippy::too_many_arguments)] async fn credssp_loop( framed: &mut ironrdp_tokio::Framed, buf: &mut ironrdp_pdu::WriteBuf, @@ -562,6 +586,8 @@ where public_key: Vec, credentials: &crate::credential::AppCredential, kerberos_server_config: Option, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -603,7 +629,7 @@ where let result = { let mut generator = sequence.process_ts_request(ts_request); - resolve_server_generator(&mut generator).await + resolve_server_generator(&mut generator, agent_tunnel_handle, session_id).await }; // drop generator buf.clear(); @@ -634,14 +660,14 @@ where Ok(()) } -async fn send_network_request(request: &NetworkRequest) -> anyhow::Result> { +async fn send_network_request( + request: &NetworkRequest, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, +) -> anyhow::Result> { let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?; - // TODO(DGW-384): plumb `agent_tunnel_handle` through `RdpProxy` so - // CredSSP-originated Kerberos requests can traverse the agent tunnel. - // Currently these go direct from the gateway host, bypassing the - // routing pipeline used by every other proxy path. - send_krb_message(&target_addr, &request.data) + send_krb_message(&target_addr, &request.data, agent_tunnel_handle, session_id) .await .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) }