use qmux::tokio_tungstenite;
use qmux::tokio_tungstenite::tungstenite::{self, http};
use std::collections::HashSet;
use std::sync::{Arc, LazyLock, Mutex};
use std::{net, time};
use url::Url;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error("WebSocket support is disabled")]
Disabled,
#[error("missing hostname")]
MissingHostname,
#[error("unsupported URL scheme for WebSocket: {0}")]
UnsupportedScheme(String),
#[error("failed to connect WebSocket")]
Connect(#[source] qmux::Error),
#[error("failed to build WebSocket request")]
BuildRequest(#[source] tungstenite::Error),
#[error("failed to build WebSocket protocols header")]
ProtocolHeader(#[source] http::header::InvalidHeaderValue),
#[error("failed to connect WebSocket")]
WebSocketConnect(#[source] tungstenite::Error),
#[error(transparent)]
ConnectRejected(#[from] crate::ConnectError),
#[error("WebSocket accept failed")]
Accept(#[source] qmux::Error),
}
type Result<T> = std::result::Result<T, Error>;
static WEBSOCKET_WON: LazyLock<Mutex<HashSet<(String, u16)>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
#[derive(Clone, Debug, clap::Args, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
#[group(id = "websocket-client")]
#[non_exhaustive]
pub struct Client {
#[arg(
id = "websocket-enabled",
long = "websocket-enabled",
env = "MOQ_CLIENT_WEBSOCKET_ENABLED",
default_value = "true"
)]
pub enabled: bool,
#[arg(
id = "websocket-delay",
long = "websocket-delay",
env = "MOQ_CLIENT_WEBSOCKET_DELAY",
default_value = "200ms",
value_parser = humantime::parse_duration,
)]
#[serde(with = "humantime_serde")]
#[serde(skip_serializing_if = "Option::is_none")]
pub delay: Option<time::Duration>,
}
impl Default for Client {
fn default() -> Self {
Self {
enabled: true,
delay: Some(time::Duration::from_millis(200)),
}
}
}
#[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))]
pub(crate) async fn race_handle(
config: &Client,
tls: &rustls::ClientConfig,
url: Url,
alpns: &[&str],
) -> Option<Result<qmux::Session>> {
if !config.enabled {
return None;
}
match url.scheme() {
"http" | "https" | "ws" | "wss" => {}
_ => return None,
}
let res = connect(config, tls, url, alpns).await;
if let Err(err) = &res {
tracing::warn!(%err, "WebSocket connection failed");
}
Some(res)
}
pub(crate) async fn connect(
config: &Client,
tls: &rustls::ClientConfig,
mut url: Url,
alpns: &[&str],
) -> Result<qmux::Session> {
if !config.enabled {
return Err(Error::Disabled);
}
let host = url.host_str().ok_or(Error::MissingHostname)?.to_string();
let port = url.port().unwrap_or_else(|| match url.scheme() {
"https" | "wss" | "moql" | "moqt" => 443,
"http" | "ws" => 80,
_ => 443,
});
let key = (host, port);
match config.delay {
Some(delay) if !WEBSOCKET_WON.lock().unwrap().contains(&key) => {
tokio::time::sleep(delay).await;
tracing::debug!(%url, delay_ms = %delay.as_millis(), "QUIC not yet connected, attempting WebSocket fallback");
}
_ => {}
}
let needs_tls = match url.scheme() {
"http" => {
url.set_scheme("ws").expect("failed to set scheme");
false
}
"https" => {
url.set_scheme("wss").expect("failed to set scheme");
true
}
"ws" => false,
"wss" => true,
_ => return Err(Error::UnsupportedScheme(url.scheme().to_string())),
};
tracing::debug!(%url, "connecting via WebSocket");
let connector = if needs_tls {
tokio_tungstenite::Connector::Rustls(Arc::new(tls.clone()))
} else {
tokio_tungstenite::Connector::Plain
};
let session = qmux::Client::new()
.with_protocols(alpns.iter().map(|&a| (a, qmux_versions_for(a))))
.with_connector(connector)
.with_keep_alive(qmux::KeepAlive::default()) .connect(url.as_str())
.await
.map_err(Error::Connect)?;
tracing::warn!(%url, "using WebSocket fallback");
WEBSOCKET_WON.lock().unwrap().insert(key);
Ok(session)
}
const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"];
fn qmux_versions_for(alpn: &str) -> &'static [qmux::Version] {
if QMUX01_ONLY_ALPNS.contains(&alpn) {
&[qmux::Version::QMux01]
} else {
&[]
}
}
impl Error {
pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
match self {
Self::ConnectRejected(err) => Some(*err),
Self::Connect(qmux::Error::Http(status)) => crate::ConnectError::from_status_u16(*status),
_ => None,
}
}
pub(crate) fn status(&self) -> Option<u16> {
match self {
Self::Connect(qmux::Error::Http(status)) => Some(*status),
_ => None,
}
}
}
pub struct Listener {
listener: tokio::net::TcpListener,
protocols: Vec<String>,
health: crate::accept::Health,
}
impl Listener {
pub async fn bind(addr: net::SocketAddr) -> Result<Self> {
Self::bind_with_alpns(addr, moq_net::ALPNS).await
}
pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result<Self> {
let listener = tokio::net::TcpListener::bind(addr).await?;
let protocols = supported_subprotocols(alpns);
for protocol in &protocols {
http::HeaderValue::from_str(protocol).map_err(Error::ProtocolHeader)?;
}
Ok(Self {
listener,
protocols,
health: crate::accept::Health::new("websocket"),
})
}
pub fn local_addr(&self) -> Result<net::SocketAddr> {
Ok(self.listener.local_addr()?)
}
pub fn accept_health(&self) -> crate::accept::Health {
self.health.clone()
}
pub async fn accept(&self) -> Option<Result<qmux::Session>> {
self.accept_with_url()
.await
.map(|result| result.map(|(session, _)| session))
}
pub(crate) async fn accept_with_url(&self) -> Option<Result<(qmux::Session, Url)>> {
let (stream, addr) = self.accept_socket().await;
tracing::debug!(%addr, "accepted WebSocket TCP connection");
let accepted = Arc::new(Mutex::new(None::<(Option<String>, Url)>));
let accepted_callback = accepted.clone();
let protocols = self.protocols.clone();
#[allow(clippy::result_large_err)]
let callback = move |request: &tungstenite::handshake::server::Request,
mut response: tungstenite::handshake::server::Response|
-> std::result::Result<_, tungstenite::handshake::server::ErrorResponse> {
let offered: Vec<_> = request
.headers()
.get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|value| !value.is_empty())
.collect();
let Ok(protocol) = select_subprotocol(&offered, &protocols) else {
return Err(http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Some("no supported protocol".to_string()))
.expect("valid rejection response"));
};
let Some(url) = websocket_request_url(request) else {
return Err(http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.body(Some("invalid request URL".to_string()))
.expect("valid rejection response"));
};
if let Some(protocol) = protocol {
response.headers_mut().insert(
http::header::SEC_WEBSOCKET_PROTOCOL,
http::HeaderValue::from_str(protocol).expect("protocol validated at bind"),
);
}
*accepted_callback.lock().unwrap() = Some((protocol.map(str::to_string), url));
Ok(response)
};
let websocket = tokio_tungstenite::accept_hdr_async_with_config(stream, callback, None)
.await
.map_err(qmux::Error::from)
.map_err(Error::Accept);
Some(websocket.map(|websocket| {
let (protocol, url) = accepted
.lock()
.unwrap()
.take()
.expect("successful upgrade selected a protocol");
let upgraded = qmux::ws::Upgraded::new(websocket).with_keep_alive(qmux::KeepAlive::default());
let session = match protocol {
Some(protocol) => upgraded.with_alpn(&protocol).accept(),
None => upgraded.accept(),
};
(session, url)
}))
}
async fn accept_socket(&self) -> (tokio::net::TcpStream, net::SocketAddr) {
loop {
match self.listener.accept().await {
Ok(accepted) => {
self.health.accepted();
return accepted;
}
Err(err) => {
if let Some(delay) = self.health.failed(&err) {
tokio::time::sleep(delay).await;
}
}
}
}
}
}
fn select_subprotocol<'a>(offered: &[&str], supported: &'a [String]) -> std::result::Result<Option<&'a str>, ()> {
if offered.is_empty() {
return Ok(None);
}
supported
.iter()
.find(|protocol| offered.contains(&protocol.as_str()))
.map(|protocol| Some(protocol.as_str()))
.ok_or(())
}
fn websocket_request_url(request: &tungstenite::handshake::server::Request) -> Option<Url> {
let uri = request.uri();
if uri.scheme().is_some() && uri.authority().is_some() {
return Url::parse(&uri.to_string()).ok();
}
let host = request.headers().get(http::header::HOST)?.to_str().ok()?;
Url::parse(&format!("ws://{host}{uri}")).ok()
}
fn supported_subprotocols(alpns: &[&str]) -> Vec<String> {
let mut protocols = Vec::new();
for &alpn in alpns {
let versions = qmux_versions_for(alpn);
let versions = if versions.is_empty() {
qmux::Version::ALL
} else {
versions
};
protocols.extend(
versions
.iter()
.copied()
.filter(|version| version.is_qmux())
.map(|version| format!("{}{alpn}", version.prefix())),
);
}
protocols.extend(qmux::ALPNS.iter().map(|protocol| (*protocol).to_string()));
protocols
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subprotocol_selection_preserves_legacy_clients() {
let supported = vec!["qmux-01.moq-lite-05".to_string()];
assert_eq!(select_subprotocol(&[], &supported), Ok(None));
assert_eq!(
select_subprotocol(&["qmux-01.moq-lite-05"], &supported),
Ok(Some("qmux-01.moq-lite-05"))
);
assert_eq!(select_subprotocol(&["unsupported"], &supported), Err(()));
}
#[tokio::test]
async fn listener_accepts_legacy_client_without_subprotocol() {
let listener = Listener::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
let addr = listener.local_addr().unwrap();
let accepted = tokio::spawn(async move { listener.accept_with_url().await.unwrap().unwrap() });
let stream = tokio::net::TcpStream::connect(addr).await.unwrap();
let request_url = format!("ws://{addr}/room?jwt=test");
let (websocket, response) = tokio_tungstenite::client_async(request_url, stream).await.unwrap();
assert!(!response.headers().contains_key(http::header::SEC_WEBSOCKET_PROTOCOL));
let (session, url) = accepted.await.unwrap();
assert_eq!(url.path(), "/room");
assert_eq!(url.query(), Some("jwt=test"));
drop(session);
drop(websocket);
}
#[test]
fn moqt_18_and_19_pin_to_qmux01() {
assert_eq!(
QMUX01_ONLY_ALPNS
.iter()
.map(|&a| moq_net::Version::from_alpn(a).map(|v| v.code()))
.collect::<Vec<_>>(),
vec![Some(0xff000012), Some(0xff000013)]
);
for &alpn in QMUX01_ONLY_ALPNS {
assert_eq!(qmux_versions_for(alpn), &[qmux::Version::QMux01]);
}
for &alpn in moq_net::ALPNS {
if !QMUX01_ONLY_ALPNS.contains(&alpn) {
assert!(qmux_versions_for(alpn).is_empty(), "{alpn} should not be pinned");
}
}
}
}