1use qmux::ws::tokio_tungstenite;
9use qmux::ws::tokio_tungstenite::tungstenite::{self, client::IntoClientRequest, 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 tls_host_name: Option<&str>,
112 url: Url,
113 alpns: &[&str],
114) -> Option<Result<qmux::Session>> {
115 if !config.enabled {
116 return None;
117 }
118
119 match url.scheme() {
122 "http" | "https" | "ws" | "wss" => {}
123 _ => return None,
124 }
125
126 let res = connect(config, tls, tls_host_name, url, alpns).await;
127 if let Err(err) = &res {
128 tracing::warn!(%err, "WebSocket connection failed");
129 }
130 Some(res)
131}
132
133pub(crate) async fn connect(
134 config: &Client,
135 tls: &rustls::ClientConfig,
136 tls_host_name: Option<&str>,
137 mut url: Url,
138 alpns: &[&str],
139) -> Result<qmux::Session> {
140 if !config.enabled {
141 return Err(Error::Disabled);
142 }
143
144 let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
145 let port = url.port().unwrap_or_else(|| match url.scheme() {
146 "https" | "wss" | "moql" | "moqt" => 443,
147 "http" | "ws" => 80,
148 _ => 443,
149 });
150 let key = (host.clone(), port);
151
152 match config.delay {
156 Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
157 tokio::time::sleep(delay).await;
158 tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
159 }
160 _ => {}
161 }
162
163 let needs_tls = match url.scheme() {
166 "http" => {
167 url.set_scheme("ws").expect("failed to set scheme");
168 false
169 }
170 "https" => {
171 url.set_scheme("wss").expect("failed to set scheme");
172 true
173 }
174 "ws" => false,
175 "wss" => true,
176 _ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
177 };
178
179 tracing::debug!(%url, "connecting via WebSocket");
180
181 let session = match (needs_tls, tls_host_name) {
182 (true, Some(tls_host_name)) => connect_tls_override(tls, tls_host_name, &url, &host, port, alpns).await?,
183 _ => {
184 let connector = if needs_tls {
186 tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
187 } else {
188 tokio_tungstenite::Connector::Plain
189 };
190
191 qmux::ws::Client::new()
197 .with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
198 .with_connector(connector)
199 .with_keep_alive(qmux::ws::KeepAlive::default()) .connect(url.as_str())
201 .await
202 .map_err(Error::Connect)?
203 }
204 };
205
206 tracing::warn!(%url, "using WebSocket fallback");
207 WEBSOCKET_WON.lock().unwrap().insert(key);
208
209 Ok(session)
210}
211
212async fn connect_tls_override(
214 tls: &rustls::ClientConfig,
215 tls_host_name: &str,
216 url: &Url,
217 host: &str,
218 port: u16,
219 alpns: &[&str],
220) -> Result<qmux::Session> {
221 let original_request = url.as_str().into_client_request().map_err(Error::BuildRequest)?;
222 let original_host = original_request
223 .headers()
224 .get(http::header::HOST)
225 .cloned()
226 .ok_or(Error::MissingHostname)?;
227 let mut tls_url = url.clone();
228 tls_url
229 .set_host(Some(tls_host_name))
230 .map_err(|_| Error::Connect(qmux::Error::InvalidServerName))?;
231 let mut request = tls_url.as_str().into_client_request().map_err(Error::BuildRequest)?;
232 request.headers_mut().insert(http::header::HOST, original_host);
233 let protocols = supported_subprotocols(alpns).join(", ");
234 request.headers_mut().insert(
235 http::header::SEC_WEBSOCKET_PROTOCOL,
236 http::HeaderValue::from_str(&protocols).map_err(Error::ProtocolHeader)?,
237 );
238
239 let host = host
240 .strip_prefix('[')
241 .and_then(|host| host.strip_suffix(']'))
242 .unwrap_or(host);
243 let stream = tokio::net::TcpStream::connect((host, port)).await?;
244 let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()));
245 let (websocket, response) = tokio_tungstenite::client_async_tls_with_config(request, stream, None, Some(connector))
246 .await
247 .map_err(qmux::Error::from)
248 .map_err(Error::Connect)?;
249
250 let negotiated = response
251 .headers()
252 .get(http::header::SEC_WEBSOCKET_PROTOCOL)
253 .and_then(|value| value.to_str().ok());
254 let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
255 Ok(match negotiated {
256 Some(protocol) => upgraded.with_alpn(protocol).connect(),
257 None => upgraded.connect(),
258 })
259}
260
261const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
267
268fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
269 if QMUX01_ONLY_ALPNS.contains(&alpn) {
270 &[qmux::Version::QMux01]
271 } else {
272 &[]
273 }
274}
275
276impl Error {
277 pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
278 match self {
279 Self::ConnectRejected(err) => Some(*err),
280 Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
283 _ => None,
284 }
285 }
286
287 pub(crate) fn status(&self) -> Option<u16> {
292 match self {
293 Self::Connect(qmux::Error::Http(status)) => Some(*status),
294 _ => None,
295 }
296 }
297}
298
299pub struct Listener {
304 listener: tokio::net::TcpListener,
305 protocols: Vec<String>,
306 health: crate::accept::Health,
307}
308
309impl Listener {
310 pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
312 Self::bind_with_alpns(addr, moq_net::ALPNS).await
313 }
314
315 pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
317 let listener = tokio::net::TcpListener::bind(addr).await?;
318 let protocols = supported_subprotocols(alpns);
319 for protocol in &protocols {
320 http::HeaderValue::from_str(protocol).map_err(Error::ProtocolHeader)?;
321 }
322 Ok(Self {
323 listener,
324 protocols,
325 health: crate::accept::Health::new("websocket"),
326 })
327 }
328
329 pub fn local_addr(&self) -> Result<net::SocketAddr> {
331 Ok(self.listener.local_addr()?)
332 }
333
334 pub fn accept_health(&self) -> crate::accept::Health {
337 self.health.clone()
338 }
339
340 pub async fn accept(&self) -> Option<Result<qmux::Session>> {
349 self.accept_with_url()
350 .await
351 .map(|result| result.map(|(session, _)| session))
352 }
353
354 pub(crate) async fn accept_with_url(&self) -> Option<Result<(qmux::Session, Url)>> {
356 let (stream, addr) = self.accept_socket().await;
357 tracing::debug!(%addr, "accepted WebSocket TCP connection");
358
359 let accepted = Arc::new(Mutex::new(None::<(Option<String>, Url)>));
360 let accepted_callback = accepted.clone();
361 let protocols = self.protocols.clone();
362 #[allow(clippy::result_large_err)]
363 let callback = move |request: &tungstenite::handshake::server::Request,
364 mut response: tungstenite::handshake::server::Response|
365 -> std::result::Result<_, tungstenite::handshake::server::ErrorResponse> {
366 let offered: Vec<_> = request
367 .headers()
368 .get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
369 .iter()
370 .filter_map(|value| value.to_str().ok())
371 .flat_map(|value| value.split(','))
372 .map(str::trim)
373 .filter(|value| !value.is_empty())
374 .collect();
375 let Ok(protocol) = select_subprotocol(&offered, &protocols) else {
376 return Err(http::Response::builder()
377 .status(http::StatusCode::BAD_REQUEST)
378 .body(Some("no supported protocol".to_string()))
379 .expect("valid rejection response"));
380 };
381 let Some(url) = websocket_request_url(request) else {
382 return Err(http::Response::builder()
383 .status(http::StatusCode::BAD_REQUEST)
384 .body(Some("invalid request URL".to_string()))
385 .expect("valid rejection response"));
386 };
387
388 if let Some(protocol) = protocol {
389 response.headers_mut().insert(
390 http::header::SEC_WEBSOCKET_PROTOCOL,
391 http::HeaderValue::from_str(protocol).expect("protocol validated at bind"),
392 );
393 }
394 *accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url));
395 Ok(response)
396 };
397
398 let websocket = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None)
399 .await
400 .map_err(qmux::Error::from)
401 .map_err(Error::Accept);
402 Some(websocket.map(|websocket| {
403 let (protocol, url) = accepted
404 .lock()
405 .unwrap()
406 .take()
407 .expect("successful upgrade selected a protocol");
408 let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
409 let session = match protocol {
410 Some(protocol) => upgraded.with_alpn(&protocol).accept(),
411 None => upgraded.accept(),
412 };
413 (session, url)
414 }))
415 }
416
417 async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
419 loop {
420 match self.listener.accept().await {
421 Ok(accepted) => {
422 self.health.accepted();
423 return accepted;
424 }
425 Err(err) => {
426 if let Some(delay) = self.health.failed(&err) {
427 tokio::time::sleep(delay).await;
428 }
429 }
430 }
431 }
432 }
433}
434
435fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result<Option<&'a str>, ()> {
437 if offered.is_empty() {
438 return Ok(None);
439 }
440
441 supported
442 .iter()
443 .find(|protocol| offered.contains(&protocol.as_str()))
444 .map(|protocol| Some(protocol.as_str()))
445 .ok_or(())
446}
447
448fn websocket_request_url(request: &tungstenite::handshake::server::Request) -> Option<Url> {
450 let uri = request.uri();
451 if uri.scheme().is_some() && uri.authority().is_some() {
452 return Url::parse(&uri.to_string()).ok();
453 }
454
455 let host = request.headers().get(http::header::HOST)?.to_str().ok()?;
456 Url::parse(&format!("ws://{host}{uri}")).ok()
457}
458
459fn supported_subprotocols(alpns: &[&str]) -> Vec<String> {
461 let mut protocols = Vec::new();
462 for &alpn in alpns {
463 let versions = qmux_versions_for(alpn);
464 let versions = if versions.is_empty() {
465 qmux::Version::ALL
466 } else {
467 versions
468 };
469 protocols.extend(
470 versions
471 .iter()
472 .copied()
473 .filter(|version| version.is_qmux())
474 .map(|version| format!("{}{alpn}", version.prefix())),
475 );
476 }
477 protocols.extend(qmux::ALPNS.iter().map(|protocol| (*protocol).to_string()));
478 protocols
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
485
486 #[test]
487 fn subprotocol_selection_preserves_legacy_clients() {
488 let supported = vec!["qmux-01.moq-lite-05".to_string()];
489 assert_eq!(select_subprotocol(&[], &supported), Ok(None));
490 assert_eq!(
491 select_subprotocol(&["qmux-01.moq-lite-05"], &supported),
492 Ok(Some("qmux-01.moq-lite-05"))
493 );
494 assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(()));
495 }
496
497 #[tokio::test]
498 async fn listener_accepts_legacy_client_without_subprotocol() {
499 let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
500 let addr = listener.local_addr().unwrap();
501 let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() });
502
503 let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
504 let request_url = format!("ws://{addr}/room?jwt=test");
505 let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap();
506 assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL));
507
508 let (session, url) = accepted.await.unwrap();
509 assert_eq!(url.path(), "/room");
510 assert_eq!(url.query(), Some("jwt=test"));
511 drop(session);
512 drop(websocket);
513 }
514
515 #[tokio::test]
516 async fn tls_host_name_override_dials_url_address() {
517 let rcgen::CertifiedKey { cert, signing_key } =
518 rcgen::generate_simple_self_signed(["relay.example".to_string()]).unwrap();
519 let cert = CertificateDer::from(cert);
520 let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
521 let provider = crate::crypto::provider();
522 let server_tls = rustls::ServerConfig::builder_with_provider(provider.clone())
523 .with_safe_default_protocol_versions()
524 .unwrap()
525 .with_no_client_auth()
526 .with_single_cert(vec![cert.clone()], key)
527 .unwrap();
528
529 let mut roots = rustls::RootCertStore::empty();
530 roots.add(cert).unwrap();
531 let client_tls = rustls::ClientConfig::builder_with_provider(provider)
532 .with_safe_default_protocol_versions()
533 .unwrap()
534 .with_root_certificates(roots)
535 .with_no_client_auth();
536
537 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
538 let addr = listener.local_addr().unwrap();
539 let accepted = tokio::spawn(async move {
540 let (stream, _) = listener.accept().await.unwrap();
541 let tls = tokio_rustls::TlsAcceptor::from(Arc::new(server_tls))
542 .accept(stream)
543 .await
544 .unwrap();
545 let server_name = tls.get_ref().1.server_name().map(str::to_string);
546 let host = Arc::new(Mutex::new(None));
547 let request_host = host.clone();
548 #[allow(clippy::result_large_err)]
549 let callback = move |request: &tungstenite::handshake::server::Request,
550 mut response: tungstenite::handshake::server::Response| {
551 *request_host.lock().unwrap() = request
552 .headers()
553 .get(http::header::HOST)
554 .and_then(|value| value.to_str().ok())
555 .map(str::to_string);
556 if let Some(protocol) = request
557 .headers()
558 .get(http::header::SEC_WEBSOCKET_PROTOCOL)
559 .and_then(|value| value.to_str().ok())
560 .and_then(|value| value.split(',').next())
561 .map(str::trim)
562 {
563 response.headers_mut().insert(
564 http::header::SEC_WEBSOCKET_PROTOCOL,
565 http::HeaderValue::from_str(protocol).unwrap(),
566 );
567 }
568 Ok(response)
569 };
570 let _websocket = tokio_tungstenite::accept_hdr_async(tls, callback).await.unwrap();
571 let host = host.lock().unwrap().take();
572 (server_name, host)
573 });
574
575 let config = Client {
576 delay: None,
577 ..Default::default()
578 };
579 let url = Url::parse(&format!("wss://127.0.0.1:{}/anon", addr.port())).unwrap();
580 let session = connect(&config, &client_tls, Some("relay.example"), url, moq_net::ALPNS)
581 .await
582 .unwrap();
583 drop(session);
584 let (server_name, host) = accepted.await.unwrap();
585 assert_eq!(server_name.as_deref(), Some("relay.example"));
586 assert_eq!(host.as_deref(), Some(format!("127.0.0.1:{}", addr.port()).as_str()));
587 }
588
589 #[test]
590 fn moqt_18_and_19_pin_to_qmux01() {
591 assert_eq!(
594 QMUX01_ONLY_ALPNS
595 .iter()
596 .map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
597 .collect::<Vec<_>>(),
598 vec![Some(0xff000012), Some(0xff000013)]
599 );
600 for &alpn in QMUX01_ONLY_ALPNS {
601 assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
602 }
603
604 for &alpn in moq_net::ALPNS {
606 if !QMUX01_ONLY_ALPNS.contains(&alpn) {
607 assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
608 }
609 }
610 }
611}