This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/crates/maestro-web/src/proxy.rs
T
Miguel PalhasandClaude Opus 5 ef3d314296
ci / rust (pull_request) Successful in 3m11s
ci / rust (push) Successful in 1m27s
refactor(maestro): finish moving the e2e harness off raw reqwest
Restores the harness's deliberate 5s per-request budget, which the shared
client's 30s default had silently replaced, and converts the last two
hand-rolled daemon calls in e2e.rs (recent-dirs, gitroot). Drops reqwest
from maestro-e2e entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 12:22:35 +01:00

420 lines
15 KiB
Rust

//! Reverse-proxy primitives: small hand-written hyper-client handler for
//! `/api/...` HTTP, and an axum WebSocket pump for `/api/attach/:id` +
//! `/api/events`.
//!
//! Keeps dependencies minimal — reqwest for HTTP, tokio_tungstenite for the
//! upstream WS leg. ~150 LOC total.
use std::sync::Arc;
use axum::body::Body;
use axum::extract::ws::{Message as AxMessage, WebSocket};
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use maestro_protocol::routes::{self, UrlError};
use maestro_protocol::ApiError;
use tokio_tungstenite::tungstenite::Message as WsMessage;
#[derive(Debug, Clone)]
pub struct UpstreamConfig {
/// HTTP base of the daemon — `http://127.0.0.1:7070`.
pub daemon_http_base: Arc<String>,
/// WS base of the daemon — derived from `daemon_http_base` by replacing
/// the scheme. Cached so we don't redo it per request.
pub daemon_ws_base: Arc<String>,
}
impl UpstreamConfig {
pub fn from_daemon_url(daemon_url: &str) -> Result<Self, UrlError> {
let http_base = routes::normalize_base(daemon_url).to_string();
let ws_base = routes::ws_base(&http_base)?;
Ok(Self {
daemon_http_base: Arc::new(http_base),
daemon_ws_base: Arc::new(ws_base),
})
}
}
/// Shared state plumbed into the proxy handlers.
#[derive(Debug, Clone)]
pub struct ProxyState {
pub upstream: UpstreamConfig,
pub client: reqwest::Client,
}
impl ProxyState {
pub fn new(upstream: UpstreamConfig) -> Self {
Self {
upstream,
client: reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("reqwest client"),
}
}
}
// ---------------------------------------------------------------------------
// HTTP proxy
// ---------------------------------------------------------------------------
/// Cap on the request body we'll buffer before forwarding upstream. The proxied
/// daemon endpoints (`/api/sessions` CRUD, etc.) all carry small JSON; 8 MiB is
/// far above anything legitimate. Without a cap, anyone reachable on the bind
/// address could POST a multi-GB body and OOM the process (NAP-257).
pub(crate) const MAX_REQUEST_BODY: usize = 8 * 1024 * 1024;
/// Forward an HTTP request to the upstream daemon and stream the response back.
pub async fn proxy_http(State(state): State<ProxyState>, req: Request) -> Response {
let (parts, body) = req.into_parts();
let upstream_uri = build_uri(&state.upstream.daemon_http_base, &parts.uri);
// Bound the buffered request body. `to_bytes` returns `Err` once the body
// exceeds the limit; treat that as 413 rather than buffering unboundedly.
let body_bytes = match axum::body::to_bytes(body, MAX_REQUEST_BODY).await {
Ok(b) => b,
Err(_) => return payload_too_large(),
};
let mut upstream_req = state
.client
.request(parts.method.clone(), upstream_uri.as_str())
.body(body_bytes.to_vec());
for (name, value) in filter_request_headers(&parts.headers).iter() {
upstream_req = upstream_req.header(name, value);
}
let upstream_resp = match upstream_req.send().await {
Ok(r) => r,
Err(e) => return bad_gateway(&format!("upstream send: {e}")),
};
let status = upstream_resp.status();
let headers = filter_response_headers(upstream_resp.headers());
// Stream the response body straight through instead of materializing it —
// a large upstream reply never gets fully buffered in RAM (NAP-257).
let mut resp = Response::builder().status(status);
for (name, value) in headers.iter() {
resp = resp.header(name, value);
}
resp.body(Body::from_stream(upstream_resp.bytes_stream()))
.unwrap_or_else(|e| bad_gateway(&format!("build response: {e}")))
}
fn build_uri(base: &str, original: &Uri) -> String {
let mut out = base.to_string();
out.push_str(original.path());
if let Some(q) = original.query() {
out.push('?');
out.push_str(q);
}
out
}
/// Headers we strip before forwarding upstream / downstream. `host` must be
/// dropped (reqwest will set it correctly for the upstream); `content-length`
/// is regenerated by the client; hop-by-hop headers per RFC 7230 §6.1 should
/// not be relayed.
const HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
];
fn filter_request_headers(headers: &HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
let n = name.as_str();
if n.eq_ignore_ascii_case("host") || n.eq_ignore_ascii_case("content-length") {
continue;
}
if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) {
continue;
}
out.append(name.clone(), value.clone());
}
out
}
fn filter_response_headers(headers: &reqwest::header::HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
let n = name.as_str();
if HOP_BY_HOP.iter().any(|h| n.eq_ignore_ascii_case(h)) {
continue;
}
if let (Ok(n), Ok(v)) = (
axum::http::HeaderName::from_bytes(name.as_str().as_bytes()),
HeaderValue::from_bytes(value.as_bytes()),
) {
out.append(n, v);
}
}
out
}
fn bad_gateway(msg: &str) -> Response {
tracing::warn!(error = msg, "proxy: bad gateway");
api_error(StatusCode::BAD_GATEWAY, "proxy_failed", msg)
}
fn payload_too_large() -> Response {
tracing::warn!(limit = MAX_REQUEST_BODY, "proxy: request body exceeds cap");
api_error(
StatusCode::PAYLOAD_TOO_LARGE,
"payload_too_large",
&format!("request body exceeds {MAX_REQUEST_BODY} bytes"),
)
}
/// Answer in the same envelope the daemon uses, so a client cannot tell whether
/// a failure came from the daemon or from this proxy.
fn api_error(status: StatusCode, code: &str, message: &str) -> Response {
(status, axum::Json(ApiError::new(code, message))).into_response()
}
// ---------------------------------------------------------------------------
// WebSocket proxy
// ---------------------------------------------------------------------------
/// Open an upstream WS connection at `daemon_ws_base + path?query` and pump
/// frames in both directions until either side closes. Used for
/// `/api/attach/:id` and `/api/events`.
pub async fn pump_ws(mut client: WebSocket, upstream_url: String) {
let (upstream, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(url = %upstream_url, error = %e, "proxy: upstream WS connect failed");
// The daemon rejects an attach it can't serve (unknown session)
// with a structured HTTP error *before* the upgrade. We've already
// upgraded the browser socket, so a bare drop reaches it as an
// opaque 1006 close — the attach pane just flashes and vanishes with
// no reason. Relay the daemon's reason as an application close frame
// the UI can show (#39).
let (code, reason) = upstream_close_reason(&e);
let _ = client
.send(AxMessage::Close(Some(axum::extract::ws::CloseFrame {
code,
reason: reason.into(),
})))
.await;
return;
}
};
let (mut client_sink, mut client_stream) = client.split();
let (mut up_sink, mut up_stream) = upstream.split();
let client_to_up = async {
while let Some(msg) = client_stream.next().await {
let Ok(msg) = msg else { break };
let frame = match msg {
AxMessage::Text(t) => WsMessage::Text(t),
AxMessage::Binary(b) => WsMessage::Binary(b),
AxMessage::Ping(p) => WsMessage::Ping(p),
AxMessage::Pong(p) => WsMessage::Pong(p),
AxMessage::Close(c) => {
let _ = up_sink
.send(WsMessage::Close(c.map(|cf| {
tokio_tungstenite::tungstenite::protocol::CloseFrame {
code: cf.code.into(),
reason: cf.reason,
}
})))
.await;
break;
}
};
if up_sink.send(frame).await.is_err() {
break;
}
}
};
let up_to_client = async {
while let Some(msg) = up_stream.next().await {
let Ok(msg) = msg else { break };
let frame = match msg {
WsMessage::Text(t) => AxMessage::Text(t),
WsMessage::Binary(b) => AxMessage::Binary(b),
WsMessage::Ping(p) => AxMessage::Ping(p),
WsMessage::Pong(p) => AxMessage::Pong(p),
WsMessage::Close(c) => {
let _ = client_sink
.send(AxMessage::Close(c.map(|cf| {
axum::extract::ws::CloseFrame {
code: cf.code.into(),
reason: cf.reason.to_string().into(),
}
})))
.await;
break;
}
WsMessage::Frame(_) => continue,
};
if client_sink.send(frame).await.is_err() {
break;
}
}
};
// Stop as soon as either side hangs up.
tokio::select! {
_ = client_to_up => {}
_ = up_to_client => {}
}
}
/// Largest application close code we emit; the browser surfaces it as
/// `CloseEvent.code`. Codes in 4000-4999 are reserved for application use.
const CLOSE_UPSTREAM_REJECTED: u16 = 4502;
/// Turn a failed upstream WS handshake into a browser-facing close code +
/// reason. When the daemon rejected the upgrade with an [`ApiError`] body,
/// surface that message; otherwise fall back to a generic gateway reason. The
/// reason rides a WS control frame, so it's truncated to the 123-byte limit.
fn upstream_close_reason(e: &tokio_tungstenite::tungstenite::Error) -> (u16, String) {
use tokio_tungstenite::tungstenite::Error as WsError;
let msg = match e {
WsError::Http(resp) => resp
.body()
.as_ref()
.and_then(|b| serde_json::from_slice::<ApiError>(b).ok())
.map(|e| e.error.message)
.unwrap_or_else(|| "the session's daemon rejected the attach".to_owned()),
_ => "could not reach the session's daemon".to_owned(),
};
(CLOSE_UPSTREAM_REJECTED, truncate_reason(&msg))
}
/// Clamp a close reason to the WS control-frame budget (125 bytes total, 2 for
/// the code → 123 for the reason), respecting UTF-8 char boundaries.
fn truncate_reason(s: &str) -> String {
const MAX: usize = 123;
if s.len() <= MAX {
return s.to_owned();
}
let mut end = MAX;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_owned()
}
/// Compose the upstream WS URL from the base + path + query of the inbound
/// request.
pub fn upstream_ws_url(state: &ProxyState, uri: &Uri) -> String {
let mut out = state.upstream.daemon_ws_base.as_str().to_string();
out.push_str(uri.path());
if let Some(q) = uri.query() {
out.push('?');
out.push_str(q);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ws_base_swaps_http_scheme() {
let u = UpstreamConfig::from_daemon_url("http://127.0.0.1:7070/").unwrap();
assert_eq!(&*u.daemon_http_base, "http://127.0.0.1:7070");
assert_eq!(&*u.daemon_ws_base, "ws://127.0.0.1:7070");
}
#[test]
fn ws_base_swaps_https_scheme() {
let u = UpstreamConfig::from_daemon_url("https://daemon.example/").unwrap();
assert_eq!(&*u.daemon_ws_base, "wss://daemon.example");
}
/// NAP-257: a request body over the cap is rejected with 413 before we ever
/// dial upstream — so an oversized POST can't OOM the proxy. The upstream is
/// a bogus unreachable address; a 413 (not a 502) proves we bailed early.
#[tokio::test]
async fn oversized_request_body_is_rejected_with_413() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let big = vec![0u8; MAX_REQUEST_BODY + 1];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(big))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
/// A body at the cap still proceeds to the upstream send (which fails here
/// with 502 since nothing is listening) — i.e. the cap is exclusive of the
/// limit, not off-by-one against legitimate traffic.
#[tokio::test]
async fn body_at_cap_is_not_rejected_by_the_size_check() {
let state = ProxyState::new(UpstreamConfig::from_daemon_url("http://127.0.0.1:9").unwrap());
let body = vec![0u8; MAX_REQUEST_BODY];
let req = Request::builder()
.method("POST")
.uri("/api/sessions")
.body(Body::from(body))
.unwrap();
let resp = proxy_http(State(state), req).await;
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
/// #39: a daemon rejection carrying the structured error body surfaces its
/// `message` as the close reason, under an application close code, so the
/// browser can show *why* the attach failed instead of an opaque 1006.
#[test]
fn upstream_close_reason_extracts_daemon_message() {
use tokio_tungstenite::tungstenite::{http, Error as WsError};
let body =
br#"{"error":{"code":"unknown_session","message":"no such session on this daemon"}}"#
.to_vec();
let resp = http::Response::builder()
.status(404)
.body(Some(body))
.unwrap();
let (code, reason) = upstream_close_reason(&WsError::Http(resp));
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert_eq!(reason, "no such session on this daemon");
}
/// A non-HTTP failure (e.g. TCP refused — peer down) still yields a usable
/// generic reason rather than a panic or empty string.
#[test]
fn upstream_close_reason_falls_back_for_non_http_errors() {
use tokio_tungstenite::tungstenite::Error as WsError;
let e = WsError::Io(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"nope",
));
let (code, reason) = upstream_close_reason(&e);
assert_eq!(code, CLOSE_UPSTREAM_REJECTED);
assert!(!reason.is_empty());
}
/// Close reasons ride a control frame capped at 123 bytes; an over-long
/// message is truncated on a char boundary (no panic on multi-byte chars).
#[test]
fn truncate_reason_respects_control_frame_budget() {
let ascii = "x".repeat(200);
assert_eq!(truncate_reason(&ascii).len(), 123);
let multibyte = "é".repeat(100); // 2 bytes each
let out = truncate_reason(&multibyte);
assert!(out.len() <= 123);
assert!(out.is_char_boundary(out.len()));
}
}