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
105pub(crate) async fn race_handle(
106 config: &Client,
107 tls: &rustls::ClientConfig,
108 url: Url,
109 alpns: &[&str],
110) -> Option<Result<qmux::Session>> {
111 if !config.enabled {
112 return None;
113 }
114
115 match url.scheme() {
118 "http" | "https" | "ws" | "wss" => {}
119 _ => return None,
120 }
121
122 let res = connect(config, tls, url, alpns).await;
123 if let Err(err) = &res {
124 tracing::warn!(%err, "WebSocket connection failed");
125 }
126 Some(res)
127}
128
129pub(crate) async fn connect(
130 config: &Client,
131 tls: &rustls::ClientConfig,
132 mut url: Url,
133 alpns: &[&str],
134) -> Result<qmux::Session> {
135 if !config.enabled {
136 return Err(Error::Disabled);
137 }
138
139 let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
140 let port = url.port().unwrap_or_else(|| match url.scheme() {
141 "https" | "wss" | "moql" | "moqt" => 443,
142 "http" | "ws" => 80,
143 _ => 443,
144 });
145 let key = (host, port);
146
147 match config.delay {
151 Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
152 tokio::time::sleep(delay).await;
153 tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
154 }
155 _ => {}
156 }
157
158 let needs_tls = match url.scheme() {
161 "http" => {
162 url.set_scheme("ws").expect("failed to set scheme");
163 false
164 }
165 "https" => {
166 url.set_scheme("wss").expect("failed to set scheme");
167 true
168 }
169 "ws" => false,
170 "wss" => true,
171 _ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
172 };
173
174 tracing::debug!(%url, "connecting via WebSocket");
175
176 let connector = if needs_tls {
178 tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
179 } else {
180 tokio_tungstenite::Connector::Plain
181 };
182
183 let session = qmux::Client::new()
189 .with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
190 .with_connector(connector)
191 .with_keep_alive(qmux::KeepAlive::default()) .connect(url.as_str())
193 .await
194 .map_err(Error::Connect)?;
195
196 tracing::warn!(%url, "using WebSocket fallback");
197 WEBSOCKET_WON.lock().unwrap().insert(key);
198
199 Ok(session)
200}
201
202const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
208
209fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
210 if QMUX01_ONLY_ALPNS.contains(&alpn) {
211 &[qmux::Version::QMux01]
212 } else {
213 &[]
214 }
215}
216
217impl Error {
218 pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
219 match self {
220 Self::ConnectRejected(err) => Some(*err),
221 Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
224 _ => None,
225 }
226 }
227
228 pub(crate) fn status(&self) -> Option<u16> {
233 match self {
234 Self::Connect(qmux::Error::Http(status)) => Some(*status),
235 _ => None,
236 }
237 }
238}
239
240pub struct Listener {
245 listener: tokio::net::TcpListener,
246 server: qmux::Server,
247 health: crate::accept::Health,
248}
249
250impl Listener {
251 pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
253 Self::bind_with_alpns(addr, moq_net::ALPNS).await
254 }
255
256 pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
258 let listener = tokio::net::TcpListener::bind(addr).await?;
259 let server = qmux::Server::new().with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))));
263 Ok(Self {
264 listener,
265 server,
266 health: crate::accept::Health::new("websocket"),
267 })
268 }
269
270 pub fn local_addr(&self) -> Result<net::SocketAddr> {
272 Ok(self.listener.local_addr()?)
273 }
274
275 pub fn accept_health(&self) -> crate::accept::Health {
278 self.health.clone()
279 }
280
281 pub async fn accept(&self) -> Option<Result<qmux::Session>> {
290 let (stream, addr) = self.accept_socket().await;
291 tracing::debug!(%addr, "accepted WebSocket TCP connection");
292 let server = self.server.clone();
293 Some(server.accept(stream).await.map_err(Error::Accept))
294 }
295
296 async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
298 loop {
299 match self.listener.accept().await {
300 Ok(accepted) => {
301 self.health.accepted();
302 return accepted;
303 }
304 Err(err) => {
305 if let Some(delay) = self.health.failed(&err) {
306 tokio::time::sleep(delay).await;
307 }
308 }
309 }
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn moqt_18_and_19_pin_to_qmux01() {
320 assert_eq!(
323 QMUX01_ONLY_ALPNS
324 .iter()
325 .map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
326 .collect::<Vec<_>>(),
327 vec![Some(0xff000012), Some(0xff000013)]
328 );
329 for &alpn in QMUX01_ONLY_ALPNS {
330 assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
331 }
332
333 for &alpn in moq_net::ALPNS {
335 if !QMUX01_ONLY_ALPNS.contains(&alpn) {
336 assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
337 }
338 }
339 }
340}