Skip to content

Commit 06706d0

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`) bundles the three inputs the routing pipeline needs (`session_id`, `explicit_agent_id`, `agent_tunnel_handle`) into a single value and always defers the routing decision to `agent_tunnel::routing::try_route`. Callers never pre-decide "direct" vs "via tunnel": the routing pipeline does, and its existing `explicit_agent_id` enforcement (pin without tunnel handle must error, never silently fall back to direct) is preserved end-to-end. Session correlation: - RDP CredSSP callers pass the parent association's `claims.jet_aid` as `session_id`, 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. `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 parent association's `jet_agent_id`, when set, is forwarded to `try_route`. 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` (no parent association). - A new UDP-via-agent guard rejects `udp://` KDC targets whenever the routing pipeline selects an agent. Without it, an explicit `jet_agent_id` pin could be downgraded to direct UDP, since the agent tunnel currently carries only TCP. 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 at the direct-connect branch (preserved). Tests: - `kdc_connector` unit tests cover the two cases that don't need a live `AgentTunnelHandle`: pin-without-tunnel must error, no-pin-no-tunnel falls through to direct. The remaining cases (pin-with-missing-agent, no-match-falls-back, tunnel success, UDP-via-agent guard) need an integration-style listener fixture and are left as a follow-up. Issue: DGW-384
1 parent b50e5d0 commit 06706d0

9 files changed

Lines changed: 365 additions & 143 deletions

File tree

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 23 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,21 @@ 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+
// session_id: the HTTP /jet/KdcProxy endpoint has no parent association token, so we
76+
// use the KDC token's own `jti` for log correlation (the RDP CredSSP/NLA caller
77+
// passes `claims.jet_aid` so KDC sub-traffic correlates with its parent RDP session).
78+
// explicit_agent_id: HTTP has no parent association, hence no `jet_agent_id` pin.
79+
let kdc_connector = KdcConnector::new(token_jti, None, agent_tunnel_handle);
80+
7381
forward_to_real_kdc(
7482
kdc_proxy_message,
7583
envelope_realm,
7684
&krb_realm,
7785
&krb_kdc,
7886
conf.debug.override_kdc.as_ref(),
7987
conf.debug.disable_token_validation,
88+
&kdc_connector,
8089
)
8190
.await
8291
}
@@ -107,6 +116,7 @@ async fn forward_to_real_kdc(
107116
token_kdc_addr: &TargetAddr,
108117
override_kdc: Option<&TargetAddr>,
109118
bypass_realm_check: bool,
119+
kdc_connector: &KdcConnector,
110120
) -> Result<Vec<u8>, HttpError> {
111121
let realm = envelope_realm.ok_or_else(|| HttpError::bad_request().msg("realm is missing from KDC request"))?;
112122
debug!(resolved_realm = %realm, "Forward-to-real-KDC realm resolved");
@@ -120,7 +130,9 @@ async fn forward_to_real_kdc(
120130
None => token_kdc_addr,
121131
};
122132

123-
let kdc_reply_bytes = send_krb_message(kdc_addr, &kdc_proxy_message.kerb_message.0.0).await?;
133+
let kdc_reply_bytes = kdc_connector
134+
.send(kdc_addr, &kdc_proxy_message.kerb_message.0.0)
135+
.await?;
124136

125137
let reply = KdcProxyMessage::from_raw_kerb_message(&kdc_reply_bytes)
126138
.map_err(HttpError::internal().with_msg("couldn't create KDC proxy reply").err())?;
@@ -130,7 +142,7 @@ async fn forward_to_real_kdc(
130142
reply.to_vec().map_err(HttpError::internal().err())
131143
}
132144

133-
fn enforce_credential_injection_enabled(jet_cred_id: uuid::Uuid, enable_unstable: bool) -> Result<(), HttpError> {
145+
fn enforce_credential_injection_enabled(jet_cred_id: Uuid, enable_unstable: bool) -> Result<(), HttpError> {
134146
if enable_unstable {
135147
return Ok(());
136148
}
@@ -165,104 +177,6 @@ fn enforce_realm_token_match(token_realm: &str, request_realm: &str, bypass: boo
165177
.err()(format!("expected: {token_realm}, got: {request_realm}")))
166178
}
167179

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-
266180
#[cfg(test)]
267181
mod tests {
268182
use super::*;
@@ -288,11 +202,11 @@ mod tests {
288202

289203
#[test]
290204
fn credential_injection_gate_allows_jet_cred_id_when_enabled() {
291-
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), true).is_ok());
205+
assert!(enforce_credential_injection_enabled(Uuid::new_v4(), true).is_ok());
292206
}
293207

294208
#[test]
295209
fn credential_injection_gate_rejects_jet_cred_id_when_disabled() {
296-
assert!(enforce_credential_injection_enabled(uuid::Uuid::new_v4(), false).is_err());
210+
assert!(enforce_credential_injection_enabled(Uuid::new_v4(), false).is_err());
297211
}
298212
}

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ where
163163
"RDP-TLS forwarding with credential injection"
164164
);
165165

166+
let kdc_connector = crate::kdc_connector::KdcConnector::new(
167+
claims.jet_aid,
168+
claims.jet_agent_id,
169+
agent_tunnel_handle.clone(),
170+
);
171+
166172
// NOTE: In the future, we could imagine performing proxy-based recording as well using RdpProxy.
167173
return crate::rdp_proxy::RdpProxy::builder()
168174
.conf(conf)
@@ -177,6 +183,7 @@ where
177183
.client_stream_leftover_bytes(leftover_bytes)
178184
.server_dns_name(selected_target.host().to_owned())
179185
.disconnect_interest(disconnect_interest)
186+
.kdc_connector(kdc_connector)
180187
.build()
181188
.run()
182189
.await

0 commit comments

Comments
 (0)