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