krabka_client_core/transport.rs
1//! Outbound TCP connections and name resolution.
2//!
3//! [`dial`] opens every outbound TCP connection of this crate: the broker
4//! connections of [`Connection`](crate::Connection) and the connections to an
5//! OAuth token endpoint. By default it connects through the operating system.
6//!
7//! An embedder that supplies its own sockets installs a [`Connector`] once per
8//! process with [`install_connector`]. After that, [`dial`] gets every
9//! connection from the connector. WASI preview 1 has no `connect` call, so on
10//! `target_os = "wasi"` a connector is the only source of connections.
11//!
12//! [`resolve`] turns a `host:port` address into socket addresses. An IP
13//! address literal needs no lookup on any target. WASI has no name
14//! resolution, so on WASI a host name is an error.
15
16use std::{
17 future::Future,
18 io,
19 net::{IpAddr, SocketAddr},
20 pin::Pin,
21 sync::OnceLock,
22};
23
24use krabka_units::ByteSize;
25use thiserror::Error;
26use tokio::net::TcpStream;
27
28// The operating system side of this module: connections and name resolution.
29#[cfg_attr(not(target_os = "wasi"), path = "transport/native.rs")]
30#[cfg_attr(target_os = "wasi", path = "transport/wasi.rs")]
31mod platform;
32
33/// The future of [`Connector::connect`].
34pub type ConnectFuture = Pin<Box<dyn Future<Output = io::Result<TcpStream>> + Send + 'static>>;
35
36/// A source of outbound TCP connections in place of the operating system.
37///
38/// An embedder implements this trait when it supplies the sockets itself, for
39/// example a WASI host that connects sockets on a virtual network.
40/// [`install_connector`] makes a connector the source of every connection
41/// that [`dial`] opens, for the life of the process.
42///
43/// # Examples
44///
45/// ```
46/// use krabka_client_core::transport::{ConnectFuture, Connector, install_connector};
47/// use tokio::net::TcpStream;
48///
49/// /// Connects every broker address to the same port on this machine.
50/// struct Loopback;
51///
52/// impl Connector for Loopback {
53/// fn connect(&self, _host: &str, port: u16) -> ConnectFuture {
54/// Box::pin(TcpStream::connect(("127.0.0.1", port)))
55/// }
56/// }
57///
58/// install_connector(Box::new(Loopback)).expect("no other connector in this process");
59/// ```
60pub trait Connector: Send + Sync + 'static {
61 /// Start a TCP connection to `host` at `port`, and return the future of
62 /// the connection.
63 ///
64 /// `host` is the text that the caller dials: a host name or an IP address
65 /// literal, with no resolution. The future owns the data that it needs,
66 /// so it can outlive `host`.
67 ///
68 /// An implementation must not block the thread, in this call or in a
69 /// poll of the future. [`dial`] awaits the future, so the timeouts of its
70 /// callers stop a connection that does not open, and other tasks on the
71 /// same runtime continue. The stream can still be connecting when the
72 /// future completes. A refused connection then shows as an error on the
73 /// first read or write.
74 ///
75 /// # Errors
76 ///
77 /// The future fails with the error that stops the connection, for
78 /// example an unknown host. [`dial`] gives this error to its caller.
79 fn connect(&self, host: &str, port: u16) -> ConnectFuture;
80}
81
82/// The error of [`install_connector`] when the process already has a
83/// connector.
84#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
85#[error("a transport connector is already installed")]
86pub struct ConnectorAlreadyInstalled;
87
88/// The connector of the process, once installed.
89static CONNECTOR: OnceLock<Box<dyn Connector>> = OnceLock::new();
90
91/// Make `connector` the source of every connection that [`dial`] opens.
92///
93/// The connector stays installed for the life of the process. Install it
94/// before the first connection: a connection that is already open keeps its
95/// socket.
96///
97/// # Errors
98///
99/// Returns [`ConnectorAlreadyInstalled`] if the process already has a
100/// connector. The installed connector stays, and `connector` is dropped.
101pub fn install_connector(connector: Box<dyn Connector>) -> Result<(), ConnectorAlreadyInstalled> {
102 CONNECTOR
103 .set(connector)
104 .map_err(|_| ConnectorAlreadyInstalled)
105}
106
107/// Socket settings that [`dial`] applies to a connection that it opens
108/// through the operating system.
109///
110/// A connection from a [`Connector`] gets none of these settings.
111#[derive(Clone, Copy, Debug, Default, PartialEq)]
112pub struct SocketOptions {
113 /// The socket send buffer (`SO_SNDBUF`), set before the connection
114 /// starts. `None` keeps the operating system default.
115 pub send_buffer: Option<ByteSize>,
116 /// The socket receive buffer (`SO_RCVBUF`), set before the connection
117 /// starts. `None` keeps the operating system default.
118 pub receive_buffer: Option<ByteSize>,
119 /// Turn off Nagle's algorithm (`TCP_NODELAY`) when the connection is
120 /// open. If the operating system refuses the option, the connection
121 /// stays usable.
122 pub nodelay: bool,
123}
124
125/// Open a TCP connection to `host` at `port`.
126///
127/// With an installed [`Connector`], `dial` awaits the future of
128/// [`Connector::connect`]. The stream can still be connecting, so a refused
129/// connection can show as an error on the first read or write.
130///
131/// With no connector, `dial` resolves `host` as [`resolve`] does and connects
132/// to each address in turn with `options`. It returns the first connection
133/// that opens. On WASI, `dial` needs a connector, because WASI preview 1 has
134/// no `connect` call.
135///
136/// # Errors
137///
138/// Returns the error of the connector, the error of the name lookup, or the
139/// error of the last address that did not connect. With no connector on
140/// WASI, returns an error of kind [`io::ErrorKind::Unsupported`].
141pub async fn dial(host: &str, port: u16, options: SocketOptions) -> io::Result<TcpStream> {
142 match CONNECTOR.get() {
143 Some(connector) => connector.connect(host, port).await,
144 None => platform::connect(host, port, options).await,
145 }
146}
147
148/// [`dial`] the IP address and port of `address`.
149pub(crate) async fn dial_address(
150 address: SocketAddr,
151 options: SocketOptions,
152) -> io::Result<TcpStream> {
153 dial(&host_text(address), address.port(), options).await
154}
155
156/// The IP address of `address` as the host text for [`dial`], with the scope
157/// id of a scoped IPv6 address.
158fn host_text(address: SocketAddr) -> String {
159 match address {
160 SocketAddr::V6(v6) if v6.scope_id() != 0 => format!("{}%{}", v6.ip(), v6.scope_id()),
161 address => address.ip().to_string(),
162 }
163}
164
165/// Resolve a `host:port` address to the socket addresses to connect to, in
166/// order.
167///
168/// An address with an IP address literal, such as `10.0.0.1:9092` or
169/// `[::1]:9092`, resolves to itself with no lookup. The operating system
170/// resolves a host name. WASI has no name resolution, so on WASI a host name
171/// fails with an error of kind [`io::ErrorKind::Unsupported`].
172///
173/// # Errors
174///
175/// Returns an error of kind [`io::ErrorKind::InvalidInput`] if `address` has
176/// no valid port, and the error of the operating system resolver if the
177/// lookup fails.
178pub async fn resolve(address: &str) -> io::Result<Vec<SocketAddr>> {
179 if let Ok(literal) = address.parse::<SocketAddr>() {
180 return Ok(vec![literal]);
181 }
182 // The errors of std's `ToSocketAddrs for str`, which splits the same way.
183 let (host, port) = address
184 .rsplit_once(':')
185 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid socket address"))?;
186 let port = port
187 .parse()
188 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid port value"))?;
189 resolve_host(host, port).await
190}
191
192/// Resolve `host` at `port` to the socket addresses to connect to, as
193/// [`resolve`] does.
194pub(crate) async fn resolve_host(host: &str, port: u16) -> io::Result<Vec<SocketAddr>> {
195 match literal_address(host, port) {
196 Some(literal) => Ok(vec![literal]),
197 None => platform::resolve_name(host, port).await,
198 }
199}
200
201/// The socket address of `host` at `port` when `host` is an IP address
202/// literal.
203fn literal_address(host: &str, port: u16) -> Option<SocketAddr> {
204 host.parse::<IpAddr>()
205 .map(|ip| SocketAddr::new(ip, port))
206 .ok()
207 // Only the socket address syntax carries the scope id of an IPv6
208 // address, such as the `3` of `fe80::1%3`.
209 .or_else(|| format!("[{host}]:{port}").parse().ok())
210}
211
212#[cfg(test)]
213mod tests {
214 use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6};
215
216 use assert2::check;
217
218 use super::*;
219
220 fn scoped(port: u16) -> SocketAddr {
221 SocketAddr::V6(SocketAddrV6::new("fe80::1".parse().unwrap(), port, 0, 3))
222 }
223
224 #[tokio::test]
225 async fn resolve_takes_literals_and_rejects_an_address_without_a_port() {
226 let v4 = SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1), 9092));
227 let v6 = SocketAddr::from((Ipv6Addr::LOCALHOST, 9093));
228 let invalid = |message: &str| Err((io::ErrorKind::InvalidInput, message.to_owned()));
229 for (address, expected) in [
230 ("10.0.0.1:9092", Ok(vec![v4])),
231 ("[::1]:9093", Ok(vec![v6])),
232 ("[fe80::1%3]:9092", Ok(vec![scoped(9092)])),
233 ("broker", invalid("invalid socket address")),
234 (":", invalid("invalid port value")),
235 ("broker:port", invalid("invalid port value")),
236 ("broker:65536", invalid("invalid port value")),
237 ] {
238 let resolved = resolve(address)
239 .await
240 .map_err(|error| (error.kind(), error.to_string()));
241 check!(resolved == expected, "{address}");
242 }
243 }
244
245 #[tokio::test]
246 async fn resolve_host_takes_ip_literals_with_and_without_a_scope() {
247 for (host, expected) in [
248 (
249 "10.0.0.1",
250 SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1), 9092)),
251 ),
252 ("::1", SocketAddr::from((Ipv6Addr::LOCALHOST, 9092))),
253 ("fe80::1%3", scoped(9092)),
254 ] {
255 check!(
256 resolve_host(host, 9092).await.unwrap() == vec![expected],
257 "{host}"
258 );
259 }
260 }
261
262 #[tokio::test]
263 async fn resolve_asks_the_operating_system_for_a_host_name() {
264 let resolved = resolve("localhost:9092").await.unwrap();
265 check!(resolved.contains(&SocketAddr::from((Ipv4Addr::LOCALHOST, 9092))));
266 }
267
268 #[test]
269 fn host_text_keeps_the_scope_of_an_ipv6_address() {
270 for (address, expected) in [
271 (
272 SocketAddr::from((Ipv4Addr::new(10, 0, 0, 1), 9092)),
273 "10.0.0.1",
274 ),
275 (SocketAddr::from((Ipv6Addr::LOCALHOST, 9092)), "::1"),
276 (scoped(9092), "fe80::1%3"),
277 ] {
278 check!(host_text(address) == expected);
279 check!(literal_address(&host_text(address), address.port()) == Some(address));
280 }
281 }
282
283 #[test]
284 fn a_host_name_is_not_a_literal() {
285 for host in ["localhost", "broker.example", "[::1]", "fe80::1%eth0", ""] {
286 check!(literal_address(host, 9092) == None, "{host}");
287 }
288 }
289}