|
| 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 | +} |
0 commit comments