Skip to main content

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}