Skip to content

Commit c0a9a8c

Browse files
fix(agent): unbracket IPv6 hostname before passing to Rustls ServerName
1 parent 2ce4ed9 commit c0a9a8c

3 files changed

Lines changed: 169 additions & 5 deletions

File tree

devolutions-agent/src/endpoint.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
//! Helpers for parsing `host:port` endpoint strings used by the agent tunnel.
2+
//!
3+
//! The agent persists gateway endpoints as `format_endpoint(host, port)` (see
4+
//! [`crate::enrollment::format_endpoint`]) — DNS / IPv4 stay as-is, IPv6
5+
//! literals are wrapped in brackets: `[fd00::7]:4433`.
6+
//!
7+
//! When that string is later split back into `(host, port)` we MUST drop the
8+
//! brackets from the IPv6 host before passing it to Rustls / Quinn: Rustls'
9+
//! [`rustls_pki_types::ServerName`] does not accept a bracketed IPv6 literal,
10+
//! and a naive `rsplit_once(':')` would leave `[fd00::7]` as the "host" half.
11+
//!
12+
//! Both `tunnel.rs` (runtime) and `verify_tunnel` (one-shot probe) need this
13+
//! same split, hence the shared module.
14+
15+
use anyhow::{Context as _, Result, bail};
16+
17+
/// The host part of a parsed endpoint, ready to be used as a TLS server name
18+
/// and/or DNS-resolved.
19+
///
20+
/// IPv6 literals are returned **without** surrounding brackets — that's the
21+
/// form Rustls expects.
22+
#[derive(Debug, Clone, PartialEq, Eq)]
23+
pub struct EndpointHost(String);
24+
25+
impl EndpointHost {
26+
/// View the host as a plain string (no brackets for IPv6 literals).
27+
pub fn as_str(&self) -> &str {
28+
&self.0
29+
}
30+
}
31+
32+
impl std::fmt::Display for EndpointHost {
33+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34+
f.write_str(&self.0)
35+
}
36+
}
37+
38+
/// Split a `host:port` endpoint string into its host and port components.
39+
///
40+
/// Accepts:
41+
/// - `gateway.example.com:4433` (DNS)
42+
/// - `10.10.0.7:4433` (IPv4)
43+
/// - `[fd00::7]:4433` (IPv6 literal, bracketed)
44+
///
45+
/// The returned host is always unbracketed — safe to pass to
46+
/// [`rustls_pki_types::ServerName::try_from`] and to DNS resolvers. The full
47+
/// original string (with brackets, if any) is still appropriate for
48+
/// `tokio::net::lookup_host` because both bracketed and unbracketed IPv6
49+
/// `host:port` forms are accepted there; callers that already have the raw
50+
/// endpoint can keep using it directly for lookup.
51+
pub fn split_endpoint(endpoint: &str) -> Result<(EndpointHost, u16)> {
52+
let trimmed = endpoint.trim();
53+
if trimmed.is_empty() {
54+
bail!("endpoint is empty");
55+
}
56+
57+
// IPv6 bracketed form first: "[<host>]:<port>".
58+
if let Some(after_open) = trimmed.strip_prefix('[') {
59+
let (host_part, rest) = after_open
60+
.split_once(']')
61+
.with_context(|| format!("missing ']' in bracketed endpoint: {endpoint}"))?;
62+
let port_str = rest
63+
.strip_prefix(':')
64+
.with_context(|| format!("missing ':' after ']' in bracketed endpoint: {endpoint}"))?;
65+
let port: u16 = port_str
66+
.parse()
67+
.with_context(|| format!("invalid port in endpoint: {endpoint}"))?;
68+
if host_part.is_empty() {
69+
bail!("empty host inside brackets: {endpoint}");
70+
}
71+
return Ok((EndpointHost(host_part.to_owned()), port));
72+
}
73+
74+
// Unbracketed: DNS or IPv4. Split on the last ':' — DNS / IPv4 have no
75+
// other colons in the host part.
76+
let (host, port_str) = trimmed
77+
.rsplit_once(':')
78+
.with_context(|| format!("endpoint missing port: {endpoint}"))?;
79+
if host.is_empty() {
80+
bail!("empty host in endpoint: {endpoint}");
81+
}
82+
let port: u16 = port_str
83+
.parse()
84+
.with_context(|| format!("invalid port in endpoint: {endpoint}"))?;
85+
Ok((EndpointHost(host.to_owned()), port))
86+
}
87+
88+
#[cfg(test)]
89+
mod tests {
90+
use super::*;
91+
92+
#[test]
93+
fn split_dns_endpoint() {
94+
let (host, port) = split_endpoint("gateway.example.com:4433").expect("dns");
95+
assert_eq!(host.as_str(), "gateway.example.com");
96+
assert_eq!(port, 4433);
97+
}
98+
99+
#[test]
100+
fn split_ipv4_endpoint() {
101+
let (host, port) = split_endpoint("10.10.0.7:4433").expect("ipv4");
102+
assert_eq!(host.as_str(), "10.10.0.7");
103+
assert_eq!(port, 4433);
104+
}
105+
106+
#[test]
107+
fn split_ipv6_bracketed_endpoint_unbrackets_host() {
108+
let (host, port) = split_endpoint("[fd00::7]:4433").expect("ipv6 bracketed");
109+
// Critical: the host must NOT include the surrounding brackets so it
110+
// can be passed straight to `rustls_pki_types::ServerName::try_from`.
111+
assert_eq!(host.as_str(), "fd00::7");
112+
assert_eq!(port, 4433);
113+
}
114+
115+
#[test]
116+
fn split_ipv6_bracketed_host_parses_as_rustls_server_name() {
117+
let (host, _port) = split_endpoint("[fd00::7]:4433").expect("ipv6 bracketed");
118+
let server_name = rustls_pki_types::ServerName::try_from(host.as_str().to_owned());
119+
assert!(
120+
server_name.is_ok(),
121+
"unbracketed IPv6 literal must be a valid rustls ServerName, got: {:?}",
122+
server_name.err()
123+
);
124+
}
125+
126+
#[test]
127+
fn split_dns_host_parses_as_rustls_server_name() {
128+
let (host, _port) = split_endpoint("gateway.example.com:4433").expect("dns");
129+
let server_name = rustls_pki_types::ServerName::try_from(host.as_str().to_owned());
130+
assert!(server_name.is_ok());
131+
}
132+
133+
#[test]
134+
fn split_rejects_missing_port() {
135+
let err = split_endpoint("gateway.example.com").expect_err("must reject");
136+
let msg = format!("{err:#}");
137+
assert!(msg.contains("missing port"), "got: {msg}");
138+
}
139+
140+
#[test]
141+
fn split_rejects_empty_host_brackets() {
142+
let err = split_endpoint("[]:4433").expect_err("must reject empty brackets");
143+
let msg = format!("{err:#}");
144+
assert!(msg.contains("empty host"), "got: {msg}");
145+
}
146+
147+
#[test]
148+
fn split_rejects_unparseable_port() {
149+
let err = split_endpoint("gateway.example.com:notaport").expect_err("must reject");
150+
let msg = format!("{err:#}");
151+
assert!(msg.contains("invalid port"), "got: {msg}");
152+
}
153+
}

devolutions-agent/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ extern crate tracing;
77

88
pub mod config;
99
pub mod domain_detect;
10+
pub mod endpoint;
1011
pub mod enrollment;
1112
pub mod log;
1213
pub mod remote_desktop;

devolutions-agent/src/tunnel.rs

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,10 +325,20 @@ async fn run_single_connection(
325325
// -- DNS resolve --
326326

327327
// Extract hostname for TLS server name validation.
328-
let (gateway_hostname, _) = tunnel_conf
329-
.gateway_endpoint
330-
.rsplit_once(':')
331-
.context("gateway_endpoint missing port separator")?;
328+
//
329+
// We split via the shared `endpoint::split_endpoint` helper rather than a
330+
// raw `rsplit_once(':')` because the persisted endpoint may be an IPv6
331+
// literal in bracketed form — e.g. `[fd00::7]:4433`. A raw `rsplit_once`
332+
// would leave `gateway_hostname` as `[fd00::7]` (brackets included), and
333+
// `rustls_pki_types::ServerName::try_from` would then reject it. The
334+
// helper hands back an unbracketed host suitable for Rustls / Quinn.
335+
//
336+
// Lookup uses the original endpoint string (brackets + port) — both
337+
// bracketed and unbracketed IPv6 host:port forms are accepted by
338+
// `tokio::net::lookup_host`, but the bracketed form is the canonical
339+
// one we persist.
340+
let (gateway_hostname, _) = crate::endpoint::split_endpoint(&tunnel_conf.gateway_endpoint)
341+
.context("parse gateway_endpoint")?;
332342

333343
let gateway_addr = tokio::net::lookup_host(&tunnel_conf.gateway_endpoint)
334344
.await
@@ -356,7 +366,7 @@ async fn run_single_connection(
356366
endpoint.set_default_client_config(client_config);
357367

358368
let connection = endpoint
359-
.connect(gateway_addr, gateway_hostname)
369+
.connect(gateway_addr, gateway_hostname.as_str())
360370
.context("initiate QUIC connection")?
361371
.await
362372
.context("QUIC handshake")?;

0 commit comments

Comments
 (0)