Skip to main content

rust_web_server/proxy/
mod.rs

1//! Reverse proxy middleware with round-robin load balancing.
2//!
3//! `ReverseProxy` implements [`Middleware`] — wrap any application with it and
4//! all matching requests are forwarded to one of the configured backends over
5//! plain HTTP/1.1.  Failed backends are skipped and the next one is tried
6//! before returning `502 Bad Gateway`.
7//!
8//! # Example
9//!
10//! ```rust,no_run
11//! use rust_web_server::app::App;
12//! use rust_web_server::core::New;
13//! use rust_web_server::proxy::{LoadBalancing, ReverseProxy};
14//!
15//! // Proxy every request across two backends in round-robin order.
16//! let app = App::new()
17//!     .wrap(ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"])
18//!         .strategy(LoadBalancing::RoundRobin));
19//!
20//! // Only proxy /api/* requests; everything else is handled locally.
21//! let app2 = App::new()
22//!     .wrap(ReverseProxy::new(["http://api-service:3000"])
23//!         .path_prefix("/api"));
24//! ```
25
26pub mod pool;
27#[cfg(feature = "http2")]
28pub(crate) mod async_upstream;
29#[cfg(not(target_arch = "wasm32"))]
30pub mod mirror;
31#[cfg(feature = "http2")]
32pub(crate) mod h2_upstream;
33#[cfg(feature = "http3")]
34pub(crate) mod h3_upstream;
35#[cfg(feature = "http2")]
36pub mod grpc_web;
37#[cfg(feature = "http2")]
38pub use grpc_web::GrpcWebLayer;
39
40#[cfg(test)]
41mod tests;
42
43use std::io::{Read, Write};
44use crate::ext::decimal::Decimal;
45use std::net::{TcpStream, ToSocketAddrs};
46use std::sync::atomic::{AtomicUsize, Ordering};
47use std::sync::Arc;
48use std::time::Duration;
49
50pub use pool::{ConnPool, Pool};
51
52use crate::application::Application;
53use crate::core::New;
54use crate::middleware::Middleware;
55use crate::mime_type::MimeType;
56use crate::range::Range;
57use crate::request::Request;
58use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
59use crate::server::ConnectionInfo;
60
61// Hop-by-hop headers that must not be forwarded (RFC 9110 §7.6.1), plus
62// `proxy-connection` (legacy clients). Headers named in `Connection` are
63// dropped too (`connection_listed`).
64const HOP_BY_HOP: &[&str] = &[
65    "connection",
66    "keep-alive",
67    "proxy-authenticate",
68    "proxy-authorization",
69    "proxy-connection",
70    "te",
71    "trailer",
72    "transfer-encoding",
73    "upgrade",
74];
75
76/// Load balancing strategy used by [`ReverseProxy`].
77pub enum LoadBalancing {
78    /// Distribute requests across backends in a cyclic order.
79    RoundRobin,
80}
81
82/// Reverse proxy middleware.
83///
84/// Forwards incoming requests to one of the configured backends over HTTP/1.1.
85/// On connection failure the next backend in the list is tried; when all
86/// backends have failed the middleware returns `502 Bad Gateway`.
87///
88/// Hop-by-hop headers are stripped before forwarding.  `X-Forwarded-For` and
89/// `Via` are added to every forwarded request.
90///
91/// Idle connections are pooled and reused across requests (up to
92/// 64 idle per backend, 60-second timeout).
93/// This eliminates per-request TCP handshake overhead and ephemeral-port
94/// exhaustion.  Use [`ReverseProxy::with_pool`] to share a pool across
95/// multiple proxy instances or to tune pool parameters.
96pub struct ReverseProxy {
97    backends: Vec<Backend>,
98    path_prefix: Option<String>,
99    connect_timeout: Duration,
100    read_timeout: Duration,
101    counter: AtomicUsize,
102    pool: Arc<ConnPool>,
103    breaker: Option<Arc<dyn crate::circuit_breaker::Breaker>>,
104    trust: Option<Arc<UpstreamTls>>,
105    /// What `trust` was built from, so each TLS option adds to the others.
106    tls_ca: Option<String>,
107    tls_client: Option<(String, String)>,
108}
109
110impl ReverseProxy {
111    /// Create a proxy that distributes requests across `backends` in
112    /// round-robin order.  Each entry must be `"http://host:port"` or
113    /// `"host:port"` (port defaults to 80).
114    pub fn new<I, S>(backends: I) -> Self
115    where
116        I: IntoIterator<Item = S>,
117        S: AsRef<str>,
118    {
119        Self {
120            backends: backends
121                .into_iter()
122                .filter_map(|u| Backend::parse(u.as_ref()))
123                .collect(),
124            path_prefix: None,
125            connect_timeout: Duration::from_secs(5),
126            read_timeout: Duration::from_secs(30),
127            counter: AtomicUsize::new(0),
128            pool: Arc::new(ConnPool::new(POOL_MAX_IDLE, Duration::from_secs(60))),
129            breaker: None,
130            trust: None,
131            tls_ca: None,
132            tls_client: None,
133        }
134    }
135
136    /// Wire a [`crate::circuit_breaker::CircuitBreaker`] or
137    /// [`crate::circuit_breaker::RedisCircuitBreaker`] into this proxy:
138    /// before dialing a backend, its breaker state is checked (an `Open`
139    /// backend is skipped with no TCP attempt, exactly like a caller
140    /// manually checking `is_available` around each proxied call); after a
141    /// dial, `record_success`/`record_failure` is called automatically.
142    /// No breaker is wired by default — existing `ReverseProxy` callers see
143    /// no behavior change unless they opt in here.
144    ///
145    /// ```rust,no_run
146    /// use std::sync::Arc;
147    /// use rust_web_server::circuit_breaker::global as global_breaker;
148    /// use rust_web_server::proxy::ReverseProxy;
149    ///
150    /// let proxy = ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"])
151    ///     .with_circuit_breaker(Arc::new(global_breaker()));
152    /// ```
153    pub fn with_circuit_breaker(mut self, breaker: Arc<dyn crate::circuit_breaker::Breaker>) -> Self {
154        self.breaker = Some(breaker);
155        self
156    }
157
158    /// Only proxy requests whose URI starts with `prefix`.
159    ///
160    /// Other requests are passed through to the next layer in the middleware
161    /// chain (or the inner application).
162    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
163        self.path_prefix = Some(prefix.into());
164        self
165    }
166
167    /// Override the load balancing strategy (currently only `RoundRobin`).
168    pub fn strategy(self, _strategy: LoadBalancing) -> Self {
169        self
170    }
171
172    /// Override the TCP connect timeout (default: 5 000 ms).
173    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
174        self.connect_timeout = Duration::from_millis(ms);
175        self
176    }
177
178    /// Override the response read timeout (default: 30 000 ms).
179    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
180        self.read_timeout = Duration::from_millis(ms);
181        self
182    }
183
184    /// Attach a shared connection pool.
185    ///
186    /// Useful for sharing one pool across multiple `ReverseProxy` instances
187    /// or for tuning pool parameters (capacity, idle timeout).
188    pub fn with_pool(mut self, pool: Arc<ConnPool>) -> Self {
189        self.pool = pool;
190        self
191    }
192
193    /// Trust the CA certificates in the PEM file `path` for `https://`
194    /// backends, **instead of** the public WebPKI roots — for internal
195    /// services behind a private CA or a self-signed certificate.
196    pub fn tls_ca_file(mut self, path: &str) -> Result<Self, String> {
197        self.tls_ca = Some(path.to_string());
198        self.rebuild_trust()?;
199        Ok(self)
200    }
201
202    /// Present the client certificate in the PEM file `cert` (with its
203    /// private key in `key`) to `https://` backends that require one —
204    /// mutual TLS to the upstream. Since 18.3.0.
205    pub fn tls_client_cert(mut self, cert: &str, key: &str) -> Result<Self, String> {
206        self.tls_client = Some((cert.to_string(), key.to_string()));
207        self.rebuild_trust()?;
208        Ok(self)
209    }
210
211    fn rebuild_trust(&mut self) -> Result<(), String> {
212        let client = self.tls_client.as_ref().map(|(c, k)| (c.as_str(), k.as_str()));
213        self.trust = Some(Arc::new(UpstreamTls::new(self.tls_ca.as_deref(), client)?));
214        Ok(())
215    }
216
217    /// Set the maximum number of idle connections per backend (default: 64).
218    pub fn max_idle_conns(mut self, n: usize) -> Self {
219        self.pool = Arc::new(ConnPool::new(n, Duration::from_secs(60)));
220        self
221    }
222
223    fn proxy(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
224        if self.backends.is_empty() {
225            return Err("no backends configured".to_string());
226        }
227        let n = self.backends.len();
228        let start = self.counter.fetch_add(1, Ordering::Relaxed);
229        let mut last_err = "all backends failed".to_string();
230        for attempt in 0..n {
231            let idx = (start + attempt) % n;
232            let backend = &self.backends[idx];
233            let key = format!("{}:{}", backend.host, backend.port);
234
235            if let Some(breaker) = &self.breaker {
236                if !breaker.is_available(&key) {
237                    last_err = format!("circuit open for {}", key);
238                    continue;
239                }
240            }
241
242            match self.try_backend(request, connection, backend) {
243                Ok(resp) => {
244                    if let Some(breaker) = &self.breaker {
245                        breaker.record_success(&key);
246                    }
247                    return Ok(resp);
248                }
249                Err(e) => {
250                    if let Some(breaker) = &self.breaker {
251                        breaker.record_failure(&key);
252                    }
253                    last_err = e;
254                }
255            }
256        }
257        Err(last_err)
258    }
259
260    fn try_backend(
261        &self,
262        request: &Request,
263        connection: &ConnectionInfo,
264        backend: &Backend,
265    ) -> Result<Response, String> {
266        forward_upstream_trusting(
267            &self.pool,
268            request,
269            crate::proxy::Peer::of(connection),
270            &backend.host,
271            backend.port,
272            backend.tls,
273            self.trust.as_deref(),
274            self.connect_timeout,
275            self.read_timeout,
276        )
277    }
278}
279
280impl Middleware for ReverseProxy {
281    fn handle(
282        &self,
283        request: &Request,
284        connection: &ConnectionInfo,
285        next: &dyn Application,
286    ) -> Result<Response, String> {
287        if let Some(prefix) = &self.path_prefix {
288            if !request.request_uri.starts_with(prefix.as_str()) {
289                return next.execute(request, connection);
290            }
291        }
292        match self.proxy(request, connection) {
293            Ok(resp) => Ok(resp),
294            Err(_) => Ok(bad_gateway()),
295        }
296    }
297}
298
299// ── helpers ───────────────────────────────────────────────────────────────────
300
301/// The client as the forwarders need it: its address for `X-Forwarded-For`
302/// and whether it connected over TLS, for `X-Forwarded-Proto`.
303#[derive(Clone, Copy, Debug)]
304pub(crate) struct Peer<'a> {
305    pub ip: &'a str,
306    pub tls: bool,
307}
308
309impl<'a> Peer<'a> {
310    pub(crate) fn of(connection: &'a ConnectionInfo) -> Self {
311        Peer { ip: &connection.client.ip, tls: connection.tls }
312    }
313
314    /// A plain-HTTP client at `ip` (tests and callers without a connection).
315    pub(crate) fn plain(ip: &'a str) -> Self {
316        Peer { ip, tls: false }
317    }
318
319    pub(crate) fn proto(&self) -> &'static str {
320        if self.tls { "https" } else { "http" }
321    }
322}
323
324pub(crate) fn build_request(
325    request: &Request,
326    backend_host: &str,
327    client: Peer<'_>,
328    keep_alive: bool,
329) -> Vec<u8> {
330    // One buffer, sized up front and filled with extend_from_slice — no
331    // write!/format machinery, no lower-cased copy of each header name.
332    // `Content-Length` is regenerated from the (possibly de-chunked) body and
333    // `X-Forwarded-For` is extended below, so the client's copies are dropped:
334    // two `Content-Length` lines are a protocol error and a desync risk.
335    let connection = request.header(crate::header::Header::_CONNECTION).map(|h| h.value.as_ref());
336    // Inside a traced request the current span's `traceparent` (and the
337    // request's `baggage`) replace the client's.
338    #[cfg(not(target_arch = "wasm32"))]
339    let trace = crate::otel::propagation_headers();
340    #[cfg(target_arch = "wasm32")]
341    let trace: Option<(String, Option<String>)> = None;
342    let forwarded = |h: &&crate::header::Header| {
343        // An empty name would be sent as a malformed ": value" line.
344        !h.name.is_empty()
345            && !is_hop_by_hop_or_host(&h.name)
346            && !h.name.eq_ignore_ascii_case(crate::header::Header::_CONTENT_LENGTH)
347            && !h.name.eq_ignore_ascii_case("X-Forwarded-For")
348            && !connection.is_some_and(|c| connection_listed(c, &h.name))
349            && !(trace.is_some() && (h.name.eq_ignore_ascii_case("traceparent") || h.name.eq_ignore_ascii_case("baggage")))
350    };
351    let prior_xff = request.header("X-Forwarded-For").map(|h| h.value.as_ref()).filter(|v| !v.trim().is_empty());
352    // The Host the client asked for, unless an outer proxy already said
353    // (its X-Forwarded-Host passes through like any header).
354    // http or https, as the client connected, unless an outer proxy said.
355    let forwarded_proto = request.header("X-Forwarded-Proto").is_none().then(|| client.proto());
356    let forwarded_host = match request.header("X-Forwarded-Host") {
357        Some(_) => None,
358        None => request.header(crate::header::Header::_HOST).map(|h| h.value.as_ref()).filter(|v| !v.is_empty()),
359    };
360    let body_len = Decimal::new(request.body.len() as u128);
361    let capacity = request.method.len() + request.request_uri.len() + backend_host.len() + 25
362        + request.headers.iter().filter(forwarded).map(|h| h.name.len() + h.value.len() + 4).sum::<usize>()
363        + client.ip.len() + 19 + prior_xff.map_or(0, |v| v.len() + 2)
364        + forwarded_host.map_or(0, |v| v.len() + 20)
365        + forwarded_proto.map_or(0, |v| v.len() + 21)
366        + trace.as_ref().map_or(0, |(t, b)| t.len() + 15 + b.as_ref().map_or(0, |b| b.len() + 11))
367        + 13
368        + 24
369        + if request.body.is_empty() { 0 } else { body_len.as_str().len() + 18 }
370        + 2
371        + request.body.len();
372    let mut out: Vec<u8> = Vec::with_capacity(capacity);
373    for part in [request.method.as_str(), " ", request.request_uri.as_str(), " HTTP/1.1\r\nHost: ", backend_host, "\r\n"] {
374        out.extend_from_slice(part.as_bytes());
375    }
376    for h in request.headers.iter().filter(forwarded) {
377        for part in [h.name.as_ref(), ": ", h.value.as_ref(), "\r\n"] {
378            out.extend_from_slice(part.as_bytes());
379        }
380    }
381    out.extend_from_slice(b"X-Forwarded-For: ");
382    if let Some(prior) = prior_xff {
383        out.extend_from_slice(prior.as_bytes());
384        out.extend_from_slice(b", ");
385    }
386    for part in [client.ip, "\r\n", "Via: 1.1 rws\r\n"] {
387        out.extend_from_slice(part.as_bytes());
388    }
389    if let Some(host) = forwarded_host {
390        for part in ["X-Forwarded-Host: ", host, "\r\n"] {
391            out.extend_from_slice(part.as_bytes());
392        }
393    }
394    if let Some(proto) = forwarded_proto {
395        for part in ["X-Forwarded-Proto: ", proto, "\r\n"] {
396            out.extend_from_slice(part.as_bytes());
397        }
398    }
399    if let Some((traceparent, baggage)) = &trace {
400        for part in ["traceparent: ", traceparent, "\r\n"] {
401            out.extend_from_slice(part.as_bytes());
402        }
403        if let Some(baggage) = baggage {
404            for part in ["baggage: ", baggage, "\r\n"] {
405                out.extend_from_slice(part.as_bytes());
406            }
407        }
408    }
409    if keep_alive {
410        out.extend_from_slice(b"Connection: keep-alive\r\n");
411    } else {
412        out.extend_from_slice(b"Connection: close\r\n");
413    }
414    if !request.body.is_empty() {
415        out.extend_from_slice(b"Content-Length: ");
416        out.extend_from_slice(body_len.as_str().as_bytes());
417        out.extend_from_slice(b"\r\n");
418    }
419    out.extend_from_slice(b"\r\n");
420    out.extend_from_slice(&request.body);
421    out
422}
423
424/// Whether a request header is dropped on the way upstream: hop-by-hop
425/// headers and `Host` (rws sends its own). Matches `to_lowercase()` against
426/// the lower-case list — Unicode lower-casing, so a non-ASCII name (where
427/// e.g. the Kelvin sign lower-cases to `k`) still takes that path.
428fn is_hop_by_hop_or_host(name: &str) -> bool {
429    if name.is_ascii() {
430        name.eq_ignore_ascii_case("host") || HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
431    } else {
432        let lower = name.to_lowercase();
433        lower == "host" || HOP_BY_HOP.contains(&lower.as_str())
434    }
435}
436
437/// Whether `name` is one of the comma-separated tokens of a `Connection`
438/// header value — those headers are hop-by-hop for this connection only.
439fn connection_listed(connection: &str, name: &str) -> bool {
440    connection.split(',').any(|token| token.trim().eq_ignore_ascii_case(name))
441}
442
443/// Decode HTTP/1.1 chunked transfer-encoding from `stream`.
444///
445/// `buf[header_end..]` may already contain some body bytes that arrived in
446/// the same read as the headers.  Returns the fully decoded body.
447fn decode_chunked<S: Read>(
448    stream: &mut S,
449    buf: &[u8],
450    header_end: usize,
451    tmp: &mut [u8],
452) -> Result<Vec<u8>, String> {
453    // Seed `raw` with any body bytes already buffered alongside the headers.
454    let mut raw: Vec<u8> = buf[header_end..].to_vec();
455    let mut decoded: Vec<u8> = Vec::new();
456
457    loop {
458        // Wait until we have at least one complete chunk-size line (ends with \r\n).
459        let crlf = loop {
460            if let Some(p) = raw.windows(2).position(|w| w == b"\r\n") {
461                break p;
462            }
463            let n = stream.read(tmp).map_err(|e| e.to_string())?;
464            if n == 0 {
465                return Err("chunked: premature EOF reading chunk size".to_string());
466            }
467            raw.extend_from_slice(&tmp[..n]);
468        };
469
470        // Chunk size is hex, optionally followed by chunk-extensions (";…").
471        let size_line = std::str::from_utf8(&raw[..crlf])
472            .map_err(|_| "chunked: non-UTF-8 chunk size line".to_string())?;
473        let size_str = size_line.split(';').next().unwrap_or("").trim();
474        let chunk_size = usize::from_str_radix(size_str, 16)
475            .map_err(|_| format!("chunked: invalid chunk size '{}'", size_str))?;
476        raw.drain(..crlf + 2); // consume "<size>\r\n"
477
478        if chunk_size == 0 {
479            // Last chunk: skip trailer fields up to the empty line that ends
480            // the message. Leaving any of it unread would corrupt the next
481            // response on a pooled connection.
482            loop {
483                let line_end = loop {
484                    if let Some(p) = raw.windows(2).position(|w| w == b"\r\n") {
485                        break p;
486                    }
487                    let n = stream.read(tmp).map_err(|e| e.to_string())?;
488                    if n == 0 {
489                        return Err("chunked: premature EOF in trailer".to_string());
490                    }
491                    raw.extend_from_slice(&tmp[..n]);
492                };
493                raw.drain(..line_end + 2);
494                if line_end == 0 {
495                    break;
496                }
497            }
498            break;
499        }
500
501        // Read chunk data + trailing CRLF.
502        while raw.len() < chunk_size + 2 {
503            let n = stream.read(tmp).map_err(|e| e.to_string())?;
504            if n == 0 {
505                return Err("chunked: premature EOF reading chunk body".to_string());
506            }
507            raw.extend_from_slice(&tmp[..n]);
508        }
509        decoded.extend_from_slice(&raw[..chunk_size]);
510        raw.drain(..chunk_size + 2); // consume "<data>\r\n"
511    }
512
513    Ok(decoded)
514}
515
516/// Rewrite `buf` in-place: strip `Transfer-Encoding`, add `Content-Length`,
517/// replace the old (undecoded) body with `decoded`.
518fn rewrite_as_content_length(buf: &mut Vec<u8>, header_end: usize, decoded: &[u8]) {
519    let header_str = std::str::from_utf8(&buf[..header_end]).unwrap_or("").to_string();
520    buf.clear();
521    for line in header_str.lines() {
522        if line.to_ascii_lowercase().starts_with("transfer-encoding:") || line.is_empty() {
523            continue;
524        }
525        buf.extend_from_slice(line.as_bytes());
526        buf.extend_from_slice(b"\r\n");
527    }
528    let _ = write!(buf, "Content-Length: {}\r\n\r\n", decoded.len());
529    buf.extend_from_slice(decoded);
530}
531
532/// Headers rws regenerates for a buffered body, plus hop-by-hop headers
533/// (RFC 9110 §7.6.1) that must not be forwarded.
534pub(crate) const REGENERATED_OR_HOP_BY_HOP: &[&str] = &[
535    "content-length", "transfer-encoding", "connection", "keep-alive",
536    "proxy-connection", "te", "trailer", "upgrade",
537];
538
539/// Make a buffered upstream response safe to re-serialize. The body is
540/// already held in `content_range_list`, from which `Response::generate`
541/// (and the HTTP/2 / HTTP/3 writers) emit `Content-Type` and
542/// `Content-Length` themselves, so the upstream's own copies would be
543/// duplicated — and a duplicate `content-length` is a protocol error that
544/// HTTP/2 clients reject outright. The upstream's `Content-Type` is moved
545/// onto the body instead; the status line is re-emitted as HTTP/1.1
546/// regardless of the backend's version.
547#[cfg_attr(not(feature = "http2"), allow(dead_code))]
548pub(crate) fn normalize_upstream_response(mut response: Response) -> Response {
549    response.http_version.clear();
550    response.http_version.push_str(crate::http::VERSION.http_1_1);
551    let has_body = !response.content_range_list.is_empty();
552    if let [range] = response.content_range_list.as_mut_slice() {
553        if let Some(h) = response
554            .headers
555            .iter()
556            .find(|h| h.name.eq_ignore_ascii_case(crate::header::Header::_CONTENT_TYPE))
557        {
558            range.content_type.clear();
559            range.content_type.push_str(&h.value);
560        }
561    }
562    // Compared in place (the list is lower-case ASCII), not lower-cased
563    // into a new String per header.
564    response.headers.retain(|h| {
565        if h.name.eq_ignore_ascii_case("content-type") {
566            // Keep it only when there's no body to carry it (e.g. HEAD, 304).
567            return !has_body;
568        }
569        !REGENERATED_OR_HOP_BY_HOP.iter().any(|name| h.name.eq_ignore_ascii_case(name))
570    });
571    response
572}
573
574pub(crate) fn bad_gateway() -> Response {
575    let cr = Range::get_content_range(
576        b"502 Bad Gateway".to_vec(),
577        MimeType::TEXT_PLAIN.to_string(),
578    );
579    let mut r = Response::new();
580    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
581    r.reason_phrase = STATUS_CODE_REASON_PHRASE
582        .n502_bad_gateway
583        .reason_phrase
584        .to_string();
585    r.content_range_list = vec![cr];
586    r
587}
588
589// ── Streaming proxy helpers ───────────────────────────────────────────────────
590
591/// Responses larger than this threshold are streamed instead of buffered.
592const STREAM_THRESHOLD: usize = 1024 * 1024; // 1 MB
593
594/// A `Read` implementation that drains a prefix buffer before reading from the
595/// inner stream. Used to replay body bytes that arrived with the HTTP headers.
596pub(crate) struct ConcatReader<R: Read + Send> {
597    prefix: Vec<u8>,
598    prefix_pos: usize,
599    inner: R,
600}
601
602impl<R: Read + Send> ConcatReader<R> {
603    fn new(prefix: Vec<u8>, inner: R) -> Self {
604        ConcatReader { prefix, prefix_pos: 0, inner }
605    }
606}
607
608impl<R: Read + Send> Read for ConcatReader<R> {
609    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
610        if self.prefix_pos < self.prefix.len() {
611            let avail = &self.prefix[self.prefix_pos..];
612            let n = buf.len().min(avail.len());
613            buf[..n].copy_from_slice(&avail[..n]);
614            self.prefix_pos += n;
615            return Ok(n);
616        }
617        self.inner.read(buf)
618    }
619}
620
621
622/// Idle backend connections kept per `host:port`. 64 matches common nginx
623/// `keepalive` settings and the benchmark's concurrency; fewer meant most
624/// requests at 64-way concurrency still paid a TCP connect.
625pub(crate) const POOL_MAX_IDLE: usize = 64;
626
627/// The process-wide backend connection pool used by config-driven proxy
628/// routes (`proxy_config::DynamicProxy`), keyed by `host:port`.
629pub(crate) fn shared_pool() -> &'static ConnPool {
630    static POOL: std::sync::OnceLock<ConnPool> = std::sync::OnceLock::new();
631    POOL.get_or_init(|| ConnPool::new(POOL_MAX_IDLE, Duration::from_secs(60)))
632}
633
634/// A backend connection the pooled forwarder drives: plain TCP, or TLS
635/// over TCP for `https://` backends.
636pub(crate) trait UpstreamIo: Read + Write + Send + 'static {
637    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()>;
638}
639
640impl UpstreamIo for TcpStream {
641    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()> {
642        self.set_read_timeout(Some(read))?;
643        self.set_write_timeout(Some(write))
644    }
645}
646
647#[cfg(any(feature = "http-client", feature = "http2"))]
648pub(crate) type TlsStream = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
649
650#[cfg(any(feature = "http-client", feature = "http2"))]
651impl UpstreamIo for TlsStream {
652    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()> {
653        self.sock.set_io_timeouts(read, write)
654    }
655}
656
657/// Forward `request` to an HTTP or HTTPS backend at `host:port`, over a
658/// pooled keep-alive connection either way: plain backends use `plain_pool`,
659/// TLS backends the process-wide TLS pool (one handshake per connection, not
660/// per request). The single entry point for `ReverseProxy`, config-driven
661/// routes, `canary` and `ingress`.
662pub(crate) fn forward_upstream(
663    plain_pool: &ConnPool,
664    request: &Request,
665    client: Peer<'_>,
666    host: &str,
667    port: u16,
668    tls: bool,
669    connect_timeout: Duration,
670    read_timeout: Duration,
671) -> Result<Response, String> {
672    forward_upstream_trusting(plain_pool, request, client, host, port, tls, None, connect_timeout, read_timeout)
673}
674
675/// [`forward_upstream`] with the trust settings of one upstream: `trust`
676/// replaces the WebPKI roots for TLS backends (a private CA).
677#[allow(clippy::too_many_arguments)]
678pub(crate) fn forward_upstream_trusting(
679    plain_pool: &ConnPool,
680    request: &Request,
681    client: Peer<'_>,
682    host: &str,
683    port: u16,
684    tls: bool,
685    trust: Option<&UpstreamTls>,
686    connect_timeout: Duration,
687    read_timeout: Duration,
688) -> Result<Response, String> {
689    // Inside a traced request: a client span per forward, current while the
690    // request is built, so the upstream's `traceparent` names it.
691    #[cfg(not(target_arch = "wasm32"))]
692    let span = crate::otel::outbound_span(&request.method);
693    #[cfg(not(target_arch = "wasm32"))]
694    if let Some(span) = &span {
695        let scheme = if tls { "https" } else { "http" };
696        let path = request.request_uri.as_str();
697        for (key, value) in crate::otel::http_client_attributes(&request.method, &format!("{scheme}://{host}:{port}{path}")) {
698            span.set_attribute(key, value);
699        }
700    }
701    let result = forward_upstream_untraced(plain_pool, request, client, host, port, tls, trust, connect_timeout, read_timeout);
702    #[cfg(not(target_arch = "wasm32"))]
703    if let Some(span) = &span {
704        span.finish_http_client(result.as_ref().map(|r| r.status_code as u16).map_err(String::as_str));
705    }
706    result
707}
708
709#[allow(clippy::too_many_arguments)]
710fn forward_upstream_untraced(
711    plain_pool: &ConnPool,
712    request: &Request,
713    client: Peer<'_>,
714    host: &str,
715    port: u16,
716    tls: bool,
717    trust: Option<&UpstreamTls>,
718    connect_timeout: Duration,
719    read_timeout: Duration,
720) -> Result<Response, String> {
721    if tls {
722        #[cfg(any(feature = "http-client", feature = "http2"))]
723        {
724            let (pool, config) = match trust {
725                Some(t) => (t.pool, Arc::clone(&t.config)),
726                None => (shared_tls_pool(), tls_client_config()),
727            };
728            return forward_via(pool, request, client, host, port, host, read_timeout, || {
729                connect_tls_with(Arc::clone(&config), host, port, connect_timeout)
730            });
731        }
732        #[cfg(not(any(feature = "http-client", feature = "http2")))]
733        {
734            let _ = trust;
735            return Err("HTTPS backends need the http2 or http-client feature, which this build doesn't include".to_string());
736        }
737    }
738    forward_pooled(plain_pool, request, client, host, port, connect_timeout, read_timeout)
739}
740
741/// Trust settings for one upstream's TLS backends: the certificates in a CA
742/// file **instead of** the WebPKI roots — for internal services behind a
743/// private CA or a self-signed certificate (nginx's
744/// `proxy_ssl_trusted_certificate`) — and/or a client certificate rws
745/// presents to backends that require one (`proxy_ssl_certificate`, since
746/// 18.3.0). Connections are pooled per setting, so two upstreams at the same
747/// address with different trust never share one.
748pub struct UpstreamTls {
749    #[cfg(any(feature = "http-client", feature = "http2"))]
750    config: Arc<rustls::ClientConfig>,
751    #[cfg(any(feature = "http-client", feature = "http2"))]
752    pool: &'static Pool<TlsStream>,
753    /// Connections for the non-blocking forwarder, made with `config`.
754    #[cfg(feature = "http2")]
755    async_pool: &'static Pool<async_upstream::AsyncTlsStream>,
756    /// `config` offering HTTP/2 by ALPN, for HTTP/2 and gRPC upstreams.
757    #[cfg(feature = "http2")]
758    h2_config: Arc<rustls::ClientConfig>,
759}
760
761impl UpstreamTls {
762    /// Load the PEM certificates in `path` as the only trusted roots.
763    pub fn from_ca_file(path: &str) -> Result<UpstreamTls, String> {
764        UpstreamTls::new(Some(path), None)
765    }
766
767    /// Trust the PEM certificates in `ca_file` (the WebPKI roots when `None`)
768    /// and, with `client_cert` = `(certificate chain PEM, private key PEM)`,
769    /// present that certificate to backends that ask for one.
770    pub fn new(ca_file: Option<&str>, client_cert: Option<(&str, &str)>) -> Result<UpstreamTls, String> {
771        #[cfg(any(feature = "http-client", feature = "http2"))]
772        {
773            use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
774            let roots = match ca_file {
775                Some(path) => {
776                    let bytes = std::fs::read(path).map_err(|e| format!("cannot read CA file '{path}': {e}"))?;
777                    let certs = CertificateDer::pem_slice_iter(&bytes)
778                        .collect::<Result<Vec<_>, _>>()
779                        .map_err(|e| format!("cannot parse CA file '{path}': {e}"))?;
780                    if certs.is_empty() {
781                        return Err(format!("no certificates in CA file '{path}'"));
782                    }
783                    let mut roots = rustls::RootCertStore::empty();
784                    for cert in certs {
785                        roots.add(cert).map_err(|e| format!("invalid CA certificate in '{path}': {e}"))?;
786                    }
787                    roots
788                }
789                None => rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()),
790            };
791            let builder = crate::tls_client_builder().with_root_certificates(roots);
792            let config = match client_cert {
793                None => builder.with_no_client_auth(),
794                Some((cert_path, key_path)) => {
795                    let bytes = std::fs::read(cert_path).map_err(|e| format!("cannot read client certificate '{cert_path}': {e}"))?;
796                    let chain = CertificateDer::pem_slice_iter(&bytes)
797                        .collect::<Result<Vec<_>, _>>()
798                        .map_err(|e| format!("cannot parse client certificate '{cert_path}': {e}"))?;
799                    if chain.is_empty() {
800                        return Err(format!("no certificate in '{cert_path}'"));
801                    }
802                    let bytes = std::fs::read(key_path).map_err(|e| format!("cannot read client key '{key_path}': {e}"))?;
803                    let key = PrivateKeyDer::from_pem_slice(&bytes).map_err(|e| format!("cannot parse client key '{key_path}': {e}"))?;
804                    builder.with_client_auth_cert(chain, key).map_err(|e| format!("client certificate '{cert_path}': {e}"))?
805                }
806            };
807            let label = format!("{}|{}", ca_file.unwrap_or("webpki"), client_cert.map_or("", |(cert, _)| cert));
808            Ok(UpstreamTls::with_config(config, &label))
809        }
810        #[cfg(not(any(feature = "http-client", feature = "http2")))]
811        {
812            let _ = client_cert;
813            Err(format!("tls_ca_file '{}': HTTPS backends need the http2 or http-client feature", ca_file.unwrap_or("")))
814        }
815    }
816
817    #[cfg(any(feature = "http-client", feature = "http2"))]
818    fn with_config(config: rustls::ClientConfig, label: &str) -> UpstreamTls {
819        #[cfg(feature = "http2")]
820        let h2_config = Arc::new(h2_upstream::with_h2_alpn(config.clone()));
821        UpstreamTls {
822            config: Arc::new(config),
823            pool: tls_pool_for_ca(label),
824            #[cfg(feature = "http2")]
825            async_pool: async_tls_pool_for_ca(label),
826            #[cfg(feature = "http2")]
827            h2_config,
828        }
829    }
830
831    /// The client configuration for HTTP/2 upstreams (ALPN `h2`).
832    #[cfg(feature = "http2")]
833    pub(crate) fn h2_config(&self) -> &Arc<rustls::ClientConfig> {
834        &self.h2_config
835    }
836
837    /// Trust nothing: every TLS handshake fails, so the upstream answers 502.
838    /// Used when a configured CA file can't be loaded — failing closed rather
839    /// than falling back to the public roots.
840    pub(crate) fn reject_all(label: &str) -> UpstreamTls {
841        #[cfg(any(feature = "http-client", feature = "http2"))]
842        {
843            let config = crate::tls_client_builder().with_root_certificates(rustls::RootCertStore::empty()).with_no_client_auth();
844            UpstreamTls::with_config(config, &format!("reject-all:{label}"))
845        }
846        #[cfg(not(any(feature = "http-client", feature = "http2")))]
847        {
848            let _ = label;
849            UpstreamTls {}
850        }
851    }
852
853    /// How the non-blocking forwarder reaches these backends.
854    #[cfg(feature = "http2")]
855    pub(crate) fn async_tls(&self) -> async_upstream::AsyncTls {
856        async_upstream::AsyncTls { config: Arc::clone(&self.config), pool: self.async_pool }
857    }
858
859    /// The client configuration (for health checks).
860    #[cfg(any(feature = "http-client", feature = "http2"))]
861    pub(crate) fn client_config(&self) -> Arc<rustls::ClientConfig> {
862        Arc::clone(&self.config)
863    }
864}
865
866/// One non-blocking connection pool per CA file, for the life of the process
867/// (a reload that keeps the same file keeps its warm connections).
868#[cfg(feature = "http2")]
869fn async_tls_pool_for_ca(path: &str) -> &'static Pool<async_upstream::AsyncTlsStream> {
870    static POOLS: std::sync::OnceLock<
871        std::sync::Mutex<std::collections::HashMap<String, &'static Pool<async_upstream::AsyncTlsStream>>>,
872    > = std::sync::OnceLock::new();
873    let mut pools = POOLS.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
874    pools
875        .entry(path.to_string())
876        .or_insert_with(|| Box::leak(Box::new(Pool::new(POOL_MAX_IDLE, Duration::from_secs(60)))))
877}
878
879/// One pool per CA file, for the life of the process.
880#[cfg(any(feature = "http-client", feature = "http2"))]
881fn tls_pool_for_ca(path: &str) -> &'static Pool<TlsStream> {
882    static POOLS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, &'static Pool<TlsStream>>>> =
883        std::sync::OnceLock::new();
884    let mut pools = POOLS.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner());
885    pools
886        .entry(path.to_string())
887        .or_insert_with(|| Box::leak(Box::new(Pool::new(POOL_MAX_IDLE, Duration::from_secs(60)))))
888}
889
890/// Forward `request` to `host:port` over plain HTTP/1.1, reusing an idle
891/// connection from `pool` when there is one and returning the connection to
892/// `pool` after a complete, reusable response.
893pub(crate) fn forward_pooled(
894    pool: &ConnPool,
895    request: &Request,
896    client: Peer<'_>,
897    host: &str,
898    port: u16,
899    connect_timeout: Duration,
900    read_timeout: Duration,
901) -> Result<Response, String> {
902    forward_via(pool, request, client, host, port, host, read_timeout, || connect_tcp(host, port, connect_timeout))
903}
904
905/// [`forward_pooled`], sending `host_header` as the `Host` (the client's, for
906/// a gateway that must preserve it) instead of the backend's address.
907#[cfg(feature = "gateway-api")]
908#[allow(clippy::too_many_arguments)]
909pub(crate) fn forward_pooled_with_host(
910    pool: &ConnPool,
911    request: &Request,
912    client: Peer<'_>,
913    host: &str,
914    port: u16,
915    host_header: &str,
916    connect_timeout: Duration,
917    read_timeout: Duration,
918) -> Result<Response, String> {
919    forward_via(pool, request, client, host, port, host_header, read_timeout, || connect_tcp(host, port, connect_timeout))
920}
921
922/// The pooled request/response cycle, for any connection type.
923///
924/// A backend may close an idle keep-alive connection at any moment, so a
925/// pooled connection that fails before *any* response byte arrives is
926/// retried once on a fresh connection — but only for idempotent methods
927/// (RFC 9110 §9.2.2): the request may already have reached the backend.
928#[allow(clippy::too_many_arguments)]
929fn forward_via<S: UpstreamIo>(
930    pool: &Pool<S>,
931    request: &Request,
932    client: Peer<'_>,
933    host: &str,
934    port: u16,
935    host_header: &str,
936    read_timeout: Duration,
937    connect: impl Fn() -> Result<S, String>,
938) -> Result<Response, String> {
939    let key = format!("{}:{}", host, port);
940    if let Some(stream) = pool.acquire(&key) {
941        match exchange(pool, &key, stream, request, host_header, client, read_timeout) {
942            Exchange::Done(response) => return Ok(response),
943            Exchange::NoResponse(_) if is_idempotent(&request.method) => {} // stale: retry fresh
944            Exchange::NoResponse(e) | Exchange::Failed(e) => return Err(e),
945        }
946    }
947    let stream = connect()?;
948    match exchange(pool, &key, stream, request, host_header, client, read_timeout) {
949        Exchange::Done(response) => Ok(response),
950        Exchange::NoResponse(e) | Exchange::Failed(e) => Err(e),
951    }
952}
953
954/// Connect to the first address of `host:port` that accepts. `localhost`
955/// resolves to `::1` before `127.0.0.1` on many systems; a backend listening
956/// on IPv4 only must still be reachable by name.
957fn connect_tcp(host: &str, port: u16, connect_timeout: Duration) -> Result<TcpStream, String> {
958    let key = format!("{}:{}", host, port);
959    let addrs: Vec<_> = key
960        .to_socket_addrs()
961        .map_err(|e| format!("DNS lookup for {} failed: {}", key, e))?
962        .collect();
963    let mut last_error = format!("no address resolved for {}", key);
964    for addr in addrs {
965        match TcpStream::connect_timeout(&addr, connect_timeout) {
966            Ok(stream) => {
967                let _ = stream.set_nodelay(true);
968                return Ok(stream);
969            }
970            Err(e) => last_error = format!("connect to {} ({}) failed: {}", key, addr, e),
971        }
972    }
973    Err(last_error)
974}
975
976/// The process-wide pool of TLS connections to `https://` backends.
977#[cfg(any(feature = "http-client", feature = "http2"))]
978fn shared_tls_pool() -> &'static Pool<TlsStream> {
979    static POOL: std::sync::OnceLock<Pool<TlsStream>> = std::sync::OnceLock::new();
980    POOL.get_or_init(|| Pool::new(POOL_MAX_IDLE, Duration::from_secs(60)))
981}
982
983/// TLS client settings for backends: the Mozilla root store, no client
984/// certificate. Built once — parsing the root store per request was a large
985/// part of the per-request cost of HTTPS upstreams.
986#[cfg(any(feature = "http-client", feature = "http2"))]
987pub(crate) fn tls_client_config() -> Arc<rustls::ClientConfig> {
988    static CONFIG: std::sync::OnceLock<Arc<rustls::ClientConfig>> = std::sync::OnceLock::new();
989    Arc::clone(CONFIG.get_or_init(|| {
990        let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
991        Arc::new(crate::tls_client_builder().with_root_certificates(roots).with_no_client_auth())
992    }))
993}
994
995#[cfg(any(feature = "http-client", feature = "http2"))]
996fn connect_tls_with(config: Arc<rustls::ClientConfig>, host: &str, port: u16, connect_timeout: Duration) -> Result<TlsStream, String> {
997    let tcp = connect_tcp(host, port, connect_timeout)?;
998    let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
999        .map_err(|e| format!("invalid upstream hostname '{}': {}", host, e))?;
1000    let conn = rustls::ClientConnection::new(config, server_name).map_err(|e| e.to_string())?;
1001    Ok(rustls::StreamOwned::new(conn, tcp))
1002}
1003
1004fn is_idempotent(method: &str) -> bool {
1005    matches!(method, "GET" | "HEAD" | "OPTIONS" | "TRACE" | "PUT" | "DELETE")
1006}
1007
1008enum Exchange {
1009    Done(Response),
1010    /// Failed before any response byte (write error, or closed/errored on the first read).
1011    NoResponse(String),
1012    Failed(String),
1013}
1014
1015/// One request/response on `stream`. A fully read, keep-alive response puts
1016/// the connection back in `pool`; a streamed response keeps it until done.
1017fn exchange<S: UpstreamIo>(
1018    pool: &Pool<S>,
1019    key: &str,
1020    mut stream: S,
1021    request: &Request,
1022    host: &str,
1023    client: Peer<'_>,
1024    read_timeout: Duration,
1025) -> Exchange {
1026    if let Err(e) = stream.set_io_timeouts(read_timeout, Duration::from_secs(10)) {
1027        return Exchange::Failed(e.to_string());
1028    }
1029    // keep_alive = true: ask the backend to hold the connection open.
1030    let req_bytes = build_request(request, host, client, true);
1031    if let Err(e) = stream.write_all(&req_bytes) {
1032        return Exchange::NoResponse(format!("write to backend failed: {}", e));
1033    }
1034
1035    let mut tmp = [0u8; 4096];
1036    let (header_bytes, body_prefix) = match read_final_response_head(&mut stream, &mut tmp) {
1037        Ok(head) => head,
1038        Err(HeadError::NothingReceived(e)) => return Exchange::NoResponse(e),
1039        Err(HeadError::Partial(e)) => return Exchange::Failed(e),
1040    };
1041    let header_lower = std::str::from_utf8(&header_bytes).unwrap_or("").to_ascii_lowercase();
1042
1043    if response_has_no_body(&request.method, &header_lower) {
1044        if !header_lower.lines().any(|l| l.starts_with("connection:") && l.contains("close")) {
1045            pool.release(key, stream);
1046        }
1047        return match Response::parse_upstream(&header_bytes, REGENERATED_OR_HOP_BY_HOP) {
1048            Ok(response) => Exchange::Done(response),
1049            Err(e) => Exchange::Failed(e),
1050        };
1051    }
1052
1053    if should_stream_response(&header_lower) {
1054        // Streaming path — pipe bytes straight to the client; the connection
1055        // can't be reused while the body is in flight.
1056        return match parse_status_and_headers(&header_bytes) {
1057            Ok(mut response) => {
1058                response.stream_pipe = Some(Box::new(ConcatReader::new(body_prefix, stream)));
1059                Exchange::Done(response)
1060            }
1061            Err(e) => Exchange::Failed(e),
1062        };
1063    }
1064    // Buffered path — read the full body, then return the connection to the pool.
1065    match read_response_from_partial(&mut stream, header_bytes, body_prefix, &mut tmp) {
1066        Ok((resp_bytes, reusable)) => {
1067            if reusable {
1068                pool.release(key, stream);
1069            }
1070            match Response::parse_upstream(&resp_bytes, REGENERATED_OR_HOP_BY_HOP) {
1071                Ok(response) => Exchange::Done(response),
1072                Err(e) => Exchange::Failed(e),
1073            }
1074        }
1075        Err(e) => Exchange::Failed(e),
1076    }
1077}
1078
1079/// Read exactly the HTTP response headers (up to and including `\r\n\r\n`).
1080///
1081/// Returns `(header_bytes, body_prefix)` where `body_prefix` contains any
1082/// body bytes that arrived in the same TCP segment as the headers.
1083#[cfg(test)]
1084fn read_headers_only(stream: &mut TcpStream, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
1085    read_response_head(stream, tmp).map_err(|e| match e {
1086        HeadError::NothingReceived(msg) | HeadError::Partial(msg) => msg,
1087    })
1088}
1089
1090/// Why reading a response head failed: before any byte arrived (the request
1091/// may never have been processed — e.g. a pooled connection the backend had
1092/// already closed) or part-way through.
1093enum HeadError {
1094    NothingReceived(String),
1095    Partial(String),
1096}
1097
1098fn read_response_head<S: Read>(stream: &mut S, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
1099    read_response_head_from(stream, tmp, Vec::with_capacity(4096), false)
1100}
1101
1102/// The final response head: interim `1xx` heads (`100 Continue`,
1103/// `103 Early Hints`, …) are read past (RFC 9110 §15.2); a `103`'s `Link`
1104/// values are passed on to the client (`early_hints::forward_upstream`). Returning one as
1105/// the response left the real response in the connection, and once pooled
1106/// the next request — possibly another client's — received it.
1107fn read_final_response_head<S: Read>(stream: &mut S, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
1108    let (mut head, mut rest) = read_response_head(stream, tmp)?;
1109    let mut interim = 0;
1110    while is_interim_response(&head) {
1111        crate::early_hints::forward_upstream(&head);
1112        interim += 1;
1113        if interim > MAX_INTERIM_RESPONSES {
1114            return Err(HeadError::Partial("too many interim (1xx) responses from the backend".to_string()));
1115        }
1116        (head, rest) = read_response_head_from(stream, tmp, rest, true)?;
1117    }
1118    Ok((head, rest))
1119}
1120
1121/// Read until a complete head is in `buf` (which may already hold bytes
1122/// that followed a previous head). `received` — bytes already came from this
1123/// response, so a failure is never "nothing received".
1124fn read_response_head_from<S: Read>(
1125    stream: &mut S,
1126    tmp: &mut [u8],
1127    mut buf: Vec<u8>,
1128    received: bool,
1129) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
1130    loop {
1131        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1132            let body_prefix = buf[pos + 4..].to_vec();
1133            buf.truncate(pos + 4);
1134            return Ok((buf, body_prefix));
1135        }
1136        let nothing = buf.is_empty() && !received;
1137        let n = match stream.read(tmp) {
1138            Ok(n) => n,
1139            Err(e) if nothing => return Err(HeadError::NothingReceived(e.to_string())),
1140            Err(e) => return Err(HeadError::Partial(e.to_string())),
1141        };
1142        if n == 0 {
1143            let msg = "backend closed connection before headers were complete".to_string();
1144            return Err(if nothing { HeadError::NothingReceived(msg) } else { HeadError::Partial(msg) });
1145        }
1146        buf.extend_from_slice(&tmp[..n]);
1147    }
1148}
1149
1150/// Interim responses tolerated before the final one.
1151pub(crate) const MAX_INTERIM_RESPONSES: usize = 16;
1152
1153/// An interim (1xx) response head other than `101 Switching Protocols`
1154/// (which ends the HTTP exchange): the final response follows it.
1155pub(crate) fn is_interim_response(head: &[u8]) -> bool {
1156    let line_end = head.iter().position(|b| *b == b'\r' || *b == b'\n').unwrap_or(head.len());
1157    let status = head[..line_end].split(|b| *b == b' ').nth(1).unwrap_or(&[]);
1158    status.len() == 3 && status.iter().all(u8::is_ascii_digit) && status[0] == b'1' && status != b"101"
1159}
1160
1161/// RFC 9112 §6.3: a response to HEAD, and any 1xx, 204 or 304 response, ends
1162/// with its head — whatever `Content-Length` says (for HEAD it describes the
1163/// GET body). Waiting for that body hung until the read timeout (a 502).
1164pub(crate) fn response_has_no_body(method: &str, header_lower: &str) -> bool {
1165    let status = header_lower.split_whitespace().nth(1).unwrap_or("");
1166    method.eq_ignore_ascii_case("HEAD") || status.starts_with('1') || status == "204" || status == "304"
1167}
1168
1169/// Returns `true` when the response should be streamed rather than buffered.
1170///
1171/// Streams when any of the following hold:
1172/// - `Content-Type: text/event-stream` — SSE
1173/// - `Transfer-Encoding: chunked` — AI token streams, etc.
1174/// - `Content-Length` exceeds 1 MB — large file downloads
1175pub(crate) fn should_stream_response(header_lower: &str) -> bool {
1176    let is_sse = header_lower.lines().any(|l| {
1177        l.starts_with("content-type:") && l.contains("text/event-stream")
1178    });
1179    let is_chunked = header_lower.lines().any(|l| {
1180        l.starts_with("transfer-encoding:") && l.contains("chunked")
1181    });
1182    let content_length: Option<usize> = header_lower.lines().find_map(|l| {
1183        l.strip_prefix("content-length:")?.trim().parse().ok()
1184    });
1185    let is_large = content_length.map_or(false, |n| n > STREAM_THRESHOLD);
1186    is_sse || is_chunked || is_large
1187}
1188
1189/// What the forwarders need from an upstream response head, found in one
1190/// case-insensitive pass over the raw bytes — the same answers as
1191/// [`response_has_no_body`], [`should_stream_response`] and the
1192/// `connection:` / `content-length:` scans over a lower-cased copy, without
1193/// making that copy.
1194#[cfg_attr(not(feature = "http2"), allow(dead_code))]
1195pub(crate) struct HeadFacts {
1196    /// The second whitespace-separated token of the head (the status code).
1197    pub(crate) status_no_body: bool,
1198    pub(crate) sse: bool,
1199    pub(crate) chunked: bool,
1200    /// The first `content-length:` value that parses.
1201    pub(crate) content_length: Option<usize>,
1202    pub(crate) connection_close: bool,
1203}
1204
1205#[cfg_attr(not(feature = "http2"), allow(dead_code))]
1206impl HeadFacts {
1207    pub(crate) fn scan(head: &[u8]) -> HeadFacts {
1208        let text = std::str::from_utf8(head).unwrap_or("");
1209        let status = text.split_whitespace().nth(1).unwrap_or("");
1210        let mut facts = HeadFacts {
1211            status_no_body: status.starts_with('1') || status == "204" || status == "304",
1212            sse: false,
1213            chunked: false,
1214            content_length: None,
1215            connection_close: false,
1216        };
1217        for line in text.lines() {
1218            if let Some(value) = strip_prefix_ignore_ascii_case(line, "content-type:") {
1219                facts.sse |= contains_ignore_ascii_case(value, "text/event-stream");
1220            } else if let Some(value) = strip_prefix_ignore_ascii_case(line, "transfer-encoding:") {
1221                facts.chunked |= contains_ignore_ascii_case(value, "chunked");
1222            } else if let Some(value) = strip_prefix_ignore_ascii_case(line, "content-length:") {
1223                if facts.content_length.is_none() {
1224                    facts.content_length = value.trim().parse().ok();
1225                }
1226            } else if let Some(value) = strip_prefix_ignore_ascii_case(line, "connection:") {
1227                facts.connection_close |= contains_ignore_ascii_case(value, "close");
1228            }
1229        }
1230        facts
1231    }
1232
1233    /// [`response_has_no_body`].
1234    pub(crate) fn no_body(&self, method: &str) -> bool {
1235        method.eq_ignore_ascii_case("HEAD") || self.status_no_body
1236    }
1237
1238    /// [`should_stream_response`].
1239    pub(crate) fn should_stream(&self) -> bool {
1240        self.sse || self.chunked || self.content_length.is_some_and(|n| n > STREAM_THRESHOLD)
1241    }
1242}
1243
1244fn strip_prefix_ignore_ascii_case<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
1245    let bytes = line.as_bytes();
1246    // `prefix` is ASCII, so a byte match ends on a char boundary.
1247    (bytes.len() >= prefix.len() && bytes[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes()))
1248        .then(|| &line[prefix.len()..])
1249}
1250
1251fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
1252    haystack.as_bytes().windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
1253}
1254
1255/// Parse the status line and headers from raw header bytes (ending at `\r\n\r\n`).
1256fn parse_status_and_headers(header_bytes: &[u8]) -> Result<Response, String> {
1257    let s = std::str::from_utf8(header_bytes)
1258        .map_err(|e| format!("non-UTF-8 response headers: {}", e))?;
1259    let mut lines = s.lines();
1260    let status_line = lines.next().ok_or("empty backend response")?;
1261    let mut parts = status_line.splitn(3, ' ');
1262    let http_version = parts.next().unwrap_or("HTTP/1.1").to_string();
1263    let status_code: i16 = parts
1264        .next()
1265        .unwrap_or("502")
1266        .parse()
1267        .map_err(|_| format!("invalid status code in '{}'", status_line))?;
1268    let reason_phrase = parts.next().unwrap_or("").trim_end_matches('\r').to_string();
1269    let mut headers = Vec::new();
1270    for line in lines {
1271        let line = line.trim_end_matches('\r');
1272        if line.is_empty() { break; }
1273        if let Some(colon) = line.find(':') {
1274            headers.push(crate::header::Header {
1275                name: line[..colon].trim().to_string().into(),
1276                value: (line[colon + 1..].trim().to_string()).into(),
1277            });
1278        }
1279    }
1280    Ok(Response {
1281        http_version,
1282        status_code,
1283        reason_phrase,
1284        headers,
1285        content_range_list: vec![],
1286        stream_file: None,
1287        stream_pipe: None,
1288        trailers: None,
1289    })
1290}
1291
1292/// Read the remaining body after headers have already been read.
1293///
1294/// `header_bytes` ends with `\r\n\r\n`; `body_prefix` holds any body bytes
1295/// that arrived in the same TCP read.  Handles all three body mechanisms
1296/// (chunked, content-length, read-to-EOF).  Returns `(full_response_bytes, can_reuse)`.
1297fn read_response_from_partial<S: Read>(
1298    stream: &mut S,
1299    header_bytes: Vec<u8>,
1300    body_prefix: Vec<u8>,
1301    tmp: &mut [u8],
1302) -> Result<(Vec<u8>, bool), String> {
1303    let header_end = header_bytes.len();
1304    let mut buf = header_bytes;
1305    buf.extend_from_slice(&body_prefix);
1306
1307    let header_lower =
1308        std::str::from_utf8(&buf[..header_end]).unwrap_or("").to_ascii_lowercase();
1309    let connection_close =
1310        header_lower.lines().any(|l| l.starts_with("connection:") && l.contains("close"));
1311    let is_chunked = header_lower
1312        .lines()
1313        .any(|l| l.starts_with("transfer-encoding:") && l.contains("chunked"));
1314    let content_length: Option<usize> = header_lower.lines().find_map(|l| {
1315        l.strip_prefix("content-length:")?.trim().parse().ok()
1316    });
1317
1318    if is_chunked {
1319        let decoded = decode_chunked(stream, &buf, header_end, tmp)?;
1320        rewrite_as_content_length(&mut buf, header_end, &decoded);
1321        Ok((buf, !connection_close))
1322    } else if let Some(len) = content_length {
1323        while buf.len() < header_end + len {
1324            let n = stream.read(tmp).map_err(|e| e.to_string())?;
1325            if n == 0 { break; }
1326            buf.extend_from_slice(&tmp[..n]);
1327        }
1328        Ok((buf, !connection_close))
1329    } else {
1330        loop {
1331            let n = stream.read(tmp).map_err(|e| e.to_string())?;
1332            if n == 0 { break; }
1333            buf.extend_from_slice(&tmp[..n]);
1334        }
1335        Ok((buf, false))
1336    }
1337}
1338
1339// ── Backend URL parsing ───────────────────────────────────────────────────────
1340
1341struct Backend {
1342    host: String,
1343    port: u16,
1344    /// Whether the upstream connection should use TLS.
1345    /// Set when the URL scheme is `https://`, `h2s://`, or `grpcs://`.
1346    tls: bool,
1347}
1348
1349impl Backend {
1350    fn parse(url: &str) -> Option<Self> {
1351        let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("https://") {
1352            (r, true, 443u16)
1353        } else if let Some(r) = url.strip_prefix("h2s://") {
1354            (r, true, 443u16)
1355        } else if let Some(r) = url.strip_prefix("grpcs://") {
1356            (r, true, 443u16)
1357        } else if let Some(r) = url.strip_prefix("http://") {
1358            (r, false, 80u16)
1359        } else if let Some(r) = url.strip_prefix("h2://") {
1360            (r, false, 80u16)
1361        } else if let Some(r) = url.strip_prefix("grpc://") {
1362            (r, false, 80u16)
1363        } else {
1364            (url, false, 80u16)
1365        };
1366        // Drop any path component.
1367        let host_port = rest.split('/').next().unwrap_or(rest);
1368        let (host, port) = if let Some(colon) = host_port.rfind(':') {
1369            let port_str = &host_port[colon + 1..];
1370            if let Ok(p) = port_str.parse::<u16>() {
1371                (host_port[..colon].to_string(), p)
1372            } else {
1373                (host_port.to_string(), default_port)
1374            }
1375        } else {
1376            (host_port.to_string(), default_port)
1377        };
1378        if host.is_empty() {
1379            return None;
1380        }
1381        Some(Backend { host, port, tls })
1382    }
1383}
1384
1385// ── HTTP/2 reverse proxy ──────────────────────────────────────────────────────
1386
1387/// Reverse proxy that forwards requests to HTTP/2 backends.
1388///
1389/// Wraps [`ReverseProxy`] and forces HTTP/2 (`h2`) for all upstream connections.
1390/// Requires the `http2` Cargo feature.
1391///
1392/// This proxy also transparently handles gRPC traffic
1393/// (`Content-Type: application/grpc*`) — gRPC DATA frames are forwarded
1394/// as-is because gRPC is layered directly on HTTP/2.
1395#[cfg(feature = "http2")]
1396pub struct H2ReverseProxy {
1397    inner: ReverseProxy,
1398}
1399
1400#[cfg(feature = "http2")]
1401impl H2ReverseProxy {
1402    /// Create a proxy distributing requests across `backends` in round-robin order.
1403    ///
1404    /// Each backend entry can be:
1405    /// - `"host:port"` — plain TCP (HTTP/2 cleartext)
1406    /// - `"h2://host:port"` — plain TCP (explicit scheme)
1407    /// - `"h2s://host:port"` — TLS (HTTP/2 over HTTPS; port defaults to 443)
1408    /// - `"https://host:port"` — TLS (same as `h2s://`)
1409    ///
1410    /// TLS backends require the `http2` Cargo feature (includes `rustls` +
1411    /// `webpki-roots`).  Certificate verification uses the WebPKI trust store.
1412    pub fn new<I, S>(backends: I) -> Self
1413    where
1414        I: IntoIterator<Item = S>,
1415        S: AsRef<str>,
1416    {
1417        H2ReverseProxy {
1418            inner: ReverseProxy::new(backends),
1419        }
1420    }
1421
1422    /// Only proxy requests whose URI starts with `prefix`; pass others through.
1423    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
1424        self.inner = self.inner.path_prefix(prefix);
1425        self
1426    }
1427
1428    /// Override the TCP connect timeout (default: 5 s).
1429    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
1430        self.inner = self.inner.connect_timeout_ms(ms);
1431        self
1432    }
1433
1434    /// Override the read timeout (default: 30 s). It bounds each wait for
1435    /// the upstream — the handshake, the response head, every body frame — so
1436    /// a streaming response (a gRPC server stream) ends only when it goes
1437    /// quiet for that long.
1438    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
1439        self.inner = self.inner.read_timeout_ms(ms);
1440        self
1441    }
1442
1443    /// Trust the CA certificates in the PEM file `path` for `h2s://` /
1444    /// `https://` backends instead of the WebPKI roots. Since 18.3.0.
1445    pub fn tls_ca_file(mut self, path: &str) -> Result<Self, String> {
1446        self.inner = self.inner.tls_ca_file(path)?;
1447        Ok(self)
1448    }
1449
1450    /// Present a client certificate to TLS backends that require one (mutual
1451    /// TLS). Since 18.3.0.
1452    pub fn tls_client_cert(mut self, cert: &str, key: &str) -> Result<Self, String> {
1453        self.inner = self.inner.tls_client_cert(cert, key)?;
1454        Ok(self)
1455    }
1456
1457    fn matches(&self, request: &Request) -> bool {
1458        self.inner.path_prefix.as_ref().is_none_or(|prefix| request.request_uri.starts_with(prefix.as_str()))
1459    }
1460}
1461
1462#[cfg(feature = "http2")]
1463impl crate::middleware::Middleware for H2ReverseProxy {
1464    fn handle(
1465        &self,
1466        request: &crate::request::Request,
1467        connection: &crate::server::ConnectionInfo,
1468        next: &dyn crate::application::Application,
1469    ) -> Result<crate::response::Response, String> {
1470        if !self.matches(request) {
1471            return next.execute(request, connection);
1472        }
1473        if self.inner.backends.is_empty() {
1474            return Ok(bad_gateway());
1475        }
1476        // A streamed request body can be sent once: no second backend then.
1477        let attempts = if matches!(request.body, crate::request::Body::Stream(_)) { 1 } else { self.inner.backends.len() };
1478        let n = self.inner.backends.len();
1479        let start = self.inner.counter.fetch_add(1, Ordering::Relaxed);
1480        for attempt in 0..attempts {
1481            let backend = &self.inner.backends[(start + attempt) % n];
1482            let tls = backend.tls.then(|| match &self.inner.trust {
1483                Some(trust) => trust.h2_config(),
1484                None => h2_upstream::default_tls(),
1485            });
1486            let target = h2_upstream::Target {
1487                backend,
1488                tls,
1489                connect_timeout: self.inner.connect_timeout,
1490                read_timeout: self.inner.read_timeout,
1491                authority: None,
1492            };
1493            if let Ok(response) = h2_upstream::forward(request, Peer::of(connection), &target) {
1494                return Ok(response);
1495            }
1496        }
1497        Ok(bad_gateway())
1498    }
1499
1500    /// Headers only: a request this proxy doesn't forward passes through
1501    /// untouched.
1502    fn accepts_streamed_body(&self, _request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1503        true
1504    }
1505
1506    /// Requests this proxy forwards are sent as their body arrives (gRPC
1507    /// client streaming and bidirectional calls).
1508    fn answers_streamed_body(&self, request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1509        self.matches(request)
1510    }
1511}
1512
1513// ── HTTP/3 reverse proxy ──────────────────────────────────────────────────────
1514
1515/// Reverse proxy that forwards requests to `https://` backends over HTTP/3
1516/// (QUIC). Since 18.13.0; requires the `http3` feature (default).
1517///
1518/// A backend QUIC can't reach (UDP blocked, no handshake within the connect
1519/// timeout, a TLS failure) is sent the request over HTTPS on TCP instead,
1520/// and stays on TCP for five minutes; `rws_upstream_h3_fallbacks_total`
1521/// counts it under the proxy's [`name`](Self::name).
1522///
1523/// ```rust,no_run
1524/// use rust_web_server::app::App;
1525/// use rust_web_server::core::New;
1526/// use rust_web_server::proxy::H3ReverseProxy;
1527///
1528/// let proxy = H3ReverseProxy::new(["https://edge1.internal:443", "https://edge2.internal:443"])
1529///     .path_prefix("/api")
1530///     .tls_ca_file("/etc/rws/internal-ca.pem")
1531///     .unwrap();
1532/// let app = App::new().wrap(proxy);
1533/// ```
1534#[cfg(feature = "http3")]
1535pub struct H3ReverseProxy {
1536    inner: ReverseProxy,
1537    name: String,
1538    /// Backends on TCP until the time kept here.
1539    tcp_until: std::sync::Mutex<std::collections::HashMap<String, std::time::Instant>>,
1540}
1541
1542#[cfg(feature = "http3")]
1543impl H3ReverseProxy {
1544    /// A proxy distributing requests across `backends` (`https://host:port`,
1545    /// port 443 by default) in round-robin order. Certificates are checked
1546    /// against the WebPKI roots unless [`tls_ca_file`](Self::tls_ca_file)
1547    /// says otherwise. A backend that isn't `https://` is reached over
1548    /// HTTP/1.1 on TCP.
1549    pub fn new<I, S>(backends: I) -> Self
1550    where
1551        I: IntoIterator<Item = S>,
1552        S: AsRef<str>,
1553    {
1554        H3ReverseProxy { inner: ReverseProxy::new(backends), name: "h3-reverse-proxy".to_string(), tcp_until: std::sync::Mutex::default() }
1555    }
1556
1557    /// The `upstream` label of this proxy's fallback metric.
1558    pub fn name(mut self, name: impl Into<String>) -> Self {
1559        self.name = name.into();
1560        self
1561    }
1562
1563    /// Only proxy requests whose URI starts with `prefix`; pass others through.
1564    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
1565        self.inner = self.inner.path_prefix(prefix);
1566        self
1567    }
1568
1569    /// QUIC handshake (and TCP connect) timeout (default: 5 s).
1570    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
1571        self.inner = self.inner.connect_timeout_ms(ms);
1572        self
1573    }
1574
1575    /// Longest wait for the backend — the response head and each body
1576    /// chunk (default: 30 s).
1577    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
1578        self.inner = self.inner.read_timeout_ms(ms);
1579        self
1580    }
1581
1582    /// Trust the CA certificates in the PEM file `path` instead of the
1583    /// WebPKI roots.
1584    pub fn tls_ca_file(mut self, path: &str) -> Result<Self, String> {
1585        self.inner = self.inner.tls_ca_file(path)?;
1586        Ok(self)
1587    }
1588
1589    /// Present a client certificate to backends that require one.
1590    pub fn tls_client_cert(mut self, cert: &str, key: &str) -> Result<Self, String> {
1591        self.inner = self.inner.tls_client_cert(cert, key)?;
1592        Ok(self)
1593    }
1594
1595    fn matches(&self, request: &Request) -> bool {
1596        self.inner.path_prefix.as_ref().is_none_or(|prefix| request.request_uri.starts_with(prefix.as_str()))
1597    }
1598
1599    fn on_tcp(&self, key: &str) -> bool {
1600        let mut tcp_until = self.tcp_until.lock().unwrap_or_else(|e| e.into_inner());
1601        match tcp_until.get(key) {
1602            Some(until) if std::time::Instant::now() < *until => true,
1603            Some(_) => {
1604                tcp_until.remove(key);
1605                false
1606            }
1607            None => false,
1608        }
1609    }
1610
1611    /// One attempt at `backend`: HTTP/3 unless it's on TCP, TCP when QUIC
1612    /// can't connect.
1613    fn attempt(&self, backend: &Backend, request: &Request, connection: &crate::server::ConnectionInfo) -> Result<Response, String> {
1614        let key = format!("{}:{}", backend.host, backend.port);
1615        if backend.tls && !self.on_tcp(&key) {
1616            let config = match &self.inner.trust {
1617                Some(trust) => trust.client_config(),
1618                None => tls_client_config(),
1619            };
1620            let target = h3_upstream::Target {
1621                host: &backend.host,
1622                port: backend.port,
1623                tls: &config,
1624                connect_timeout: self.inner.connect_timeout,
1625                read_timeout: self.inner.read_timeout,
1626            };
1627            match h3_upstream::forward(request, Peer::of(connection), &target) {
1628                Err(h3_upstream::H3Error::Connect(e)) => {
1629                    self.tcp_until.lock().unwrap_or_else(|e| e.into_inner()).insert(key.clone(), std::time::Instant::now() + crate::proxy_config::H3_FALLBACK);
1630                    crate::proxy_config::upstream::observe_h3_fallback(&self.name);
1631                    eprintln!("{}: HTTP/3 to {key} failed ({e}); using TCP for {} s", self.name, crate::proxy_config::H3_FALLBACK.as_secs());
1632                }
1633                result => return result.map_err(|e| e.to_string()),
1634            }
1635        }
1636        forward_upstream_trusting(
1637            &self.inner.pool,
1638            request,
1639            Peer::of(connection),
1640            &backend.host,
1641            backend.port,
1642            backend.tls,
1643            self.inner.trust.as_deref(),
1644            self.inner.connect_timeout,
1645            self.inner.read_timeout,
1646        )
1647    }
1648}
1649
1650#[cfg(feature = "http3")]
1651impl crate::middleware::Middleware for H3ReverseProxy {
1652    fn handle(
1653        &self,
1654        request: &crate::request::Request,
1655        connection: &crate::server::ConnectionInfo,
1656        next: &dyn crate::application::Application,
1657    ) -> Result<crate::response::Response, String> {
1658        if !self.matches(request) {
1659            return next.execute(request, connection);
1660        }
1661        if self.inner.backends.is_empty() {
1662            return Ok(bad_gateway());
1663        }
1664        // A streamed request body can be sent once: no second backend then.
1665        let attempts = if matches!(request.body, crate::request::Body::Stream(_)) { 1 } else { self.inner.backends.len() };
1666        let n = self.inner.backends.len();
1667        let start = self.inner.counter.fetch_add(1, Ordering::Relaxed);
1668        for attempt in 0..attempts {
1669            if let Ok(response) = self.attempt(&self.inner.backends[(start + attempt) % n], request, connection) {
1670                return Ok(response);
1671            }
1672        }
1673        Ok(bad_gateway())
1674    }
1675
1676    fn accepts_streamed_body(&self, _request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1677        true
1678    }
1679
1680    /// Requests this proxy forwards are sent as their body arrives.
1681    fn answers_streamed_body(&self, request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1682        self.matches(request)
1683    }
1684}
1685
1686/// Forward `request` over HTTP/2 to `url` (`h2://`, `grpc://`, `h2s://`,
1687/// `grpcs://`, `https://` or `host:port`) — the config file's `grpc` action.
1688/// `tls` forces TLS for a bare `host:port` (`[[upstream]] tls = true`).
1689#[cfg(feature = "http2")]
1690pub(crate) fn forward_h2(
1691    request: &Request,
1692    client: Peer<'_>,
1693    url: &str,
1694    tls: bool,
1695    trust: Option<&UpstreamTls>,
1696    connect_timeout: Duration,
1697    read_timeout: Duration,
1698) -> Result<Response, String> {
1699    let mut backend = Backend::parse(url).ok_or_else(|| format!("invalid upstream address '{url}'"))?;
1700    backend.tls |= tls;
1701    let tls = backend.tls.then(|| match trust {
1702        Some(t) => t.h2_config(),
1703        None => h2_upstream::default_tls(),
1704    });
1705    h2_upstream::forward(request, client, &h2_upstream::Target { backend: &backend, tls, connect_timeout, read_timeout, authority: None })
1706}
1707
1708/// Forward over TLS to `ip:port` with `config`, `server_name` as SNI (and
1709/// the name `config`'s verifier checks), `host_header` as the `Host` — the
1710/// Gateway API router's BackendTLSPolicy backends. Connections are pooled
1711/// per `label` (one per distinct policy content).
1712#[cfg(feature = "gateway-api")]
1713#[allow(clippy::too_many_arguments)]
1714pub(crate) fn forward_tls_with_host(
1715    config: &Arc<rustls::ClientConfig>,
1716    label: &str,
1717    request: &Request,
1718    client: Peer<'_>,
1719    ip: &str,
1720    port: u16,
1721    server_name: &str,
1722    host_header: &str,
1723    connect_timeout: Duration,
1724    read_timeout: Duration,
1725) -> Result<Response, String> {
1726    let pool = tls_pool_for_ca(label);
1727    forward_via(pool, request, client, ip, port, host_header, read_timeout, || {
1728        let tcp = connect_tcp(ip, port, connect_timeout)?;
1729        let name = rustls::pki_types::ServerName::try_from(server_name.to_string()).map_err(|e| format!("invalid TLS server name '{server_name}': {e}"))?;
1730        let conn = rustls::ClientConnection::new(Arc::clone(config), name).map_err(|e| e.to_string())?;
1731        Ok(rustls::StreamOwned::new(conn, tcp))
1732    })
1733}
1734
1735/// Forward over cleartext HTTP/2 with prior knowledge (h2c) to `host:port`,
1736/// sending `authority` as `:authority` — the Gateway API router's
1737/// `kubernetes.io/h2c` backends.
1738#[cfg(feature = "gateway-api")]
1739pub(crate) fn forward_h2c_with_host(
1740    request: &Request,
1741    client: Peer<'_>,
1742    host: &str,
1743    port: u16,
1744    authority: &str,
1745    connect_timeout: Duration,
1746    read_timeout: Duration,
1747) -> Result<Response, String> {
1748    let backend = Backend { host: host.to_string(), port, tls: false };
1749    h2_upstream::forward(request, client, &h2_upstream::Target { backend: &backend, tls: None, connect_timeout, read_timeout, authority: Some(authority) })
1750}
1751
1752// ── gRPC proxy ────────────────────────────────────────────────────────────────
1753
1754/// gRPC reverse proxy middleware.
1755///
1756/// Recognises requests with `Content-Type: application/grpc*` and forwards them
1757/// to a backend over HTTP/2, leaving all other requests to the next layer.
1758///
1759/// Requires the `http2` Cargo feature.
1760///
1761/// # Example
1762///
1763/// ```rust,no_run
1764/// use rust_web_server::app::App;
1765/// use rust_web_server::core::New;
1766/// use rust_web_server::proxy::GrpcProxy;
1767///
1768/// let app = App::new()
1769///     .wrap(GrpcProxy::new(["grpc-service:50051"]));
1770/// ```
1771#[cfg(feature = "http2")]
1772pub struct GrpcProxy {
1773    inner: H2ReverseProxy,
1774}
1775
1776#[cfg(feature = "http2")]
1777impl GrpcProxy {
1778    /// Create a proxy distributing gRPC connections across `backends` in round-robin order.
1779    ///
1780    /// Each backend entry can be:
1781    /// - `"host:port"` — plain TCP (gRPC cleartext)
1782    /// - `"grpc://host:port"` — plain TCP (explicit scheme)
1783    /// - `"grpcs://host:port"` — TLS (gRPC over TLS; port defaults to 443)
1784    /// - `"https://host:port"` — TLS (same as `grpcs://`)
1785    ///
1786    /// TLS backends require the `http2` Cargo feature.
1787    pub fn new<I, S>(backends: I) -> Self
1788    where
1789        I: IntoIterator<Item = S>,
1790        S: AsRef<str>,
1791    {
1792        GrpcProxy { inner: H2ReverseProxy::new(backends) }
1793    }
1794
1795    /// Only proxy requests whose URI starts with `prefix`; pass others through.
1796    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
1797        self.inner = self.inner.path_prefix(prefix);
1798        self
1799    }
1800
1801    /// See [`H2ReverseProxy::connect_timeout_ms`].
1802    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
1803        self.inner = self.inner.connect_timeout_ms(ms);
1804        self
1805    }
1806
1807    /// See [`H2ReverseProxy::read_timeout_ms`]: a stream quiet for longer ends.
1808    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
1809        self.inner = self.inner.read_timeout_ms(ms);
1810        self
1811    }
1812
1813    /// Trust this CA for `grpcs://` backends. Since 18.3.0.
1814    pub fn tls_ca_file(mut self, path: &str) -> Result<Self, String> {
1815        self.inner = self.inner.tls_ca_file(path)?;
1816        Ok(self)
1817    }
1818
1819    /// Present a client certificate to `grpcs://` backends (mutual TLS).
1820    /// Since 18.3.0.
1821    pub fn tls_client_cert(mut self, cert: &str, key: &str) -> Result<Self, String> {
1822        self.inner = self.inner.tls_client_cert(cert, key)?;
1823        Ok(self)
1824    }
1825}
1826
1827#[cfg(feature = "http2")]
1828impl crate::middleware::Middleware for GrpcProxy {
1829    fn handle(
1830        &self,
1831        request: &crate::request::Request,
1832        connection: &crate::server::ConnectionInfo,
1833        next: &dyn crate::application::Application,
1834    ) -> Result<crate::response::Response, String> {
1835        if is_grpc(request) {
1836            self.inner.handle(request, connection, next)
1837        } else {
1838            next.execute(request, connection)
1839        }
1840    }
1841
1842    fn accepts_streamed_body(&self, _request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1843        true
1844    }
1845
1846    fn answers_streamed_body(&self, request: &Request, _connection: &crate::server::ConnectionInfo) -> bool {
1847        is_grpc(request) && self.inner.matches(request)
1848    }
1849}
1850
1851#[cfg(feature = "http2")]
1852fn is_grpc(request: &Request) -> bool {
1853    request.header(crate::header::Header::_CONTENT_TYPE).is_some_and(|h| h.value.starts_with("application/grpc"))
1854}
1855
1856// ── Backend::parse unit tests ─────────────────────────────────────────────────
1857
1858#[cfg(test)]
1859mod backend_parse_tests {
1860    use super::Backend;
1861
1862    fn parse(url: &str) -> Option<(String, u16, bool)> {
1863        Backend::parse(url).map(|b| (b.host, b.port, b.tls))
1864    }
1865
1866    #[test]
1867    fn bare_host_port() {
1868        assert_eq!(Some(("api.example.com".into(), 8080, false)), parse("api.example.com:8080"));
1869    }
1870
1871    #[test]
1872    fn http_scheme() {
1873        assert_eq!(Some(("backend".into(), 3000, false)), parse("http://backend:3000"));
1874    }
1875
1876    #[test]
1877    fn h2_scheme_plain() {
1878        assert_eq!(Some(("svc".into(), 50051, false)), parse("h2://svc:50051"));
1879    }
1880
1881    #[test]
1882    fn grpc_scheme_plain() {
1883        assert_eq!(Some(("svc".into(), 50051, false)), parse("grpc://svc:50051"));
1884    }
1885
1886    #[test]
1887    fn https_scheme_sets_tls_and_default_port() {
1888        assert_eq!(Some(("api.example.com".into(), 443, true)), parse("https://api.example.com"));
1889    }
1890
1891    #[test]
1892    fn https_scheme_explicit_port() {
1893        assert_eq!(Some(("api.example.com".into(), 8443, true)), parse("https://api.example.com:8443"));
1894    }
1895
1896    #[test]
1897    fn h2s_scheme_sets_tls() {
1898        assert_eq!(Some(("svc".into(), 443, true)), parse("h2s://svc"));
1899    }
1900
1901    #[test]
1902    fn h2s_scheme_explicit_port() {
1903        assert_eq!(Some(("svc".into(), 8443, true)), parse("h2s://svc:8443"));
1904    }
1905
1906    #[test]
1907    fn grpcs_scheme_sets_tls() {
1908        assert_eq!(Some(("grpc-svc".into(), 443, true)), parse("grpcs://grpc-svc"));
1909    }
1910
1911    #[test]
1912    fn grpcs_scheme_explicit_port() {
1913        assert_eq!(Some(("grpc-svc".into(), 50052, true)), parse("grpcs://grpc-svc:50052"));
1914    }
1915
1916    #[test]
1917    fn empty_host_returns_none() {
1918        assert_eq!(None, parse("https://"));
1919    }
1920
1921    #[test]
1922    fn bare_host_no_port_defaults_to_80() {
1923        assert_eq!(Some(("myhost".into(), 80, false)), parse("myhost"));
1924    }
1925}