1use 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#[derive(Debug, thiserror::Error)]
17#[non_exhaustive]
18pub enum Error {
19 #[error(transparent)]
22 Io(#[from] std::io::Error),
23
24 #[error("WebSocket support is disabled")]
26 Disabled,
27
28 #[error("missing hostname")]
30 MissingHostname,
31
32 #[error("unsupported URL scheme for WebSocket: {0}")]
34 UnsupportedScheme(String),
35
36 #[error("failed to connect WebSocket")]
39 Connect(#[source] qmux::Error),
40
41 #[error("failed to build WebSocket request")]
43 BuildRequest(#[source] tungstenite::Error),
44
45 #[error("failed to build WebSocket protocols header")]
47 ProtocolHeader(#[source] http::header::InvalidHeaderValue),
48
49 #[error("failed to connect WebSocket")]
51 WebSocketConnect(#[source] tungstenite::Error),
52
53 #[error(transparent)]
55 ConnectRejected(#[from] crate::ConnectError),
56
57 #[error("WebSocket accept failed")]
59 Accept(#[source] qmux::Error),
60}
61
62type Result<T> = std::result::Result<T, Error>;
63
64static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
66
67#[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 #[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 #[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#[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 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 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 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 let connector = if needs_tls {
181 tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
182 } else {
183 tokio_tungstenite::Connector::Plain
184 };
185
186 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()) .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
205const 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 Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
227 _ => None,
228 }
229 }
230
231 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
243pub struct Listener {
248 listener: tokio::net::TcpListener,
249 protocols: Vec<String>,
250 health: crate::accept::Health,
251}
252
253impl Listener {
254 pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
256 Self::bind_with_alpns(addr, moq_net::ALPNS).await
257 }
258
259 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 pub fn local_addr(&self) -> Result<net::SocketAddr> {
275 Ok(self.listener.local_addr()?)
276 }
277
278 pub fn accept_health(&self) -> crate::accept::Health {
281 self.health.clone()
282 }
283
284 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 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 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
379fn 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
392fn 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
403fn 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 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 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}