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, accept, or connect.
41	#[error(transparent)]
42	Io(#[from] std::io::Error),
43
44	/// The `tcp://` URL had no host.
45	#[error("missing hostname")]
46	MissingHostname,
47
48	/// The `tcp://` URL had no port. Unlike `https`, there is no default.
49	#[error("missing port")]
50	MissingPort,
51
52	/// The qmux handshake failed while dialing.
53	#[error("qmux connect failed")]
54	Connect(#[source] qmux::Error),
55
56	/// The qmux handshake failed while accepting.
57	#[error("qmux accept failed")]
58	Accept(#[source] qmux::Error),
59
60	/// DNS resolved the host to no addresses at all.
61	#[error("no addresses resolved")]
62	NoAddresses,
63
64	/// Two or more addresses were raced and every attempt failed, each paired
65	/// with its own error in dial order. All of them are kept: picking one to
66	/// report would bury a refused port behind whichever address happened to be
67	/// unroutable or to blackhole until its timeout. A host with a single address
68	/// reports that error directly instead.
69	#[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))]
70	Failover(Vec<crate::failover::Failure<Error>>),
71}
72
73impl crate::failover::Aggregate for Error {
74	fn aggregate(failures: Vec<crate::failover::Failure<Self>>) -> Self {
75		Self::Failover(failures)
76	}
77}
78
79type Result<T> = std::result::Result<T, Error>;
80
81/// Dial a `tcp://host:port` URL, advertising `protocols` for in-band ALPN
82/// negotiation. Returns a qmux session over plain TCP.
83///
84/// When DNS returns multiple addresses they are raced Happy Eyeballs style,
85/// staggered by `failover_delay` (see [`crate::failover`]).
86///
87/// The port is required; there is no default for the `tcp` scheme.
88pub(crate) async fn connect(
89	url: Url,
90	protocols: &[&str],
91	failover_delay: std::time::Duration,
92) -> Result<qmux::Session> {
93	let host = url.host_str().ok_or(Error::MissingHostname)?;
94	let port = url.port().ok_or(Error::MissingPort)?;
95
96	tracing::debug!(%url, "connecting via TCP");
97	let addrs = tokio::net::lookup_host((host, port)).await?;
98	connect_addrs(crate::failover::interleave(addrs), protocols, failover_delay).await
99}
100
101/// Dial the already-resolved `candidates` in Happy Eyeballs order, performing the
102/// qmux handshake on each attempt; the first session to complete wins.
103async fn connect_addrs(
104	candidates: Vec<net::SocketAddr>,
105	protocols: &[&str],
106	failover_delay: std::time::Duration,
107) -> Result<qmux::Session> {
108	if candidates.is_empty() {
109		return Err(Error::NoAddresses);
110	}
111
112	crate::failover::race(candidates, failover_delay, |addr| {
113		let protocols: Vec<String> = protocols.iter().map(|&p| p.to_owned()).collect();
114		async move {
115			qmux::tcp::Config::new(WIRE_VERSION)
116				.protocols(protocols.iter().map(String::as_str))
117				.connect(addr)
118				.await
119				.map_err(Error::Connect)
120		}
121	})
122	.await
123}
124
125/// Listens for incoming plain-TCP qmux connections on a TCP port.
126pub struct Listener {
127	listener: tokio::net::TcpListener,
128	protocols: Vec<String>,
129}
130
131impl Listener {
132	/// Bind a TCP listener to the given address.
133	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
134		let listener = tokio::net::TcpListener::bind(addr).await?;
135		Ok(Self {
136			listener,
137			protocols: Vec::new(),
138		})
139	}
140
141	/// Advertise these application protocols (moq ALPNs) for in-band negotiation,
142	/// in preference order. The first server entry the client also offers wins.
143	pub fn with_protocols<I, S>(mut self, protocols: I) -> Self
144	where
145		I: IntoIterator<Item = S>,
146		S: Into<String>,
147	{
148		self.protocols = protocols.into_iter().map(Into::into).collect();
149		self
150	}
151
152	/// The local address the listener is bound to.
153	pub fn local_addr(&self) -> Result<net::SocketAddr> {
154		Ok(self.listener.local_addr()?)
155	}
156
157	/// Accept the next connection, performing the qmux handshake over plain TCP.
158	///
159	/// Returns `None` only if the listener itself is gone; a per-connection
160	/// failure is yielded as `Some(Err(..))` so the accept loop keeps running.
161	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
162		match self.listener.accept().await {
163			Ok((stream, addr)) => {
164				tracing::debug!(%addr, "accepted TCP connection");
165				let session = qmux::tcp::Config::new(WIRE_VERSION)
166					.protocols(self.protocols.iter().map(String::as_str))
167					.accept(stream)
168					.await
169					.map_err(Error::Accept);
170				Some(session)
171			}
172			Err(e) => Some(Err(e.into())),
173		}
174	}
175}
176
177#[cfg(test)]
178mod tests {
179	use super::*;
180	use std::time::Duration;
181	use web_transport_trait::Session as _;
182
183	/// End-to-end failover: the preferred candidate blackholes (TEST-NET-1 never
184	/// answers, or is unroutable outright in a sandbox), so the race must fall
185	/// through to the loopback listener within the stagger delay.
186	#[tokio::test]
187	async fn failover_recovers_from_blackhole_candidate() {
188		let listener = Listener::bind("127.0.0.1:0".parse().unwrap())
189			.await
190			.expect("bind listener")
191			.with_protocols(["moq-test"]);
192		let addr = listener.local_addr().expect("local addr");
193
194		let accept = tokio::spawn(async move { listener.accept().await.expect("listener gone").expect("accept") });
195
196		let blackhole: net::SocketAddr = "192.0.2.1:9".parse().unwrap();
197		let session = tokio::time::timeout(
198			Duration::from_secs(5),
199			connect_addrs(vec![blackhole, addr], &["moq-test"], Duration::from_millis(50)),
200		)
201		.await
202		.expect("failover timed out")
203		.expect("connect failed");
204
205		assert_eq!(session.protocol(), Some("moq-test"));
206		accept.await.expect("accept task panicked");
207	}
208
209	#[tokio::test]
210	async fn connect_addrs_rejects_empty() {
211		let res = connect_addrs(Vec::new(), &["moq-test"], Duration::ZERO).await;
212		assert!(matches!(res, Err(Error::NoAddresses)));
213	}
214}