Skip to content

Commit 80aed20

Browse files
feat: transparent routing through agent tunnel
When a connection target matches an agent's advertised subnets or domains, the gateway automatically routes through the QUIC tunnel instead of connecting directly. This enables access to private network resources without VPN or inbound firewall rules. - Add routing pipeline (subnet match → domain suffix → direct) - Integrate tunnel routing into RDP, SSH, VNC, ARD, and KDC proxy paths - Support ServerTransport enum (Tcp/Quic) in rd_clean_path - Add 7 routing unit tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe7aa96 commit 80aed20

17 files changed

Lines changed: 970 additions & 385 deletions

File tree

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

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,48 @@
1-
use ipnetwork::Ipv4Network;
1+
use ipnetwork::IpNetwork;
22
use serde::{Deserialize, Serialize};
33

44
use crate::version::CURRENT_PROTOCOL_VERSION;
55

66
/// Maximum encoded message size (1 MiB) to prevent denial-of-service via oversized frames.
77
pub const MAX_CONTROL_MESSAGE_SIZE: u32 = 1024 * 1024;
88

9+
/// A normalized DNS domain name (lowercase).
10+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11+
#[serde(transparent)]
12+
pub struct DomainName(String);
13+
14+
impl DomainName {
15+
pub fn new(domain: impl Into<String>) -> Self {
16+
Self(domain.into().to_ascii_lowercase())
17+
}
18+
19+
pub fn as_str(&self) -> &str {
20+
&self.0
21+
}
22+
23+
/// Returns `true` if `hostname` matches this domain via DNS suffix matching.
24+
///
25+
/// Matches if `hostname == domain` (exact) or `hostname` ends with `.domain`.
26+
pub fn matches_hostname(&self, hostname: &str) -> bool {
27+
let hostname = hostname.to_ascii_lowercase();
28+
hostname == self.0
29+
|| (hostname.len() > self.0.len()
30+
&& hostname.as_bytes()[hostname.len() - self.0.len() - 1] == b'.'
31+
&& hostname.ends_with(&self.0))
32+
}
33+
}
34+
35+
impl std::fmt::Display for DomainName {
36+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37+
self.0.fmt(f)
38+
}
39+
}
40+
941
/// A DNS domain advertisement with its source.
1042
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1143
pub struct DomainAdvertisement {
1244
/// The DNS domain (e.g., "contoso.local").
13-
pub domain: String,
45+
pub domain: DomainName,
1446
/// Whether this domain was auto-detected (`true`) or explicitly configured (`false`).
1547
pub auto_detected: bool,
1648
}
@@ -23,8 +55,8 @@ pub enum ControlMessage {
2355
protocol_version: u16,
2456
/// Monotonically increasing epoch within this agent process lifetime.
2557
epoch: u64,
26-
/// Reachable IPv4 subnets.
27-
subnets: Vec<Ipv4Network>,
58+
/// Reachable subnets (IPv4 and IPv6).
59+
subnets: Vec<IpNetwork>,
2860
/// DNS domains this agent can resolve, with source tracking.
2961
domains: Vec<DomainAdvertisement>,
3062
},
@@ -48,7 +80,7 @@ pub enum ControlMessage {
4880

4981
impl ControlMessage {
5082
/// Create a new RouteAdvertise with the current protocol version.
51-
pub fn route_advertise(epoch: u64, subnets: Vec<Ipv4Network>, domains: Vec<DomainAdvertisement>) -> Self {
83+
pub fn route_advertise(epoch: u64, subnets: Vec<IpNetwork>, domains: Vec<DomainAdvertisement>) -> Self {
5284
Self::RouteAdvertise {
5385
protocol_version: CURRENT_PROTOCOL_VERSION,
5486
epoch,
@@ -118,11 +150,11 @@ mod tests {
118150
vec!["10.0.0.0/8".parse().expect("valid CIDR")],
119151
vec![
120152
DomainAdvertisement {
121-
domain: "contoso.local".to_owned(),
153+
domain: DomainName::new("contoso.local"),
122154
auto_detected: false,
123155
},
124156
DomainAdvertisement {
125-
domain: "finance.contoso.local".to_owned(),
157+
domain: DomainName::new("finance.contoso.local"),
126158
auto_detected: true,
127159
},
128160
],
@@ -134,9 +166,9 @@ mod tests {
134166
match &decoded {
135167
ControlMessage::RouteAdvertise { domains, .. } => {
136168
assert_eq!(domains.len(), 2);
137-
assert_eq!(domains[0].domain, "contoso.local");
169+
assert_eq!(domains[0].domain.as_str(), "contoso.local");
138170
assert!(!domains[0].auto_detected);
139-
assert_eq!(domains[1].domain, "finance.contoso.local");
171+
assert_eq!(domains[1].domain.as_str(), "finance.contoso.local");
140172
assert!(domains[1].auto_detected);
141173
}
142174
_ => panic!("expected RouteAdvertise"),
@@ -180,25 +212,29 @@ mod proptests {
180212
use crate::stream::ControlStream;
181213
use crate::version::CURRENT_PROTOCOL_VERSION;
182214

183-
fn arb_ipv4_network() -> impl Strategy<Value = Ipv4Network> {
215+
fn arb_ip_network() -> impl Strategy<Value = IpNetwork> {
184216
(any::<[u8; 4]>(), 0u8..=32).prop_map(|(octets, prefix)| {
185217
let ip = std::net::Ipv4Addr::from(octets);
186-
Ipv4Network::new(ip, prefix)
187-
.map(|n| Ipv4Network::new(n.network(), prefix).expect("normalized network should be valid"))
188-
.unwrap_or_else(|_| Ipv4Network::new(std::net::Ipv4Addr::UNSPECIFIED, 0).expect("0.0.0.0/0 is valid"))
218+
ipnetwork::Ipv4Network::new(ip, prefix)
219+
.map(|n| IpNetwork::V4(ipnetwork::Ipv4Network::new(n.network(), prefix).expect("normalized")))
220+
.unwrap_or_else(|_| {
221+
IpNetwork::V4(ipnetwork::Ipv4Network::new(std::net::Ipv4Addr::UNSPECIFIED, 0).expect("0.0.0.0/0"))
222+
})
189223
})
190224
}
191225

192226
fn arb_domain_advertisement() -> impl Strategy<Value = DomainAdvertisement> {
193-
("[a-z]{3,10}\\.[a-z]{2,5}", any::<bool>())
194-
.prop_map(|(domain, auto_detected)| DomainAdvertisement { domain, auto_detected })
227+
("[a-z]{3,10}\\.[a-z]{2,5}", any::<bool>()).prop_map(|(domain, auto_detected)| DomainAdvertisement {
228+
domain: DomainName::new(domain),
229+
auto_detected,
230+
})
195231
}
196232

197233
fn arb_control_message() -> impl Strategy<Value = ControlMessage> {
198234
prop_oneof![
199235
(
200236
any::<u64>(),
201-
proptest::collection::vec(arb_ipv4_network(), 0..50),
237+
proptest::collection::vec(arb_ip_network(), 0..50),
202238
proptest::collection::vec(arb_domain_advertisement(), 0..5),
203239
)
204240
.prop_map(|(epoch, subnets, domains)| {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub mod session;
1919
pub mod stream;
2020
pub mod version;
2121

22-
pub use control::{ControlMessage, DomainAdvertisement, MAX_CONTROL_MESSAGE_SIZE};
22+
pub use control::{ControlMessage, DomainAdvertisement, DomainName, MAX_CONTROL_MESSAGE_SIZE};
2323
pub use error::ProtoError;
2424
pub use session::{ConnectRequest, ConnectResponse, MAX_SESSION_MESSAGE_SIZE};
2525
pub use stream::{ControlRecvStream, ControlSendStream, ControlStream, SessionStream};

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

Lines changed: 46 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -12,42 +12,6 @@ use crate::control::{ControlMessage, MAX_CONTROL_MESSAGE_SIZE};
1212
use crate::error::ProtoError;
1313
use crate::session::{ConnectRequest, ConnectResponse, MAX_SESSION_MESSAGE_SIZE};
1414

15-
/// Encode a message as length-prefixed bincode and write to a stream.
16-
async fn write_framed<W: AsyncWrite + Unpin>(writer: &mut W, payload: &[u8], max_size: u32) -> Result<(), ProtoError> {
17-
let len = u32::try_from(payload.len()).map_err(|_| ProtoError::MessageTooLarge {
18-
size: u32::MAX,
19-
max: max_size,
20-
})?;
21-
if len > max_size {
22-
return Err(ProtoError::MessageTooLarge {
23-
size: len,
24-
max: max_size,
25-
});
26-
}
27-
writer.write_all(&len.to_be_bytes()).await?;
28-
writer.write_all(payload).await?;
29-
writer.flush().await?;
30-
Ok(())
31-
}
32-
33-
/// Read a length-prefixed bincode message from a stream.
34-
async fn read_framed<R: AsyncRead + Unpin>(reader: &mut R, max_size: u32) -> Result<Vec<u8>, ProtoError> {
35-
let mut len_buf = [0u8; 4];
36-
reader.read_exact(&mut len_buf).await?;
37-
let len = u32::from_be_bytes(len_buf);
38-
39-
if len > max_size {
40-
return Err(ProtoError::MessageTooLarge {
41-
size: len,
42-
max: max_size,
43-
});
44-
}
45-
46-
let mut payload = vec![0u8; len as usize];
47-
reader.read_exact(&mut payload).await?;
48-
Ok(payload)
49-
}
50-
5115
// ---------------------------------------------------------------------------
5216
// Control stream — bidirectional, send-only, recv-only
5317
// ---------------------------------------------------------------------------
@@ -58,6 +22,12 @@ pub struct ControlStream<S, R> {
5822
pub recv: R,
5923
}
6024

25+
/// Send-only half of a control stream.
26+
pub struct ControlSendStream<S>(pub S);
27+
28+
/// Recv-only half of a control stream.
29+
pub struct ControlRecvStream<R>(pub R);
30+
6131
impl<S, R> From<(S, R)> for ControlStream<S, R> {
6232
fn from((send, recv): (S, R)) -> Self {
6333
Self { send, recv }
@@ -86,19 +56,13 @@ impl<S: AsyncWrite + Unpin, R: AsyncRead + Unpin> ControlStream<S, R> {
8656
}
8757
}
8858

89-
/// Send-only half of a control stream.
90-
pub struct ControlSendStream<S>(pub S);
91-
9259
impl<S: AsyncWrite + Unpin> ControlSendStream<S> {
9360
pub async fn send(&mut self, msg: &ControlMessage) -> Result<(), ProtoError> {
9461
let payload = bincode::serialize(msg)?;
9562
write_framed(&mut self.0, &payload, MAX_CONTROL_MESSAGE_SIZE).await
9663
}
9764
}
9865

99-
/// Recv-only half of a control stream.
100-
pub struct ControlRecvStream<R>(pub R);
101-
10266
impl<R: AsyncRead + Unpin> ControlRecvStream<R> {
10367
pub async fn recv(&mut self) -> Result<ControlMessage, ProtoError> {
10468
let payload = read_framed(&mut self.0, MAX_CONTROL_MESSAGE_SIZE).await?;
@@ -158,3 +122,43 @@ impl<S: AsyncWrite + Unpin, R: AsyncRead + Unpin> SessionStream<S, R> {
158122
(self.send, self.recv)
159123
}
160124
}
125+
126+
// ---------------------------------------------------------------------------
127+
// Framing helpers (length-prefixed bincode)
128+
// ---------------------------------------------------------------------------
129+
130+
/// Encode a message as length-prefixed bincode and write to a stream.
131+
async fn write_framed<W: AsyncWrite + Unpin>(writer: &mut W, payload: &[u8], max_size: u32) -> Result<(), ProtoError> {
132+
let len = u32::try_from(payload.len()).map_err(|_| ProtoError::MessageTooLarge {
133+
size: u32::MAX,
134+
max: max_size,
135+
})?;
136+
if len > max_size {
137+
return Err(ProtoError::MessageTooLarge {
138+
size: len,
139+
max: max_size,
140+
});
141+
}
142+
writer.write_all(&len.to_be_bytes()).await?;
143+
writer.write_all(payload).await?;
144+
writer.flush().await?;
145+
Ok(())
146+
}
147+
148+
/// Read a length-prefixed bincode message from a stream.
149+
async fn read_framed<R: AsyncRead + Unpin>(reader: &mut R, max_size: u32) -> Result<Vec<u8>, ProtoError> {
150+
let mut len_buf = [0u8; 4];
151+
reader.read_exact(&mut len_buf).await?;
152+
let len = u32::from_be_bytes(len_buf);
153+
154+
if len > max_size {
155+
return Err(ProtoError::MessageTooLarge {
156+
size: len,
157+
max: max_size,
158+
});
159+
}
160+
161+
let mut payload = vec![0u8; len as usize];
162+
reader.read_exact(&mut payload).await?;
163+
Ok(payload)
164+
}

devolutions-agent/src/tunnel.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use agent_tunnel_proto::{
1212
use anyhow::{Context as _, bail};
1313
use async_trait::async_trait;
1414
use devolutions_gateway_task::{ShutdownSignal, Task};
15-
use ipnetwork::Ipv4Network;
15+
use ipnetwork::IpNetwork;
1616
use sha2::Digest as _;
1717

1818
use crate::config::ConfHandle;
@@ -185,7 +185,7 @@ async fn run_single_connection(conf_handle: &ConfHandle, shutdown_signal: &mut S
185185
let key_path = &tunnel_conf.client_key_path;
186186
let ca_path = &tunnel_conf.gateway_ca_cert_path;
187187

188-
let advertise_subnets: Vec<Ipv4Network> = tunnel_conf
188+
let advertise_subnets: Vec<IpNetwork> = tunnel_conf
189189
.advertise_subnets
190190
.iter()
191191
.map(|subnet| subnet.parse())
@@ -201,7 +201,7 @@ async fn run_single_connection(conf_handle: &ConfHandle, shutdown_signal: &mut S
201201
.advertise_domains
202202
.iter()
203203
.map(|d| agent_tunnel_proto::DomainAdvertisement {
204-
domain: d.clone(),
204+
domain: agent_tunnel_proto::DomainName::new(d),
205205
auto_detected: false,
206206
})
207207
.collect();
@@ -210,11 +210,11 @@ async fn run_single_connection(conf_handle: &ConfHandle, shutdown_signal: &mut S
210210
if let Some(detected) = crate::domain_detect::detect_domain() {
211211
if !advertise_domains
212212
.iter()
213-
.any(|d| d.domain.eq_ignore_ascii_case(&detected))
213+
.any(|d| d.domain.as_str().eq_ignore_ascii_case(&detected))
214214
{
215215
info!(domain = %detected, "Auto-detected DNS domain");
216216
advertise_domains.push(agent_tunnel_proto::DomainAdvertisement {
217-
domain: detected,
217+
domain: agent_tunnel_proto::DomainName::new(detected),
218218
auto_detected: true,
219219
});
220220
}
@@ -435,7 +435,7 @@ async fn run_control_reader<R: tokio::io::AsyncRead + Unpin>(mut ctrl: ControlRe
435435
// Session proxy
436436
// ---------------------------------------------------------------------------
437437

438-
async fn run_session_proxy(advertise_subnets: Vec<Ipv4Network>, send: quinn::SendStream, recv: quinn::RecvStream) {
438+
async fn run_session_proxy(advertise_subnets: Vec<IpNetwork>, send: quinn::SendStream, recv: quinn::RecvStream) {
439439
let _: anyhow::Result<()> = async {
440440
let mut session: SessionStream<_, _> = (send, recv).into();
441441

devolutions-agent/src/tunnel_helpers.rs

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
1-
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
1+
use std::net::{IpAddr, SocketAddr};
22

33
use anyhow::{Context as _, bail};
4-
use ipnetwork::Ipv4Network;
4+
use ipnetwork::IpNetwork;
55
use tokio::net::TcpStream;
66

77
/// Parsed connection target — either a raw IP or a domain name.
88
#[derive(Debug)]
99
pub(crate) enum Target {
10-
Ip(Ipv4Addr, u16),
10+
Ip(IpAddr, u16),
1111
Domain(String, u16),
1212
}
1313

1414
impl Target {
1515
/// Parse a `host:port` string into a typed target.
1616
pub(crate) fn parse(target: &str) -> anyhow::Result<Self> {
17-
// Try IP:port first.
17+
// Try IP:port first (handles both IPv4 and IPv6).
1818
if let Ok(addr) = target.parse::<SocketAddr>() {
19-
return match addr.ip() {
20-
IpAddr::V4(ip) => Ok(Self::Ip(ip, addr.port())),
21-
IpAddr::V6(_) => bail!("IPv6 targets are not supported: {target}"),
22-
};
19+
return Ok(Self::Ip(addr.ip(), addr.port()));
2320
}
2421

2522
// Otherwise it's domain:port — split on last ':'.
@@ -37,24 +34,21 @@ impl Target {
3734
/// Resolve a target to candidate socket addresses within the advertised subnets.
3835
pub(crate) async fn resolve_target(
3936
target: &Target,
40-
advertise_subnets: &[Ipv4Network],
37+
advertise_subnets: &[IpNetwork],
4138
) -> anyhow::Result<Vec<SocketAddr>> {
4239
match target {
4340
Target::Ip(ip, port) => {
4441
if !advertise_subnets.iter().any(|subnet| subnet.contains(*ip)) {
4542
bail!("target {ip}:{port} is not in advertised subnets");
4643
}
47-
Ok(vec![SocketAddr::new(IpAddr::V4(*ip), *port)])
44+
Ok(vec![SocketAddr::new(*ip, *port)])
4845
}
4946
Target::Domain(host, port) => {
5047
let lookup = format!("{host}:{port}");
5148
let resolved: Vec<SocketAddr> = tokio::net::lookup_host(&lookup)
5249
.await
5350
.with_context(|| format!("resolve target {lookup}"))?
54-
.filter(|addr| match addr.ip() {
55-
IpAddr::V4(ipv4) => advertise_subnets.iter().any(|subnet| subnet.contains(ipv4)),
56-
IpAddr::V6(_) => false,
57-
})
51+
.filter(|addr| advertise_subnets.iter().any(|subnet| subnet.contains(addr.ip())))
5852
.collect();
5953

6054
if resolved.is_empty() {

devolutions-gateway/src/agent_tunnel/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ pub mod cert;
77
pub mod enrollment_store;
88
pub mod listener;
99
pub mod registry;
10+
pub mod routing;
1011
pub mod stream;
1112

13+
// Integration test needs rewriting for Quinn — kept as local-only file.
14+
// #[cfg(test)]
15+
// mod integration_test;
16+
1217
pub use enrollment_store::EnrollmentTokenStore;
1318
pub use listener::{AgentTunnelHandle, AgentTunnelListener};
1419
pub use registry::AgentRegistry;

0 commit comments

Comments
 (0)