Skip to content

Commit c60e014

Browse files
fix(agent-tunnel): address Copilot review feedback on PR #1741
- routing.rs: when `explicit_agent_id` is set but the gateway has no tunnel handle, return `Err` instead of silently falling back to a direct connect. A token that names a specific `jet_agent_id` is declaring a required network boundary; silent fallback would bypass it. - api/fwd.rs, generic_client.rs, rd_clean_path.rs, api/kdc_proxy.rs: use `TargetAddr::as_addr()` (which brackets IPv6) instead of `format!("{host}:{port}")` or `to_string()` (which includes scheme). Fixes two real bugs: IPv6 targets were malformed (`::1:443` vs `[::1]:443`), and kdc_proxy was passing `tcp://host:88` to the tunnel target parser — which only accepts bare `host:port`. - rdp_proxy.rs: add a `TODO(agent-tunnel)` documenting that CredSSP Kerberos network requests cannot currently traverse the agent tunnel because `send_network_request` hardcodes `None` for the handle. Edge case (KDC behind a NAT'd site only reachable via an enrolled agent); plumbing the handle through `RdpProxy` is a follow-up. - tests/agent_tunnel_routing.rs: replace a flaky `thread::sleep(10ms)` (Windows timer resolution is ~16 ms) with an explicit `set_received_at_for_test` helper. Adds two new tests for the new explicit-agent-without-handle error path. - registry.rs: expose `set_received_at_for_test` for the above. - agent-tunnel-proto/control.rs: fix a stale doc comment that claimed `subnets` is IPv4+IPv6 (it is IPv4-only; `Vec<Ipv4Network>`).
1 parent 42a77ee commit c60e014

9 files changed

Lines changed: 76 additions & 16 deletions

File tree

crates/agent-tunnel-proto/src/control.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ pub enum ControlMessage {
6060
protocol_version: u16,
6161
/// Monotonically increasing epoch within this agent process lifetime.
6262
epoch: u64,
63-
/// Reachable subnets (IPv4 and IPv6).
63+
/// Reachable IPv4 subnets.
6464
subnets: Vec<Ipv4Network>,
6565
/// DNS domains this agent can resolve, with source tracking.
6666
domains: Vec<DomainAdvertisement>,

crates/agent-tunnel/src/registry.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ impl AgentPeer {
117117
self.last_seen.store(last_seen_ms, Ordering::Release);
118118
}
119119

120+
/// Overwrite `received_at` on the current route state.
121+
///
122+
/// Intended for tests that need to assert ordering by arrival time without
123+
/// relying on wall-clock `thread::sleep` — which is flaky on platforms with
124+
/// coarse timer resolution (e.g. Windows ~16 ms).
125+
pub fn set_received_at_for_test(&self, received_at: SystemTime) {
126+
let mut state = self.route_state.write();
127+
state.received_at = received_at;
128+
}
129+
120130
/// Returns the last-seen timestamp as milliseconds since UNIX epoch.
121131
pub fn last_seen_ms(&self) -> u64 {
122132
self.last_seen.load(Ordering::Acquire)

crates/agent-tunnel/src/routing.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,15 @@ pub async fn try_route(
6767
target_addr: &str,
6868
) -> Result<Option<(TunnelStream, Arc<AgentPeer>)>> {
6969
let Some(handle) = handle else {
70-
return Ok(None);
70+
// An explicit `jet_agent_id` claim means the token requires routing via that
71+
// specific agent; silently falling back to a direct connect would bypass the
72+
// intended network boundary. Reject instead.
73+
return match explicit_agent_id {
74+
Some(id) => Err(anyhow!(
75+
"agent {id} specified in token requires agent tunnel routing, but no tunnel handle is configured"
76+
)),
77+
None => Ok(None),
78+
};
7179
};
7280

7381
match resolve_route(handle.registry(), explicit_agent_id, target_host).await {

devolutions-gateway/src/api/fwd.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,16 +233,16 @@ where
233233

234234
let span = tracing::Span::current();
235235

236-
// Route via agent tunnel: explicit agent_id → subnet → domain → direct
236+
// Route via agent tunnel: explicit agent_id → subnet → domain → direct.
237+
// `as_addr()` is IPv6-safe (bracketed); `format!("{host}:{port}")` is not.
237238
let first_target = targets.first();
238-
let target_str = format!("{}:{}", first_target.host(), first_target.port());
239239

240240
if let Some((server_stream, _agent)) = agent_tunnel::routing::try_route(
241241
agent_tunnel_handle.as_deref(),
242242
claims.jet_agent_id,
243243
first_target.host(),
244244
claims.jet_aid,
245-
&target_str,
245+
first_target.as_addr(),
246246
)
247247
.await
248248
.map_err(ForwardError::BadGateway)?

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,16 @@ pub async fn send_krb_message(
164164
agent_tunnel_handle: Option<&agent_tunnel::AgentTunnelHandle>,
165165
) -> Result<Vec<u8>, HttpError> {
166166
// Route through agent tunnel using the SAME pipeline as connection forwarding.
167-
let kdc_target = kdc_addr.to_string();
167+
// `as_addr()` returns `host:port` (with IPv6 brackets), which is what the agent
168+
// tunnel target parser expects — unlike `to_string()` which includes the scheme.
169+
let kdc_target = kdc_addr.as_addr();
168170

169171
if let Some((mut stream, _agent)) = agent_tunnel::routing::try_route(
170172
agent_tunnel_handle,
171173
None,
172174
kdc_addr.host(),
173175
uuid::Uuid::new_v4(),
174-
&kdc_target,
176+
kdc_target,
175177
)
176178
.await
177179
.map_err(|e| HttpError::bad_gateway().build(format!("KDC routing through agent tunnel failed: {e:#}")))?

devolutions-gateway/src/generic_client.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,16 @@ where
113113
RecordingPolicy::Proxy => anyhow::bail!("can't meet recording policy"),
114114
}
115115

116-
// Route via agent tunnel: explicit agent_id → subnet → domain → direct
116+
// Route via agent tunnel: explicit agent_id → subnet → domain → direct.
117+
// `as_addr()` is IPv6-safe (bracketed); `format!("{host}:{port}")` is not.
117118
let first_target = targets.first();
118-
let target_str = format!("{}:{}", first_target.host(), first_target.port());
119119

120120
if let Some((server_stream, _agent)) = agent_tunnel::routing::try_route(
121121
agent_tunnel_handle.as_deref(),
122122
claims.jet_agent_id,
123123
first_target.host(),
124124
claims.jet_aid,
125-
&target_str,
125+
first_target.as_addr(),
126126
)
127127
.await?
128128
{

devolutions-gateway/src/rd_clean_path.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,21 +302,22 @@ async fn connect_rdp_server(
302302
trace!(?targets, "Connecting to destination server");
303303

304304
// Route through agent tunnel if available, otherwise connect directly.
305+
// `as_addr()` is IPv6-safe (bracketed); `format!("{host}:{port}")` is not.
305306
let first_target = targets.first();
306-
let target_str = format!("{}:{}", first_target.host(), first_target.port());
307+
let target_addr = first_target.as_addr();
307308

308309
let (mut server_stream, server_addr, selected_target): (ServerTransport, SocketAddr, &TargetAddr) =
309310
match agent_tunnel::routing::try_route(
310311
agent_tunnel_handle.map(AsRef::as_ref),
311312
claims.jet_agent_id,
312313
first_target.host(),
313314
claims.jet_aid,
314-
&target_str,
315+
target_addr,
315316
)
316317
.await
317318
{
318319
Ok(Some((quic_stream, _agent))) => {
319-
info!(target = %target_str, "Routing RDP via agent tunnel");
320+
info!(target = %target_addr, "Routing RDP via agent tunnel");
320321
let placeholder_addr: SocketAddr = "0.0.0.0:0".parse().expect("valid placeholder");
321322
(ServerTransport::Quic(quic_stream), placeholder_addr, first_target)
322323
}

devolutions-gateway/src/rdp_proxy.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,12 @@ where
637637
async fn send_network_request(request: &NetworkRequest) -> anyhow::Result<Vec<u8>> {
638638
let target_addr = TargetAddr::parse(request.url.as_str(), Some(88))?;
639639

640+
// TODO(agent-tunnel): plumb `agent_tunnel_handle` through `RdpProxy` and pass it here
641+
// so CredSSP-originated Kerberos network requests can traverse the agent tunnel when
642+
// the KDC is only reachable from an enrolled agent's network. Currently these requests
643+
// always go out direct from the gateway host, which bypasses the transparent routing
644+
// pipeline used by every other proxy path (fwd / rd_clean_path / generic_client /
645+
// kdc_proxy). Edge case: KDC behind a NAT'd site with no direct path from the gateway.
640646
send_krb_message(&target_addr, &request.data, None)
641647
.await
642648
.map_err(|err| anyhow::Error::msg("failed to send KDC message").context(err))

devolutions-gateway/tests/agent_tunnel_routing.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
use std::sync::Arc;
55

66
use agent_tunnel::registry::{AgentPeer, AgentRegistry};
7-
use agent_tunnel::routing::{RoutingDecision, resolve_route};
7+
use agent_tunnel::routing::{RoutingDecision, resolve_route, try_route};
88
use agent_tunnel_proto::{DomainAdvertisement, DomainName};
99
use ipnetwork::Ipv4Network;
1010
use uuid::Uuid;
@@ -137,14 +137,17 @@ async fn route_domain_match_returns_multiple_agents_ordered() {
137137
let peer_a = make_peer("agent-a");
138138
let subnet_a: Ipv4Network = "10.1.0.0/16".parse().expect("valid test subnet");
139139
peer_a.update_routes(1, vec![subnet_a], vec![domain("contoso.local")]);
140+
// Pin `received_at` explicitly — do NOT rely on thread::sleep to advance
141+
// SystemTime, since Windows timer resolution is ~16 ms and makes the
142+
// ordering assertion below flaky.
143+
peer_a.set_received_at_for_test(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1));
140144
registry.register(Arc::clone(&peer_a)).await;
141145

142-
std::thread::sleep(std::time::Duration::from_millis(10));
143-
144146
let peer_b = make_peer("agent-b");
145147
let id_b = peer_b.agent_id;
146148
let subnet_b: Ipv4Network = "10.2.0.0/16".parse().expect("valid test subnet");
147149
peer_b.update_routes(1, vec![subnet_b], vec![domain("contoso.local")]);
150+
peer_b.set_received_at_for_test(std::time::UNIX_EPOCH + std::time::Duration::from_secs(2));
148151
registry.register(Arc::clone(&peer_b)).await;
149152

150153
match resolve_route(&registry, None, "dc01.contoso.local").await {
@@ -155,3 +158,33 @@ async fn route_domain_match_returns_multiple_agents_ordered() {
155158
other => panic!("expected ViaAgent, got {other:?}"),
156159
}
157160
}
161+
162+
// A token that names a specific `jet_agent_id` must NOT silently fall back to a
163+
// direct connect when the gateway has no agent-tunnel configured — doing so
164+
// would bypass the intended network boundary.
165+
#[tokio::test]
166+
async fn try_route_rejects_explicit_agent_when_handle_missing() {
167+
let result = try_route(
168+
None,
169+
Some(Uuid::new_v4()),
170+
"host.example.com",
171+
Uuid::new_v4(),
172+
"host.example.com:443",
173+
)
174+
.await;
175+
176+
assert!(result.is_err(), "expected Err for explicit agent with no handle");
177+
}
178+
179+
// Without an explicit `jet_agent_id`, a missing handle just means "no tunneling
180+
// available" — falling back to a direct connect is correct.
181+
#[tokio::test]
182+
async fn try_route_without_explicit_agent_falls_through_when_handle_missing() {
183+
let result = try_route(None, None, "host.example.com", Uuid::new_v4(), "host.example.com:443").await;
184+
185+
match result {
186+
Ok(None) => {}
187+
Ok(Some(_)) => panic!("expected Ok(None), got Ok(Some(_))"),
188+
Err(e) => panic!("expected Ok(None), got Err: {e:#}"),
189+
}
190+
}

0 commit comments

Comments
 (0)