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