Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/agent-tunnel/src/routing.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
103 changes: 96 additions & 7 deletions devolutions-gateway/src/api/kdc_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -25,6 +26,7 @@ async fn kdc_proxy(
token_cache,
jrl,
recordings,
agent_tunnel_handle,
..
}): State<DgwState>,
extract::Path(token): extract::Path<String>,
Expand Down Expand Up @@ -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())?;
Expand All @@ -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<Vec<u8>> {
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<R: AsyncReadExt + Unpin>(reader: &mut R) -> io::Result<Vec<u8>> {
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)
}

Expand Down Expand Up @@ -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<Vec<u8>, 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<Vec<u8>, 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}...");
Expand Down
1 change: 1 addition & 0 deletions devolutions-gateway/src/generic_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions devolutions-gateway/src/rd_clean_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down
46 changes: 36 additions & 10 deletions devolutions-gateway/src/rdp_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +34,8 @@ pub struct RdpProxy<C, S> {
subscriber_tx: SubscriberSender,
server_dns_name: String,
disconnect_interest: Option<DisconnectInterest>,
#[builder(default)]
agent_tunnel_handle: Option<Arc<agent_tunnel::AgentTunnelHandle>>,
}

impl<A, B> RdpProxy<A, B>
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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<S>(
framed: &mut ironrdp_tokio::Framed<S>,
Expand All @@ -401,6 +413,8 @@ pub(crate) async fn perform_credssp_with_server<S>(
security_protocol: nego::SecurityProtocol,
credentials: &crate::credential::AppCredential,
kerberos_config: Option<ironrdp_connector::credssp::KerberosConfig>,
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
session_id: Uuid,
) -> anyhow::Result<()>
where
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -465,13 +479,15 @@ where

async fn resolve_server_generator(
generator: &mut CredsspServerProcessGenerator<'_>,
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
session_id: Uuid,
) -> Result<sspi::credssp::ServerState, sspi::credssp::ServerError> {
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,
Expand All @@ -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<sspi::credssp::ClientState> {
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) => {
Expand All @@ -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<S>(
framed: &mut ironrdp_tokio::Framed<S>,
Expand All @@ -515,6 +534,8 @@ pub(crate) async fn perform_credssp_with_client<S>(
security_protocol: nego::SecurityProtocol,
credentials: &crate::credential::AppCredential,
kerberos_server_config: Option<sspi::KerberosServerConfig>,
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
session_id: Uuid,
) -> anyhow::Result<()>
where
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
Expand All @@ -535,6 +556,8 @@ where
gateway_public_key,
credentials,
kerberos_server_config,
agent_tunnel_handle,
session_id,
)
.await;

Expand All @@ -555,13 +578,16 @@ where

return result;

#[expect(clippy::too_many_arguments)]
async fn credssp_loop<S>(
framed: &mut ironrdp_tokio::Framed<S>,
buf: &mut ironrdp_pdu::WriteBuf,
client_computer_name: ironrdp_connector::ServerName,
public_key: Vec<u8>,
credentials: &crate::credential::AppCredential,
kerberos_server_config: Option<sspi::KerberosServerConfig>,
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
session_id: Uuid,
) -> anyhow::Result<()>
where
S: ironrdp_tokio::FramedRead + ironrdp_tokio::FramedWrite,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -634,14 +660,14 @@ where
Ok(())
}

async fn send_network_request(request: &NetworkRequest) -> anyhow::Result<Vec<u8>> {
async fn send_network_request(
request: &NetworkRequest,
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
session_id: Uuid,
) -> anyhow::Result<Vec<u8>> {
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))
}
Loading