use hyper::body::Incoming;
use hyper::header::{CONNECTION, HeaderMap, HeaderName, HeaderValue, UPGRADE};
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tracing::{debug, error};
use crate::RouteTarget;
pub(crate) fn is_websocket_upgrade(req: &Request<Incoming>) -> bool {
req.headers()
.get("upgrade")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
}
pub(crate) async fn handle_websocket_proxy(
mut req: Request<Incoming>,
target: &RouteTarget,
host: &str,
is_tls: bool,
client_ip: &str,
) -> Response<crate::body::ProxyBody> {
let upgrade_fut = hyper::upgrade::on(&mut req);
let backend_addr = target.address.clone();
let (parts, _body) = req.into_parts();
let path_and_query = parts
.uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let path = crate::forward::strip_target_prefix(target, path_and_query);
let raw_req = build_upgrade_request(
&parts.method,
&path,
&parts.headers,
host,
is_tls,
client_ip,
);
let mut backend = match tokio::time::timeout(
std::time::Duration::from_secs(5),
TcpStream::connect(&backend_addr),
)
.await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
error!("WebSocket backend connect failed ({backend_addr}): {e}");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::BAD_GATEWAY;
return r;
}
Err(_) => {
error!("WebSocket backend connect timed out ({backend_addr})");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::GATEWAY_TIMEOUT;
return r;
}
};
if let Err(e) = backend.write_all(&raw_req).await {
error!("WebSocket write to backend failed: {e}");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::BAD_GATEWAY;
return r;
}
let mut hdr = Vec::with_capacity(512);
let read_result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
let mut byte = [0u8; 1];
loop {
backend.read_exact(&mut byte).await?;
hdr.push(byte[0]);
if hdr.len() >= 4 && hdr[hdr.len() - 4..] == *b"\r\n\r\n" {
return Ok::<(), std::io::Error>(());
}
if hdr.len() > 8192 {
return Err(std::io::Error::other("response header too large"));
}
}
})
.await;
match read_result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
error!("WebSocket backend header read failed: {e}");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::BAD_GATEWAY;
return r;
}
Err(_) => {
error!("WebSocket backend header read timed out ({backend_addr})");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::GATEWAY_TIMEOUT;
return r;
}
}
if !is_switching_protocols(&hdr) {
let head = String::from_utf8_lossy(&hdr);
let first_line = head.lines().next().unwrap_or("");
error!("WebSocket backend refused upgrade: {first_line}");
let mut r = Response::new(crate::body::empty_body());
*r.status_mut() = StatusCode::BAD_GATEWAY;
return r;
}
let resp = switching_protocols_response(&hdr);
tokio::spawn(async move {
let upgraded = match upgrade_fut.await {
Ok(u) => u,
Err(e) => {
error!("WebSocket client upgrade failed: {e}");
return;
}
};
debug!("WebSocket tunnel established to {backend_addr}");
let mut client_io = TokioIo::new(upgraded);
let _ = tokio::io::copy_bidirectional(&mut client_io, &mut backend).await;
});
resp
}
const RELAYED_HANDSHAKE_HEADERS: [&str; 3] = [
"sec-websocket-accept",
"sec-websocket-protocol",
"sec-websocket-extensions",
];
pub(crate) fn build_upgrade_request(
method: &Method,
path: &str,
headers: &HeaderMap,
host: &str,
is_tls: bool,
client_ip: &str,
) -> Vec<u8> {
let mut out = format!("{method} {path} HTTP/1.1\r\n").into_bytes();
let mut incoming_xff: Option<String> = None;
let (mut saw_proto, mut saw_fhost) = (false, false);
for (name, value) in headers {
match name.as_str() {
"x-forwarded-for" => {
if let Ok(v) = value.to_str() {
incoming_xff = Some(match incoming_xff.take() {
Some(prev) => format!("{prev}, {v}"),
None => v.to_string(),
});
}
continue;
}
"x-forwarded-proto" => saw_proto = true,
"x-forwarded-host" => saw_fhost = true,
_ => {}
}
push_header(&mut out, name.as_str(), value.as_bytes());
}
if !saw_proto {
let scheme = if is_tls { "https" } else { "http" };
push_header(&mut out, "x-forwarded-proto", scheme.as_bytes());
}
if !saw_fhost {
push_header(&mut out, "x-forwarded-host", host.as_bytes());
}
let xff = crate::forward::forwarded_for_value(incoming_xff.as_deref(), client_ip);
push_header(&mut out, "x-forwarded-for", xff.as_bytes());
out.extend_from_slice(b"\r\n");
out
}
fn push_header(out: &mut Vec<u8>, name: &str, value: &[u8]) {
out.extend_from_slice(name.as_bytes());
out.extend_from_slice(b": ");
out.extend_from_slice(value);
out.extend_from_slice(b"\r\n");
}
pub(crate) fn is_switching_protocols(head: &[u8]) -> bool {
let line = head.split(|&b| b == b'\n').next().unwrap_or_default();
let line = line.strip_suffix(b"\r").unwrap_or(line);
line.split(|&b| b == b' ').nth(1) == Some(b"101".as_slice())
}
pub(crate) fn switching_protocols_response(head: &[u8]) -> Response<crate::body::ProxyBody> {
let mut resp = Response::new(crate::body::empty_body());
*resp.status_mut() = StatusCode::SWITCHING_PROTOCOLS;
let headers = resp.headers_mut();
headers.insert(UPGRADE, HeaderValue::from_static("websocket"));
headers.insert(CONNECTION, HeaderValue::from_static("Upgrade"));
for line in head.split(|&b| b == b'\n').skip(1) {
let Some(colon) = line.iter().position(|&b| b == b':') else {
continue;
};
let Ok(name) = HeaderName::from_bytes(line[..colon].trim_ascii()) else {
continue;
};
if !RELAYED_HANDSHAKE_HEADERS.contains(&name.as_str()) {
continue;
}
if let Ok(value) = HeaderValue::from_bytes(line[colon + 1..].trim_ascii()) {
headers.append(name, value);
}
}
resp
}
#[cfg(test)]
#[path = "websocket_tests.rs"]
mod tests;