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)) }