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
15use crate::RedactedUrl;
16
17#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum Error {
21 #[error(transparent)]
24 Io(#[from] std::io::Error),
25
26 #[error("WebSocket support is disabled")]
28 Disabled,
29
30 #[error("missing hostname")]
32 MissingHostname,
33
34 #[error("unsupported URL scheme for WebSocket: {0}")]
36 UnsupportedScheme(String),
37
38 #[error("failed to connect WebSocket")]
41 Connect(#[source] qmux::Error),
42
43 #[error("failed to build WebSocket request")]
45 BuildRequest(#[source] tungstenite::Error),
46
47 #[error("failed to build WebSocket protocols header")]
49 ProtocolHeader(#[source] http::header::InvalidHeaderValue),
50
51 #[error("failed to connect WebSocket")]
53 WebSocketConnect(#[source] tungstenite::Error),
54
55 #[error(transparent)]
57 ConnectRejected(#[from] crate::ConnectError),
58
59 #[error("WebSocket accept failed")]
61 Accept(#[source] qmux::Error),
62}
63
64type Result<T> = std::result::Result<T, Error>;
65
66static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
68
69#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
71#[serde(default, deny_unknown_fields)]
72#[group(id = "websocket-client")]
73#[non_exhaustive]
74pub struct Client {
75 #[arg(
77 id = "websocket-enabled",
78 long = "websocket-enabled",
79 env = "MOQ_CLIENT_WEBSOCKET_ENABLED",
80 default_value = "true"
81 )]
82 pub enabled: bool,
83
84 #[arg(
87 id = "websocket-delay",
88 long = "websocket-delay",
89 env = "MOQ_CLIENT_WEBSOCKET_DELAY",
90 default_value = "200ms",
91 value_parser = humantime::parse_duration,
92 )]
93 #[serde(with = "humantime_serde")]
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub delay: Option<time::Duration>,
96}
97
98impl Default for Client {
99 fn default() -> Self {
100 Self {
101 enabled: true,
102 delay: Some(time::Duration::from_millis(200)),
103 }
104 }
105}
106
107#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
110pub(crate) async fn race_handle(
111 config: &Client,
112 tls: &rustls::ClientConfig,
113 tls_host_name: Option<&str>,
114 url: Url,
115 alpns: &[&str],
116) -> Option<Result<qmux::Session>> {
117 if !config.enabled {
118 return None;
119 }
120
121 match url.scheme() {
124 "http" | "https" | "ws" | "wss" => {}
125 _ => return None,
126 }
127
128 let res = connect(config, tls, tls_host_name, url, alpns).await;
129 if let Err(err) = &res {
130 tracing::warn!(%err, "WebSocket connection failed");
131 }
132 Some(res)
133}
134
135pub(crate) async fn connect(
136 config: &Client,
137 tls: &rustls::ClientConfig,
138 tls_host_name: Option<&str>,
139 mut url: Url,
140 alpns: &[&str],
141) -> Result<qmux::Session> {
142 if !config.enabled {
143 return Err(Error::Disabled);
144 }
145
146 let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
147 let port = url.port().unwrap_or_else(|| match url.scheme() {
148 "https" | "wss" | "moql" | "moqt" => 443,
149 "http" | "ws" => 80,
150 _ => 443,
151 });
152 let key = (host.clone(), port);
153
154 match config.delay {
158 Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
159 tokio::time::sleep(delay).await;
160 tracing::debug!(url = %RedactedUrl::new(&url), delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
161 }
162 _ => {}
163 }
164
165 let needs_tls = match url.scheme() {
168 "http" => {
169 url.set_scheme("ws").expect("failed to set scheme");
170 false
171 }
172 "https" => {
173 url.set_scheme("wss").expect("failed to set scheme");
174 true
175 }
176 "ws" => false,
177 "wss" => true,
178 _ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
179 };
180
181 tracing::debug!(url = %RedactedUrl::new(&url), "connecting via WebSocket");
182
183 let session = match (needs_tls, tls_host_name) {
184 (true, Some(tls_host_name)) => connect_tls_override(tls, tls_host_name, &url, &host, port, alpns).await?,
185 _ => {
186 let connector = if needs_tls {
188 tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
189 } else {
190 tokio_tungstenite::Connector::Plain
191 };
192
193 qmux::ws::Client::new()
199 .with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
200 .with_connector(connector)
201 .with_keep_alive(qmux::ws::KeepAlive::default()) .connect(url.as_str())
203 .await
204 .map_err(Error::Connect)?
205 }
206 };
207
208 tracing::warn!(url = %RedactedUrl::new(&url), "using WebSocket fallback");
209 WEBSOCKET_WON.lock().unwrap().insert(key);
210
211 Ok(session)
212}
213
214async fn connect_tls_override(
216 tls: &rustls::ClientConfig,
217 tls_host_name: &str,
218 url: &Url,
219 host: &str,
220 port: u16,
221 alpns: &[&str],
222) -> Result<qmux::Session> {
223 let original_request = url.as_str().into_client_request().map_err(Error::BuildRequest)?;
224 let original_host = original_request
225 .headers()
226 .get(http::header::HOST)
227 .cloned()
228 .ok_or(Error::MissingHostname)?;
229 let mut tls_url = url.clone();
230 tls_url
231 .set_host(Some(tls_host_name))
232 .map_err(|_| Error::Connect(qmux::Error::InvalidServerName))?;
233 let mut request = tls_url.as_str().into_client_request().map_err(Error::BuildRequest)?;
234 request.headers_mut().insert(http::header::HOST, original_host);
235 let protocols = supported_subprotocols(alpns).join(", ");
236 request.headers_mut().insert(
237 http::header::SEC_WEBSOCKET_PROTOCOL,
238 http::HeaderValue::from_str(&protocols).map_err(Error::ProtocolHeader)?,
239 );
240
241 let host = host
242 .strip_prefix('[')
243 .and_then(|host| host.strip_suffix(']'))
244 .unwrap_or(host);
245 let stream = tokio::net::TcpStream::connect((host, port)).await?;
246 let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()));
247 let (websocket, response) = tokio_tungstenite::client_async_tls_with_config(request, stream, None, Some(connector))
248 .await
249 .map_err(qmux::Error::from)
250 .map_err(Error::Connect)?;
251
252 let negotiated = response
253 .headers()
254 .get(http::header::SEC_WEBSOCKET_PROTOCOL)
255 .and_then(|value| value.to_str().ok());
256 let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
257 Ok(match negotiated {
258 Some(protocol) => upgraded.with_alpn(protocol).connect(),
259 None => upgraded.connect(),
260 })
261}
262
263const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19", "moqt-20"];
269
270fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
271 if QMUX01_ONLY_ALPNS.contains(&alpn) {
272 &[qmux::Version::QMux01]
273 } else {
274 &[]
275 }
276}
277
278impl Error {
279 pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
280 match self {
281 Self::ConnectRejected(err) => Some(*err),
282 Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
285 _ => None,
286 }
287 }
288
289 pub(crate) fn status(&self) -> Option<u16> {
294 match self {
295 Self::Connect(qmux::Error::Http(status)) => Some(*status),
296 _ => None,
297 }
298 }
299}
300
301pub struct Listener {
306 listener: tokio::net::TcpListener,
307 protocols: Vec<String>,
308 health: crate::accept::Health,
309}
310
311impl Listener {
312 pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
314 Self::bind_with_alpns(addr, moq_net::ALPNS).await
315 }
316
317 pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
319 let listener = tokio::net::TcpListener::bind(addr).await?;
320 let protocols = supported_subprotocols(alpns);
321 for protocol in &protocols {
322 http::HeaderValue::from_str(protocol).map_err(Error::ProtocolHeader)?;
323 }
324 Ok(Self {
325 listener,
326 protocols,
327 health: crate::accept::Health::new("websocket"),
328 })
329 }
330
331 pub fn local_addr(&self) -> Result<net::SocketAddr> {
333 Ok(self.listener.local_addr()?)
334 }
335
336 pub fn accept_health(&self) -> crate::accept::Health {
339 self.health.clone()
340 }
341
342 pub async fn accept(&self) -> Option<Result<qmux::Session>> {
351 self.accept_with_url()
352 .await
353 .map(|result| result.map(|(session, _)| session))
354 }
355
356 pub(crate) async fn accept_with_url(&self) -> Option<Result<(qmux::Session, Url)>> {
358 let (stream, addr) = self.accept_socket().await;
359 tracing::debug!(%addr, "accepted WebSocket TCP connection");
360
361 let accepted = Arc::new(Mutex::new(None::<(Option<String>, Url)>));
362 let accepted_callback = accepted.clone();
363 let protocols = self.protocols.clone();
364 #[allow(clippy::result_large_err)]
365 let callback = move |request: &tungstenite::handshake::server::Request,
366 mut response: tungstenite::handshake::server::Response|
367 -> std::result::Result<_, tungstenite::handshake::server::ErrorResponse> {
368 let offered: Vec<_> = request
369 .headers()
370 .get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
371 .iter()
372 .filter_map(|value| value.to_str().ok())
373 .flat_map(|value| value.split(','))
374 .map(str::trim)
375 .filter(|value| !value.is_empty())
376 .collect();
377 let Ok(protocol) = select_subprotocol(&offered, &protocols) else {
378 return Err(http::Response::builder()
379 .status(http::StatusCode::BAD_REQUEST)
380 .body(Some("no supported protocol".to_string()))
381 .expect("valid rejection response"));
382 };
383 let Some(url) = websocket_request_url(request) else {
384 return Err(http::Response::builder()
385 .status(http::StatusCode::BAD_REQUEST)
386 .body(Some("invalid request URL".to_string()))
387 .expect("valid rejection response"));
388 };
389
390 if let Some(protocol) = protocol {
391 response.headers_mut().insert(
392 http::header::SEC_WEBSOCKET_PROTOCOL,
393 http::HeaderValue::from_str(protocol).expect("protocol validated at bind"),
394 );
395 }
396 *accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url));
397 Ok(response)
398 };
399
400 let websocket = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None)
401 .await
402 .map_err(qmux::Error::from)
403 .map_err(Error::Accept);
404 Some(websocket.map(|websocket| {
405 let (protocol, url) = accepted
406 .lock()
407 .unwrap()
408 .take()
409 .expect("successful upgrade selected a protocol");
410 let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::ws::KeepAlive::default());
411 let session = match protocol {
412 Some(protocol) => upgraded.with_alpn(&protocol).accept(),
413 None => upgraded.accept(),
414 };
415 (session, url)
416 }))
417 }
418
419 async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
421 loop {
422 match self.listener.accept().await {
423 Ok(accepted) => {
424 self.health.accepted();
425 return accepted;
426 }
427 Err(err) => {
428 if let Some(delay) = self.health.failed(&err) {
429 tokio::time::sleep(delay).await;
430 }
431 }
432 }
433 }
434 }
435}
436
437fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result<Option<&'a str>, ()> {
439 if offered.is_empty() {
440 return Ok(None);
441 }
442
443 supported
444 .iter()
445 .find(|protocol| offered.contains(&protocol.as_str()))
446 .map(|protocol| Some(protocol.as_str()))
447 .ok_or(())
448}
449
450fn websocket_request_url(request: &tungstenite::handshake::server::Request) -> Option<Url> {
452 let uri = request.uri();
453 if uri.scheme().is_some() && uri.authority().is_some() {
454 return Url::parse(&uri.to_string()).ok();
455 }
456
457 let host = request.headers().get(http::header::HOST)?.to_str().ok()?;
458 Url::parse(&format!("ws://{host}{uri}")).ok()
459}
460
461fn supported_subprotocols(alpns: &[&str]) -> Vec<String> {
463 let mut protocols = Vec::new();
464 for &alpn in alpns {
465 let versions = qmux_versions_for(alpn);
466 let versions = if versions.is_empty() {
467 qmux::Version::ALL
468 } else {
469 versions
470 };
471 protocols.extend(
472 versions
473 .iter()
474 .copied()
475 .filter(|version| version.is_qmux())
476 .map(|version| format!("{}{alpn}", version.prefix())),
477 );
478 }
479 protocols.extend(qmux::ALPNS.iter().map(|protocol| (*protocol).to_string()));
480 protocols
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
487
488 #[test]
489 fn subprotocol_selection_preserves_legacy_clients() {
490 let supported = vec!["qmux-01.moq-lite-05".to_string()];
491 assert_eq!(select_subprotocol(&[], &supported), Ok(None));
492 assert_eq!(
493 select_subprotocol(&["qmux-01.moq-lite-05"], &supported),
494 Ok(Some("qmux-01.moq-lite-05"))
495 );
496 assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(()));
497 }
498
499 #[tokio::test]
500 async fn listener_accepts_legacy_client_without_subprotocol() {
501 let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
502 let addr = listener.local_addr().unwrap();
503 let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() });
504
505 let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
506 let request_url = format!("ws://{addr}/room?jwt=test");
507 let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap();
508 assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL));
509
510 let (session, url) = accepted.await.unwrap();
511 assert_eq!(url.path(), "/room");
512 assert_eq!(url.query(), Some("jwt=test"));
513 drop(session);
514 drop(websocket);
515 }
516
517 #[tokio::test]
518 async fn tls_host_name_override_dials_url_address() {
519 let rcgen::CertifiedKey { cert, signing_key } =
520 rcgen::generate_simple_self_signed(["relay.example".to_string()]).unwrap();
521 let cert = CertificateDer::from(cert);
522 let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der()));
523 let provider = crate::crypto::provider();
524 let server_tls = rustls::ServerConfig::builder_with_provider(provider.clone())
525 .with_safe_default_protocol_versions()
526 .unwrap()
527 .with_no_client_auth()
528 .with_single_cert(vec![cert.clone()], key)
529 .unwrap();
530
531 let mut roots = rustls::RootCertStore::empty();
532 roots.add(cert).unwrap();
533 let client_tls = rustls::ClientConfig::builder_with_provider(provider)
534 .with_safe_default_protocol_versions()
535 .unwrap()
536 .with_root_certificates(roots)
537 .with_no_client_auth();
538
539 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
540 let addr = listener.local_addr().unwrap();
541 let accepted = tokio::spawn(async move {
542 let (stream, _) = listener.accept().await.unwrap();
543 let tls = tokio_rustls::TlsAcceptor::from(Arc::new(server_tls))
544 .accept(stream)
545 .await
546 .unwrap();
547 let server_name = tls.get_ref().1.server_name().map(str::to_string);
548 let host = Arc::new(Mutex::new(None));
549 let request_host = host.clone();
550 #[allow(clippy::result_large_err)]
551 let callback = move |request: &tungstenite::handshake::server::Request,
552 mut response: tungstenite::handshake::server::Response| {
553 *request_host.lock().unwrap() = request
554 .headers()
555 .get(http::header::HOST)
556 .and_then(|value| value.to_str().ok())
557 .map(str::to_string);
558 if let Some(protocol) = request
559 .headers()
560 .get(http::header::SEC_WEBSOCKET_PROTOCOL)
561 .and_then(|value| value.to_str().ok())
562 .and_then(|value| value.split(',').next())
563 .map(str::trim)
564 {
565 response.headers_mut().insert(
566 http::header::SEC_WEBSOCKET_PROTOCOL,
567 http::HeaderValue::from_str(protocol).unwrap(),
568 );
569 }
570 Ok(response)
571 };
572 let _websocket = tokio_tungstenite::accept_hdr_async(tls, callback).await.unwrap();
573 let host = host.lock().unwrap().take();
574 (server_name, host)
575 });
576
577 let config = Client {
578 delay: None,
579 ..Default::default()
580 };
581 let url = Url::parse(&format!("wss://127.0.0.1:{}/anon", addr.port())).unwrap();
582 let session = connect(&config, &client_tls, Some("relay.example"), url, moq_net::ALPNS)
583 .await
584 .unwrap();
585 drop(session);
586 let (server_name, host) = accepted.await.unwrap();
587 assert_eq!(server_name.as_deref(), Some("relay.example"));
588 assert_eq!(host.as_deref(), Some(format!("127.0.0.1:{}", addr.port()).as_str()));
589 }
590
591 #[test]
592 fn moqt_18_and_19_pin_to_qmux01() {
593 assert_eq!(
596 QMUX01_ONLY_ALPNS
597 .iter()
598 .map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
599 .collect::<Vec<_>>(),
600 vec![Some(0xff000012), Some(0xff000013), Some(0xff000014)]
601 );
602 for &alpn in QMUX01_ONLY_ALPNS {
603 assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
604 }
605
606 for &alpn in moq_net::ALPNS {
608 if !QMUX01_ONLY_ALPNS.contains(&alpn) {
609 assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
610 }
611 }
612 }
613}