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
105pub(crate) async fn race_handle(
106	config: &Client,
107	tls: &rustls::ClientConfig,
108	url: Url,
109	alpns: &[&str],
110) -> Option<Result<qmux::Session>> {
111	if !config.enabled {
112		return None;
113	}
114
115	// Only attempt WebSocket for HTTP-based schemes.
116	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
117	match url.scheme() {
118		"http" | "https" | "ws" | "wss" => {}
119		_ => return None,
120	}
121
122	let res = connect(config, tls, url, alpns).await;
123	if let Err(err) = &res {
124		tracing::warn!(%err, "WebSocket connection failed");
125	}
126	Some(res)
127}
128
129pub(crate) async fn connect(
130	config: &Client,
131	tls: &rustls::ClientConfig,
132	mut url: Url,
133	alpns: &[&str],
134) -> Result<qmux::Session> {
135	if !config.enabled {
136		return Err(Error::Disabled);
137	}
138
139	let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
140	let port = url.port().unwrap_or_else(|| match url.scheme() {
141		"https" | "wss" | "moql" | "moqt" => 443,
142		"http" | "ws" => 80,
143		_ => 443,
144	});
145	let key = (host, port);
146
147	// Apply a small penalty to WebSocket to improve odds for QUIC to connect first,
148	// unless we've already had to fall back to WebSockets for this server.
149	// TODO if let chain
150	match config.delay {
151		Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
152			tokio::time::sleep(delay).await;
153			tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
154		}
155		_ => {}
156	}
157
158	// Convert URL scheme: http:// -> ws://, https:// -> wss://
159	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
160	let needs_tls = match url.scheme() {
161		"http" => {
162			url.set_scheme("ws").expect("failed to set scheme");
163			false
164		}
165		"https" => {
166			url.set_scheme("wss").expect("failed to set scheme");
167			true
168		}
169		"ws" => false,
170		"wss" => true,
171		_ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
172	};
173
174	tracing::debug!(%url, "connecting via WebSocket");
175
176	// Use the existing TLS config (which respects tls-disable-verify) for secure connections.
177	let connector = if needs_tls {
178		tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
179	} else {
180		tokio_tungstenite::Connector::Plain
181	};
182
183	// Most moq ALPNs can ride on any QMux draft (`&[]` lets the polyfill expand
184	// to every version it knows). `qmux_versions_for` pins the few that the spec
185	// restricts. qmux also offers the bare ALPNs (`qmux-01`, `qmux-00`,
186	// `webtransport`) by default so we still interop with relays that only know a
187	// wire-format version.
188	let session = qmux::Client::new()
189		.with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
190		.with_connector(connector)
191		.with_keep_alive(qmux::KeepAlive::default()) // 5s ping / 30s deadline, parity with QUIC
192		.connect(url.as_str())
193		.await
194		.map_err(Error::Connect)?;
195
196	tracing::warn!(%url, "using WebSocket fallback");
197	WEBSOCKET_WON.lock().unwrap().insert(key);
198
199	Ok(session)
200}
201
202/// The QMux drafts a moq ALPN is allowed to ride on, for `qmux::*::with_protocols`.
203///
204/// moq-transport-18 and -19 require qmux-01, so we never pair them with qmux-00.
205/// This mirrors the policy in `js/net`'s `connect.ts`. Every other ALPN returns
206/// `&[]`, which qmux expands to every draft it knows about.
207const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
208
209fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
210	if QMUX01_ONLY_ALPNS.contains(&alpn) {
211		&[qmux::Version::QMux01]
212	} else {
213		&[]
214	}
215}
216
217impl Error {
218	pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
219		match self {
220			Self::ConnectRejected(err) => Some(*err),
221			// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`;
222			// map an auth rejection (401/403) so the caller sees it as terminal.
223			Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
224			_ => None,
225		}
226	}
227
228	/// The HTTP status the server answered the upgrade with, if it answered with one at all.
229	///
230	/// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`. See
231	/// [`crate::Error::status`].
232	pub(crate) fn status(&self) -> Option<u16> {
233		match self {
234			Self::Connect(qmux::Error::Http(status)) => Some(*status),
235			_ => None,
236		}
237	}
238}
239
240/// Listens for incoming WebSocket connections on a TCP port.
241///
242/// Use with [`crate::Server::with_websocket`] to accept WebSocket connections
243/// alongside QUIC connections on a separate port.
244pub struct Listener {
245	listener: tokio::net::TcpListener,
246	server: qmux::Server,
247	health: crate::accept::Health,
248}
249
250impl Listener {
251	/// Bind a listener to the given address, accepting every moq ALPN we know about.
252	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
253		Self::bind_with_alpns(addr, moq_net::ALPNS).await
254	}
255
256	/// Bind a listener that only accepts the given moq ALPNs, in preference order.
257	pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
258		let listener = tokio::net::TcpListener::bind(addr).await?;
259		// `qmux_versions_for` returns `&[]` (every QMux draft) for ALPNs the spec
260		// doesn't restrict; qmux by default also accepts legacy clients that
261		// only offer a bare wire-format ALPN (today's moq-net clients still do).
262		let server = qmux::Server::new().with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))));
263		Ok(Self {
264			listener,
265			server,
266			health: crate::accept::Health::new("websocket"),
267		})
268	}
269
270	/// The local address the listener is bound to.
271	pub fn local_addr(&self) -> Result<net::SocketAddr> {
272		Ok(self.listener.local_addr()?)
273	}
274
275	/// A live handle to this listener's accept-loop health, for an embedder that
276	/// publishes it (see [`crate::accept`]).
277	pub fn accept_health(&self) -> crate::accept::Health {
278		self.health.clone()
279	}
280
281	/// Accept the next connection, performing the WebSocket upgrade and qmux handshake.
282	///
283	/// A failed `accept(2)` is handled here rather than yielded: it is classified,
284	/// counted, logged, and paced by [`accept_health`](Self::accept_health), then
285	/// retried, because the caller has no better answer than to ask again. A
286	/// per-connection upgrade failure is still yielded as `Some(Err(..))`.
287	///
288	/// As in [`crate::tcp`], the `Option` has no `None` case left to report.
289	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
290		let (stream, addr) = self.accept_socket().await;
291		tracing::debug!(%addr, "accepted WebSocket TCP connection");
292		let server = self.server.clone();
293		Some(server.accept(stream).await.map_err(Error::Accept))
294	}
295
296	/// The `accept(2)` half: keep asking until a connection comes back.
297	async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
298		loop {
299			match self.listener.accept().await {
300				Ok(accepted) => {
301					self.health.accepted();
302					return accepted;
303				}
304				Err(err) => {
305					if let Some(delay) = self.health.failed(&err) {
306						tokio::time::sleep(delay).await;
307					}
308				}
309			}
310		}
311	}
312}
313
314#[cfg(test)]
315mod tests {
316	use super::*;
317
318	#[test]
319	fn moqt_18_and_19_pin_to_qmux01() {
320		// The literals in `qmux_versions_for` must stay the IETF draft ALPNs;
321		// otherwise the pin silently stops matching.
322		assert_eq!(
323			QMUX01_ONLY_ALPNS
324				.iter()
325				.map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
326				.collect::<Vec<_>>(),
327			vec![Some(0xff000012), Some(0xff000013)]
328		);
329		for &alpn in QMUX01_ONLY_ALPNS {
330			assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
331		}
332
333		// Everything else stays unrestricted (qmux expands `&[]` to all drafts).
334		for &alpn in moq_net::ALPNS {
335			if !QMUX01_ONLY_ALPNS.contains(&alpn) {
336				assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
337			}
338		}
339	}
340}