1use axum::body::Body;
19use axum::extract::FromRequestParts;
20use axum::extract::ws::{
21 CloseFrame as AxumCloseFrame, Message as AxumMessage, WebSocket, WebSocketUpgrade,
22};
23use axum::http::{
24 HeaderMap, HeaderName, HeaderValue, Request, StatusCode, header, uri::PathAndQuery,
25};
26use axum::response::{IntoResponse, Response};
27use futures::{SinkExt, StreamExt};
28use tokio::net::TcpStream;
29use tokio_tungstenite::tungstenite::Message as TMessage;
30use tokio_tungstenite::tungstenite::client::IntoClientRequest;
31use tokio_tungstenite::tungstenite::protocol::CloseFrame as TCloseFrame;
32use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
33
34use super::ProxyState;
35
36pub(super) fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
40 let connection_upgrade = headers
41 .get(header::CONNECTION)
42 .and_then(|v| v.to_str().ok())
43 .is_some_and(|v| {
44 v.split(',')
45 .any(|t| t.trim().eq_ignore_ascii_case("upgrade"))
46 });
47 let upgrade_websocket = headers
48 .get(header::UPGRADE)
49 .and_then(|v| v.to_str().ok())
50 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"));
51 connection_upgrade && upgrade_websocket
52}
53
54fn is_handshake_header(name: &HeaderName) -> bool {
59 matches!(
60 name.as_str().to_ascii_lowercase().as_str(),
61 "host"
62 | "connection"
63 | "upgrade"
64 | "content-length"
65 | "content-type"
66 | "sec-websocket-key"
67 | "sec-websocket-version"
68 | "sec-websocket-accept"
69 | "sec-websocket-extensions"
70 )
71}
72
73fn capture_forward_headers(headers: &HeaderMap) -> Vec<(HeaderName, HeaderValue)> {
74 headers
75 .iter()
76 .filter(|(name, _)| !is_handshake_header(name))
77 .map(|(name, value)| (name.clone(), value.clone()))
78 .collect()
79}
80
81fn to_ws_url(upstream: &str, path: &str) -> Option<String> {
83 let base = upstream.trim_end_matches('/');
84 let ws_base = if let Some(rest) = base.strip_prefix("https://") {
85 format!("wss://{rest}")
86 } else {
87 let rest = base.strip_prefix("http://")?;
88 format!("ws://{rest}")
89 };
90 Some(format!("{ws_base}{path}"))
91}
92
93fn merge_cookie(headers: &mut HeaderMap, cf_cookie: &str) {
96 let merged = match headers.get(header::COOKIE).and_then(|v| v.to_str().ok()) {
97 Some(existing) if !existing.trim().is_empty() => format!("{existing}; {cf_cookie}"),
98 _ => cf_cookie.to_string(),
99 };
100 if let Ok(value) = HeaderValue::from_str(&merged) {
101 headers.insert(header::COOKIE, value);
102 }
103}
104
105pub(super) async fn passthrough(state: ProxyState, req: Request<Body>) -> Response {
109 let (mut parts, _body) = req.into_parts();
110
111 let path = parts
112 .uri
113 .path_and_query()
114 .map_or("/backend-api", PathAndQuery::as_str)
115 .to_string();
116 let Some(ws_url) = to_ws_url(&state.chatgpt_upstream(), &path) else {
117 return (StatusCode::BAD_GATEWAY, "invalid ChatGPT upstream").into_response();
118 };
119
120 let forwarded = capture_forward_headers(&parts.headers);
121 let cf_cookie = state.chatgpt_cookie_header();
122
123 let ws = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
124 Ok(ws) => ws,
125 Err(rejection) => return rejection.into_response(),
126 };
127
128 ws.on_upgrade(move |client| async move {
129 if let Err(err) = tunnel(client, ws_url, forwarded, cf_cookie).await {
130 tracing::warn!("lean-ctx proxy: ChatGPT WebSocket passthrough failed: {err}");
131 }
132 })
133}
134
135async fn tunnel(
136 client: WebSocket,
137 ws_url: String,
138 forwarded: Vec<(HeaderName, HeaderValue)>,
139 cf_cookie: Option<String>,
140) -> Result<(), tokio_tungstenite::tungstenite::Error> {
141 let mut request = ws_url.into_client_request()?;
142 {
143 let headers = request.headers_mut();
144 for (name, value) in forwarded {
145 headers.insert(name, value);
146 }
147 if let Some(cf) = cf_cookie {
148 merge_cookie(headers, &cf);
149 }
150 }
151
152 let (upstream, _response) = tokio_tungstenite::connect_async(request).await?;
153 relay(client, upstream).await;
154 Ok(())
155}
156
157async fn relay(client: WebSocket, upstream: WebSocketStream<MaybeTlsStream<TcpStream>>) {
158 let (mut client_tx, mut client_rx) = client.split();
159 let (mut upstream_tx, mut upstream_rx) = upstream.split();
160
161 let client_to_upstream = async {
162 while let Some(Ok(msg)) = client_rx.next().await {
163 let closing = matches!(msg, AxumMessage::Close(_));
164 if upstream_tx.send(axum_to_tungstenite(msg)).await.is_err() {
165 break;
166 }
167 if closing {
168 break;
169 }
170 }
171 };
172
173 let upstream_to_client = async {
174 while let Some(Ok(msg)) = upstream_rx.next().await {
175 let Some(msg) = tungstenite_to_axum(msg) else {
176 continue;
177 };
178 let closing = matches!(msg, AxumMessage::Close(_));
179 if client_tx.send(msg).await.is_err() {
180 break;
181 }
182 if closing {
183 break;
184 }
185 }
186 };
187
188 tokio::select! {
191 () = client_to_upstream => {},
192 () = upstream_to_client => {},
193 }
194}
195
196fn axum_to_tungstenite(msg: AxumMessage) -> TMessage {
197 match msg {
198 AxumMessage::Text(text) => TMessage::Text(text.as_str().into()),
199 AxumMessage::Binary(data) => TMessage::Binary(data),
200 AxumMessage::Ping(data) => TMessage::Ping(data),
201 AxumMessage::Pong(data) => TMessage::Pong(data),
202 AxumMessage::Close(None) => TMessage::Close(None),
203 AxumMessage::Close(Some(frame)) => TMessage::Close(Some(TCloseFrame {
204 code: frame.code.into(),
205 reason: frame.reason.as_str().into(),
206 })),
207 }
208}
209
210fn tungstenite_to_axum(msg: TMessage) -> Option<AxumMessage> {
211 match msg {
212 TMessage::Text(text) => Some(AxumMessage::Text(text.as_str().into())),
213 TMessage::Binary(data) => Some(AxumMessage::Binary(data)),
214 TMessage::Ping(data) => Some(AxumMessage::Ping(data)),
215 TMessage::Pong(data) => Some(AxumMessage::Pong(data)),
216 TMessage::Close(None) => Some(AxumMessage::Close(None)),
217 TMessage::Close(Some(frame)) => Some(AxumMessage::Close(Some(AxumCloseFrame {
218 code: frame.code.into(),
219 reason: frame.reason.as_str().into(),
220 }))),
221 TMessage::Frame(_) => None,
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn detects_websocket_upgrade() {
232 let mut headers = HeaderMap::new();
233 headers.insert(header::CONNECTION, HeaderValue::from_static("Upgrade"));
234 headers.insert(header::UPGRADE, HeaderValue::from_static("websocket"));
235 assert!(is_websocket_upgrade(&headers));
236 }
237
238 #[test]
239 fn detects_websocket_upgrade_case_and_list_insensitive() {
240 let mut headers = HeaderMap::new();
241 headers.insert(
242 header::CONNECTION,
243 HeaderValue::from_static("keep-alive, Upgrade"),
244 );
245 headers.insert(header::UPGRADE, HeaderValue::from_static("WebSocket"));
246 assert!(is_websocket_upgrade(&headers));
247 }
248
249 #[test]
250 fn plain_request_is_not_an_upgrade() {
251 let mut headers = HeaderMap::new();
252 headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive"));
253 assert!(!is_websocket_upgrade(&headers));
254
255 let empty = HeaderMap::new();
256 assert!(!is_websocket_upgrade(&empty));
257 }
258
259 #[test]
260 fn to_ws_url_rewrites_scheme_and_keeps_path() {
261 assert_eq!(
262 to_ws_url("https://chatgpt.com", "/backend-api/wham/connect?x=1"),
263 Some("wss://chatgpt.com/backend-api/wham/connect?x=1".to_string())
264 );
265 assert_eq!(
266 to_ws_url("http://127.0.0.1:4444/", "/backend-api/ws"),
267 Some("ws://127.0.0.1:4444/backend-api/ws".to_string())
268 );
269 assert_eq!(to_ws_url("ftp://nope", "/x"), None);
270 }
271
272 #[test]
273 fn handshake_headers_are_dropped_but_auth_is_forwarded() {
274 let mut headers = HeaderMap::new();
275 headers.insert(header::HOST, HeaderValue::from_static("127.0.0.1:4444"));
276 headers.insert(header::CONNECTION, HeaderValue::from_static("Upgrade"));
277 headers.insert(header::UPGRADE, HeaderValue::from_static("websocket"));
278 headers.insert(
279 "sec-websocket-key",
280 HeaderValue::from_static("dGhlIHNhbXBsZQ=="),
281 );
282 headers.insert(
283 header::AUTHORIZATION,
284 HeaderValue::from_static("Bearer chatgpt-token"),
285 );
286 headers.insert("x-codex-installation-id", HeaderValue::from_static("abc"));
287
288 let forwarded = capture_forward_headers(&headers);
289 let names: Vec<String> = forwarded
290 .iter()
291 .map(|(n, _)| n.as_str().to_string())
292 .collect();
293
294 assert!(names.contains(&"authorization".to_string()));
295 assert!(names.contains(&"x-codex-installation-id".to_string()));
296 assert!(!names.iter().any(|n| n == "host"));
297 assert!(!names.iter().any(|n| n == "connection"));
298 assert!(!names.iter().any(|n| n == "upgrade"));
299 assert!(!names.iter().any(|n| n == "sec-websocket-key"));
300 }
301
302 #[test]
303 fn merge_cookie_appends_to_existing() {
304 let mut headers = HeaderMap::new();
305 headers.insert(header::COOKIE, HeaderValue::from_static("session=abc"));
306 merge_cookie(&mut headers, "cf_clearance=xyz");
307 assert_eq!(
308 headers.get(header::COOKIE).unwrap().to_str().unwrap(),
309 "session=abc; cf_clearance=xyz"
310 );
311 }
312
313 #[test]
314 fn merge_cookie_sets_when_absent() {
315 let mut headers = HeaderMap::new();
316 merge_cookie(&mut headers, "cf_clearance=xyz");
317 assert_eq!(
318 headers.get(header::COOKIE).unwrap().to_str().unwrap(),
319 "cf_clearance=xyz"
320 );
321 }
322
323 #[test]
324 fn message_conversion_round_trips() {
325 let original = AxumMessage::Text("ping".into());
326 let back = tungstenite_to_axum(axum_to_tungstenite(original)).unwrap();
327 assert!(matches!(back, AxumMessage::Text(t) if t.as_str() == "ping"));
328
329 let binary = AxumMessage::Binary(vec![1, 2, 3].into());
330 let back = tungstenite_to_axum(axum_to_tungstenite(binary)).unwrap();
331 assert!(matches!(back, AxumMessage::Binary(b) if b.as_ref() == [1, 2, 3]));
332 }
333
334 #[tokio::test]
338 async fn tunnels_websocket_through_backend_api_to_upstream() {
339 use std::sync::Arc;
340 use std::time::Duration;
341
342 use axum::Router;
343 use axum::routing::any;
344 use tokio::net::TcpListener;
345 use tokio_tungstenite::tungstenite::Message;
346
347 let upstream_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
349 let upstream_addr = upstream_listener.local_addr().unwrap();
350 tokio::spawn(async move {
351 while let Ok((stream, _)) = upstream_listener.accept().await {
352 tokio::spawn(async move {
353 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
354 while let Some(Ok(msg)) = ws.next().await {
355 match msg {
356 Message::Text(_) | Message::Binary(_) => {
357 if ws.send(msg).await.is_err() {
358 break;
359 }
360 }
361 Message::Close(_) => break,
362 _ => {}
363 }
364 }
365 });
366 }
367 });
368
369 let (_tx, rx) = tokio::sync::watch::channel(Arc::new(crate::core::config::Upstreams {
371 anthropic: "https://api.anthropic.com".into(),
372 openai: "https://api.openai.com".into(),
373 chatgpt: format!("http://{upstream_addr}"),
374 gemini: "https://generativelanguage.googleapis.com".into(),
375 providers: Vec::new(),
376 }));
377 let state = ProxyState {
378 client: reqwest::Client::new(),
379 port: 0,
380 stats: Arc::new(crate::proxy::ProxyStats::default()),
381 break_even: Arc::new(crate::proxy::break_even::BreakEvenCalculator::new(1500)),
382 introspect: Arc::new(crate::proxy::introspect::IntrospectState::default()),
383 ocla_cache: None,
384 upstreams: rx,
385 chatgpt_cookies: crate::proxy::chatgpt_cookies::shared_chatgpt_cloudflare_cookie_store(
386 ),
387 mcp_servers: Arc::new(Vec::new()),
388 web_app_tracker: Arc::new(std::sync::Mutex::new(
389 crate::proxy::web_app::conversation_tracker::ConversationTracker::default(),
390 )),
391 };
392 let app = Router::new()
393 .route(
394 "/backend-api/{*rest}",
395 any(crate::proxy::chatgpt::backend_api_handler),
396 )
397 .with_state(state);
398 let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
399 let proxy_addr = proxy_listener.local_addr().unwrap();
400 tokio::spawn(async move {
401 axum::serve(proxy_listener, app).await.unwrap();
402 });
403
404 let url = format!("ws://{proxy_addr}/backend-api/wham/connect");
406 let (mut client, _resp) = tokio::time::timeout(
407 Duration::from_secs(3),
408 tokio_tungstenite::connect_async(url),
409 )
410 .await
411 .expect("handshake must complete")
412 .expect("proxy must tunnel the upgrade to the upstream");
413
414 client
415 .send(Message::Text("remote-control".into()))
416 .await
417 .unwrap();
418 let echoed = tokio::time::timeout(Duration::from_secs(3), client.next())
419 .await
420 .expect("echo must arrive")
421 .expect("stream open")
422 .expect("valid frame");
423 assert_eq!(echoed, Message::Text("remote-control".into()));
424
425 let binary = Message::Binary(vec![9, 8, 7].into());
426 client.send(binary.clone()).await.unwrap();
427 let echoed = tokio::time::timeout(Duration::from_secs(3), client.next())
428 .await
429 .expect("binary echo must arrive")
430 .expect("stream open")
431 .expect("valid frame");
432 assert_eq!(echoed, binary);
433 }
434}