Skip to main content

rama_http_backend/proxy/
mitm.rs

1use rama_core::error::BoxErrorExt as _;
2use std::convert::TryFrom;
3use std::sync::Arc;
4use std::time::Duration;
5
6use rama_core::{
7    Layer, Service,
8    error::{BoxError, ErrorContext as _, ErrorExt as _},
9    extensions::ExtensionsRef,
10    graceful::ShutdownGuard,
11    io::{BridgeIo, GracefulIo, Io},
12    layer::{
13        ArcLayer, ConsumeErrLayer,
14        consume_err::{StaticOutput, Trace},
15    },
16    rt::Executor,
17    service::service_fn,
18    telemetry::tracing,
19};
20use rama_http::{
21    Body, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Version,
22    conn::{H2ServerContextParams, TargetHttpVersion},
23    service::web::response::IntoResponse,
24};
25use rama_http_core::server::conn::{
26    auto::Builder as AutoConnBuilder, http1::Builder as Http1ConnBuilder,
27    http2::Builder as H2ConnBuilder,
28};
29use rama_http_types::proto::h2::frame::Settings;
30use rama_net::client::EstablishedClientConnection;
31use rama_net::uri::Uri;
32
33use tokio::sync::Mutex;
34use tokio_util::sync::CancellationToken;
35
36use crate::{
37    client::{HttpClientService, http_connect, http2_eager_handshake},
38    server::HttpServer,
39};
40
41/// Default hard cap on how long we wait for the upstream's initial h2
42/// SETTINGS frame during eager egress handshake before giving up and
43/// treating the connection as non-compliant. Keeps adversarial /
44/// dead-but-open peers from stalling the relay indefinitely. Override
45/// per-instance with [`HttpMitmRelay::with_eager_peer_settings_timeout`].
46pub const DEFAULT_EAGER_PEER_SETTINGS_TIMEOUT: Duration = Duration::from_secs(5);
47
48#[derive(Debug, Clone, Copy, Default)]
49/// Default [`Response`] used in case the inner (egress)
50/// client of the [`HttpMitmRelay`] is erroring.
51pub struct DefaultErrorResponse;
52
53impl DefaultErrorResponse {
54    #[inline(always)]
55    pub fn new() -> Self {
56        Self
57    }
58
59    #[inline(always)]
60    fn response() -> Response {
61        (
62            [
63                (
64                    HeaderName::from_static("x-proxy-framework-name"),
65                    HeaderValue::from_static(rama_utils::info::NAME),
66                ),
67                (
68                    HeaderName::from_static("x-proxy-framework-version"),
69                    HeaderValue::from_static(rama_utils::info::VERSION),
70                ),
71            ],
72            StatusCode::BAD_GATEWAY,
73        )
74            .into_response()
75    }
76
77    #[inline(always)]
78    fn response_for_version(version: Version) -> Response {
79        let mut response = Self::response();
80        if matches!(
81            version,
82            Version::HTTP_09 | Version::HTTP_10 | Version::HTTP_11
83        ) {
84            response.headers_mut().insert(
85                HeaderName::from_static("connection"),
86                HeaderValue::from_static("close"),
87            );
88        }
89        response
90    }
91
92    #[inline(always)]
93    fn best_effort_response_after_ingress_cancellation(version: Version) -> Response {
94        // Once ingress cancellation has fired, GracefulIo may cut off the downstream transport
95        // before this response can actually be written. This placeholder only exists because
96        // the service contract still requires a response value.
97        Self::response_for_version(version)
98    }
99
100    #[inline(always)]
101    fn cancel_ingress_and_return_best_effort_response(
102        version: Version,
103        close_ingress: &CancellationToken,
104    ) -> Response {
105        close_ingress.cancel();
106        Self::best_effort_response_after_ingress_cancellation(version)
107    }
108}
109
110impl From<DefaultErrorResponse> for Response {
111    #[inline(always)]
112    fn from(_: DefaultErrorResponse) -> Self {
113        DefaultErrorResponse::response()
114    }
115}
116
117/// Default middleware used by [`HttpMitmRelay`],
118/// most likely you'll want to overwrite it with custom middleware,
119/// unless you do not require MITM middleware.
120pub type DefaultMiddleware = (
121    ConsumeErrLayer<Trace, StaticOutput<DefaultErrorResponse>>,
122    ArcLayer,
123);
124
125#[derive(Debug, Clone)]
126/// A utility that can be used by MITM services such as transparent proxies,
127/// in order to relay HTTP requests and responses between a client and server,
128/// as part of a deep protocol inspection protocol (DPI) flow.
129///
130/// Useful if you have a fairly standard MITM http flow and already
131/// have pre-established ingress and egress connections (e.g. because
132/// you already MITM'd the <L7 layers, such as SOCKS5 MITM'ng, TLS, ...).
133pub struct HttpMitmRelay<M = DefaultMiddleware> {
134    http_server: HttpServer<AutoConnBuilder>,
135    middleware: M,
136    exec: Executor,
137    eager_peer_settings_timeout: Duration,
138}
139
140impl HttpMitmRelay {
141    #[inline(always)]
142    #[must_use]
143    /// Create a new [`HttpMitmRelay`], ready to serve.
144    pub fn new(exec: Executor) -> Self {
145        // Baseline CONNECT-on for the *lazy* path (plain h2, no ALPN).
146        // The eager path in `serve()` overrides this per-conn via
147        // `H2ServerContextParams`, which is what closes #932 for TLS h2.
148        // See `rama_http_core::server::conn::http2::apply_h2_server_context_params`.
149        let mut http_server = HttpServer::auto(exec.clone());
150        http_server.h2_mut().set_enable_connect_protocol();
151        Self {
152            http_server,
153            middleware: (
154                ConsumeErrLayer::trace_as_debug().with_response(DefaultErrorResponse),
155                ArcLayer::new(),
156            ),
157            exec,
158            eager_peer_settings_timeout: DEFAULT_EAGER_PEER_SETTINGS_TIMEOUT,
159        }
160    }
161
162    /// Set HTTP middleware to use between server and client.
163    ///
164    /// By default the identity middleware `()` is used,
165    /// which preserves the requests and responses as is...
166    pub fn with_http_middleware<M>(self, middleware: M) -> HttpMitmRelay<M> {
167        HttpMitmRelay {
168            http_server: self.http_server,
169            middleware,
170            exec: self.exec,
171            eager_peer_settings_timeout: self.eager_peer_settings_timeout,
172        }
173    }
174}
175
176impl<M> HttpMitmRelay<M> {
177    #[inline(always)]
178    /// Http1 builder.
179    pub fn http1(&self) -> &Http1ConnBuilder {
180        self.http_server.http1()
181    }
182
183    #[inline(always)]
184    /// Http1 mutable builder.
185    pub fn http1_mut(&mut self) -> &mut Http1ConnBuilder {
186        self.http_server.http1_mut()
187    }
188
189    #[inline(always)]
190    /// H2 builder.
191    pub fn h2(&self) -> &H2ConnBuilder {
192        self.http_server.h2()
193    }
194
195    #[inline(always)]
196    /// H2 mutable builder.
197    pub fn h2_mut(&mut self) -> &mut H2ConnBuilder {
198        self.http_server.h2_mut()
199    }
200
201    rama_utils::macros::generate_set_and_with! {
202        /// Hard cap on how long the eager phase-2 init waits for the
203        /// upstream's initial h2 SETTINGS frame before giving up and
204        /// proceeding without mirroring. Defaults to
205        /// [`DEFAULT_EAGER_PEER_SETTINGS_TIMEOUT`]. Only applies when
206        /// the egress IO carries `TargetHttpVersion(HTTP_2)`.
207        pub fn eager_peer_settings_timeout(mut self, timeout: Duration) -> Self {
208            self.eager_peer_settings_timeout = timeout;
209            self
210        }
211    }
212}
213
214impl<Ingress, Egress, M> Service<BridgeIo<Ingress, Egress>> for HttpMitmRelay<M>
215where
216    Ingress: Io + Unpin + ExtensionsRef,
217    Egress: Io + Unpin + ExtensionsRef,
218    M: Layer<
219            HttpClientService<Body>,
220            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
221        >
222        + Send
223        + Sync
224        + 'static
225        + Clone,
226{
227    type Output = ();
228    type Error = BoxError;
229
230    async fn serve(
231        &self,
232        BridgeIo(ingress_stream, egress_stream): BridgeIo<Ingress, Egress>,
233    ) -> Result<Self::Output, Self::Error> {
234        let token = CancellationToken::new();
235        let request_guard = self.exec.guard().cloned();
236
237        tracing::debug!("HTTP MITM Relay: start");
238
239        // Eager phase-2: when egress ALPN signals h2 (via
240        // `TargetHttpVersion(HTTP_2)`), handshake egress now to mirror
241        // upstream's initial SETTINGS onto ingress before its own
242        // SETTINGS frame is written. Only the *initial* frame is
243        // mirrored — subsequent upstream SETTINGS updates are handled
244        // by the h2 stack on each side. Other versions: lazy path.
245        let egress_is_h2 = egress_stream
246            .extensions()
247            .get_ref::<TargetHttpVersion>()
248            .map(|t| t.0 == Version::HTTP_2)
249            .unwrap_or(false);
250
251        let relay_state = if egress_is_h2 {
252            let exec = request_guard
253                .clone()
254                .map_or_else(Executor::default, Executor::graceful);
255            match http2_eager_handshake::<_, Body>(egress_stream, exec).await {
256                Ok((conn, peer_handle)) => {
257                    let timeout_dur = self.eager_peer_settings_timeout;
258                    let peer_settings =
259                        tokio::time::timeout(timeout_dur, peer_handle.await_settings())
260                            .await
261                            .unwrap_or_else(|_| {
262                                tracing::debug!(
263                                    "eager egress h2 peer SETTINGS not received within {:?}",
264                                    timeout_dur,
265                                );
266                                None
267                            });
268                    let mirrored = if let Some(peer) = peer_settings.as_ref() {
269                        tracing::trace!("mirroring upstream h2 SETTINGS onto ingress: {peer:?}",);
270                        // `peer: &Arc<PeerH2Settings>`. The mirror fn
271                        // takes the underlying `&Settings`.
272                        mirror_peer_settings(&peer.0)
273                    } else {
274                        // Fail-safe: force CONNECT off so a timeout /
275                        // broken upstream can't re-trigger #932 through
276                        // the relay's baseline CONNECT-on default.
277                        tracing::debug!(
278                            "no upstream h2 SETTINGS captured; forcing CONNECT off on ingress",
279                        );
280                        H2ServerContextParams {
281                            enable_connect_protocol: Some(false),
282                            ..H2ServerContextParams::default()
283                        }
284                    };
285                    ingress_stream.extensions().insert(mirrored);
286                    let client = self.middleware.clone().layer(conn);
287                    Arc::new(Mutex::new(RelayState::Http2 { client }))
288                }
289                Err(err) => {
290                    tracing::debug!("eager egress h2 handshake failed: {err}");
291                    return Err(err.into());
292                }
293            }
294        } else {
295            Arc::new(Mutex::new(RelayState::new(
296                egress_stream,
297                self.middleware.clone(),
298            )))
299        };
300
301        let result = self
302            .http_server
303            .serve(
304                GracefulIo::new(token.clone().cancelled_owned(), ingress_stream),
305                service_fn(move |req: Request| {
306                    let relay_state = relay_state.clone();
307                    let close_ingress = token.clone();
308                    let guard = request_guard.clone();
309                    async move {
310                        Ok(handle_relay_request(&relay_state, req, guard, close_ingress).await)
311                    }
312                }),
313            )
314            .await
315            .context("serve HTTP MITM relay");
316
317        tracing::debug!("HTTP MITM Relay: Shutdown: done");
318        result
319    }
320}
321
322#[derive(Debug, Clone, Copy)]
323enum RelayMode {
324    Http1,
325    Http2,
326}
327
328impl RelayMode {
329    fn as_str(self) -> &'static str {
330        match self {
331            Self::Http1 => "http1",
332            Self::Http2 => "http2",
333        }
334    }
335}
336
337impl TryFrom<Version> for RelayMode {
338    type Error = BoxError;
339
340    fn try_from(version: Version) -> Result<Self, Self::Error> {
341        match version {
342            Version::HTTP_2 => Ok(Self::Http2),
343            Version::HTTP_09 | Version::HTTP_10 | Version::HTTP_11 => Ok(Self::Http1),
344            version => Err(
345                BoxError::from_static_str("unsupported request version for MITM relay")
346                    .context_debug_field("version", version),
347            ),
348        }
349    }
350}
351
352enum RelayState<Egress, Middleware>
353where
354    Egress: Io + Unpin + ExtensionsRef,
355    Middleware: Layer<HttpClientService<Body>>,
356{
357    Uninitialized {
358        egress_stream: Option<Egress>,
359        middleware: Middleware,
360    },
361    Http1 {
362        client: Middleware::Service,
363    },
364    Http2 {
365        client: Middleware::Service,
366    },
367    Closed,
368}
369
370impl<Egress, Middleware> RelayState<Egress, Middleware>
371where
372    Egress: Io + Unpin + ExtensionsRef,
373    Middleware: Layer<HttpClientService<Body>>,
374{
375    fn new(egress_stream: Egress, middleware: Middleware) -> Self {
376        Self::Uninitialized {
377            egress_stream: Some(egress_stream),
378            middleware,
379        }
380    }
381}
382
383async fn handle_relay_request<Egress, Middleware>(
384    relay_state: &Arc<Mutex<RelayState<Egress, Middleware>>>,
385    req: Request,
386    guard: Option<ShutdownGuard>,
387    close_ingress: CancellationToken,
388) -> Response
389where
390    Egress: Io + Unpin + ExtensionsRef,
391    Middleware: Layer<
392            HttpClientService<Body>,
393            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
394        > + Clone,
395{
396    let method = req.method().clone();
397    let uri = req.uri().clone();
398    let version = req.version();
399
400    let relay_mode = match RelayMode::try_from(version) {
401        Ok(mode) => mode,
402        Err(err) => {
403            tracing::debug!("failed to derive relay mode from request version: {err}");
404            return DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
405                version,
406                &close_ingress,
407            );
408        }
409    };
410    tracing::trace!(
411        http.request.method = %method,
412        url.full = %uri,
413        ?version,
414        mode = relay_mode.as_str(),
415        "dispatching request on MITM relay egress"
416    );
417
418    match relay_mode {
419        RelayMode::Http1 => {
420            let mut state = relay_state.lock().await;
421            let resp = serve_http1_request(
422                &mut *state,
423                req,
424                guard,
425                &method,
426                &uri,
427                version,
428                close_ingress.clone(),
429            )
430            .await;
431            if let Ok(ref resp) = resp {
432                tracing::trace!(
433                    http.request.method = %method,
434                    url.full = %uri,
435                    ?version,
436                    http.response.status_code = resp.status().as_u16(),
437                    "received response from MITM relay egress"
438                );
439            }
440            resp.unwrap_or_else(|_| {
441                DefaultErrorResponse::best_effort_response_after_ingress_cancellation(version)
442            })
443        }
444        RelayMode::Http2 => {
445            // Acquire-release: only briefly grab the state lock to
446            // ensure the egress h2 client is connected, then drop
447            // it. The actual upstream serve must NOT hold the lock
448            // (see `serve_relay_request` rationale).
449            let client_and_req = {
450                let mut state = relay_state.lock().await;
451                relay_connect_http2_if_needed(&mut *state, req, guard, close_ingress.clone()).await
452            };
453
454            match client_and_req {
455                Ok((client, req)) => {
456                    let resp = serve_relay_request(
457                        &client,
458                        req,
459                        &method,
460                        &uri,
461                        version,
462                        close_ingress.clone(),
463                        relay_state,
464                    )
465                    .await;
466                    if let Ok(ref resp) = resp {
467                        tracing::trace!(
468                            http.request.method = %method,
469                            url.full = %uri,
470                            ?version,
471                            http.response.status_code = resp.status().as_u16(),
472                            "received response from MITM relay egress"
473                        );
474                    }
475                    resp.unwrap_or_else(|_| {
476                        DefaultErrorResponse::best_effort_response_after_ingress_cancellation(
477                            version,
478                        )
479                    })
480                }
481                Err(resp) => resp,
482            }
483        }
484    }
485}
486
487async fn serve_http1_request<Egress, Middleware>(
488    state: &mut RelayState<Egress, Middleware>,
489    req: Request,
490    guard: Option<ShutdownGuard>,
491    method: &Method,
492    uri: &Uri,
493    version: Version,
494    close_ingress: CancellationToken,
495) -> Result<Response, BoxError>
496where
497    Egress: Io + Unpin + ExtensionsRef,
498    Middleware: Layer<
499            HttpClientService<Body>,
500            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
501        > + Clone,
502{
503    let req = match relay_connect_http1_if_needed(state, req, guard, close_ingress.clone()).await {
504        Ok(req) => req,
505        Err(resp) => return Ok(resp),
506    };
507
508    match state {
509        RelayState::Http1 { client } => {
510            let result = client.serve(req).await.into_box_error();
511            match result {
512                Ok(resp) => Ok(resp),
513                Err(err) => {
514                    tracing::debug!(
515                        http.request.method = %method,
516                        url.full = %uri,
517                        ?version,
518                        "upstream MITM relay request failed: {err}"
519                    );
520                    *state = RelayState::Closed;
521                    close_ingress.cancel();
522                    Err(err)
523                }
524            }
525        }
526        RelayState::Closed => Ok(DefaultErrorResponse::response_for_version(version)),
527        RelayState::Http2 { .. } | RelayState::Uninitialized { .. } => {
528            close_ingress.cancel();
529            *state = RelayState::Closed;
530            Ok(DefaultErrorResponse::best_effort_response_after_ingress_cancellation(version))
531        }
532    }
533}
534
535async fn relay_connect_http1_if_needed<Egress, Middleware>(
536    state: &mut RelayState<Egress, Middleware>,
537    req: Request,
538    guard: Option<ShutdownGuard>,
539    close_ingress: CancellationToken,
540) -> Result<Request, Response>
541where
542    Egress: Io + Unpin + ExtensionsRef,
543    Middleware: Layer<
544            HttpClientService<Body>,
545            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
546        > + Clone,
547{
548    match state {
549        RelayState::Http1 { .. } => Ok(req),
550        RelayState::Http2 { .. } => {
551            tracing::debug!("received HTTP/1 relay request on HTTP/2 relay state; closing relay");
552            *state = RelayState::Closed;
553            Err(
554                DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
555                    req.version(),
556                    &close_ingress,
557                ),
558            )
559        }
560        RelayState::Closed => Err(
561            DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
562                req.version(),
563                &close_ingress,
564            ),
565        ),
566        RelayState::Uninitialized { .. } => {
567            let req = connect_relay(state, req, guard, close_ingress.clone()).await?;
568            if let RelayState::Http1 { .. } = state {
569                Ok(req)
570            } else {
571                tracing::debug!("failed to initialize HTTP/1 relay state from first request");
572                *state = RelayState::Closed;
573                Err(
574                    DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
575                        req.version(),
576                        &close_ingress,
577                    ),
578                )
579            }
580        }
581    }
582}
583
584async fn relay_connect_http2_if_needed<Egress, Middleware>(
585    state: &mut RelayState<Egress, Middleware>,
586    req: Request,
587    guard: Option<ShutdownGuard>,
588    close_ingress: CancellationToken,
589) -> Result<(Middleware::Service, Request), Response>
590where
591    Egress: Io + Unpin + ExtensionsRef,
592    Middleware: Layer<
593            HttpClientService<Body>,
594            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
595        > + Clone,
596{
597    match state {
598        RelayState::Http2 { client } => Ok((client.clone(), req)),
599        RelayState::Http1 { .. } => {
600            tracing::debug!("received HTTP/2 relay request on HTTP/1 relay state; closing relay");
601            *state = RelayState::Closed;
602            Err(
603                DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
604                    req.version(),
605                    &close_ingress,
606                ),
607            )
608        }
609        RelayState::Closed => Err(
610            DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
611                req.version(),
612                &close_ingress,
613            ),
614        ),
615        RelayState::Uninitialized { .. } => {
616            let version = req.version();
617            let req = connect_relay(state, req, guard, close_ingress.clone()).await?;
618            if let RelayState::Http2 { client } = state {
619                Ok((client.clone(), req))
620            } else {
621                tracing::debug!("failed to initialize HTTP/2 relay state from first request");
622                *state = RelayState::Closed;
623                Err(
624                    DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
625                        version,
626                        &close_ingress,
627                    ),
628                )
629            }
630        }
631    }
632}
633
634async fn connect_relay<Egress, Middleware>(
635    state: &mut RelayState<Egress, Middleware>,
636    req: Request,
637    guard: Option<ShutdownGuard>,
638    close_ingress: CancellationToken,
639) -> Result<Request, Response>
640where
641    Egress: Io + Unpin + ExtensionsRef,
642    Middleware: Layer<
643            HttpClientService<Body>,
644            Service: Service<Request, Output = Response, Error: Into<BoxError>> + Clone,
645        > + Clone,
646{
647    let RelayState::Uninitialized {
648        egress_stream,
649        middleware,
650    } = state
651    else {
652        return Ok(req);
653    };
654
655    let req_version = req.version();
656    let Some(egress_stream) = egress_stream.take() else {
657        *state = RelayState::Closed;
658        return Err(
659            DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
660                req_version,
661                &close_ingress,
662            ),
663        );
664    };
665
666    let exec = guard.map_or_else(Executor::default, Executor::graceful);
667    match http_connect(egress_stream, req, exec).await {
668        Ok(EstablishedClientConnection { input, conn }) => {
669            let version = input.version();
670            let client = middleware.layer(conn);
671            match RelayMode::try_from(version) {
672                Ok(RelayMode::Http1) => {
673                    *state = RelayState::Http1 { client };
674                    Ok(input)
675                }
676                Ok(RelayMode::Http2) => {
677                    *state = RelayState::Http2 { client };
678                    Ok(input)
679                }
680                Err(err) => {
681                    tracing::debug!("failed to derive relay mode after egress connect: {err}");
682                    *state = RelayState::Closed;
683                    Err(
684                        DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
685                            version,
686                            &close_ingress,
687                        ),
688                    )
689                }
690            }
691        }
692        Err(err) => {
693            tracing::debug!("failed to establish egress HTTP connection: {err}");
694            *state = RelayState::Closed;
695            Err(
696                DefaultErrorResponse::cancel_ingress_and_return_best_effort_response(
697                    req_version,
698                    &close_ingress,
699                ),
700            )
701        }
702    }
703}
704
705async fn serve_relay_request<Egress, Middleware, Client>(
706    client: &Client,
707    req: Request,
708    method: &Method,
709    uri: &Uri,
710    version: Version,
711    close_ingress: CancellationToken,
712    relay_state: &Arc<Mutex<RelayState<Egress, Middleware>>>,
713) -> Result<Response, BoxError>
714where
715    Egress: Io + Unpin + ExtensionsRef,
716    Middleware: Layer<HttpClientService<Body>>,
717    Client: Service<Request, Output = Response, Error: Into<BoxError>>,
718{
719    // Do NOT hold `relay_state.lock()` across the upstream serve.
720    // For h2, multiple streams share one `RelayState`; locking across
721    // `client.serve(req).await` serialises every stream on the
722    // mutex and kills multiplexing.
723    //
724    // The lock is only needed to mark state Closed on error.
725    match client.serve(req).await {
726        Ok(resp) => Ok(resp),
727        Err(err) => {
728            let err = err.into_box_error();
729            tracing::debug!(
730                http.request.method = %method,
731                url.full = %uri,
732                ?version,
733                "upstream MITM relay request failed: {err}"
734            );
735            // A peer `RST_STREAM` is scoped to this one h2 stream; the
736            // shared egress connection and its sibling streams are
737            // unaffected. Fail only this stream so we don't escalate to
738            // tearing down the whole ingress connection (GOAWAY for all
739            // siblings) — which would happen via the `Closed` + cancel
740            // below. `GOAWAY` / transport errors are connection-scoped
741            // and still take that path.
742            if egress_error_is_stream_scoped(&err) {
743                return Ok(DefaultErrorResponse::response_for_version(version));
744            }
745            *relay_state.lock().await = RelayState::Closed;
746            close_ingress.cancel();
747            Err(err)
748        }
749    }
750}
751
752/// Project upstream's initial SETTINGS onto `H2ServerContextParams` for
753/// the relay's ingress. Two fields carry across; the rest are
754/// per-direction budgets with no cross-direction meaning.
755///
756/// - `enable_connect_protocol` (RFC 8441): capability advertisement,
757///   transitively meaningful. Authoritative-wins: always emits
758///   `Some(true|false)` so this overrides the relay's builder baseline
759///   (which #932 needs when upstream omits CONNECT).
760/// - `max_concurrent_streams`: backpressure policy (relay multiplexes
761///   downstream onto one upstream conn), not a transparent mirror.
762///   Per RFC 9113 §6.5.2 `Some(0)` is legal — propagated as-is.
763fn mirror_peer_settings(settings: &Settings) -> H2ServerContextParams {
764    let cfg = &settings.config;
765    H2ServerContextParams {
766        enable_connect_protocol: Some(cfg.enable_connect_protocol.map(|v| v != 0).unwrap_or(false)),
767        max_concurrent_streams: cfg.max_concurrent_streams,
768        // Per-direction budgets — not mirrored. See fn docstring.
769        header_table_size: None,
770        max_frame_size: None,
771        max_header_list_size: None,
772        initial_stream_window_size: None,
773        initial_connection_window_size: None,
774        adaptive_window: None,
775    }
776}
777
778/// Whether an egress request error is scoped to a single h2 stream (a
779/// `RST_STREAM`) rather than the whole connection. Walks the cause
780/// chain because the reset may be wrapped in a `rama_http_core::Error`.
781fn egress_error_is_stream_scoped(err: &BoxError) -> bool {
782    if let Some(h2) = err.downcast_ref::<rama_http_core::h2::Error>() {
783        return h2.is_reset();
784    }
785    let mut current = err.source();
786    while let Some(cause) = current {
787        if let Some(h2) = cause.downcast_ref::<rama_http_core::h2::Error>() {
788            return h2.is_reset();
789        }
790        current = cause.source();
791    }
792    false
793}