use std::future::pending;
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use aimux_core::AbortSignal;
use aimux_core::error::{AiMuxError, ApiCallError};
use aimux_core::options::TimeoutConfiguration;
pub struct WebSocketRequest {
pub url: String,
pub headers: Vec<(String, String)>,
pub subprotocols: Vec<String>,
pub abort_signal: Option<AbortSignal>,
pub timeout: Option<TimeoutConfiguration>,
}
#[derive(Debug, Clone)]
pub enum WsMessage {
Text(String),
Binary(Vec<u8>),
}
pub struct WsConnection {
stream: WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
url: String,
abort: Option<AbortSignal>,
first_chunk_deadline: Option<tokio::time::Instant>,
chunk_timeout: Option<std::time::Duration>,
total_deadline: Option<tokio::time::Instant>,
}
async fn abort_future(abort: &Option<AbortSignal>) {
match abort {
Some(signal) => signal.cancelled().await,
None => pending().await,
}
}
fn abort_error(abort: &Option<AbortSignal>) -> AiMuxError {
abort
.as_ref()
.map(AiMuxError::from_abort_signal)
.unwrap_or_else(|| AiMuxError::Aborted("request aborted".into()))
}
fn ws_error(url: &str, msg: impl std::fmt::Display) -> AiMuxError {
AiMuxError::ApiCall(Box::new(ApiCallError {
is_retryable: true,
..ApiCallError::new(
msg.to_string(),
crate::http::sanitized_request_url(url),
serde_json::json!({}),
)
}))
}
enum ConnectError {
Timeout,
Tungstenite(tokio_tungstenite::tungstenite::Error),
ProxyRejected {
status: u16,
reason: String,
},
}
#[allow(clippy::result_large_err)]
async fn connect_with_timeout(
request: tokio_tungstenite::tungstenite::http::Request<()>,
proxy: Option<ProxyTunnel>,
timeout: Option<std::time::Duration>,
) -> Result<
(
WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
),
ConnectError,
> {
let fut = async {
let Some(proxy) = proxy else {
return tokio_tungstenite::connect_async(request)
.await
.map_err(ConnectError::Tungstenite);
};
connect_through_proxy(request, &proxy).await
};
match timeout {
Some(d) => match tokio::time::timeout(d, fut).await {
Ok(inner) => inner,
Err(_) => Err(ConnectError::Timeout),
},
None => fut.await,
}
}
#[doc(hidden)]
#[derive(Debug)]
pub struct ProxyTunnel {
pub host: String,
pub port: u16,
pub authorization: Option<String>,
pub target_tls: bool,
pub target_authority: String,
}
#[doc(hidden)]
#[allow(non_snake_case)]
pub fn ws__proxy_decision_for(
target: &url::Url,
config: &crate::http::ProxyConfig,
) -> Result<Option<ProxyTunnel>, AiMuxError> {
resolve_proxy(target, config)
}
#[doc(hidden)]
#[allow(non_snake_case)]
#[must_use]
pub fn ws__no_proxy_matches(no_proxy: &str, host: &str, port: u16) -> bool {
no_proxy_matches(no_proxy, host, port)
}
fn sanitized_proxy_url(raw: &str) -> String {
if let Some((scheme, rest)) = raw.split_once("://")
&& let Some((_userinfo, host_part)) = rest.split_once('@')
{
return format!("{scheme}://***@{host_part}");
}
raw.to_string()
}
fn resolve_proxy(
target: &url::Url,
config: &crate::http::ProxyConfig,
) -> Result<Option<ProxyTunnel>, AiMuxError> {
let raw = match target.scheme() {
"wss" => config.https_url.clone().or_else(|| config.all_url.clone()),
"ws" => config.http_url.clone().or_else(|| config.all_url.clone()),
_ => None,
};
let Some(raw) = raw else {
return Ok(None);
};
let proxy = url::Url::parse(&raw).map_err(|e| {
AiMuxError::InvalidArgument(format!(
"invalid proxy URL {}: {e}",
sanitized_proxy_url(&raw)
))
})?;
let scheme = proxy.scheme().to_ascii_lowercase();
if scheme != "http" {
return Err(AiMuxError::UnsupportedFunctionality(format!(
"WebSocket proxy tunneling supports http proxies only, got {scheme:?} ({}); \
refusing to bypass the configured proxy with a direct connection",
sanitized_proxy_url(&raw)
)));
}
let host = proxy
.host_str()
.ok_or_else(|| {
AiMuxError::InvalidArgument(format!(
"proxy URL has no host: {}",
sanitized_proxy_url(&raw)
))
})?
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
let port = proxy.port().unwrap_or(80);
let authorization = if proxy.username().is_empty() && proxy.password().is_none() {
None
} else {
let credentials = format!("{}:{}", proxy.username(), proxy.password().unwrap_or(""));
use base64::Engine as _;
Some(format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(credentials)
))
};
let target_host = target.host_str().unwrap_or_default();
let target_port = target
.port_or_known_default()
.unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
Ok(Some(ProxyTunnel {
host,
port,
authorization,
target_tls: target.scheme() == "wss",
target_authority: format!("{target_host}:{target_port}"),
}))
}
fn no_proxy_matches(no_proxy: &str, host: &str, port: u16) -> bool {
for entry in no_proxy.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry == "*" {
return true;
}
let (name, port_entry) = match entry.rsplit_once(':') {
Some((name, digits))
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) =>
{
(name, Some(digits))
}
_ => (entry, None),
};
let name = name.trim_start_matches('.').to_ascii_lowercase();
let host = host.to_ascii_lowercase();
if (host == name || host.ends_with(&format!(".{name}")))
&& port_entry.is_none_or(|p| p.parse::<u16>() == Ok(port))
{
return true;
}
}
false
}
#[allow(clippy::result_large_err)]
async fn connect_through_proxy(
request: tokio_tungstenite::tungstenite::http::Request<()>,
proxy: &ProxyTunnel,
) -> Result<
(
WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>,
tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
),
ConnectError,
> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut stream = tokio::net::TcpStream::connect((proxy.host.as_str(), proxy.port))
.await
.map_err(|e| ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io(e)))?;
let mut connect_request = format!(
"CONNECT {} HTTP/1.1\r\nHost: {}\r\n",
proxy.target_authority, proxy.target_authority
);
if let Some(authorization) = &proxy.authorization {
connect_request.push_str(&format!("Proxy-Authorization: {authorization}\r\n"));
}
connect_request.push_str("\r\n");
stream
.write_all(connect_request.as_bytes())
.await
.map_err(|e| ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io(e)))?;
let mut response = Vec::with_capacity(256);
let mut chunk = [0u8; 512];
while !response.windows(4).any(|w| w == b"\r\n\r\n") {
let n = stream
.read(&mut chunk)
.await
.map_err(|e| ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io(e)))?;
if n == 0 {
return Err(ConnectError::ProxyRejected {
status: 0,
reason: "proxy closed the connection before responding to CONNECT".into(),
});
}
response.extend_from_slice(&chunk[..n]);
if response.len() > 8 * 1024 {
return Err(ConnectError::ProxyRejected {
status: 0,
reason: "proxy CONNECT response headers exceed 8 KiB".into(),
});
}
}
let response_text = String::from_utf8_lossy(&response);
let status_line = response_text.lines().next().unwrap_or_default();
let status = status_line
.split_whitespace()
.nth(1)
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or(0);
if !(200..300).contains(&status) {
return Err(ConnectError::ProxyRejected {
status,
reason: status_line.to_string(),
});
}
let connector = if proxy.target_tls {
let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider());
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| {
ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Io(
std::io::Error::other(format!("building rustls client config: {e}")),
))
})?
.with_root_certificates(roots)
.with_no_client_auth();
tokio_tungstenite::Connector::Rustls(std::sync::Arc::new(config))
} else {
tokio_tungstenite::Connector::Plain
};
let (websocket, response) =
tokio_tungstenite::client_async_tls_with_config(request, stream, None, Some(connector))
.await
.map_err(ConnectError::Tungstenite)?;
Ok((websocket, response))
}
pub async fn ws_connect(req: &WebSocketRequest) -> Result<WsConnection, AiMuxError> {
let mut http_req = req
.url
.as_str()
.into_client_request()
.map_err(|e| AiMuxError::InvalidArgument(format!("invalid WebSocket URL: {e}")))?;
for (k, v) in &req.headers {
http_req.headers_mut().insert(
tokio_tungstenite::tungstenite::http::HeaderName::try_from(k.as_str()).map_err(
|e| AiMuxError::InvalidArgument(format!("invalid WS header name {k}: {e}")),
)?,
HeaderValue::from_str(v).map_err(|e| {
AiMuxError::InvalidArgument(format!("invalid WS header value for {k}: {e}"))
})?,
);
}
if !req.subprotocols.is_empty() {
http_req.headers_mut().insert(
"Sec-WebSocket-Protocol",
HeaderValue::from_str(&req.subprotocols.join(", ")).map_err(|e| {
AiMuxError::InvalidArgument(format!("invalid WS subprotocols: {e}"))
})?,
);
}
let first_chunk_deadline = req
.timeout
.as_ref()
.and_then(|t| t.first_chunk_ms)
.map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms));
let connect_deadline = first_chunk_deadline;
let target = url::Url::parse(&req.url)
.map_err(|e| AiMuxError::InvalidArgument(format!("invalid WebSocket URL: {e}")))?;
let proxy = {
let config = crate::http::global_proxy();
let target_host = target.host_str().unwrap_or_default();
let target_port = target.port_or_known_default().unwrap_or(80);
if config
.no_proxy
.as_deref()
.is_some_and(|list| no_proxy_matches(list, target_host, target_port))
{
None
} else {
resolve_proxy(&target, &config)?
}
};
let (stream, _response) = tokio::select! {
biased;
_ = abort_future(&req.abort_signal) => {
return Err(abort_error(&req.abort_signal));
}
res = connect_with_timeout(http_req, proxy, connect_deadline.map(|d| d - tokio::time::Instant::now())) => match res {
Ok(v) => v,
Err(ConnectError::Timeout) => {
return Err(AiMuxError::Timeout("websocket connect timed out".into()));
}
Err(ConnectError::ProxyRejected { status, reason }) => {
return Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: (status != 0).then_some(status),
is_retryable: if status == 0 {
true
} else {
aimux_core::error::is_retryable_status(status)
},
..ApiCallError::new(
format!("proxy rejected websocket CONNECT: {reason}"),
crate::http::sanitized_request_url(&req.url),
serde_json::json!({}),
)
})));
}
Err(ConnectError::Tungstenite(tokio_tungstenite::tungstenite::Error::Http(
response,
))) => {
let status = response.status().as_u16();
return Err(AiMuxError::ApiCall(Box::new(ApiCallError {
status_code: Some(status),
is_retryable: aimux_core::error::is_retryable_status(status),
response_body: response
.body()
.as_ref()
.map(|body| String::from_utf8_lossy(body).into_owned()),
..ApiCallError::new(
format!("websocket connect rejected: HTTP {status}"),
crate::http::sanitized_request_url(&req.url),
serde_json::json!({}),
)
})));
}
Err(ConnectError::Tungstenite(e)) => {
return Err(ws_error(&req.url, format!("websocket connect failed: {e}")));
}
},
};
Ok(WsConnection {
stream,
url: req.url.clone(),
abort: req.abort_signal.clone(),
first_chunk_deadline,
chunk_timeout: req
.timeout
.as_ref()
.and_then(|t| t.chunk_ms)
.map(tokio::time::Duration::from_millis),
total_deadline: req
.timeout
.as_ref()
.and_then(|t| t.total_ms)
.map(|ms| tokio::time::Instant::now() + tokio::time::Duration::from_millis(ms)),
})
}
impl WsConnection {
pub async fn send_text(&mut self, text: &str) -> Result<(), AiMuxError> {
tokio::select! {
biased;
_ = abort_future(&self.abort) => Err(abort_error(&self.abort)),
_ = deadline_future(self.total_deadline), if self.total_deadline.is_some() => {
Err(AiMuxError::Timeout("websocket send exceeded total timeout".into()))
}
res = self.stream.send(Message::Text(text.to_string())) => {
res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}")))
}
}
}
pub async fn send_binary(&mut self, bytes: &[u8]) -> Result<(), AiMuxError> {
tokio::select! {
biased;
_ = abort_future(&self.abort) => Err(abort_error(&self.abort)),
_ = deadline_future(self.total_deadline), if self.total_deadline.is_some() => {
Err(AiMuxError::Timeout("websocket send exceeded total timeout".into()))
}
res = self.stream.send(Message::Binary(bytes.to_vec())) => {
res.map_err(|e| ws_error(&self.url, format!("websocket send failed: {e}")))
}
}
}
pub async fn next(&mut self) -> Option<Result<WsMessage, AiMuxError>> {
let chunk_idle = self.current_chunk_deadline();
loop {
tokio::select! {
biased;
_ = abort_future(&self.abort) => return Some(Err(abort_error(&self.abort))),
_ = deadline_future(self.first_chunk_deadline), if self.first_chunk_deadline.is_some() => {
return Some(Err(AiMuxError::Timeout("timed out waiting for first websocket event".into())));
}
_ = deadline_future(chunk_idle), if chunk_idle.is_some() => {
return Some(Err(AiMuxError::Timeout("websocket chunk idle timeout".into())));
}
_ = deadline_future(self.total_deadline), if self.total_deadline.is_some() => {
return Some(Err(AiMuxError::Timeout("websocket total timeout".into())));
}
msg = self.stream.next() => match msg {
None => return None,
Some(Ok(Message::Text(t))) => {
self.on_event();
return Some(Ok(WsMessage::Text(t)));
}
Some(Ok(Message::Binary(b))) => {
self.on_event();
return Some(Ok(WsMessage::Binary(b.to_vec())));
}
Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {
continue;
}
Some(Ok(Message::Close(frame))) => {
let detail = frame
.map(|f| format!(" (code {}: {})", u16::from(f.code), f.reason))
.unwrap_or_default();
return Some(Err(ws_error(&self.url, format!(
"websocket closed by peer{detail}"
))));
}
Some(Err(e)) => {
return Some(Err(ws_error(&self.url, format!("websocket error: {e}"))));
}
},
}
}
}
pub async fn close(&mut self) {
let close_fut = self.stream.close(Some(
tokio_tungstenite::tungstenite::protocol::frame::CloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
reason: std::borrow::Cow::Borrowed("finished"),
},
));
tokio::select! {
res = close_fut => {
let _ = res;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
tracing::warn!(
"aimux: websocket close handshake timed out; abandoning socket"
);
}
}
}
fn on_event(&mut self) {
self.first_chunk_deadline = None;
}
fn current_chunk_deadline(&self) -> Option<tokio::time::Instant> {
self.chunk_timeout.map(|d| tokio::time::Instant::now() + d)
}
}
async fn deadline_future(deadline: Option<tokio::time::Instant>) {
match deadline {
Some(d) => tokio::time::sleep_until(d).await,
None => pending().await,
}
}