Skip to main content

moq_native/
tcp.rs

1//! Plain-TCP qmux transport, reachable via the `tcp://` URL scheme.
2//!
3//! Runs the QMux wire format directly over TCP with no TLS or WebSocket
4//! framing. There is no transport encryption and no authentication, so only
5//! use this on a trusted network (loopback, a private VPC interface, etc.).
6//!
7//! TCP has no TLS handshake, so the application protocol (the moq ALPN) is
8//! negotiated in-band: pass the offered/supported protocols and the resulting
9//! `qmux::Session::protocol()` is populated before connect/accept returns.
10
11use std::net;
12use url::Url;
13
14use crate::RedactedUrl;
15
16/// The QMux wire-format version both ends speak over a raw stream. Fixed (not
17/// negotiated) since there's no TLS ALPN to carry it.
18const WIRE_VERSION: qmux::Version = qmux::Version::QMux01;
19
20/// Plaintext-TCP qmux listener settings (no TLS, no UDP).
21///
22/// Flattened onto [`crate::ServerConfig::tcp`]. TCP carries no peer identity, so
23/// the listener must only be reachable from trusted clients. Bind it to loopback
24/// or a private interface; a non-loopback bind logs a warning but is allowed.
25// The derived arg group is named after the struct, so it needs an explicit id to
26// stay unique across the flattened sections.
27#[derive(clap::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
28#[group(id = "server-tcp")]
29#[serde(deny_unknown_fields, default)]
30#[non_exhaustive]
31pub struct Config {
32	/// Bind a plaintext qmux TCP listener on this address.
33	#[arg(long = "server-tcp-bind", id = "server-tcp-bind", env = "MOQ_SERVER_TCP_BIND")]
34	#[serde(default, skip_serializing_if = "Option::is_none")]
35	pub bind: Option<net::SocketAddr>,
36}
37
38/// Errors specific to the plain-TCP qmux transport.
39#[derive(Debug, thiserror::Error)]
40#[non_exhaustive]
41pub enum Error {
42	/// The TCP socket failed to bind or connect, or the host failed to resolve. Not
43	/// accept: a failed `accept(2)` is the listener's own to classify and retry
44	/// (see [`crate::accept`]).
45	#[error(transparent)]
46	Io(#[from] std::io::Error),
47
48	/// The `tcp://` URL had no host.
49	#[error("missing hostname")]
50	MissingHostname,
51
52	/// The `tcp://` URL had no port. Unlike `https`, there is no default.
53	#[error("missing port")]
54	MissingPort,
55
56	/// The qmux handshake failed while dialing.
57	#[error("qmux connect failed")]
58	Connect(#[source] qmux::Error),
59
60	/// The qmux handshake failed while accepting.
61	#[error("qmux accept failed")]
62	Accept(#[source] qmux::Error),
63
64	/// DNS resolved the host to no addresses at all.
65	#[error("no addresses resolved")]
66	NoAddresses,
67
68	/// Two or more addresses were raced and every attempt failed, each paired
69	/// with its own error in dial order. All of them are kept: picking one to
70	/// report would bury a refused port behind whichever address happened to be
71	/// unroutable or to blackhole until its timeout. A host with a single address
72	/// reports that error directly instead.
73	#[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))]
74	Failover(Vec<crate::failover::Failure<Error>>),
75}
76
77impl crate::failover::Aggregate for Error {
78	fn aggregate(failures: Vec<crate::failover::Failure<Self>>) -> Self {
79		Self::Failover(failures)
80	}
81
82	fn resolve(error: Option<std::io::Error>) -> Self {
83		match error {
84			Some(error) => Self::Io(error),
85			None => Self::NoAddresses,
86		}
87	}
88}
89
90type Result<T> = std::result::Result<T, Error>;
91
92/// Dial a `tcp://host:port` URL, advertising `protocols` for in-band ALPN
93/// negotiation. Returns a qmux session over plain TCP.
94///
95/// The host is resolved alongside an IPv4-only lookup that answers without
96/// waiting for its AAAA record, `resolution_delay` apart, and the answers raced
97/// Happy Eyeballs style, staggered by `failover_delay` (see [`crate::failover`]).
98///
99/// The port is required; there is no default for the `tcp` scheme.
100pub(crate) async fn connect(
101	url: Url,
102	protocols: &[&str],
103	failover_delay: std::time::Duration,
104	resolution_delay: std::time::Duration,
105) -> Result<qmux::Session> {
106	let host = url.host().ok_or(Error::MissingHostname)?;
107	let port = url.port().ok_or(Error::MissingPort)?;
108
109	tracing::debug!(url = %RedactedUrl::new(&url), "connecting via TCP");
110	let candidates = crate::resolve::Candidates::resolve(host, port, resolution_delay);
111	connect_addrs(candidates, protocols, failover_delay).await
112}
113
114/// Dial `candidates` in Happy Eyeballs order, performing the qmux handshake on
115/// each attempt; the first session to complete wins.
116async fn connect_addrs(
117	candidates: crate::resolve::Candidates,
118	protocols: &[&str],
119	failover_delay: std::time::Duration,
120) -> Result<qmux::Session> {
121	crate::failover::race(candidates, failover_delay, |addr| {
122		let protocols: Vec<String> = protocols.iter().map(|&p| p.to_owned()).collect();
123		async move {
124			qmux::tcp::Config::new(WIRE_VERSION)
125				.protocols(protocols.iter().map(String::as_str))
126				.connect(addr)
127				.await
128				.map_err(Error::Connect)
129		}
130	})
131	.await
132}
133
134/// Listens for incoming plain-TCP qmux connections on a TCP port.
135pub struct Listener {
136	listener: tokio::net::TcpListener,
137	protocols: Vec<String>,
138	health: crate::accept::Health,
139}
140
141impl Listener {
142	/// Bind a TCP listener to the given address.
143	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
144		let listener = tokio::net::TcpListener::bind(addr).await?;
145		Ok(Self {
146			listener,
147			protocols: Vec::new(),
148			health: crate::accept::Health::new("tcp"),
149		})
150	}
151
152	/// A live handle to this listener's accept-loop health, for an embedder that
153	/// publishes it (see [`crate::accept`]).
154	pub fn accept_health(&self) -> crate::accept::Health {
155		self.health.clone()
156	}
157
158	/// Report into `health` instead of the one this listener made for itself.
159	///
160	/// For an owner that has to hand the handle out *before* the listener exists:
161	/// [`crate::Server`] binds these lazily (they need a runtime), but an embedder
162	/// registering them with a metrics endpoint does so at startup.
163	pub fn with_accept_health(mut self, health: crate::accept::Health) -> Self {
164		self.health = health;
165		self
166	}
167
168	/// Advertise these application protocols (moq ALPNs) for in-band negotiation,
169	/// in preference order. The first server entry the client also offers wins.
170	pub fn with_protocols<I, S>(mut self, protocols: I) -> Self
171	where
172		I: IntoIterator<Item = S>,
173		S: Into<String>,
174	{
175		self.protocols = protocols.into_iter().map(Into::into).collect();
176		self
177	}
178
179	/// The local address the listener is bound to.
180	pub fn local_addr(&self) -> Result<net::SocketAddr> {
181		Ok(self.listener.local_addr()?)
182	}
183
184	/// Accept the next connection, performing the qmux handshake over plain TCP.
185	///
186	/// A failed `accept(2)` is handled here rather than yielded: it is classified,
187	/// counted, logged, and paced by [`accept_health`](Self::accept_health), then
188	/// retried, because the caller has no better answer than to ask again. A
189	/// per-connection *handshake* failure is still yielded as `Some(Err(..))`.
190	///
191	/// The `Option` no longer has a `None` case to report: nothing ends the accept
192	/// loop, so this always yields. It stays because dropping it is a breaking change
193	/// to a published signature.
194	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
195		let (stream, addr) = self.accept_socket().await;
196		tracing::debug!(%addr, "accepted TCP connection");
197		let session = qmux::tcp::Config::new(WIRE_VERSION)
198			.protocols(self.protocols.iter().map(String::as_str))
199			.accept(stream)
200			.await
201			.map_err(Error::Accept);
202		Some(session)
203	}
204
205	/// The `accept(2)` half: keep asking until a connection comes back.
206	async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
207		loop {
208			match self.listener.accept().await {
209				Ok(accepted) => {
210					self.health.accepted();
211					return accepted;
212				}
213				Err(err) => {
214					if let Some(delay) = self.health.failed(&err) {
215						tokio::time::sleep(delay).await;
216					}
217				}
218			}
219		}
220	}
221}
222
223#[cfg(test)]
224mod tests {
225	use super::*;
226	use std::time::Duration;
227	use web_transport_trait::Session as _;
228
229	/// End-to-end failover: the preferred candidate blackholes (TEST-NET-1 never
230	/// answers, or is unroutable outright in a sandbox), so the race must fall
231	/// through to the loopback listener within the stagger delay.
232	#[tokio::test]
233	async fn failover_recovers_from_blackhole_candidate() {
234		let listener = Listener::bind("127.0.0.1:0".parse().unwrap())
235			.await
236			.expect("bind listener")
237			.with_protocols(["moq-test"]);
238		let addr = listener.local_addr().expect("local addr");
239
240		let accept = tokio::spawn(async move { listener.accept().await.expect("listener gone").expect("accept") });
241
242		let blackhole: net::SocketAddr = "192.0.2.1:9".parse().unwrap();
243		let candidates = crate::resolve::Candidates::fixed([blackhole, addr]);
244		let session = tokio::time::timeout(
245			Duration::from_secs(5),
246			connect_addrs(candidates, &["moq-test"], Duration::from_millis(50)),
247		)
248		.await
249		.expect("failover timed out")
250		.expect("connect failed");
251
252		assert_eq!(session.protocol(), Some("moq-test"));
253		accept.await.expect("accept task panicked");
254	}
255
256	#[tokio::test]
257	async fn connect_addrs_rejects_empty() {
258		let candidates = crate::resolve::Candidates::fixed([]);
259		let res = connect_addrs(candidates, &["moq-test"], Duration::ZERO).await;
260		assert!(matches!(res, Err(Error::NoAddresses)));
261	}
262}