Skip to main content

moq_native/
websocket.rs

1//! WebSocket fallback transport, running the QMux wire format over `ws://` or `wss://`.
2//!
3//! Used when QUIC is unreachable: UDP blocked by a firewall, a proxy in the way, a
4//! network that only passes TCP/443. The client races this against QUIC and gives QUIC
5//! a small head start ([`Client::delay`]), so WebSocket only wins when QUIC can't get
6//! through. Servers accept it on a separate TCP port via [`Listener`].
7
8use qmux::tokio_tungstenite;
9use qmux::tokio_tungstenite::tungstenite::{self, http};
10use std::collections::HashSet;
11use std::sync::{Arc, LazyLock, Mutex};
12use std::{net, time};
13use url::Url;
14
15/// Errors specific to the WebSocket fallback backend.
16#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum Error {
19	/// The TCP socket failed to bind or connect. Not accept: a failed `accept(2)` is
20	/// the listener's own to classify and retry (see [`crate::accept`]).
21	#[error(transparent)]
22	Io(#[from] std::io::Error),
23
24	/// WebSocket fallback was turned off via [`Client::enabled`].
25	#[error("WebSocket support is disabled")]
26	Disabled,
27
28	/// The URL had no host to dial.
29	#[error("missing hostname")]
30	MissingHostname,
31
32	/// The URL scheme can't carry WebSocket. Only `http`, `https`, `ws`, and `wss` work.
33	#[error("unsupported URL scheme for WebSocket: {0}")]
34	UnsupportedScheme(String),
35
36	/// The qmux handshake failed while dialing, including a non-101 upgrade response
37	/// from the server.
38	#[error("failed to connect WebSocket")]
39	Connect(#[source] qmux::Error),
40
41	/// The URL couldn't be turned into a valid WebSocket handshake request.
42	#[error("failed to build WebSocket request")]
43	BuildRequest(#[source] tungstenite::Error),
44
45	/// An ALPN contained bytes that aren't legal in the `Sec-WebSocket-Protocol` header.
46	#[error("failed to build WebSocket protocols header")]
47	ProtocolHeader(#[source] http::header::InvalidHeaderValue),
48
49	/// The TCP/TLS connection or the WebSocket upgrade itself failed.
50	#[error("failed to connect WebSocket")]
51	WebSocketConnect(#[source] tungstenite::Error),
52
53	/// The server refused the connection outright, so retrying won't help.
54	#[error(transparent)]
55	ConnectRejected(#[from] crate::ConnectError),
56
57	/// The qmux handshake failed while accepting an incoming connection.
58	#[error("WebSocket accept failed")]
59	Accept(#[source] qmux::Error),
60}
61
62type Result<T> = std::result::Result<T, Error>;
63
64// Track servers (hostname:port) where WebSocket won the race, so we won't give QUIC a headstart next time
65static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
66
67/// WebSocket configuration for the client.
68#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
69#[serde(default, deny_unknown_fields)]
70#[group(id = "websocket-client")]
71#[non_exhaustive]
72pub struct Client {
73	/// Whether to enable WebSocket support.
74	#[arg(
75		id = "websocket-enabled",
76		long = "websocket-enabled",
77		env = "MOQ_CLIENT_WEBSOCKET_ENABLED",
78		default_value = "true"
79	)]
80	pub enabled: bool,
81
82	/// Delay in milliseconds before attempting WebSocket fallback (default: 200)
83	/// If WebSocket won the previous race for a given server, this will be 0.
84	#[arg(
85		id = "websocket-delay",
86		long = "websocket-delay",
87		env = "MOQ_CLIENT_WEBSOCKET_DELAY",
88		default_value = "200ms",
89		value_parser = humantime::parse_duration,
90	)]
91	#[serde(with = "humantime_serde")]
92	#[serde(skip_serializing_if = "Option::is_none")]
93	pub delay: Option<time::Duration>,
94}
95
96impl Default for Client {
97	fn default() -> Self {
98		Self {
99			enabled: true,
100			delay: Some(time::Duration::from_millis(200)),
101		}
102	}
103}
104
105/// The fallback arm of the QUIC-vs-WebSocket race, so only compiled when there is a
106/// QUIC dial to race against. A WebSocket-only build calls [`connect`] directly.
107#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
108pub(crate) async fn race_handle(
109	config: &Client,
110	tls: &rustls::ClientConfig,
111	url: Url,
112	alpns: &[&str],
113) -> Option<Result<qmux::Session>> {
114	if !config.enabled {
115		return None;
116	}
117
118	// Only attempt WebSocket for HTTP-based schemes.
119	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
120	match url.scheme() {
121		"http" | "https" | "ws" | "wss" => {}
122		_ => return None,
123	}
124
125	let res = connect(config, tls, url, alpns).await;
126	if let Err(err) = &res {
127		tracing::warn!(%err, "WebSocket connection failed");
128	}
129	Some(res)
130}
131
132pub(crate) async fn connect(
133	config: &Client,
134	tls: &rustls::ClientConfig,
135	mut url: Url,
136	alpns: &[&str],
137) -> Result<qmux::Session> {
138	if !config.enabled {
139		return Err(Error::Disabled);
140	}
141
142	let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
143	let port = url.port().unwrap_or_else(|| match url.scheme() {
144		"https" | "wss" | "moql" | "moqt" => 443,
145		"http" | "ws" => 80,
146		_ => 443,
147	});
148	let key = (host, port);
149
150	// Apply a small penalty to WebSocket to improve odds for QUIC to connect first,
151	// unless we've already had to fall back to WebSockets for this server.
152	// TODO if let chain
153	match config.delay {
154		Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
155			tokio::time::sleep(delay).await;
156			tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
157		}
158		_ => {}
159	}
160
161	// Convert URL scheme: http:// -> ws://, https:// -> wss://
162	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
163	let needs_tls = match url.scheme() {
164		"http" => {
165			url.set_scheme("ws").expect("failed to set scheme");
166			false
167		}
168		"https" => {
169			url.set_scheme("wss").expect("failed to set scheme");
170			true
171		}
172		"ws" => false,
173		"wss" => true,
174		_ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
175	};
176
177	tracing::debug!(%url, "connecting via WebSocket");
178
179	// Use the existing TLS config (which respects tls-disable-verify) for secure connections.
180	let connector = if needs_tls {
181		tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
182	} else {
183		tokio_tungstenite::Connector::Plain
184	};
185
186	// Most moq ALPNs can ride on any QMux draft (`&[]` lets the polyfill expand
187	// to every version it knows). `qmux_versions_for` pins the few that the spec
188	// restricts. qmux also offers the bare ALPNs (`qmux-01`, `qmux-00`,
189	// `webtransport`) by default so we still interop with relays that only know a
190	// wire-format version.
191	let session = qmux::Client::new()
192		.with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
193		.with_connector(connector)
194		.with_keep_alive(qmux::KeepAlive::default()) // 5s ping / 30s deadline, parity with QUIC
195		.connect(url.as_str())
196		.await
197		.map_err(Error::Connect)?;
198
199	tracing::warn!(%url, "using WebSocket fallback");
200	WEBSOCKET_WON.lock().unwrap().insert(key);
201
202	Ok(session)
203}
204
205/// The QMux drafts a moq ALPN is allowed to ride on, for `qmux::*::with_protocols`.
206///
207/// moq-transport-18 and -19 require qmux-01, so we never pair them with qmux-00.
208/// This mirrors the policy in `js/net`'s `connect.ts`. Every other ALPN returns
209/// `&[]`, which qmux expands to every draft it knows about.
210const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
211
212fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
213	if QMUX01_ONLY_ALPNS.contains(&alpn) {
214		&[qmux::Version::QMux01]
215	} else {
216		&[]
217	}
218}
219
220impl Error {
221	pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
222		match self {
223			Self::ConnectRejected(err) => Some(*err),
224			// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`;
225			// map an auth rejection (401/403) so the caller sees it as terminal.
226			Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
227			_ => None,
228		}
229	}
230
231	/// The HTTP status the server answered the upgrade with, if it answered with one at all.
232	///
233	/// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`. See
234	/// [`crate::Error::status`].
235	pub(crate) fn status(&self) -> Option<u16> {
236		match self {
237			Self::Connect(qmux::Error::Http(status)) => Some(*status),
238			_ => None,
239		}
240	}
241}
242
243/// Listens for incoming WebSocket connections on a TCP port.
244///
245/// Use with [`crate::Server::with_websocket`] to accept WebSocket connections
246/// alongside QUIC connections on a separate port.
247pub struct Listener {
248	listener: tokio::net::TcpListener,
249	protocols: Vec<String>,
250	health: crate::accept::Health,
251}
252
253impl Listener {
254	/// Bind a listener to the given address, accepting every moq ALPN we know about.
255	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
256		Self::bind_with_alpns(addr, moq_net::ALPNS).await
257	}
258
259	/// Bind a listener that only accepts the given moq ALPNs, in preference order.
260	pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
261		let listener = tokio::net::TcpListener::bind(addr).await?;
262		let protocols = supported_subprotocols(alpns);
263		for protocol in &protocols {
264			http::HeaderValue::from_str(protocol).map_err(Error::ProtocolHeader)?;
265		}
266		Ok(Self {
267			listener,
268			protocols,
269			health: crate::accept::Health::new("websocket"),
270		})
271	}
272
273	/// The local address the listener is bound to.
274	pub fn local_addr(&self) -> Result<net::SocketAddr> {
275		Ok(self.listener.local_addr()?)
276	}
277
278	/// A live handle to this listener's accept-loop health, for an embedder that
279	/// publishes it (see [`crate::accept`]).
280	pub fn accept_health(&self) -> crate::accept::Health {
281		self.health.clone()
282	}
283
284	/// Accept the next connection, performing the WebSocket upgrade and qmux handshake.
285	///
286	/// A failed `accept(2)` is handled here rather than yielded: it is classified,
287	/// counted, logged, and paced by [`accept_health`](Self::accept_health), then
288	/// retried, because the caller has no better answer than to ask again. A
289	/// per-connection upgrade failure is still yielded as `Some(Err(..))`.
290	///
291	/// As in [`crate::tcp`], the `Option` has no `None` case left to report.
292	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
293		self.accept_with_url()
294			.await
295			.map(|result| result.map(|(session, _)| session))
296	}
297
298	/// Accept the next connection and retain the WebSocket request URL.
299	pub(crate) async fn accept_with_url(&self) -> Option<Result<(qmux::Session, Url)>> {
300		let (stream, addr) = self.accept_socket().await;
301		tracing::debug!(%addr, "accepted WebSocket TCP connection");
302
303		let accepted = Arc::new(Mutex::new(None::<(Option<String>, Url)>));
304		let accepted_callback = accepted.clone();
305		let protocols = self.protocols.clone();
306		#[allow(clippy::result_large_err)]
307		let callback = move |request: &tungstenite::handshake::server::Request,
308		               mut response: tungstenite::handshake::server::Response|
309		      -> std::result::Result<_, tungstenite::handshake::server::ErrorResponse> {
310			let offered: Vec<_> = request
311				.headers()
312				.get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
313				.iter()
314				.filter_map(|value| value.to_str().ok())
315				.flat_map(|value| value.split(','))
316				.map(str::trim)
317				.filter(|value| !value.is_empty())
318				.collect();
319			let Ok(protocol) = select_subprotocol(&offered, &protocols) else {
320				return Err(http::Response::builder()
321					.status(http::StatusCode::BAD_REQUEST)
322					.body(Some("no supported protocol".to_string()))
323					.expect("valid rejection response"));
324			};
325			let Some(url) = websocket_request_url(request) else {
326				return Err(http::Response::builder()
327					.status(http::StatusCode::BAD_REQUEST)
328					.body(Some("invalid request URL".to_string()))
329					.expect("valid rejection response"));
330			};
331
332			if let Some(protocol) = protocol {
333				response.headers_mut().insert(
334					http::header::SEC_WEBSOCKET_PROTOCOL,
335					http::HeaderValue::from_str(protocol).expect("protocol validated at bind"),
336				);
337			}
338			*accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url));
339			Ok(response)
340		};
341
342		let websocket = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None)
343			.await
344			.map_err(qmux::Error::from)
345			.map_err(Error::Accept);
346		Some(websocket.map(|websocket| {
347			let (protocol, url) = accepted
348				.lock()
349				.unwrap()
350				.take()
351				.expect("successful upgrade selected a protocol");
352			let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::KeepAlive::default());
353			let session = match protocol {
354				Some(protocol) => upgraded.with_alpn(&protocol).accept(),
355				None => upgraded.accept(),
356			};
357			(session, url)
358		}))
359	}
360
361	/// The `accept(2)` half: keep asking until a connection comes back.
362	async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
363		loop {
364			match self.listener.accept().await {
365				Ok(accepted) => {
366					self.health.accepted();
367					return accepted;
368				}
369				Err(err) => {
370					if let Some(delay) = self.health.failed(&err) {
371						tokio::time::sleep(delay).await;
372					}
373				}
374			}
375		}
376	}
377}
378
379/// Select a supported subprotocol, while preserving legacy clients that offer none.
380fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result<Option<&'a str>, ()> {
381	if offered.is_empty() {
382		return Ok(None);
383	}
384
385	supported
386		.iter()
387		.find(|protocol| offered.contains(&protocol.as_str()))
388		.map(|protocol| Some(protocol.as_str()))
389		.ok_or(())
390}
391
392/// Reconstruct the client request URL from an absolute URI or the HTTP Host header.
393fn websocket_request_url(request: &tungstenite::handshake::server::Request) -> Option<Url> {
394	let uri = request.uri();
395	if uri.scheme().is_some() && uri.authority().is_some() {
396		return Url::parse(&uri.to_string()).ok();
397	}
398
399	let host = request.headers().get(http::header::HOST)?.to_str().ok()?;
400	Url::parse(&format!("ws://{host}{uri}")).ok()
401}
402
403/// WebSocket subprotocols accepted for the given MoQ ALPNs, in preference order.
404fn supported_subprotocols(alpns: &[&str]) -> Vec<String> {
405	let mut protocols = Vec::new();
406	for &alpn in alpns {
407		let versions = qmux_versions_for(alpn);
408		let versions = if versions.is_empty() {
409			qmux::Version::ALL
410		} else {
411			versions
412		};
413		protocols.extend(
414			versions
415				.iter()
416				.copied()
417				.filter(|version| version.is_qmux())
418				.map(|version| format!("{}{alpn}", version.prefix())),
419		);
420	}
421	protocols.extend(qmux::ALPNS.iter().map(|protocol| (*protocol).to_string()));
422	protocols
423}
424
425#[cfg(test)]
426mod tests {
427	use super::*;
428
429	#[test]
430	fn subprotocol_selection_preserves_legacy_clients() {
431		let supported = vec!["qmux-01.moq-lite-05".to_string()];
432		assert_eq!(select_subprotocol(&[], &supported), Ok(None));
433		assert_eq!(
434			select_subprotocol(&["qmux-01.moq-lite-05"], &supported),
435			Ok(Some("qmux-01.moq-lite-05"))
436		);
437		assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(()));
438	}
439
440	#[tokio::test]
441	async fn listener_accepts_legacy_client_without_subprotocol() {
442		let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
443		let addr = listener.local_addr().unwrap();
444		let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() });
445
446		let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
447		let request_url = format!("ws://{addr}/room?jwt=test");
448		let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap();
449		assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL));
450
451		let (session, url) = accepted.await.unwrap();
452		assert_eq!(url.path(), "/room");
453		assert_eq!(url.query(), Some("jwt=test"));
454		drop(session);
455		drop(websocket);
456	}
457
458	#[test]
459	fn moqt_18_and_19_pin_to_qmux01() {
460		// The literals in `qmux_versions_for` must stay the IETF draft ALPNs;
461		// otherwise the pin silently stops matching.
462		assert_eq!(
463			QMUX01_ONLY_ALPNS
464				.iter()
465				.map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
466				.collect::<Vec<_>>(),
467			vec![Some(0xff000012), Some(0xff000013)]
468		);
469		for &alpn in QMUX01_ONLY_ALPNS {
470			assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
471		}
472
473		// Everything else stays unrestricted (qmux expands `&[]` to all drafts).
474		for &alpn in moq_net::ALPNS {
475			if !QMUX01_ONLY_ALPNS.contains(&alpn) {
476				assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
477			}
478		}
479	}
480}