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, accept, or connect.
20	#[error(transparent)]
21	Io(#[from] std::io::Error),
22
23	/// WebSocket fallback was turned off via [`Client::enabled`].
24	#[error("WebSocket support is disabled")]
25	Disabled,
26
27	/// The URL had no host to dial.
28	#[error("missing hostname")]
29	MissingHostname,
30
31	/// The URL scheme can't carry WebSocket. Only `http`, `https`, `ws`, and `wss` work.
32	#[error("unsupported URL scheme for WebSocket: {0}")]
33	UnsupportedScheme(String),
34
35	/// The qmux handshake failed while dialing, including a non-101 upgrade response
36	/// from the server.
37	#[error("failed to connect WebSocket")]
38	Connect(#[source] qmux::Error),
39
40	/// The URL couldn't be turned into a valid WebSocket handshake request.
41	#[error("failed to build WebSocket request")]
42	BuildRequest(#[source] tungstenite::Error),
43
44	/// An ALPN contained bytes that aren't legal in the `Sec-WebSocket-Protocol` header.
45	#[error("failed to build WebSocket protocols header")]
46	ProtocolHeader(#[source] http::header::InvalidHeaderValue),
47
48	/// The TCP/TLS connection or the WebSocket upgrade itself failed.
49	#[error("failed to connect WebSocket")]
50	WebSocketConnect(#[source] tungstenite::Error),
51
52	/// The server refused the connection outright, so retrying won't help.
53	#[error(transparent)]
54	ConnectRejected(#[from] crate::ConnectError),
55
56	/// The qmux handshake failed while accepting an incoming connection.
57	#[error("WebSocket accept failed")]
58	Accept(#[source] qmux::Error),
59}
60
61type Result<T> = std::result::Result<T, Error>;
62
63// Track servers (hostname:port) where WebSocket won the race, so we won't give QUIC a headstart next time
64static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
65
66/// WebSocket configuration for the client.
67#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
68#[serde(default, deny_unknown_fields)]
69#[group(id = "websocket-client")]
70#[non_exhaustive]
71pub struct Client {
72	/// Whether to enable WebSocket support.
73	#[arg(
74		id = "websocket-enabled",
75		long = "websocket-enabled",
76		env = "MOQ_CLIENT_WEBSOCKET_ENABLED",
77		default_value = "true"
78	)]
79	pub enabled: bool,
80
81	/// Delay in milliseconds before attempting WebSocket fallback (default: 200)
82	/// If WebSocket won the previous race for a given server, this will be 0.
83	#[arg(
84		id = "websocket-delay",
85		long = "websocket-delay",
86		env = "MOQ_CLIENT_WEBSOCKET_DELAY",
87		default_value = "200ms",
88		value_parser = humantime::parse_duration,
89	)]
90	#[serde(with = "humantime_serde")]
91	#[serde(skip_serializing_if = "Option::is_none")]
92	pub delay: Option<time::Duration>,
93}
94
95impl Default for Client {
96	fn default() -> Self {
97		Self {
98			enabled: true,
99			delay: Some(time::Duration::from_millis(200)),
100		}
101	}
102}
103
104pub(crate) async fn race_handle(
105	config: &Client,
106	tls: &rustls::ClientConfig,
107	url: Url,
108	alpns: &[&str],
109) -> Option<Result<qmux::Session>> {
110	if !config.enabled {
111		return None;
112	}
113
114	// Only attempt WebSocket for HTTP-based schemes.
115	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
116	match url.scheme() {
117		"http" | "https" | "ws" | "wss" => {}
118		_ => return None,
119	}
120
121	let res = connect(config, tls, url, alpns).await;
122	if let Err(err) = &res {
123		tracing::warn!(%err, "WebSocket connection failed");
124	}
125	Some(res)
126}
127
128pub(crate) async fn connect(
129	config: &Client,
130	tls: &rustls::ClientConfig,
131	mut url: Url,
132	alpns: &[&str],
133) -> Result<qmux::Session> {
134	if !config.enabled {
135		return Err(Error::Disabled);
136	}
137
138	let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
139	let port = url.port().unwrap_or_else(|| match url.scheme() {
140		"https" | "wss" | "moql" | "moqt" => 443,
141		"http" | "ws" => 80,
142		_ => 443,
143	});
144	let key = (host, port);
145
146	// Apply a small penalty to WebSocket to improve odds for QUIC to connect first,
147	// unless we've already had to fall back to WebSockets for this server.
148	// TODO if let chain
149	match config.delay {
150		Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
151			tokio::time::sleep(delay).await;
152			tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
153		}
154		_ => {}
155	}
156
157	// Convert URL scheme: http:// -> ws://, https:// -> wss://
158	// Custom protocols (moqt://, moql://) use raw QUIC and don't support WebSocket.
159	let needs_tls = match url.scheme() {
160		"http" => {
161			url.set_scheme("ws").expect("failed to set scheme");
162			false
163		}
164		"https" => {
165			url.set_scheme("wss").expect("failed to set scheme");
166			true
167		}
168		"ws" => false,
169		"wss" => true,
170		_ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
171	};
172
173	tracing::debug!(%url, "connecting via WebSocket");
174
175	// Use the existing TLS config (which respects tls-disable-verify) for secure connections.
176	let connector = if needs_tls {
177		tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
178	} else {
179		tokio_tungstenite::Connector::Plain
180	};
181
182	// Most moq ALPNs can ride on any QMux draft (`&[]` lets the polyfill expand
183	// to every version it knows). `qmux_versions_for` pins the few that the spec
184	// restricts. qmux also offers the bare ALPNs (`qmux-01`, `qmux-00`,
185	// `webtransport`) by default so we still interop with relays that only know a
186	// wire-format version.
187	let session = qmux::Client::new()
188		.with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
189		.with_connector(connector)
190		.with_keep_alive(qmux::KeepAlive::default()) // 5s ping / 30s deadline, parity with QUIC
191		.connect(url.as_str())
192		.await
193		.map_err(Error::Connect)?;
194
195	tracing::warn!(%url, "using WebSocket fallback");
196	WEBSOCKET_WON.lock().unwrap().insert(key);
197
198	Ok(session)
199}
200
201/// The QMux drafts a moq ALPN is allowed to ride on, for `qmux::*::with_protocols`.
202///
203/// moq-transport-18 and -19 require qmux-01, so we never pair them with qmux-00.
204/// This mirrors the policy in `js/net`'s `connect.ts`. Every other ALPN returns
205/// `&[]`, which qmux expands to every draft it knows about.
206const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
207
208fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
209	if QMUX01_ONLY_ALPNS.contains(&alpn) {
210		&[qmux::Version::QMux01]
211	} else {
212		&[]
213	}
214}
215
216impl Error {
217	pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
218		match self {
219			Self::ConnectRejected(err) => Some(*err),
220			// qmux surfaces a non-101 WebSocket upgrade response as `Http(status)`;
221			// map an auth rejection (401/403) so the caller sees it as terminal.
222			Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
223			_ => None,
224		}
225	}
226}
227
228/// Listens for incoming WebSocket connections on a TCP port.
229///
230/// Use with [`crate::Server::with_websocket`] to accept WebSocket connections
231/// alongside QUIC connections on a separate port.
232pub struct Listener {
233	listener: tokio::net::TcpListener,
234	server: qmux::Server,
235}
236
237impl Listener {
238	/// Bind a listener to the given address, accepting every moq ALPN we know about.
239	pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
240		Self::bind_with_alpns(addr, moq_net::ALPNS).await
241	}
242
243	/// Bind a listener that only accepts the given moq ALPNs, in preference order.
244	pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
245		let listener = tokio::net::TcpListener::bind(addr).await?;
246		// `qmux_versions_for` returns `&[]` (every QMux draft) for ALPNs the spec
247		// doesn't restrict; qmux by default also accepts legacy clients that
248		// only offer a bare wire-format ALPN (today's moq-net clients still do).
249		let server = qmux::Server::new().with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))));
250		Ok(Self { listener, server })
251	}
252
253	/// The local address the listener is bound to.
254	pub fn local_addr(&self) -> Result<net::SocketAddr> {
255		Ok(self.listener.local_addr()?)
256	}
257
258	/// Accept the next connection, performing the WebSocket upgrade and qmux handshake.
259	///
260	/// Returns `None` only if the listener itself is gone; a per-connection failure is
261	/// yielded as `Some(Err(..))` so the accept loop keeps running.
262	pub async fn accept(&self) -> Option<Result<qmux::Session>> {
263		match self.listener.accept().await {
264			Ok((stream, addr)) => {
265				tracing::debug!(%addr, "accepted WebSocket TCP connection");
266				let server = self.server.clone();
267				Some(server.accept(stream).await.map_err(Error::Accept))
268			}
269			Err(e) => Some(Err(e.into())),
270		}
271	}
272}
273
274#[cfg(test)]
275mod tests {
276	use super::*;
277
278	#[test]
279	fn moqt_18_and_19_pin_to_qmux01() {
280		// The literals in `qmux_versions_for` must stay the IETF draft ALPNs;
281		// otherwise the pin silently stops matching.
282		assert_eq!(
283			QMUX01_ONLY_ALPNS
284				.iter()
285				.map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
286				.collect::<Vec<_>>(),
287			vec![Some(0xff000012), Some(0xff000013)]
288		);
289		for &alpn in QMUX01_ONLY_ALPNS {
290			assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
291		}
292
293		// Everything else stays unrestricted (qmux expands `&[]` to all drafts).
294		for &alpn in moq_net::ALPNS {
295			if !QMUX01_ONLY_ALPNS.contains(&alpn) {
296				assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
297			}
298		}
299	}
300}