Skip to content

Commit 2072710

Browse files
feat(dgw): route KDC traffic through agent tunnel
When an agent advertises the KDC's subnet or DNS domain, route Kerberos traffic through the QUIC tunnel just like every other proxy path. 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 -- the handler builds a `KdcConnector` and forwards through it. When an agent advertises the KDC subnet, the request goes through the agent tunnel; otherwise it falls back to a direct TCP/UDP connection. - RDP CredSSP/NLA -- `rdp_proxy.rs::send_network_request` previously hard-coded `None` for the agent handle. `RdpProxy` now carries a `KdcConnector` field that the CredSSP machinery (`perform_credssp_as_*` -> `resolve_*_generator` -> `send_network_request`) uses for every Kerberos sub-request. The same change reaches the credential-injection clean path (`rd_clean_path.rs`). `KdcConnector` (new `src/kdc_connector.rs`) encapsulates the routing decision behind a single value so callers no longer thread `agent_tunnel_handle`, `session_id`, and `explicit_agent_id` through every layer. CredSSP code only sees `&KdcConnector`. Session correlation: - RDP CredSSP callers build `KdcConnector::agent_tunnel(claims.jet_aid, ...)` so KDC sub-traffic ties back to its parent RDP session in agent-side logs. - The HTTP `/jet/KdcProxy` handler builds `KdcConnector::agent_tunnel(claims.jti, ...)` so all sub-requests of the same KDC token share a correlation ID. `KdcTokenClaims` now exposes `jti` through its serde helper (matching how every other `*TokenClaims` type surfaces `jti`). Explicit-agent routing (matches every other proxy path): - The `AgentTunnel` variant of `KdcConnector` carries an `explicit_agent_id: Option<Uuid>`. When the parent association pins `jet_agent_id`, KDC traffic must route via that agent or fail -- never silently fall back to a different agent or to direct connect. The HTTP handler passes `None` (it has no parent association). Hardening (came along since they share the 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. Issue: DGW-384
1 parent 64777c6 commit 2072710

9 files changed

Lines changed: 331 additions & 123 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: 26 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
use std::io;
2-
31
use axum::Router;
42
use axum::extract::State;
5-
use axum::http::StatusCode;
63
use axum::routing::post;
74
use picky_krb::messages::KdcProxyMessage;
8-
use tokio::io::{AsyncReadExt, AsyncWriteExt};
9-
use tokio::net::{TcpStream, UdpSocket};
5+
use uuid::Uuid;
106

117
use crate::DgwState;
128
use crate::credential_injection_kdc::{
139
CredentialInjectionKdcInterception, CredentialInjectionKdcRequest, CredentialInjectionKdcResolveError,
1410
kdc_proxy_message_realm,
1511
};
1612
use crate::extract::KdcToken;
17-
use crate::http::{HttpError, HttpErrorBuilder};
13+
use crate::http::HttpError;
14+
use crate::kdc_connector::KdcConnector;
1815
use crate::target_addr::TargetAddr;
1916
use crate::token::{KdcDestination, KdcTokenClaims};
2017

@@ -26,9 +23,13 @@ async fn kdc_proxy(
2623
State(DgwState {
2724
conf_handle,
2825
credentials,
26+
agent_tunnel_handle,
2927
..
3028
}): State<DgwState>,
31-
KdcToken(KdcTokenClaims { destination }): KdcToken,
29+
KdcToken(KdcTokenClaims {
30+
destination,
31+
jti: token_jti,
32+
}): KdcToken,
3233
body: axum::body::Bytes,
3334
) -> Result<Vec<u8>, HttpError> {
3435
let conf = conf_handle.get_conf();
@@ -70,13 +71,24 @@ async fn kdc_proxy(
7071
}
7172
KdcDestination::Real { krb_realm, krb_kdc } => {
7273
let envelope_realm = kdc_proxy_message_realm(&kdc_proxy_message);
74+
75+
// The HTTP /jet/KdcProxy endpoint has no parent association token, so we use the KDC
76+
// token's own `jti` for log/agent-side correlation (the RDP CredSSP/NLA caller passes
77+
// `claims.jet_aid` instead so KDC sub-traffic correlates with its parent RDP session).
78+
// No parent association also means no `jet_agent_id` pin to enforce.
79+
let kdc_connector = match agent_tunnel_handle {
80+
Some(handle) => KdcConnector::agent_tunnel(token_jti, handle, None),
81+
None => KdcConnector::direct(token_jti),
82+
};
83+
7384
forward_to_real_kdc(
7485
kdc_proxy_message,
7586
envelope_realm,
7687
&krb_realm,
7788
&krb_kdc,
7889
conf.debug.override_kdc.as_ref(),
7990
conf.debug.disable_token_validation,
91+
&kdc_connector,
8092
)
8193
.await
8294
}
@@ -107,6 +119,7 @@ async fn forward_to_real_kdc(
107119
token_kdc_addr: &TargetAddr,
108120
override_kdc: Option<&TargetAddr>,
109121
bypass_realm_check: bool,
122+
kdc_connector: &KdcConnector,
110123
) -> Result<Vec<u8>, HttpError> {
111124
let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?;
112125
debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved");
@@ -120,7 +133,9 @@ async fn forward_to_real_kdc(
120133
None => token_kdc_addr,
121134
};
122135

123-
let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
136+
let kdc_reply_bytes = kdc_connector
137+
.send(kdc_addr, &kdc_proxy_message.kerb_message.0.0)
138+
.await?;
124139

125140
let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes)
126141
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;
@@ -130,7 +145,7 @@ async fn forward_to_real_kdc(
130145
reply.to_vec().map_err(HttpError::internal().err())
131146
}
132147

133-
fn enforce_credential_injection_enabled(jet_cred_id: uuid::Uuid, enable_unstable: bool) -> Result<(), HttpError> {
148+
fn enforce_credential_injection_enabled(jet_cred_id: Uuid, enable_unstable: bool) -> Result<(), HttpError> {
134149
if enable_unstable {
135150
return Ok(());
136151
}
@@ -165,104 +180,6 @@ fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: boo
165180
.err()(format!("expected: {token_realm}, got: {request_realm}")))
166181
}
167182

168-
async fn read_kdc_reply_message(connection: &mut TcpStream) -> io::Result<Vec<u8>> {
169-
let len = connection.read_u32().await?;
170-
let mut buf = vec![0; (len + 4).try_into().expect("u32-to-usize")];
171-
buf[0..4].copy_from_slice(&(len.to_be_bytes()));
172-
connection.read_exact(&mut buf[4..]).await?;
173-
Ok(buf)
174-
}
175-
176-
#[track_caller]
177-
fn unable_to_reach_kdc_server_err(error: io::Error) -> HttpError {
178-
use io::ErrorKind;
179-
180-
let builder = match error.kind() {
181-
ErrorKind::TimedOut => HttpErrorBuilder::new(StatusCode::GATEWAY_TIMEOUT),
182-
ErrorKind::ConnectionRefused => HttpError::bad_gateway(),
183-
ErrorKind::ConnectionAborted => HttpError::bad_gateway(),
184-
ErrorKind::ConnectionReset => HttpError::bad_gateway(),
185-
ErrorKind::BrokenPipe => HttpError::bad_gateway(),
186-
ErrorKind::OutOfMemory => HttpError::internal(),
187-
// FIXME: once stabilized use new IO error variants
188-
// - https://github.com/rust-lang/rust/pull/106375
189-
// - https://github.com/rust-lang/rust/issues/86442
190-
// ErrorKind::NetworkDown => HttpErrorBuilder::new(StatusCode::SERVICE_UNAVAILABLE),
191-
// ErrorKind::NetworkUnreachable => HttpError::bad_gateway(),
192-
// ErrorKind::HostUnreachable => HttpError::bad_gateway(),
193-
// TODO: When the above is applied, we can return an internal error in the fallback branch.
194-
_ => HttpError::bad_gateway(),
195-
};
196-
197-
builder.with_msg("unable to reach KDC server").build(error)
198-
}
199-
200-
/// Sends the Kerberos message to the specified KDC address.
201-
pub async fn send_krb_message(kdc_addr: &TargetAddr, message: &[u8]) -> Result<Vec<u8>, HttpError> {
202-
let protocol = kdc_addr.scheme();
203-
204-
debug!("Connecting to KDC server located at {kdc_addr} using protocol {protocol}...");
205-
206-
if protocol == "tcp" {
207-
#[allow(clippy::redundant_closure)] // We get a better caller location for the error by using a closure.
208-
let mut connection = TcpStream::connect(kdc_addr.as_addr()).await.map_err(|e| {
209-
error!(%kdc_addr, "failed to connect to KDC server");
210-
unable_to_reach_kdc_server_err(e)
211-
})?;
212-
213-
trace!("Connected! Forwarding KDC message...");
214-
215-
connection.write_all(message).await.map_err(
216-
HttpError::bad_gateway()
217-
.with_msg("unable to send the message to the KDC server")
218-
.err(),
219-
)?;
220-
221-
trace!("Reading KDC reply...");
222-
223-
Ok(read_kdc_reply_message(&mut connection).await.map_err(
224-
HttpError::bad_gateway()
225-
.with_msg("unable to read KDC reply message")
226-
.err(),
227-
)?)
228-
} else {
229-
// We assume that ticket length is not bigger than 2048 bytes.
230-
let mut buf = [0; 2048];
231-
232-
let udp_socket = UdpSocket::bind("127.0.0.1:0")
233-
.await
234-
.map_err(HttpError::internal().with_msg("unable to bind UDP socket").err())?;
235-
236-
let port = udp_socket
237-
.local_addr()
238-
.map_err(HttpError::internal().with_msg("unable to get UDP socket address").err())?
239-
.port();
240-
241-
trace!("Binded UDP listener to 127.0.0.1:{port}, forwarding KDC message...");
242-
243-
// First 4 bytes contains message length. We don't need it for UDP.
244-
#[allow(clippy::redundant_closure)] // We get a better caller location for the error by using a closure.
245-
udp_socket
246-
.send_to(&message[4..], kdc_addr.as_addr())
247-
.await
248-
.map_err(|e| unable_to_reach_kdc_server_err(e))?;
249-
250-
trace!("Reading KDC reply...");
251-
252-
let n = udp_socket.recv(&mut buf).await.map_err(
253-
HttpError::bad_gateway()
254-
.with_msg("unable to read reply from the KDC server")
255-
.err(),
256-
)?;
257-
258-
let mut reply_buf = Vec::new();
259-
reply_buf.extend_from_slice(&u32::try_from(n).expect("n not too big").to_be_bytes());
260-
reply_buf.extend_from_slice(&buf[0..n]);
261-
262-
Ok(reply_buf)
263-
}
264-
}
265-
266183
#[cfg(test)]
267184
mod tests {
268185
use super::*;
@@ -288,11 +205,11 @@ mod tests {
288205

289206
#[test]
290207
fn credential_injection_gate_allows_jet_cred_id_when_enabled() {
291-
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), true).is_ok());
208+
assert!(enforce_credential_injection_enabled(Uuid::new_v4(), true).is_ok());
292209
}
293210

294211
#[test]
295212
fn credential_injection_gate_rejects_jet_cred_id_when_disabled() {
296-
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), false).is_err());
213+
assert!(enforce_credential_injection_enabled(Uuid::new_v4(), false).is_err());
297214
}
298215
}

devolutions-gateway/src/api/webapp.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,7 @@ pub(crate) async fn sign_session_token(
390390
krb_realm: krb_realm.into(),
391391
krb_kdc: krb_kdc.clone(),
392392
},
393+
jti,
393394
}
394395
.pipe(serde_json::to_value)
395396
.map(|mut claims| {

devolutions-gateway/src/generic_client.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ where
163163
"RDP-TLS forwarding with credential injection"
164164
);
165165

166+
let kdc_connector = match &agent_tunnel_handle {
167+
Some(handle) => crate::kdc_connector::KdcConnector::agent_tunnel(
168+
claims.jet_aid,
169+
Arc::clone(handle),
170+
claims.jet_agent_id,
171+
),
172+
None => crate::kdc_connector::KdcConnector::direct(claims.jet_aid),
173+
};
174+
166175
// NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy.
167176
return crate::rdp_proxy::RdpProxy::builder()
168177
.conf(conf)
@@ -177,6 +186,7 @@ where
177186
.client_stream_leftover_bytes(leftover_bytes)
178187
.server_dns_name(selected_target.host().to_owned())
179188
.disconnect_interest(disconnect_interest)
189+
.kdc_connector(kdc_connector)
180190
.build()
181191
.run()
182192
.await

0 commit comments

Comments
 (0)