From b7f247355511902d6cf62dd0612cf5747e0018fe 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). Two paths now use the same routing pipeline as connection forwarding: - `/jet/KdcProxy` HTTP endpoint — `send_krb_message` consults the routing pipeline before falling back to direct TCP. - RDP CredSSP/NLA — `rdp_proxy.rs::send_network_request` previously hard-coded `None` for the agent handle. Plumb `agent_tunnel_handle`, `session_id`, and `explicit_agent_id` from `RdpProxy` down through `perform_credssp_as_*` -> `resolve_*_generator` -> `send_network_request`. The same change reaches the credential-injection clean path (`rd_clean_path.rs`). Session correlation: - RDP CredSSP callers pass the parent association's `jet_aid` so KDC sub-traffic ties back to its parent RDP session in agent-side logs. - The HTTP `/jet/KdcProxy` handler passes the KDC token's own `jti` (the most persistent identifier available without a parent association). `KdcToken` now carries `jti` alongside the claims for this purpose. Explicit-agent routing (matches every other proxy path): - `send_krb_message` takes `explicit_agent_id: Option` and forwards it to `agent_tunnel::routing::try_route`. When the parent association pins `jet_agent_id`, the KDC sub-traffic is routed via that agent or fails -- never silently falls back to a different agent or to direct connect. The HTTP handler passes `None`. Hardening (came along since they live in the same file): - 64 KiB `MAX_KDC_REPLY_MESSAGE_LEN` DoS cap on the announced TCP-framed KDC reply length, with overflow-safe length math. - UDP scheme guard: KDC over UDP keeps going direct because the agent tunnel only carries TCP today. Drive-by: `crates/agent-tunnel/src/listener.rs` move-after-move on `ca_manager` introduced by #1775 -- fixed with `Arc::clone` to keep master building on `--no-default-features` configurations. Stack: based on #1741. Picks up `agent_tunnel::routing::try_route`. Issue: DGW-384 --- crates/agent-tunnel/src/listener.rs | 2 +- crates/agent-tunnel/src/routing.rs | 4 +- devolutions-gateway/src/api/kdc_proxy.rs | 131 ++++++++++++++++++++-- devolutions-gateway/src/extract.rs | 15 ++- devolutions-gateway/src/generic_client.rs | 2 + devolutions-gateway/src/rd_clean_path.rs | 6 + devolutions-gateway/src/rdp_proxy.rs | 81 +++++++++++-- 7 files changed, 213 insertions(+), 28 deletions(-) diff --git a/crates/agent-tunnel/src/listener.rs b/crates/agent-tunnel/src/listener.rs index e07a71a40..820ac6f49 100644 --- a/crates/agent-tunnel/src/listener.rs +++ b/crates/agent-tunnel/src/listener.rs @@ -152,7 +152,7 @@ impl AgentTunnelListener { let handle = AgentTunnelHandle { registry: Arc::clone(®istry), agent_connections: Arc::clone(&agent_connections), - ca_manager, + ca_manager: Arc::clone(&ca_manager), }; let listener = Self { 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 df5a09c5c..28c154ca8 100644 --- a/devolutions-gateway/src/api/kdc_proxy.rs +++ b/devolutions-gateway/src/api/kdc_proxy.rs @@ -7,6 +7,7 @@ use axum::routing::post; use picky_krb::messages::KdcProxyMessage; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpStream, UdpSocket}; +use uuid::Uuid; use crate::DgwState; use crate::credential_injection_kdc::{ @@ -26,9 +27,13 @@ async fn kdc_proxy( State(DgwState { conf_handle, credentials, + agent_tunnel_handle, .. }): State, - KdcToken(KdcTokenClaims { destination }): KdcToken, + KdcToken { + claims: KdcTokenClaims { destination }, + jti: token_jti, + }: KdcToken, body: axum::body::Bytes, ) -> Result, HttpError> { let conf = conf_handle.get_conf(); @@ -77,6 +82,8 @@ async fn kdc_proxy( &krb_kdc, conf.debug.override_kdc.as_ref(), conf.debug.disable_token_validation, + agent_tunnel_handle.as_deref(), + token_jti, ) .await } @@ -100,6 +107,7 @@ fn credential_injection_resolve_error(error: CredentialInjectionKdcResolveError) // The forward path requires the envelope realm to be set: there is no fallback since this is // not a credential-injection session. After resolving, validates the realm against the // token's `krb_realm` claim before forwarding anything. +#[expect(clippy::too_many_arguments)] async fn forward_to_real_kdc( kdc_proxy_message: KdcProxyMessage, envelope_realm: Option, @@ -107,6 +115,13 @@ async fn forward_to_real_kdc( token_kdc_addr: &TargetAddr, override_kdc: Option<&TargetAddr>, bypass_realm_check: bool, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + // The HTTP /jet/KdcProxy endpoint has no parent association token, so we use the KDC + // token's own `jti` for log/agent-side correlation. It is persistent for the lifetime of + // the KDC token (which can be reused) rather than per-request, but it is the most stable + // identifier we have here. 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. + session_id: Uuid, ) -> Result, HttpError> { let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?; debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved"); @@ -120,7 +135,19 @@ async fn forward_to_real_kdc( None => token_kdc_addr, }; - let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?; + // No parent association token here, so no `jet_agent_id` to enforce. The HTTP + // /jet/KdcProxy endpoint stands on its own — let the routing pipeline pick any + // matching agent (or fall back to direct connect). + let explicit_agent_id = None; + + let kdc_reply_bytes = send_krb_message( + kdc_addr, + &kdc_proxy_message.kerb_message.0.0, + agent_tunnel_handle, + session_id, + explicit_agent_id, + ) + .await?; let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes) .map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?; @@ -130,7 +157,7 @@ async fn forward_to_real_kdc( reply.to_vec().map_err(HttpError::internal().err()) } -fn enforce_credential_injection_enabled(jet_cred_id: uuid::Uuid, enable_unstable: bool) -> Result<(), HttpError> { +fn enforce_credential_injection_enabled(jet_cred_id: Uuid, enable_unstable: bool) -> Result<(), HttpError> { if enable_unstable { return Ok(()); } @@ -165,11 +192,33 @@ fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: boo .err()(format!("expected: {token_realm}, got: {request_realm}"))) } -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) } @@ -198,7 +247,67 @@ 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`; the HTTP `/jet/KdcProxy` endpoint passes the KDC +/// token's own `jti` (no parent association exists for that path). +/// +/// `explicit_agent_id` honors the same routing contract as every other proxy path: +/// when the parent association token pins the session to a specific agent via +/// `jet_agent_id`, that pin is enforced here too (route via that agent or fail — +/// do **not** silently fall back to another agent or to direct connect). +/// Callers with no parent association (HTTP `/jet/KdcProxy`) pass `None`. +pub async fn send_krb_message( + kdc_addr: &TargetAddr, + message: &[u8], + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, +) -> 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, explicit_agent_id, &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}..."); @@ -288,11 +397,11 @@ mod tests { #[test] fn credential_injection_gate_allows_jet_cred_id_when_enabled() { - assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), true).is_ok()); + assert!(enforce_credential_injection_enabled(Uuid::new_v4(), true).is_ok()); } #[test] fn credential_injection_gate_rejects_jet_cred_id_when_disabled() { - assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), false).is_err()); + assert!(enforce_credential_injection_enabled(Uuid::new_v4(), false).is_err()); } } diff --git a/devolutions-gateway/src/extract.rs b/devolutions-gateway/src/extract.rs index a56df75b7..52b32760f 100644 --- a/devolutions-gateway/src/extract.rs +++ b/devolutions-gateway/src/extract.rs @@ -8,7 +8,7 @@ use crate::DgwState; use crate::http::HttpError; use crate::token::{ AccessScope, AccessTokenClaims, AssociationTokenClaims, BridgeTokenClaims, JmuxTokenClaims, JrecTokenClaims, - JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, WebAppTokenClaims, + JrlTokenClaims, KdcTokenClaims, ScopeTokenClaims, WebAppTokenClaims, extract_jti, }; #[derive(Clone)] @@ -109,7 +109,13 @@ where /// the path, runs it through the same `authenticate()` routine the middleware would, and /// unwraps the `Kdc` variant so handlers receive `KdcTokenClaims` directly. #[derive(Clone)] -pub struct KdcToken(pub KdcTokenClaims); +pub struct KdcToken { + pub claims: KdcTokenClaims, + /// The KDC token's own `jti`. Carried alongside the claims so the KDC proxy handler can + /// use it as a persistent session-correlation identifier (the JWT standard `jti` claim + /// is not threaded through [`KdcTokenClaims`] itself). + pub jti: uuid::Uuid, +} impl FromRequestParts for KdcToken { type Rejection = HttpError; @@ -135,7 +141,10 @@ impl FromRequestParts for KdcToken { .map_err(HttpError::unauthorized().err())?; match claims { - AccessTokenClaims::Kdc(claims) => Ok(Self(claims)), + AccessTokenClaims::Kdc(claims) => { + let jti = extract_jti(&token).map_err(HttpError::internal().with_msg("KDC token missing jti").err())?; + Ok(Self { claims, jti }) + } _ => Err(HttpError::forbidden().msg("token not allowed (expected KDC token)")), } } diff --git a/devolutions-gateway/src/generic_client.rs b/devolutions-gateway/src/generic_client.rs index d44595864..c172ec0e5 100644 --- a/devolutions-gateway/src/generic_client.rs +++ b/devolutions-gateway/src/generic_client.rs @@ -177,6 +177,8 @@ 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) + .explicit_agent_id(claims.jet_agent_id) .build() .run() .await diff --git a/devolutions-gateway/src/rd_clean_path.rs b/devolutions-gateway/src/rd_clean_path.rs index bbe69290c..8eefade4a 100644 --- a/devolutions-gateway/src/rd_clean_path.rs +++ b/devolutions-gateway/src/rd_clean_path.rs @@ -425,6 +425,9 @@ async fn handle_with_credential_injection( credential_injection_kdc.proxy_credential(), krb_server_config, &credential_injection_kdc, + agent_tunnel_handle.as_deref(), + claims.jet_aid, + claims.jet_agent_id, ); let krb_client_config = if conf.debug.enable_unstable @@ -448,6 +451,9 @@ async fn handle_with_credential_injection( server_security_protocol, credential_injection_kdc.target_credential(), krb_client_config, + agent_tunnel_handle.as_deref(), + claims.jet_aid, + claims.jet_agent_id, ); 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 17a37e757..a4d627416 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; @@ -36,6 +37,13 @@ pub struct RdpProxy { subscriber_tx: SubscriberSender, server_dns_name: String, disconnect_interest: Option, + #[builder(default)] + agent_tunnel_handle: Option>, + /// `jet_agent_id` from the parent association token, if any. When set, the routing + /// pipeline must route via that agent (or fail) — never silently fall back to + /// another agent or to direct connect. See [`send_krb_message`]. + #[builder(default)] + explicit_agent_id: Option, } impl RdpProxy @@ -67,8 +75,13 @@ where subscriber_tx, server_dns_name, disconnect_interest, + agent_tunnel_handle, + explicit_agent_id, } = 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(); @@ -127,6 +140,9 @@ where credential_injection_kdc.proxy_credential(), krb_server_config, &credential_injection_kdc, + agent_tunnel_handle.as_deref(), + session_id, + explicit_agent_id, ); let krb_client_config = if conf.debug.enable_unstable @@ -150,6 +166,9 @@ where handshake_result.server_security_protocol, credential_injection_kdc.target_credential(), krb_client_config, + agent_tunnel_handle.as_deref(), + session_id, + explicit_agent_id, ); let (client_credssp_res, server_credssp_res) = tokio::join!(client_credssp_fut, server_credssp_fut); @@ -380,6 +399,7 @@ pub(crate) fn credential_injection_kerberos_server_config( } } +#[expect(clippy::too_many_arguments)] #[instrument(name = "server_credssp", level = "debug", ret, skip_all)] pub(crate) async fn perform_credssp_as_client( framed: &mut ironrdp_tokio::Framed, @@ -388,6 +408,9 @@ pub(crate) async fn perform_credssp_as_client( security_protocol: nego::SecurityProtocol, credentials: &AppCredential, kerberos_config: Option, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -420,7 +443,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, explicit_agent_id).await? }; // drop generator buf.clear(); @@ -453,6 +476,9 @@ where async fn resolve_server_generator( generator: &mut CredsspServerProcessGenerator<'_>, credential_injection_kdc: &CredentialInjectionKdc, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, ) -> Result { let mut state = generator.start(); @@ -461,7 +487,9 @@ async fn resolve_server_generator( GeneratorState::Suspended(request) => { let response = match credential_injection_kdc.intercept_network_request(&request) { Ok(CredentialInjectionKdcInterception::Intercepted(response)) => Ok(response), - Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => send_network_request(&request).await, + Ok(CredentialInjectionKdcInterception::NotInjectionRequest) => { + send_network_request(&request, agent_tunnel_handle, session_id, explicit_agent_id).await + } Ok(CredentialInjectionKdcInterception::NotInjectionRealm(mismatch)) => Err(anyhow::anyhow!( "kdc request realm does not match credential-injection session realm: {mismatch}" )), @@ -483,13 +511,17 @@ async fn resolve_server_generator( async fn resolve_client_generator( generator: &mut CredsspClientProcessGenerator<'_>, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, ) -> 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, explicit_agent_id).await?; state = generator.resume(Ok(response)); } GeneratorState::Completed(client_state) => { @@ -501,6 +533,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_as_server( framed: &mut ironrdp_tokio::Framed, @@ -510,6 +543,9 @@ pub(crate) async fn perform_credssp_as_server( credentials: &AppCredential, kerberos_server_config: Option, credential_injection_kdc: &CredentialInjectionKdc, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -531,6 +567,9 @@ where credentials, kerberos_server_config, credential_injection_kdc, + agent_tunnel_handle, + session_id, + explicit_agent_id, ) .await; @@ -551,6 +590,7 @@ where return result; + #[expect(clippy::too_many_arguments)] async fn credssp_loop( framed: &mut ironrdp_tokio::Framed, buf: &mut ironrdp_pdu::WriteBuf, @@ -559,6 +599,9 @@ where credentials: &AppCredential, kerberos_server_config: Option, credential_injection_kdc: &CredentialInjectionKdc, + agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>, + session_id: Uuid, + explicit_agent_id: Option, ) -> anyhow::Result<()> where S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite, @@ -600,7 +643,14 @@ where let result = { let mut generator = sequence.process_ts_request(ts_request); - resolve_server_generator(&mut generator, credential_injection_kdc).await + resolve_server_generator( + &mut generator, + credential_injection_kdc, + agent_tunnel_handle, + session_id, + explicit_agent_id, + ) + .await }; // drop generator buf.clear(); @@ -639,18 +689,25 @@ where /// TODO(sspi-rs#664): when sspi-rs ships a pluggable KDC dispatcher API, the URL trick for /// credential injection goes away entirely and this helper can be inlined back into the /// CredSSP loops. -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, + explicit_agent_id: Option, +) -> anyhow::Result> { match request.url.scheme() { "tcp" | "udp" => { 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) - .await - .map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err)) + send_krb_message( + &target_addr, + &request.data, + agent_tunnel_handle, + session_id, + explicit_agent_id, + ) + .await + .map_err(|err| anyhow::anyhow!("failed to send KDC message: {err}")) } unsupported => anyhow::bail!("unsupported KDC request scheme: {unsupported}"), }