Skip to main content

featherbit/server/
websocket.rs

1//! WebSocket proxying: detect a client upgrade, open the matching upstream
2//! WebSocket handshake, and relay the two upgraded connections byte-for-byte.
3//!
4//! This is the one place raw hyper connection-upgrade machinery lives. The
5//! node-graph still runs for a WebSocket request (so access-phase plugins —
6//! auth, cors, rate-limit, path rewrite — apply); the `upstream` node resolves
7//! the target and signals intent with `101` + `__ws_upstream_*` context keys,
8//! and the listener calls [`proxy_upgrade`] to finish the handshake and start
9//! the relay.
10//!
11//! The relay is a transparent byte pump ([`tokio::io::copy_bidirectional`]) —
12//! no frame parsing. The upstream leg is always an HTTP/1.1 WebSocket
13//! handshake — `ws://` by default, or `wss://` when the `upstream` node sets
14//! `tls` (see [`proxy_upgrade`]).
15//!
16//! Two **client** transports are supported: HTTP/1.1 (`Connection: Upgrade` →
17//! `101`, key/accept forwarded transparently) and HTTP/2 (RFC 8441 extended
18//! CONNECT → `200`; the h2 client sends no `Sec-WebSocket-Key`, so the gateway
19//! synthesizes one for the upstream handshake). Client-facing `wss://` works
20//! for either, because TLS is terminated before this runs.
21
22use std::sync::Arc;
23
24use bytes::Bytes;
25use http::HeaderMap;
26use http_body_util::Full;
27use hyper::upgrade::OnUpgrade;
28use hyper::{Request, Response};
29use hyper_util::rt::TokioIo;
30use tokio::net::TcpStream;
31use tracing::{debug, warn};
32
33/// Failure establishing the upstream side of a WebSocket proxy. All variants
34/// map to a `502 Bad Gateway` at the listener — the client was never sent the
35/// `101`, so it sees a failed handshake.
36#[derive(Debug, thiserror::Error)]
37pub enum WsError {
38    #[error("failed to connect to upstream {0}: {1}")]
39    Connect(String, String),
40    #[error("upstream websocket handshake failed: {0}")]
41    Handshake(String),
42    #[error("upstream rejected the websocket upgrade (status {0})")]
43    UpstreamRejected(u16),
44    #[error("upstream upgrade failed: {0}")]
45    Upgrade(String),
46    #[error("upstream TLS setup failed: {0}")]
47    Tls(String),
48}
49
50/// Returns true when the request headers ask for a WebSocket upgrade:
51/// `Connection` carries an `upgrade` token (comma-separated, case-insensitive)
52/// **and** `Upgrade: websocket`.
53pub fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
54    let connection_upgrade = headers
55        .get(http::header::CONNECTION)
56        .and_then(|v| v.to_str().ok())
57        .map(|v| {
58            v.split(',')
59                .any(|token| token.trim().eq_ignore_ascii_case("upgrade"))
60        })
61        .unwrap_or(false);
62
63    let upgrade_websocket = headers
64        .get(http::header::UPGRADE)
65        .and_then(|v| v.to_str().ok())
66        .map(|v| v.eq_ignore_ascii_case("websocket"))
67        .unwrap_or(false);
68
69    connection_upgrade && upgrade_websocket
70}
71
72/// Returns true when this is an HTTP/2 RFC 8441 extended-CONNECT WebSocket
73/// request: `:method == CONNECT` and a `:protocol` extension of `websocket`.
74///
75/// hyper surfaces the `:protocol` pseudo-header as a [`hyper::ext::Protocol`]
76/// in the request extensions (behind the enabled `http2` feature).
77pub fn is_h2_websocket_connect(method: &http::Method, extensions: &http::Extensions) -> bool {
78    method == http::Method::CONNECT
79        && extensions
80            .get::<hyper::ext::Protocol>()
81            .is_some_and(|p| p.as_str().eq_ignore_ascii_case("websocket"))
82}
83
84/// Generates a fresh `Sec-WebSocket-Key` (16 random bytes, base64) for the
85/// upstream HTTP/1.1 handshake when the client came in over HTTP/2 and thus
86/// never sent one.
87fn synthesize_ws_key() -> String {
88    use base64::{engine::general_purpose::STANDARD, Engine};
89    use ring::rand::{SecureRandom, SystemRandom};
90    let mut buf = [0u8; 16];
91    SystemRandom::new()
92        .fill(&mut buf)
93        .expect("system RNG unavailable");
94    STANDARD.encode(buf)
95}
96
97/// The WebSocket handshake headers to forward verbatim from the client request
98/// to the upstream. `Host` is set separately to the upstream target.
99const FORWARD_HEADERS: &[&str] = &[
100    "upgrade",
101    "connection",
102    "sec-websocket-key",
103    "sec-websocket-version",
104    "sec-websocket-protocol",
105    "sec-websocket-extensions",
106];
107
108/// The upstream 101-response headers to echo back to the client.
109const ECHO_HEADERS: &[&str] = &[
110    "upgrade",
111    "connection",
112    "sec-websocket-accept",
113    "sec-websocket-protocol",
114    "sec-websocket-extensions",
115];
116
117/// Opens the (always HTTP/1.1) upstream WebSocket handshake to `host:port` at
118/// `path`, and — on a successful upstream `101` — returns the client-facing
119/// response and spawns a task that relays bytes between the client and upstream
120/// once the client connection upgrades.
121///
122/// `client_is_h2` selects the client-facing semantics:
123/// - `false` (HTTP/1.1 client): forward the client's `Sec-WebSocket-*` headers
124///   to the upstream and return a `101 Switching Protocols` echoing the
125///   upstream's `Sec-WebSocket-Accept`.
126/// - `true` (HTTP/2 RFC 8441 client): the client sent no `Sec-WebSocket-Key`,
127///   so synthesize one (and `Version: 13`) for the upstream handshake, and
128///   return a `200 OK` (extended CONNECT success) with no `Sec-WebSocket-*`
129///   headers.
130///
131/// `fwd_headers` are the client request's headers (name → values); only the
132/// WebSocket-relevant ones are forwarded. `client_on_upgrade` is the client's
133/// [`OnUpgrade`] captured by the listener before the request was consumed; it
134/// resolves only after the returned response is written back to the client.
135///
136/// `tls_identity`, when set, is the per-upstream client certificate (and
137/// private CA) to present during the `wss` handshake; `None` falls back to
138/// the shared verified/insecure connector selected by `verify`.
139// Each parameter is a distinct, meaningful part of the upstream handshake;
140// bundling them into a struct would only move the same fields around.
141#[allow(clippy::too_many_arguments)]
142pub async fn proxy_upgrade(
143    host: String,
144    port: u16,
145    path: String,
146    tls: bool,
147    verify: bool,
148    tls_identity: Option<Arc<crate::outbound::tls::UpstreamTls>>,
149    fwd_headers: &std::collections::HashMap<String, Vec<String>>,
150    client_on_upgrade: OnUpgrade,
151    client_is_h2: bool,
152) -> Result<Response<Full<Bytes>>, WsError> {
153    // 1. Raw TCP to the upstream (TLS-wrapped for `wss`), driven by a hyper
154    //    client connection that allows upgrades. Both arms yield the same
155    //    `SendRequest` type (generic over the body, not the IO), so the rest of
156    //    the handshake below is written once.
157    let tcp = TcpStream::connect((host.as_str(), port))
158        .await
159        .map_err(|e| WsError::Connect(format!("{}:{}", host, port), e.to_string()))?;
160
161    let mut sender = if tls {
162        let connector = crate::outbound::client_tls_connector(verify, tls_identity.as_ref())
163            .map_err(WsError::Tls)?;
164        let server_name = rustls::pki_types::ServerName::try_from(host.clone())
165            .map_err(|e| WsError::Tls(format!("invalid server name '{}': {}", host, e)))?;
166        let tls_stream = connector
167            .connect(server_name, tcp)
168            .await
169            .map_err(|e| WsError::Tls(e.to_string()))?;
170        let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tls_stream))
171            .await
172            .map_err(|e| WsError::Handshake(e.to_string()))?;
173        tokio::spawn(async move {
174            if let Err(e) = conn.with_upgrades().await {
175                debug!("upstream wss connection closed: {}", e);
176            }
177        });
178        sender
179    } else {
180        let (sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(tcp))
181            .await
182            .map_err(|e| WsError::Handshake(e.to_string()))?;
183        tokio::spawn(async move {
184            if let Err(e) = conn.with_upgrades().await {
185                debug!("upstream ws connection closed: {}", e);
186            }
187        });
188        sender
189    };
190
191    // 2. Build the upstream handshake request (always HTTP/1.1), overriding
192    //    Host with the target. Forward the client's WebSocket headers; for an
193    //    h2 client, the key/version handshake headers don't exist on the wire,
194    //    so synthesize them.
195    let mut builder = Request::builder()
196        .method(http::Method::GET)
197        .uri(&path)
198        .header(http::header::HOST, format!("{}:{}", host, port));
199    for name in FORWARD_HEADERS {
200        // An h2 extended-CONNECT client sends none of the h1 handshake headers
201        // (`upgrade`/`connection`/`sec-websocket-key`/`-version`) on the wire —
202        // we synthesize them below. Only `sec-websocket-protocol`/`-extensions`
203        // may carry over.
204        if client_is_h2
205            && matches!(
206                *name,
207                "upgrade" | "connection" | "sec-websocket-key" | "sec-websocket-version"
208            )
209        {
210            continue;
211        }
212        if let Some(values) = fwd_headers.get(*name) {
213            for value in values {
214                builder = builder.header(*name, value);
215            }
216        }
217    }
218    if client_is_h2 {
219        builder = builder
220            .header("upgrade", "websocket")
221            .header("connection", "upgrade")
222            .header("sec-websocket-key", synthesize_ws_key());
223        if !fwd_headers.contains_key("sec-websocket-version") {
224            builder = builder.header("sec-websocket-version", "13");
225        }
226    }
227    let req = builder
228        .body(Full::new(Bytes::new()))
229        .map_err(|e| WsError::Handshake(e.to_string()))?;
230
231    // 3. Send it and require a 101.
232    let resp = sender
233        .send_request(req)
234        .await
235        .map_err(|e| WsError::Handshake(e.to_string()))?;
236    if resp.status() != http::StatusCode::SWITCHING_PROTOCOLS {
237        return Err(WsError::UpstreamRejected(resp.status().as_u16()));
238    }
239
240    // 4. Build the client-facing response, then take the upstream's upgraded
241    //    stream. An h1 client gets a `101` echoing the upstream handshake
242    //    headers; an h2 (RFC 8441) client gets a `200` — hyper strips
243    //    hop-by-hop headers and completing the extended CONNECT needs a 2xx
244    //    with an empty body.
245    let client_response = if client_is_h2 {
246        let mut b = Response::builder().status(http::StatusCode::OK);
247        // A negotiated subprotocol is a normal header in h2; forward it.
248        if let Some(value) = resp.headers().get("sec-websocket-protocol") {
249            b = b.header("sec-websocket-protocol", value);
250        }
251        b
252    } else {
253        let mut b = Response::builder().status(http::StatusCode::SWITCHING_PROTOCOLS);
254        for name in ECHO_HEADERS {
255            if let Some(value) = resp.headers().get(*name) {
256                b = b.header(*name, value);
257            }
258        }
259        b
260    };
261
262    let upstream_upgraded = hyper::upgrade::on(resp)
263        .await
264        .map_err(|e| WsError::Upgrade(e.to_string()))?;
265
266    let client_response = client_response
267        .body(Full::new(Bytes::new()))
268        .map_err(|e| WsError::Handshake(e.to_string()))?;
269
270    // 5. Relay once the client side upgrades (after our 101 is written back).
271    tokio::spawn(async move {
272        match client_on_upgrade.await {
273            Ok(client_upgraded) => {
274                let mut client_io = TokioIo::new(client_upgraded);
275                let mut upstream_io = TokioIo::new(upstream_upgraded);
276                if let Err(e) =
277                    tokio::io::copy_bidirectional(&mut client_io, &mut upstream_io).await
278                {
279                    debug!("websocket relay closed: {}", e);
280                }
281            }
282            Err(e) => warn!("client websocket upgrade failed: {}", e),
283        }
284    });
285
286    Ok(client_response)
287}
288
289/// A `502 Bad Gateway` JSON response, returned when the upstream WebSocket
290/// handshake could not be completed.
291pub fn bad_gateway_502() -> Response<Full<Bytes>> {
292    Response::builder()
293        .status(http::StatusCode::BAD_GATEWAY)
294        .header(http::header::CONTENT_TYPE, "application/json")
295        .body(Full::new(Bytes::from_static(
296            br#"{"error":"bad_gateway","message":"upstream websocket handshake failed"}"#,
297        )))
298        .unwrap()
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
306        let mut h = HeaderMap::new();
307        for (k, v) in pairs {
308            h.append(
309                http::HeaderName::from_bytes(k.as_bytes()).unwrap(),
310                http::HeaderValue::from_str(v).unwrap(),
311            );
312        }
313        h
314    }
315
316    #[test]
317    fn test_detect_plain_upgrade() {
318        assert!(is_websocket_upgrade(&headers(&[
319            ("connection", "Upgrade"),
320            ("upgrade", "websocket"),
321        ])));
322    }
323
324    #[test]
325    fn test_detect_multi_token_connection() {
326        // Browsers commonly send "keep-alive, Upgrade".
327        assert!(is_websocket_upgrade(&headers(&[
328            ("connection", "keep-alive, Upgrade"),
329            ("upgrade", "WebSocket"),
330        ])));
331    }
332
333    #[test]
334    fn test_detect_requires_both_headers() {
335        assert!(!is_websocket_upgrade(&headers(&[(
336            "connection",
337            "Upgrade"
338        )])));
339        assert!(!is_websocket_upgrade(&headers(&[("upgrade", "websocket")])));
340        assert!(!is_websocket_upgrade(&headers(&[])));
341    }
342
343    #[test]
344    fn test_detect_rejects_non_websocket_upgrade() {
345        // h2c upgrade is not a WebSocket.
346        assert!(!is_websocket_upgrade(&headers(&[
347            ("connection", "Upgrade"),
348            ("upgrade", "h2c"),
349        ])));
350    }
351
352    #[test]
353    fn test_bad_gateway_502_shape() {
354        let resp = bad_gateway_502();
355        assert_eq!(resp.status(), 502);
356        assert_eq!(
357            resp.headers().get("content-type").unwrap(),
358            "application/json"
359        );
360    }
361
362    fn req_with(method: http::Method, protocol: Option<&'static str>) -> http::Request<()> {
363        let mut req = http::Request::builder().method(method).body(()).unwrap();
364        if let Some(p) = protocol {
365            req.extensions_mut()
366                .insert(hyper::ext::Protocol::from_static(p));
367        }
368        req
369    }
370
371    #[test]
372    fn test_detect_h2_extended_connect() {
373        let req = req_with(http::Method::CONNECT, Some("websocket"));
374        assert!(is_h2_websocket_connect(req.method(), req.extensions()));
375    }
376
377    #[test]
378    fn test_detect_h2_rejects_non_connect_and_non_websocket() {
379        // GET with the extension is not an extended CONNECT.
380        let get = req_with(http::Method::GET, Some("websocket"));
381        assert!(!is_h2_websocket_connect(get.method(), get.extensions()));
382        // CONNECT without a :protocol is a classic tunnel, not a WebSocket.
383        let plain = req_with(http::Method::CONNECT, None);
384        assert!(!is_h2_websocket_connect(plain.method(), plain.extensions()));
385        // CONNECT with a different :protocol.
386        let h2c = req_with(http::Method::CONNECT, Some("h2c"));
387        assert!(!is_h2_websocket_connect(h2c.method(), h2c.extensions()));
388    }
389
390    #[test]
391    fn test_synthesize_ws_key_is_16_bytes() {
392        use base64::{engine::general_purpose::STANDARD, Engine};
393        let key = synthesize_ws_key();
394        assert_eq!(STANDARD.decode(key).unwrap().len(), 16);
395    }
396}