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)]
21 Io(#[from] std::io::Error),
22
23 #[error("WebSocket support is disabled")]
25 Disabled,
26
27 #[error("missing hostname")]
29 MissingHostname,
30
31 #[error("unsupported URL scheme for WebSocket: {0}")]
33 UnsupportedScheme(String),
34
35 #[error("failed to connect WebSocket")]
38 Connect(#[source] qmux::Error),
39
40 #[error("failed to build WebSocket request")]
42 BuildRequest(#[source] tungstenite::Error),
43
44 #[error("failed to build WebSocket protocols header")]
46 ProtocolHeader(#[source] http::header::InvalidHeaderValue),
47
48 #[error("failed to connect WebSocket")]
50 WebSocketConnect(#[source] tungstenite::Error),
51
52 #[error(transparent)]
54 ConnectRejected(#[from] crate::ConnectError),
55
56 #[error("WebSocket accept failed")]
58 Accept(#[source] qmux::Error),
59}
60
61type Result<T> = std::result::Result<T, Error>;
62
63static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
65
66#[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 #[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 #[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 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 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 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 let connector = if needs_tls {
177 tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
178 } else {
179 tokio_tungstenite::Connector::Plain
180 };
181
182 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()) .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
201const 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 Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
223 _ => None,
224 }
225 }
226}
227
228pub struct Listener {
233 listener: tokio::net::TcpListener,
234 server: qmux::Server,
235}
236
237impl Listener {
238 pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
240 Self::bind_with_alpns(addr, moq_net::ALPNS).await
241 }
242
243 pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
245 let listener = tokio::net::TcpListener::bind(addr).await?;
246 let server = qmux::Server::new().with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))));
250 Ok(Self { listener, server })
251 }
252
253 pub fn local_addr(&self) -> Result<net::SocketAddr> {
255 Ok(self.listener.local_addr()?)
256 }
257
258 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 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 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}