Skip to content

Commit 1188fbe

Browse files
refactor(pr2): split cert.rs refactor and agent-tunnel tests into own PRs
Tests (testsuite/tests/agent_tunnel/{integration,registry,routing}.rs) and the read_cert_chain rewrite are not part of the routing/upstream feature itself — they ship as their own PRs so this one stays focused on the feature code: - Cert PEM parsing fix → #1771 - Agent-tunnel test suite → follow-up PR (stacked on this one) After this trim, PR2's diff is purely: - Routing: agent-tunnel/{routing,registry,listener}.rs - Upstream refactor: devolutions-gateway/upstream.rs and the proxy paths (fwd, kdc_proxy, rdp, rdp_proxy, rd_clean_path, generic_client) - Agent client: devolutions-agent/tunnel_helpers.rs (TargetAddr widening to handle IPv6 alongside IPv4)
1 parent 69ef61e commit 1188fbe

11 files changed

Lines changed: 73 additions & 798 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agent-tunnel/src/registry.rs

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ use serde::Serialize;
1010
use tokio::sync::RwLock as TokioRwLock;
1111
use uuid::Uuid;
1212

13+
use crate::routing::RouteTarget;
14+
1315
/// Duration after which an agent is considered offline if no heartbeat has been received.
1416
pub const AGENT_OFFLINE_TIMEOUT: Duration = Duration::from_secs(90);
1517

@@ -33,31 +35,29 @@ pub struct RouteAdvertisementState {
3335
}
3436

3537
impl RouteAdvertisementState {
36-
/// Match this route set against a target host (IP or domain name).
38+
/// Match this route set against a parsed target host.
3739
///
3840
/// Returns a specificity score if matched, or `None` if no match.
3941
/// IP subnet matches return `usize::MAX` (always highest priority).
4042
/// Domain matches return the matched domain length (longer = more specific).
41-
pub fn matches_target(&self, target_host: &str) -> Option<usize> {
43+
pub fn matches_target(&self, target: &RouteTarget) -> Option<usize> {
4244
use std::net::IpAddr;
4345

44-
if let Ok(ip) = target_host.parse::<IpAddr>() {
46+
match target {
4547
// Only IPv4 subnets are currently tracked; only match IPv4 target IPs.
46-
if let IpAddr::V4(ipv4) = ip {
47-
return self
48-
.subnets
49-
.iter()
50-
.any(|subnet| subnet.contains(ipv4))
51-
.then_some(usize::MAX);
52-
}
53-
return None;
48+
RouteTarget::Ip(IpAddr::V4(ipv4)) => self
49+
.subnets
50+
.iter()
51+
.any(|subnet| subnet.contains(*ipv4))
52+
.then_some(usize::MAX),
53+
RouteTarget::Ip(IpAddr::V6(_)) => None,
54+
RouteTarget::Hostname(hostname) => self
55+
.domains
56+
.iter()
57+
.filter(|adv| adv.domain.matches_hostname(hostname.as_str()))
58+
.map(|adv| adv.domain.as_str().len())
59+
.max(),
5460
}
55-
56-
self.domains
57-
.iter()
58-
.filter(|adv| adv.domain.matches_hostname(target_host))
59-
.map(|adv| adv.domain.as_str().len())
60-
.max()
6161
}
6262
}
6363

@@ -281,13 +281,13 @@ impl AgentRegistry {
281281
self.agents.read().await.values().map(AgentInfo::from).collect()
282282
}
283283

284-
/// Find all online agents that can route to the given target host (IP or domain).
284+
/// Find all online agents that can route to the given parsed target host.
285285
///
286286
/// For IP targets: matches against advertised subnets.
287287
/// For domain targets: uses longest suffix match (more specific domain wins).
288288
///
289289
/// Results with equal specificity are sorted by `received_at` descending (most recent first).
290-
pub async fn find_agents_for(&self, target_host: &str) -> Vec<Arc<AgentPeer>> {
290+
pub async fn find_agents_for(&self, target: &RouteTarget) -> Vec<Arc<AgentPeer>> {
291291
let mut best_specificity: usize = 0;
292292
let mut candidates: Vec<(SystemTime, Arc<AgentPeer>)> = Vec::new();
293293

@@ -299,7 +299,7 @@ impl AgentRegistry {
299299

300300
let route_state = agent.route_state();
301301

302-
if let Some(specificity) = route_state.matches_target(target_host) {
302+
if let Some(specificity) = route_state.matches_target(target) {
303303
if specificity > best_specificity {
304304
best_specificity = specificity;
305305
candidates.clear();

crates/agent-tunnel/src/routing.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,46 @@
33
//! Used by both connection forwarding (`fwd.rs`) and KDC proxy (`kdc_proxy.rs`)
44
//! to ensure consistent routing behavior and error messages.
55
6+
use std::net::IpAddr;
67
use std::sync::Arc;
78

9+
use agent_tunnel_proto::DomainName;
810
use anyhow::{Result, anyhow};
911
use uuid::Uuid;
1012

1113
use super::listener::AgentTunnelHandle;
1214
use super::registry::{AgentPeer, AgentRegistry};
1315
use super::stream::TunnelStream;
1416

17+
/// A parsed target host used for route matching.
18+
///
19+
/// Routing cares only about the host identity, not the port or scheme used by
20+
/// the eventual connection attempt.
21+
#[derive(Debug, Clone, PartialEq, Eq)]
22+
pub enum RouteTarget {
23+
Ip(IpAddr),
24+
Hostname(DomainName),
25+
}
26+
27+
impl RouteTarget {
28+
pub fn ip(ip: IpAddr) -> Self {
29+
Self::Ip(ip)
30+
}
31+
32+
pub fn hostname(hostname: impl Into<String>) -> Self {
33+
Self::Hostname(DomainName::new(hostname))
34+
}
35+
}
36+
37+
impl std::fmt::Display for RouteTarget {
38+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39+
match self {
40+
Self::Ip(ip) => ip.fmt(f),
41+
Self::Hostname(hostname) => hostname.fmt(f),
42+
}
43+
}
44+
}
45+
1546
/// Result of the routing pipeline.
1647
///
1748
/// Each variant carries enough context for the caller to produce an actionable error message.
@@ -34,7 +65,7 @@ pub enum RoutingDecision {
3465
pub async fn resolve_route(
3566
registry: &AgentRegistry,
3667
explicit_agent_id: Option<Uuid>,
37-
target_host: &str,
68+
target: &RouteTarget,
3869
) -> RoutingDecision {
3970
// Step 1: Explicit agent ID (from JWT)
4071
if let Some(id) = explicit_agent_id {
@@ -45,7 +76,7 @@ pub async fn resolve_route(
4576
}
4677

4778
// Step 2: Match target against all agents (IP subnet or domain suffix)
48-
let agents = registry.find_agents_for(target_host).await;
79+
let agents = registry.find_agents_for(target).await;
4980

5081
if agents.is_empty() {
5182
RoutingDecision::Direct
@@ -62,7 +93,7 @@ pub async fn resolve_route(
6293
pub async fn try_route(
6394
handle: Option<&AgentTunnelHandle>,
6495
explicit_agent_id: Option<Uuid>,
65-
target_host: &str,
96+
target: &RouteTarget,
6697
session_id: Uuid,
6798
target_addr: &str,
6899
) -> Result<Option<(TunnelStream, Arc<AgentPeer>)>> {
@@ -78,7 +109,7 @@ pub async fn try_route(
78109
};
79110
};
80111

81-
match resolve_route(handle.registry(), explicit_agent_id, target_host).await {
112+
match resolve_route(handle.registry(), explicit_agent_id, target).await {
82113
RoutingDecision::ExplicitAgentNotFound(id) => {
83114
Err(anyhow!("agent {id} specified in token not found in registry"))
84115
}

devolutions-gateway/src/api/kdc_proxy.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,15 @@ pub async fn send_krb_message(
201201
None
202202
};
203203

204+
let route_target = match kdc_addr.host_ip() {
205+
Some(ip) => agent_tunnel::routing::RouteTarget::ip(ip),
206+
None => agent_tunnel::routing::RouteTarget::hostname(kdc_addr.host()),
207+
};
208+
204209
if let Some((mut stream, _agent)) = agent_tunnel::routing::try_route(
205210
agent_tunnel_handle,
206211
None,
207-
kdc_addr.host(),
212+
&route_target,
208213
uuid::Uuid::new_v4(),
209214
kdc_target,
210215
)

devolutions-gateway/src/upstream.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use std::task::{Context, Poll};
2020

2121
use agent_tunnel::AgentTunnelHandle;
2222
use agent_tunnel::registry::AgentPeer;
23-
use agent_tunnel::routing::{RoutingDecision, resolve_route};
23+
use agent_tunnel::routing::{RouteTarget, RoutingDecision, resolve_route};
2424
use agent_tunnel::stream::TunnelStream;
2525
use anyhow::{Context as _, Result, anyhow};
2626
use nonempty::NonEmpty;
@@ -197,10 +197,10 @@ impl<'a> RoutePlan<'a> {
197197
return Ok(Self::Direct(target));
198198
};
199199

200-
let target_host = target.host();
201-
let decision = resolve_route(handle.registry(), None, target_host).await;
200+
let route_target = route_target_from_target_addr(target);
201+
let decision = resolve_route(handle.registry(), None, &route_target).await;
202202
debug!(
203-
target_host,
203+
target = %route_target,
204204
decision = ?match &decision {
205205
RoutingDecision::ViaAgent(c) => format!("ViaAgent({} candidates)", c.len()),
206206
RoutingDecision::Direct => "Direct".to_owned(),
@@ -298,6 +298,13 @@ impl<'a> RoutePlan<'a> {
298298
}
299299
}
300300

301+
fn route_target_from_target_addr(target: &TargetAddr) -> RouteTarget {
302+
match target.host_ip() {
303+
Some(ip) => RouteTarget::ip(ip),
304+
None => RouteTarget::hostname(target.host()),
305+
}
306+
}
307+
301308
// ---------------------------------------------------------------------------
302309
// Public entry points
303310
// ---------------------------------------------------------------------------

testsuite/Cargo.toml

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,16 @@ typed-builder = "0.21"
3131
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
3232

3333
[dev-dependencies]
34-
agent-tunnel = { path = "../crates/agent-tunnel" }
35-
agent-tunnel-proto = { path = "../crates/agent-tunnel-proto", features = ["serde"] }
36-
devolutions-gateway-task = { path = "../crates/devolutions-gateway-task" }
3734
base64 = "0.22"
38-
camino = "1"
39-
ipnetwork = "0.20"
35+
proxy-socks = { path = "../crates/proxy-socks" }
4036
libsql = { version = "0.9", default-features = false, features = ["core"] }
4137
mcp-proxy.path = "../crates/mcp-proxy"
42-
proxy-socks = { path = "../crates/proxy-socks" }
43-
quinn = "0.11"
44-
rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
4538
rstest = "0.25"
46-
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
47-
rustls-pemfile = "2"
48-
rustls-pki-types = "1"
4939
serde_json = "1"
5040
sysevent.path = "../crates/sysevent"
5141
tempfile = "3"
5242
test-utils.path = "../crates/test-utils"
5343
tokio-rustls = { version = "0.26", features = ["ring"] }
54-
uuid = { version = "1", features = ["v4"] }
5544

5645
[target.'cfg(unix)'.dev-dependencies]
5746
sysevent-syslog.path = "../crates/sysevent-syslog"

0 commit comments

Comments
 (0)