fix(appgate): splice websocket upgrades through to the app

The gate accepted a client's websocket upgrade (.with_upgrades() on the
serving side) but proxy_to_app forwarded every request with a plain
hyper::Client, which completes the app's 101 handshake and then drops the
upgraded connection. Result: any ws-driven app behind a gated port loads
its page fine and then dies with close code 1006 on every connect —
mempool's entire UI is such an app, and the operator's browser console
was the only place the failure was visible. This was the THIRD layer of
the same outage (electrumx sync, then the nginx proxy path, now the app
tile's gated-port path — each fix exposed the next).

On an Upgrade request the gate now takes the client's OnUpgrade handle
from the request extensions, hands the handshake to the app with the same
sanitized headers (cookie/authorization stripping applies unchanged), and
on a 101 bridges the two upgraded connections with copy_bidirectional.
Non-101 upstream answers pass through so auth failures stay visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-09 12:35:38 -04:00
co-authored by Claude Fable 5
parent f10a0d5393
commit 47b563ec4f
+38
View File
@@ -480,6 +480,44 @@ async fn proxy_to_app(
parts.headers.remove(header::AUTHORIZATION);
}
// Websocket upgrades need splicing, not request forwarding. hyper::Client
// alone completes the app's 101 handshake and then DROPS the upgraded
// connection, so every ws-driven app (mempool's entire UI is one) loaded
// fine through the gate and then died with close code 1006 on connect —
// reported from a browser console, 2026-08-09, after two other layers of
// the same symptom had already been fixed. The gate's server side already
// accepts client upgrades (.with_upgrades() in listener.rs); this is the
// missing upstream half: hand the handshake to the app and, on 101, bridge
// the two upgraded connections byte-for-byte. Header stripping above still
// applies — the app sees the same sanitized headers on a ws handshake as
// on any other request.
if parts.headers.contains_key(header::UPGRADE) {
// The client side's OnUpgrade handle rides in the request extensions;
// take it before the parts become the upstream request. It resolves
// once serve_connection's with_upgrades hands us the raw socket after
// we return the 101.
let client_upgrade = parts.extensions.remove::<hyper::upgrade::OnUpgrade>();
let upstream_req = Request::from_parts(parts, Body::empty());
let client = hyper::Client::new();
let mut upstream_resp = match client.request(upstream_req).await {
Ok(resp) => resp,
Err(_) => return bad_gateway(),
};
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
if let Some(client_upgrade) = client_upgrade {
let upstream_upgrade = hyper::upgrade::on(&mut upstream_resp);
tokio::spawn(async move {
if let (Ok(mut client_io), Ok(mut app_io)) =
(client_upgrade.await, upstream_upgrade.await)
{
let _ = tokio::io::copy_bidirectional(&mut client_io, &mut app_io).await;
}
});
}
}
return upstream_resp;
}
let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await {
Ok(resp) => resp,