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
30#[cfg(test)]
31mod tests;
32
33use std::io::{Read, Write};
34use std::net::{TcpStream, ToSocketAddrs};
35use std::sync::atomic::{AtomicUsize, Ordering};
36use std::sync::Arc;
37use std::time::Duration;
38
39pub use pool::{ConnPool, Pool};
40
41use crate::application::Application;
42use crate::core::New;
43use crate::middleware::Middleware;
44use crate::mime_type::MimeType;
45use crate::range::Range;
46use crate::request::Request;
47use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
48use crate::server::ConnectionInfo;
49
50// Hop-by-hop headers that must not be forwarded (RFC 7230 §6.1)
51const HOP_BY_HOP: &[&str] = &[
52    "connection",
53    "keep-alive",
54    "proxy-authenticate",
55    "proxy-authorization",
56    "te",
57    "trailers",
58    "transfer-encoding",
59    "upgrade",
60];
61
62/// Load balancing strategy used by [`ReverseProxy`].
63pub enum LoadBalancing {
64    /// Distribute requests across backends in a cyclic order.
65    RoundRobin,
66}
67
68/// Reverse proxy middleware.
69///
70/// Forwards incoming requests to one of the configured backends over HTTP/1.1.
71/// On connection failure the next backend in the list is tried; when all
72/// backends have failed the middleware returns `502 Bad Gateway`.
73///
74/// Hop-by-hop headers are stripped before forwarding.  `X-Forwarded-For` and
75/// `Via` are added to every forwarded request.
76///
77/// Idle connections are pooled and reused across requests (up to
78/// [`ConnPool::new_default`] limits: 8 idle per backend, 60-second timeout).
79/// This eliminates per-request TCP handshake overhead and ephemeral-port
80/// exhaustion.  Use [`ReverseProxy::with_pool`] to share a pool across
81/// multiple proxy instances or to tune pool parameters.
82pub struct ReverseProxy {
83    backends: Vec<Backend>,
84    path_prefix: Option<String>,
85    connect_timeout: Duration,
86    read_timeout: Duration,
87    counter: AtomicUsize,
88    pool: Arc<ConnPool>,
89    breaker: Option<Arc<dyn crate::circuit_breaker::Breaker>>,
90}
91
92impl ReverseProxy {
93    /// Create a proxy that distributes requests across `backends` in
94    /// round-robin order.  Each entry must be `"http://host:port"` or
95    /// `"host:port"` (port defaults to 80).
96    pub fn new<I, S>(backends: I) -> Self
97    where
98        I: IntoIterator<Item = S>,
99        S: AsRef<str>,
100    {
101        Self {
102            backends: backends
103                .into_iter()
104                .filter_map(|u| Backend::parse(u.as_ref()))
105                .collect(),
106            path_prefix: None,
107            connect_timeout: Duration::from_secs(5),
108            read_timeout: Duration::from_secs(30),
109            counter: AtomicUsize::new(0),
110            pool: Arc::new(ConnPool::new(POOL_MAX_IDLE, Duration::from_secs(60))),
111            breaker: None,
112        }
113    }
114
115    /// Wire a [`crate::circuit_breaker::CircuitBreaker`] or
116    /// [`crate::circuit_breaker::RedisCircuitBreaker`] into this proxy:
117    /// before dialing a backend, its breaker state is checked (an `Open`
118    /// backend is skipped with no TCP attempt, exactly like a caller
119    /// manually checking `is_available` around each proxied call); after a
120    /// dial, `record_success`/`record_failure` is called automatically.
121    /// No breaker is wired by default — existing `ReverseProxy` callers see
122    /// no behavior change unless they opt in here.
123    ///
124    /// ```rust,no_run
125    /// use std::sync::Arc;
126    /// use rust_web_server::circuit_breaker::global as global_breaker;
127    /// use rust_web_server::proxy::ReverseProxy;
128    ///
129    /// let proxy = ReverseProxy::new(["http://backend-1:8080", "http://backend-2:8080"])
130    ///     .with_circuit_breaker(Arc::new(global_breaker()));
131    /// ```
132    pub fn with_circuit_breaker(mut self, breaker: Arc<dyn crate::circuit_breaker::Breaker>) -> Self {
133        self.breaker = Some(breaker);
134        self
135    }
136
137    /// Only proxy requests whose URI starts with `prefix`.
138    ///
139    /// Other requests are passed through to the next layer in the middleware
140    /// chain (or the inner application).
141    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
142        self.path_prefix = Some(prefix.into());
143        self
144    }
145
146    /// Override the load balancing strategy (currently only `RoundRobin`).
147    pub fn strategy(self, _strategy: LoadBalancing) -> Self {
148        self
149    }
150
151    /// Override the TCP connect timeout (default: 5 000 ms).
152    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
153        self.connect_timeout = Duration::from_millis(ms);
154        self
155    }
156
157    /// Override the response read timeout (default: 30 000 ms).
158    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
159        self.read_timeout = Duration::from_millis(ms);
160        self
161    }
162
163    /// Attach a shared connection pool.
164    ///
165    /// Useful for sharing one pool across multiple `ReverseProxy` instances
166    /// or for tuning pool parameters (capacity, idle timeout).
167    pub fn with_pool(mut self, pool: Arc<ConnPool>) -> Self {
168        self.pool = pool;
169        self
170    }
171
172    /// Set the maximum number of idle connections per backend (default: 8).
173    pub fn max_idle_conns(mut self, n: usize) -> Self {
174        self.pool = Arc::new(ConnPool::new(n, Duration::from_secs(60)));
175        self
176    }
177
178    fn proxy(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
179        if self.backends.is_empty() {
180            return Err("no backends configured".to_string());
181        }
182        let n = self.backends.len();
183        let start = self.counter.fetch_add(1, Ordering::Relaxed);
184        let mut last_err = "all backends failed".to_string();
185        for attempt in 0..n {
186            let idx = (start + attempt) % n;
187            let backend = &self.backends[idx];
188            let key = format!("{}:{}", backend.host, backend.port);
189
190            if let Some(breaker) = &self.breaker {
191                if !breaker.is_available(&key) {
192                    last_err = format!("circuit open for {}", key);
193                    continue;
194                }
195            }
196
197            match self.try_backend(request, connection, backend) {
198                Ok(resp) => {
199                    if let Some(breaker) = &self.breaker {
200                        breaker.record_success(&key);
201                    }
202                    return Ok(resp);
203                }
204                Err(e) => {
205                    if let Some(breaker) = &self.breaker {
206                        breaker.record_failure(&key);
207                    }
208                    last_err = e;
209                }
210            }
211        }
212        Err(last_err)
213    }
214
215    fn try_backend(
216        &self,
217        request: &Request,
218        connection: &ConnectionInfo,
219        backend: &Backend,
220    ) -> Result<Response, String> {
221        forward_upstream(
222            &self.pool,
223            request,
224            &connection.client.ip,
225            &backend.host,
226            backend.port,
227            backend.tls,
228            self.connect_timeout,
229            self.read_timeout,
230        )
231    }
232}
233
234impl Middleware for ReverseProxy {
235    fn handle(
236        &self,
237        request: &Request,
238        connection: &ConnectionInfo,
239        next: &dyn Application,
240    ) -> Result<Response, String> {
241        if let Some(prefix) = &self.path_prefix {
242            if !request.request_uri.starts_with(prefix.as_str()) {
243                return next.execute(request, connection);
244            }
245        }
246        match self.proxy(request, connection) {
247            Ok(resp) => Ok(resp),
248            Err(_) => Ok(bad_gateway()),
249        }
250    }
251}
252
253// ── helpers ───────────────────────────────────────────────────────────────────
254
255pub(crate) fn build_request(
256    request: &Request,
257    backend_host: &str,
258    client_ip: &str,
259    keep_alive: bool,
260) -> Vec<u8> {
261    let mut out: Vec<u8> = Vec::new();
262    let _ = write!(
263        out,
264        "{} {} HTTP/1.1\r\nHost: {}\r\n",
265        request.method, request.request_uri, backend_host
266    );
267    for h in &request.headers {
268        let lower = h.name.to_lowercase();
269        // An empty name would be sent as a malformed ": value" line.
270        if lower.is_empty() || HOP_BY_HOP.contains(&lower.as_str()) || lower == "host" {
271            continue;
272        }
273        let _ = write!(out, "{}: {}\r\n", h.name, h.value);
274    }
275    let _ = write!(out, "X-Forwarded-For: {}\r\n", client_ip);
276    let _ = write!(out, "Via: 1.1 rws\r\n");
277    if keep_alive {
278        let _ = write!(out, "Connection: keep-alive\r\n");
279    } else {
280        let _ = write!(out, "Connection: close\r\n");
281    }
282    if !request.body.is_empty() {
283        let _ = write!(out, "Content-Length: {}\r\n", request.body.len());
284    }
285    let _ = write!(out, "\r\n");
286    out.extend_from_slice(&request.body);
287    out
288}
289
290/// Decode HTTP/1.1 chunked transfer-encoding from `stream`.
291///
292/// `buf[header_end..]` may already contain some body bytes that arrived in
293/// the same read as the headers.  Returns the fully decoded body.
294fn decode_chunked<S: Read>(
295    stream: &mut S,
296    buf: &[u8],
297    header_end: usize,
298    tmp: &mut [u8],
299) -> Result<Vec<u8>, String> {
300    // Seed `raw` with any body bytes already buffered alongside the headers.
301    let mut raw: Vec<u8> = buf[header_end..].to_vec();
302    let mut decoded: Vec<u8> = Vec::new();
303
304    loop {
305        // Wait until we have at least one complete chunk-size line (ends with \r\n).
306        let crlf = loop {
307            if let Some(p) = raw.windows(2).position(|w| w == b"\r\n") {
308                break p;
309            }
310            let n = stream.read(tmp).map_err(|e| e.to_string())?;
311            if n == 0 {
312                return Err("chunked: premature EOF reading chunk size".to_string());
313            }
314            raw.extend_from_slice(&tmp[..n]);
315        };
316
317        // Chunk size is hex, optionally followed by chunk-extensions (";…").
318        let size_line = std::str::from_utf8(&raw[..crlf])
319            .map_err(|_| "chunked: non-UTF-8 chunk size line".to_string())?;
320        let size_str = size_line.split(';').next().unwrap_or("").trim();
321        let chunk_size = usize::from_str_radix(size_str, 16)
322            .map_err(|_| format!("chunked: invalid chunk size '{}'", size_str))?;
323        raw.drain(..crlf + 2); // consume "<size>\r\n"
324
325        if chunk_size == 0 {
326            // Last chunk: skip trailer fields up to the empty line that ends
327            // the message. Leaving any of it unread would corrupt the next
328            // response on a pooled connection.
329            loop {
330                let line_end = loop {
331                    if let Some(p) = raw.windows(2).position(|w| w == b"\r\n") {
332                        break p;
333                    }
334                    let n = stream.read(tmp).map_err(|e| e.to_string())?;
335                    if n == 0 {
336                        return Err("chunked: premature EOF in trailer".to_string());
337                    }
338                    raw.extend_from_slice(&tmp[..n]);
339                };
340                raw.drain(..line_end + 2);
341                if line_end == 0 {
342                    break;
343                }
344            }
345            break;
346        }
347
348        // Read chunk data + trailing CRLF.
349        while raw.len() < chunk_size + 2 {
350            let n = stream.read(tmp).map_err(|e| e.to_string())?;
351            if n == 0 {
352                return Err("chunked: premature EOF reading chunk body".to_string());
353            }
354            raw.extend_from_slice(&tmp[..n]);
355        }
356        decoded.extend_from_slice(&raw[..chunk_size]);
357        raw.drain(..chunk_size + 2); // consume "<data>\r\n"
358    }
359
360    Ok(decoded)
361}
362
363/// Rewrite `buf` in-place: strip `Transfer-Encoding`, add `Content-Length`,
364/// replace the old (undecoded) body with `decoded`.
365fn rewrite_as_content_length(buf: &mut Vec<u8>, header_end: usize, decoded: &[u8]) {
366    let header_str = std::str::from_utf8(&buf[..header_end]).unwrap_or("").to_string();
367    buf.clear();
368    for line in header_str.lines() {
369        if line.to_ascii_lowercase().starts_with("transfer-encoding:") || line.is_empty() {
370            continue;
371        }
372        buf.extend_from_slice(line.as_bytes());
373        buf.extend_from_slice(b"\r\n");
374    }
375    let _ = write!(buf, "Content-Length: {}\r\n\r\n", decoded.len());
376    buf.extend_from_slice(decoded);
377}
378
379/// Headers rws regenerates for a buffered body, plus hop-by-hop headers
380/// (RFC 9110 §7.6.1) that must not be forwarded.
381const REGENERATED_OR_HOP_BY_HOP: &[&str] = &[
382    "content-length", "transfer-encoding", "connection", "keep-alive",
383    "proxy-connection", "te", "trailer", "upgrade",
384];
385
386/// Make a buffered upstream response safe to re-serialize. The body is
387/// already held in `content_range_list`, from which `Response::generate`
388/// (and the HTTP/2 / HTTP/3 writers) emit `Content-Type` and
389/// `Content-Length` themselves, so the upstream's own copies would be
390/// duplicated — and a duplicate `content-length` is a protocol error that
391/// HTTP/2 clients reject outright. The upstream's `Content-Type` is moved
392/// onto the body instead; the status line is re-emitted as HTTP/1.1
393/// regardless of the backend's version.
394pub(crate) fn normalize_upstream_response(mut response: Response) -> Response {
395    response.http_version = crate::http::VERSION.http_1_1.to_string();
396    let content_type = response
397        .headers
398        .iter()
399        .find(|h| h.name.eq_ignore_ascii_case(crate::header::Header::_CONTENT_TYPE))
400        .map(|h| h.value.clone());
401    let has_body = !response.content_range_list.is_empty();
402    if let (Some(content_type), [range]) = (&content_type, response.content_range_list.as_mut_slice()) {
403        range.content_type = content_type.clone();
404    }
405    response.headers.retain(|h| {
406        let name = h.name.to_ascii_lowercase();
407        if name == "content-type" {
408            // Keep it only when there's no body to carry it (e.g. HEAD, 304).
409            return !has_body;
410        }
411        !REGENERATED_OR_HOP_BY_HOP.contains(&name.as_str())
412    });
413    response
414}
415
416fn bad_gateway() -> Response {
417    let cr = Range::get_content_range(
418        b"502 Bad Gateway".to_vec(),
419        MimeType::TEXT_PLAIN.to_string(),
420    );
421    let mut r = Response::new();
422    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
423    r.reason_phrase = STATUS_CODE_REASON_PHRASE
424        .n502_bad_gateway
425        .reason_phrase
426        .to_string();
427    r.content_range_list = vec![cr];
428    r
429}
430
431// ── Streaming proxy helpers ───────────────────────────────────────────────────
432
433/// Responses larger than this threshold are streamed instead of buffered.
434const STREAM_THRESHOLD: usize = 1024 * 1024; // 1 MB
435
436/// A `Read` implementation that drains a prefix buffer before reading from the
437/// inner stream. Used to replay body bytes that arrived with the HTTP headers.
438pub(crate) struct ConcatReader<R: Read + Send> {
439    prefix: Vec<u8>,
440    prefix_pos: usize,
441    inner: R,
442}
443
444impl<R: Read + Send> ConcatReader<R> {
445    fn new(prefix: Vec<u8>, inner: R) -> Self {
446        ConcatReader { prefix, prefix_pos: 0, inner }
447    }
448}
449
450impl<R: Read + Send> Read for ConcatReader<R> {
451    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
452        if self.prefix_pos < self.prefix.len() {
453            let avail = &self.prefix[self.prefix_pos..];
454            let n = buf.len().min(avail.len());
455            buf[..n].copy_from_slice(&avail[..n]);
456            self.prefix_pos += n;
457            return Ok(n);
458        }
459        self.inner.read(buf)
460    }
461}
462
463
464/// Idle backend connections kept per `host:port`. 64 matches common nginx
465/// `keepalive` settings and the benchmark's concurrency; fewer meant most
466/// requests at 64-way concurrency still paid a TCP connect.
467pub(crate) const POOL_MAX_IDLE: usize = 64;
468
469/// The process-wide backend connection pool used by config-driven proxy
470/// routes (`proxy_config::DynamicProxy`), keyed by `host:port`.
471pub(crate) fn shared_pool() -> &'static ConnPool {
472    static POOL: std::sync::OnceLock<ConnPool> = std::sync::OnceLock::new();
473    POOL.get_or_init(|| ConnPool::new(POOL_MAX_IDLE, Duration::from_secs(60)))
474}
475
476/// A backend connection the pooled forwarder drives: plain TCP, or TLS
477/// over TCP for `https://` backends.
478pub(crate) trait UpstreamIo: Read + Write + Send + 'static {
479    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()>;
480}
481
482impl UpstreamIo for TcpStream {
483    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()> {
484        self.set_read_timeout(Some(read))?;
485        self.set_write_timeout(Some(write))
486    }
487}
488
489#[cfg(any(feature = "http-client", feature = "http2"))]
490pub(crate) type TlsStream = rustls::StreamOwned<rustls::ClientConnection, TcpStream>;
491
492#[cfg(any(feature = "http-client", feature = "http2"))]
493impl UpstreamIo for TlsStream {
494    fn set_io_timeouts(&self, read: Duration, write: Duration) -> std::io::Result<()> {
495        self.sock.set_io_timeouts(read, write)
496    }
497}
498
499/// Forward `request` to an HTTP or HTTPS backend at `host:port`, over a
500/// pooled keep-alive connection either way: plain backends use `plain_pool`,
501/// TLS backends the process-wide TLS pool (one handshake per connection, not
502/// per request). The single entry point for `ReverseProxy`, config-driven
503/// routes, `canary` and `ingress`.
504pub(crate) fn forward_upstream(
505    plain_pool: &ConnPool,
506    request: &Request,
507    client_ip: &str,
508    host: &str,
509    port: u16,
510    tls: bool,
511    connect_timeout: Duration,
512    read_timeout: Duration,
513) -> Result<Response, String> {
514    if tls {
515        #[cfg(any(feature = "http-client", feature = "http2"))]
516        return forward_via(shared_tls_pool(), request, client_ip, host, port, read_timeout, || {
517            connect_tls(host, port, connect_timeout)
518        });
519        #[cfg(not(any(feature = "http-client", feature = "http2")))]
520        return Err("HTTPS backends need the http2 or http-client feature, which this build doesn't include".to_string());
521    }
522    forward_pooled(plain_pool, request, client_ip, host, port, connect_timeout, read_timeout)
523}
524
525/// Forward `request` to `host:port` over plain HTTP/1.1, reusing an idle
526/// connection from `pool` when there is one and returning the connection to
527/// `pool` after a complete, reusable response.
528pub(crate) fn forward_pooled(
529    pool: &ConnPool,
530    request: &Request,
531    client_ip: &str,
532    host: &str,
533    port: u16,
534    connect_timeout: Duration,
535    read_timeout: Duration,
536) -> Result<Response, String> {
537    forward_via(pool, request, client_ip, host, port, read_timeout, || connect_tcp(host, port, connect_timeout))
538}
539
540/// The pooled request/response cycle, for any connection type.
541///
542/// A backend may close an idle keep-alive connection at any moment, so a
543/// pooled connection that fails before *any* response byte arrives is
544/// retried once on a fresh connection — but only for idempotent methods
545/// (RFC 9110 §9.2.2): the request may already have reached the backend.
546fn forward_via<S: UpstreamIo>(
547    pool: &Pool<S>,
548    request: &Request,
549    client_ip: &str,
550    host: &str,
551    port: u16,
552    read_timeout: Duration,
553    connect: impl Fn() -> Result<S, String>,
554) -> Result<Response, String> {
555    let key = format!("{}:{}", host, port);
556    if let Some(stream) = pool.acquire(&key) {
557        match exchange(pool, &key, stream, request, host, client_ip, read_timeout) {
558            Exchange::Done(response) => return Ok(response),
559            Exchange::NoResponse(_) if is_idempotent(&request.method) => {} // stale: retry fresh
560            Exchange::NoResponse(e) | Exchange::Failed(e) => return Err(e),
561        }
562    }
563    let stream = connect()?;
564    match exchange(pool, &key, stream, request, host, client_ip, read_timeout) {
565        Exchange::Done(response) => Ok(response),
566        Exchange::NoResponse(e) | Exchange::Failed(e) => Err(e),
567    }
568}
569
570/// Connect to the first address of `host:port` that accepts. `localhost`
571/// resolves to `::1` before `127.0.0.1` on many systems; a backend listening
572/// on IPv4 only must still be reachable by name.
573fn connect_tcp(host: &str, port: u16, connect_timeout: Duration) -> Result<TcpStream, String> {
574    let key = format!("{}:{}", host, port);
575    let addrs: Vec<_> = key
576        .to_socket_addrs()
577        .map_err(|e| format!("DNS lookup for {} failed: {}", key, e))?
578        .collect();
579    let mut last_error = format!("no address resolved for {}", key);
580    for addr in addrs {
581        match TcpStream::connect_timeout(&addr, connect_timeout) {
582            Ok(stream) => {
583                let _ = stream.set_nodelay(true);
584                return Ok(stream);
585            }
586            Err(e) => last_error = format!("connect to {} ({}) failed: {}", key, addr, e),
587        }
588    }
589    Err(last_error)
590}
591
592/// The process-wide pool of TLS connections to `https://` backends.
593#[cfg(any(feature = "http-client", feature = "http2"))]
594fn shared_tls_pool() -> &'static Pool<TlsStream> {
595    static POOL: std::sync::OnceLock<Pool<TlsStream>> = std::sync::OnceLock::new();
596    POOL.get_or_init(|| Pool::new(POOL_MAX_IDLE, Duration::from_secs(60)))
597}
598
599/// TLS client settings for backends: the Mozilla root store, no client
600/// certificate. Built once — parsing the root store per request was a large
601/// part of the per-request cost of HTTPS upstreams.
602#[cfg(any(feature = "http-client", feature = "http2"))]
603fn tls_client_config() -> Arc<rustls::ClientConfig> {
604    static CONFIG: std::sync::OnceLock<Arc<rustls::ClientConfig>> = std::sync::OnceLock::new();
605    Arc::clone(CONFIG.get_or_init(|| {
606        let roots = rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
607        Arc::new(crate::tls_client_builder().with_root_certificates(roots).with_no_client_auth())
608    }))
609}
610
611/// Connect to an `https://` backend. The handshake itself runs on the first
612/// write, bounded by the exchange's read/write timeouts.
613#[cfg(any(feature = "http-client", feature = "http2"))]
614fn connect_tls(host: &str, port: u16, connect_timeout: Duration) -> Result<TlsStream, String> {
615    connect_tls_with(tls_client_config(), host, port, connect_timeout)
616}
617
618#[cfg(any(feature = "http-client", feature = "http2"))]
619fn connect_tls_with(config: Arc<rustls::ClientConfig>, host: &str, port: u16, connect_timeout: Duration) -> Result<TlsStream, String> {
620    let tcp = connect_tcp(host, port, connect_timeout)?;
621    let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
622        .map_err(|e| format!("invalid upstream hostname '{}': {}", host, e))?;
623    let conn = rustls::ClientConnection::new(config, server_name).map_err(|e| e.to_string())?;
624    Ok(rustls::StreamOwned::new(conn, tcp))
625}
626
627fn is_idempotent(method: &str) -> bool {
628    matches!(method, "GET" | "HEAD" | "OPTIONS" | "TRACE" | "PUT" | "DELETE")
629}
630
631enum Exchange {
632    Done(Response),
633    /// Failed before any response byte (write error, or closed/errored on the first read).
634    NoResponse(String),
635    Failed(String),
636}
637
638/// One request/response on `stream`. A fully read, keep-alive response puts
639/// the connection back in `pool`; a streamed response keeps it until done.
640fn exchange<S: UpstreamIo>(
641    pool: &Pool<S>,
642    key: &str,
643    mut stream: S,
644    request: &Request,
645    host: &str,
646    client_ip: &str,
647    read_timeout: Duration,
648) -> Exchange {
649    if let Err(e) = stream.set_io_timeouts(read_timeout, Duration::from_secs(10)) {
650        return Exchange::Failed(e.to_string());
651    }
652    // keep_alive = true: ask the backend to hold the connection open.
653    let req_bytes = build_request(request, host, client_ip, true);
654    if let Err(e) = stream.write_all(&req_bytes) {
655        return Exchange::NoResponse(format!("write to backend failed: {}", e));
656    }
657
658    let mut tmp = [0u8; 4096];
659    let (header_bytes, body_prefix) = match read_final_response_head(&mut stream, &mut tmp) {
660        Ok(head) => head,
661        Err(HeadError::NothingReceived(e)) => return Exchange::NoResponse(e),
662        Err(HeadError::Partial(e)) => return Exchange::Failed(e),
663    };
664    let header_lower = std::str::from_utf8(&header_bytes).unwrap_or("").to_ascii_lowercase();
665
666    if response_has_no_body(&request.method, &header_lower) {
667        if !header_lower.lines().any(|l| l.starts_with("connection:") && l.contains("close")) {
668            pool.release(key, stream);
669        }
670        return match Response::parse(&header_bytes) {
671            Ok(response) => Exchange::Done(normalize_upstream_response(response)),
672            Err(e) => Exchange::Failed(e),
673        };
674    }
675
676    if should_stream_response(&header_lower) {
677        // Streaming path — pipe bytes straight to the client; the connection
678        // can't be reused while the body is in flight.
679        return match parse_status_and_headers(&header_bytes) {
680            Ok(mut response) => {
681                response.stream_pipe = Some(Box::new(ConcatReader::new(body_prefix, stream)));
682                Exchange::Done(response)
683            }
684            Err(e) => Exchange::Failed(e),
685        };
686    }
687    // Buffered path — read the full body, then return the connection to the pool.
688    match read_response_from_partial(&mut stream, header_bytes, body_prefix, &mut tmp) {
689        Ok((resp_bytes, reusable)) => {
690            if reusable {
691                pool.release(key, stream);
692            }
693            match Response::parse(&resp_bytes) {
694                Ok(response) => Exchange::Done(normalize_upstream_response(response)),
695                Err(e) => Exchange::Failed(e),
696            }
697        }
698        Err(e) => Exchange::Failed(e),
699    }
700}
701
702/// Read exactly the HTTP response headers (up to and including `\r\n\r\n`).
703///
704/// Returns `(header_bytes, body_prefix)` where `body_prefix` contains any
705/// body bytes that arrived in the same TCP segment as the headers.
706#[cfg(test)]
707fn read_headers_only(stream: &mut TcpStream, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
708    read_response_head(stream, tmp).map_err(|e| match e {
709        HeadError::NothingReceived(msg) | HeadError::Partial(msg) => msg,
710    })
711}
712
713/// Why reading a response head failed: before any byte arrived (the request
714/// may never have been processed — e.g. a pooled connection the backend had
715/// already closed) or part-way through.
716enum HeadError {
717    NothingReceived(String),
718    Partial(String),
719}
720
721fn read_response_head<S: Read>(stream: &mut S, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
722    read_response_head_from(stream, tmp, Vec::with_capacity(4096), false)
723}
724
725/// The final response head: interim `1xx` heads (`100 Continue`,
726/// `103 Early Hints`, …) are read past (RFC 9110 §15.2). Returning one as
727/// the response left the real response in the connection, and once pooled
728/// the next request — possibly another client's — received it.
729fn read_final_response_head<S: Read>(stream: &mut S, tmp: &mut [u8]) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
730    let (mut head, mut rest) = read_response_head(stream, tmp)?;
731    let mut interim = 0;
732    while is_interim_response(&head) {
733        interim += 1;
734        if interim > MAX_INTERIM_RESPONSES {
735            return Err(HeadError::Partial("too many interim (1xx) responses from the backend".to_string()));
736        }
737        (head, rest) = read_response_head_from(stream, tmp, rest, true)?;
738    }
739    Ok((head, rest))
740}
741
742/// Read until a complete head is in `buf` (which may already hold bytes
743/// that followed a previous head). `received` — bytes already came from this
744/// response, so a failure is never "nothing received".
745fn read_response_head_from<S: Read>(
746    stream: &mut S,
747    tmp: &mut [u8],
748    mut buf: Vec<u8>,
749    received: bool,
750) -> Result<(Vec<u8>, Vec<u8>), HeadError> {
751    loop {
752        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
753            let body_prefix = buf[pos + 4..].to_vec();
754            buf.truncate(pos + 4);
755            return Ok((buf, body_prefix));
756        }
757        let nothing = buf.is_empty() && !received;
758        let n = match stream.read(tmp) {
759            Ok(n) => n,
760            Err(e) if nothing => return Err(HeadError::NothingReceived(e.to_string())),
761            Err(e) => return Err(HeadError::Partial(e.to_string())),
762        };
763        if n == 0 {
764            let msg = "backend closed connection before headers were complete".to_string();
765            return Err(if nothing { HeadError::NothingReceived(msg) } else { HeadError::Partial(msg) });
766        }
767        buf.extend_from_slice(&tmp[..n]);
768    }
769}
770
771/// Interim responses tolerated before the final one.
772pub(crate) const MAX_INTERIM_RESPONSES: usize = 16;
773
774/// An interim (1xx) response head other than `101 Switching Protocols`
775/// (which ends the HTTP exchange): the final response follows it.
776pub(crate) fn is_interim_response(head: &[u8]) -> bool {
777    let line_end = head.iter().position(|b| *b == b'\r' || *b == b'\n').unwrap_or(head.len());
778    let status = head[..line_end].split(|b| *b == b' ').nth(1).unwrap_or(&[]);
779    status.len() == 3 && status.iter().all(u8::is_ascii_digit) && status[0] == b'1' && status != b"101"
780}
781
782/// RFC 9112 §6.3: a response to HEAD, and any 1xx, 204 or 304 response, ends
783/// with its head — whatever `Content-Length` says (for HEAD it describes the
784/// GET body). Waiting for that body hung until the read timeout (a 502).
785pub(crate) fn response_has_no_body(method: &str, header_lower: &str) -> bool {
786    let status = header_lower.split_whitespace().nth(1).unwrap_or("");
787    method.eq_ignore_ascii_case("HEAD") || status.starts_with('1') || status == "204" || status == "304"
788}
789
790/// Returns `true` when the response should be streamed rather than buffered.
791///
792/// Streams when any of the following hold:
793/// - `Content-Type: text/event-stream` — SSE
794/// - `Transfer-Encoding: chunked` — AI token streams, etc.
795/// - `Content-Length` exceeds 1 MB — large file downloads
796pub(crate) fn should_stream_response(header_lower: &str) -> bool {
797    let is_sse = header_lower.lines().any(|l| {
798        l.starts_with("content-type:") && l.contains("text/event-stream")
799    });
800    let is_chunked = header_lower.lines().any(|l| {
801        l.starts_with("transfer-encoding:") && l.contains("chunked")
802    });
803    let content_length: Option<usize> = header_lower.lines().find_map(|l| {
804        l.strip_prefix("content-length:")?.trim().parse().ok()
805    });
806    let is_large = content_length.map_or(false, |n| n > STREAM_THRESHOLD);
807    is_sse || is_chunked || is_large
808}
809
810/// Parse the status line and headers from raw header bytes (ending at `\r\n\r\n`).
811fn parse_status_and_headers(header_bytes: &[u8]) -> Result<Response, String> {
812    let s = std::str::from_utf8(header_bytes)
813        .map_err(|e| format!("non-UTF-8 response headers: {}", e))?;
814    let mut lines = s.lines();
815    let status_line = lines.next().ok_or("empty backend response")?;
816    let mut parts = status_line.splitn(3, ' ');
817    let http_version = parts.next().unwrap_or("HTTP/1.1").to_string();
818    let status_code: i16 = parts
819        .next()
820        .unwrap_or("502")
821        .parse()
822        .map_err(|_| format!("invalid status code in '{}'", status_line))?;
823    let reason_phrase = parts.next().unwrap_or("").trim_end_matches('\r').to_string();
824    let mut headers = Vec::new();
825    for line in lines {
826        let line = line.trim_end_matches('\r');
827        if line.is_empty() { break; }
828        if let Some(colon) = line.find(':') {
829            headers.push(crate::header::Header {
830                name: line[..colon].trim().to_string(),
831                value: line[colon + 1..].trim().to_string(),
832            });
833        }
834    }
835    Ok(Response {
836        http_version,
837        status_code,
838        reason_phrase,
839        headers,
840        content_range_list: vec![],
841        stream_file: None,
842        stream_pipe: None,
843    })
844}
845
846/// Read the remaining body after headers have already been read.
847///
848/// `header_bytes` ends with `\r\n\r\n`; `body_prefix` holds any body bytes
849/// that arrived in the same TCP read.  Handles all three body mechanisms
850/// (chunked, content-length, read-to-EOF).  Returns `(full_response_bytes, can_reuse)`.
851fn read_response_from_partial<S: Read>(
852    stream: &mut S,
853    header_bytes: Vec<u8>,
854    body_prefix: Vec<u8>,
855    tmp: &mut [u8],
856) -> Result<(Vec<u8>, bool), String> {
857    let header_end = header_bytes.len();
858    let mut buf = header_bytes;
859    buf.extend_from_slice(&body_prefix);
860
861    let header_lower =
862        std::str::from_utf8(&buf[..header_end]).unwrap_or("").to_ascii_lowercase();
863    let connection_close =
864        header_lower.lines().any(|l| l.starts_with("connection:") && l.contains("close"));
865    let is_chunked = header_lower
866        .lines()
867        .any(|l| l.starts_with("transfer-encoding:") && l.contains("chunked"));
868    let content_length: Option<usize> = header_lower.lines().find_map(|l| {
869        l.strip_prefix("content-length:")?.trim().parse().ok()
870    });
871
872    if is_chunked {
873        let decoded = decode_chunked(stream, &buf, header_end, tmp)?;
874        rewrite_as_content_length(&mut buf, header_end, &decoded);
875        Ok((buf, !connection_close))
876    } else if let Some(len) = content_length {
877        while buf.len() < header_end + len {
878            let n = stream.read(tmp).map_err(|e| e.to_string())?;
879            if n == 0 { break; }
880            buf.extend_from_slice(&tmp[..n]);
881        }
882        Ok((buf, !connection_close))
883    } else {
884        loop {
885            let n = stream.read(tmp).map_err(|e| e.to_string())?;
886            if n == 0 { break; }
887            buf.extend_from_slice(&tmp[..n]);
888        }
889        Ok((buf, false))
890    }
891}
892
893// ── Backend URL parsing ───────────────────────────────────────────────────────
894
895struct Backend {
896    host: String,
897    port: u16,
898    /// Whether the upstream connection should use TLS.
899    /// Set when the URL scheme is `https://`, `h2s://`, or `grpcs://`.
900    tls: bool,
901}
902
903impl Backend {
904    fn parse(url: &str) -> Option<Self> {
905        let (rest, tls, default_port) = if let Some(r) = url.strip_prefix("https://") {
906            (r, true, 443u16)
907        } else if let Some(r) = url.strip_prefix("h2s://") {
908            (r, true, 443u16)
909        } else if let Some(r) = url.strip_prefix("grpcs://") {
910            (r, true, 443u16)
911        } else if let Some(r) = url.strip_prefix("http://") {
912            (r, false, 80u16)
913        } else if let Some(r) = url.strip_prefix("h2://") {
914            (r, false, 80u16)
915        } else if let Some(r) = url.strip_prefix("grpc://") {
916            (r, false, 80u16)
917        } else {
918            (url, false, 80u16)
919        };
920        // Drop any path component.
921        let host_port = rest.split('/').next().unwrap_or(rest);
922        let (host, port) = if let Some(colon) = host_port.rfind(':') {
923            let port_str = &host_port[colon + 1..];
924            if let Ok(p) = port_str.parse::<u16>() {
925                (host_port[..colon].to_string(), p)
926            } else {
927                (host_port.to_string(), default_port)
928            }
929        } else {
930            (host_port.to_string(), default_port)
931        };
932        if host.is_empty() {
933            return None;
934        }
935        Some(Backend { host, port, tls })
936    }
937}
938
939// ── HTTP/2 reverse proxy ──────────────────────────────────────────────────────
940
941/// Reverse proxy that forwards requests to HTTP/2 backends.
942///
943/// Wraps [`ReverseProxy`] and forces HTTP/2 (`h2`) for all upstream connections.
944/// Requires the `http2` Cargo feature.
945///
946/// This proxy also transparently handles gRPC traffic
947/// (`Content-Type: application/grpc*`) — gRPC DATA frames are forwarded
948/// as-is because gRPC is layered directly on HTTP/2.
949#[cfg(feature = "http2")]
950pub struct H2ReverseProxy {
951    inner: ReverseProxy,
952}
953
954#[cfg(feature = "http2")]
955impl H2ReverseProxy {
956    /// Create a proxy distributing requests across `backends` in round-robin order.
957    ///
958    /// Each backend entry can be:
959    /// - `"host:port"` — plain TCP (HTTP/2 cleartext)
960    /// - `"h2://host:port"` — plain TCP (explicit scheme)
961    /// - `"h2s://host:port"` — TLS (HTTP/2 over HTTPS; port defaults to 443)
962    /// - `"https://host:port"` — TLS (same as `h2s://`)
963    ///
964    /// TLS backends require the `http2` Cargo feature (includes `rustls` +
965    /// `webpki-roots`).  Certificate verification uses the WebPKI trust store.
966    pub fn new<I, S>(backends: I) -> Self
967    where
968        I: IntoIterator<Item = S>,
969        S: AsRef<str>,
970    {
971        H2ReverseProxy {
972            inner: ReverseProxy::new(backends),
973        }
974    }
975
976    /// Only proxy requests whose URI starts with `prefix`; pass others through.
977    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
978        self.inner = self.inner.path_prefix(prefix);
979        self
980    }
981
982    /// Override the TCP connect timeout (default: 5 s).
983    pub fn connect_timeout_ms(mut self, ms: u64) -> Self {
984        self.inner = self.inner.connect_timeout_ms(ms);
985        self
986    }
987
988    /// Override the response read timeout (default: 30 s).
989    pub fn read_timeout_ms(mut self, ms: u64) -> Self {
990        self.inner = self.inner.read_timeout_ms(ms);
991        self
992    }
993}
994
995#[cfg(feature = "http2")]
996impl crate::middleware::Middleware for H2ReverseProxy {
997    fn handle(
998        &self,
999        request: &crate::request::Request,
1000        connection: &crate::server::ConnectionInfo,
1001        next: &dyn crate::application::Application,
1002    ) -> Result<crate::response::Response, String> {
1003        if let Some(prefix) = &self.inner.path_prefix {
1004            if !request.request_uri.starts_with(prefix.as_str()) {
1005                return next.execute(request, connection);
1006            }
1007        }
1008        if self.inner.backends.is_empty() {
1009            return Ok(bad_gateway());
1010        }
1011        let n = self.inner.backends.len();
1012        let start = self.inner.counter.fetch_add(1, Ordering::Relaxed);
1013        for attempt in 0..n {
1014            let idx = (start + attempt) % n;
1015            match try_backend_h2(request, &connection.client.ip, &self.inner.backends[idx],
1016                                  self.inner.connect_timeout, self.inner.read_timeout) {
1017                Ok(resp) => return Ok(resp),
1018                Err(_) if attempt + 1 < n => continue,
1019                Err(_) => break,
1020            }
1021        }
1022        Ok(bad_gateway())
1023    }
1024}
1025
1026#[cfg(feature = "http2")]
1027fn try_backend_h2(
1028    request: &Request,
1029    client_ip: &str,
1030    backend: &Backend,
1031    connect_timeout: Duration,
1032    _read_timeout: Duration,
1033) -> Result<Response, String> {
1034    // `block_on_isolated` works on any tokio runtime flavor (including
1035    // `current_thread`, unlike `tokio::task::block_in_place`, which requires
1036    // `multi_thread` and panics otherwise) and doesn't pull a worker thread
1037    // out of a shared pool; it also works with no runtime at all (HTTP/1.1
1038    // thread pool), unlike the previous implementation, which just gave up.
1039    crate::async_bridge::block_on_isolated(|| forward_h2_async(request, client_ip, backend, connect_timeout))
1040}
1041
1042#[cfg(feature = "http2")]
1043async fn forward_h2_async(
1044    request: &Request,
1045    client_ip: &str,
1046    backend: &Backend,
1047    connect_timeout: Duration,
1048) -> Result<Response, String> {
1049    let addr = format!("{}:{}", backend.host, backend.port);
1050    let tcp = tokio::time::timeout(
1051        connect_timeout,
1052        tokio::net::TcpStream::connect(&addr),
1053    )
1054    .await
1055    .map_err(|_| format!("h2 proxy: connect to {} timed out", addr))?
1056    .map_err(|e| format!("h2 proxy: connect to {} failed: {}", addr, e))?;
1057
1058    if backend.tls {
1059        use rustls::pki_types::ServerName;
1060        use std::sync::Arc;
1061        use tokio_rustls::TlsConnector;
1062
1063        let root_store = rustls::RootCertStore::from_iter(
1064            webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
1065        );
1066        let mut config = crate::tls_client_builder()
1067            .with_root_certificates(root_store)
1068            .with_no_client_auth();
1069        // Advertise h2 via ALPN so the server selects HTTP/2.
1070        config.alpn_protocols = vec![b"h2".to_vec()];
1071        let connector = TlsConnector::from(Arc::new(config));
1072        let server_name = ServerName::try_from(backend.host.as_str())
1073            .map_err(|e| format!("invalid upstream hostname '{}': {}", backend.host, e))?
1074            .to_owned();
1075        let tls_stream = connector
1076            .connect(server_name, tcp)
1077            .await
1078            .map_err(|e| format!("h2 proxy: TLS handshake with {} failed: {}", addr, e))?;
1079        send_h2_request(request, client_ip, backend, tls_stream).await
1080    } else {
1081        send_h2_request(request, client_ip, backend, tcp).await
1082    }
1083}
1084
1085/// Drive the h2 client handshake + request/response over any async I/O stream.
1086///
1087/// Accepts both plain `TcpStream` and `TlsStream<TcpStream>` — anything that
1088/// satisfies `AsyncRead + AsyncWrite + Unpin + Send + 'static`.
1089#[cfg(feature = "http2")]
1090async fn send_h2_request<T>(
1091    request: &Request,
1092    client_ip: &str,
1093    backend: &Backend,
1094    stream: T,
1095) -> Result<Response, String>
1096where
1097    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
1098{
1099    use bytes::Bytes;
1100    use http as hc;
1101
1102    let addr = format!("{}:{}", backend.host, backend.port);
1103
1104    let (send_req, conn) = h2::client::handshake(stream)
1105        .await
1106        .map_err(|e| format!("h2 proxy: handshake with {} failed: {}", addr, e))?;
1107
1108    tokio::spawn(async move {
1109        let _ = conn.await;
1110    });
1111
1112    let scheme = if backend.tls { "https" } else { "http" };
1113    let uri_str = format!("{}://{}{}", scheme, addr, request.request_uri);
1114    let uri: hc::Uri = uri_str.parse().map_err(|e: hc::uri::InvalidUri| e.to_string())?;
1115    let method = hc::Method::from_bytes(request.method.as_bytes()).map_err(|e| e.to_string())?;
1116
1117    let mut builder = hc::Request::builder().method(method).uri(uri);
1118    builder = builder.header("host", &backend.host);
1119    for h in &request.headers {
1120        let lower = h.name.to_lowercase();
1121        // An empty name would be sent as a malformed ": value" line.
1122        if lower.is_empty() || HOP_BY_HOP.contains(&lower.as_str()) || lower == "host" {
1123            continue;
1124        }
1125        builder = builder.header(&h.name, &h.value);
1126    }
1127    builder = builder.header("x-forwarded-for", client_ip);
1128    builder = builder.header("via", "2 rws");
1129
1130    let body_bytes = Bytes::from(request.body.clone());
1131    let end_of_stream = body_bytes.is_empty();
1132    let http_req = builder.body(()).map_err(|e| e.to_string())?;
1133
1134    let mut send_req = send_req.ready().await.map_err(|e| e.to_string())?;
1135    let (resp_future, mut req_body) = send_req
1136        .send_request(http_req, end_of_stream)
1137        .map_err(|e| e.to_string())?;
1138    if !end_of_stream {
1139        req_body.send_data(body_bytes, true).map_err(|e| e.to_string())?;
1140    }
1141
1142    let resp = resp_future.await.map_err(|e| e.to_string())?;
1143    let (parts, mut body) = resp.into_parts();
1144
1145    let content_type = parts
1146        .headers
1147        .get("content-type")
1148        .and_then(|v| v.to_str().ok())
1149        .unwrap_or("application/octet-stream")
1150        .to_string();
1151
1152    let mut body_bytes: Vec<u8> = Vec::new();
1153    while let Some(chunk) = body.data().await {
1154        body_bytes.extend_from_slice(&chunk.map_err(|e| e.to_string())?);
1155    }
1156
1157    let mut response = Response::new();
1158    response.status_code = parts.status.as_u16() as i16;
1159    response.reason_phrase = parts.status.canonical_reason().unwrap_or("").to_string();
1160
1161    const H2_HOP: &[&str] = &[
1162        "connection", "keep-alive", "transfer-encoding", "upgrade", "proxy-connection", "te",
1163    ];
1164    for (name, value) in &parts.headers {
1165        let lower = name.as_str().to_lowercase();
1166        if H2_HOP.contains(&lower.as_str()) {
1167            continue;
1168        }
1169        if let Ok(v) = value.to_str() {
1170            response.headers.push(crate::header::Header {
1171                name: name.as_str().to_string(),
1172                value: v.to_string(),
1173            });
1174        }
1175    }
1176
1177    if !body_bytes.is_empty() {
1178        response.content_range_list = vec![Range::get_content_range(body_bytes, content_type)];
1179    }
1180
1181    Ok(normalize_upstream_response(response))
1182}
1183
1184// ── gRPC proxy ────────────────────────────────────────────────────────────────
1185
1186/// gRPC reverse proxy middleware.
1187///
1188/// Recognises requests with `Content-Type: application/grpc*` and forwards them
1189/// to a backend over HTTP/2, leaving all other requests to the next layer.
1190///
1191/// Requires the `http2` Cargo feature.
1192///
1193/// # Example
1194///
1195/// ```rust,no_run
1196/// use rust_web_server::app::App;
1197/// use rust_web_server::core::New;
1198/// use rust_web_server::proxy::GrpcProxy;
1199///
1200/// let app = App::new()
1201///     .wrap(GrpcProxy::new(["grpc-service:50051"]));
1202/// ```
1203#[cfg(feature = "http2")]
1204pub struct GrpcProxy {
1205    inner: H2ReverseProxy,
1206}
1207
1208#[cfg(feature = "http2")]
1209impl GrpcProxy {
1210    /// Create a proxy distributing gRPC connections across `backends` in round-robin order.
1211    ///
1212    /// Each backend entry can be:
1213    /// - `"host:port"` — plain TCP (gRPC cleartext)
1214    /// - `"grpc://host:port"` — plain TCP (explicit scheme)
1215    /// - `"grpcs://host:port"` — TLS (gRPC over TLS; port defaults to 443)
1216    /// - `"https://host:port"` — TLS (same as `grpcs://`)
1217    ///
1218    /// TLS backends require the `http2` Cargo feature.
1219    pub fn new<I, S>(backends: I) -> Self
1220    where
1221        I: IntoIterator<Item = S>,
1222        S: AsRef<str>,
1223    {
1224        GrpcProxy { inner: H2ReverseProxy::new(backends) }
1225    }
1226
1227    /// Only proxy requests whose URI starts with `prefix`; pass others through.
1228    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
1229        self.inner = self.inner.path_prefix(prefix);
1230        self
1231    }
1232}
1233
1234#[cfg(feature = "http2")]
1235impl crate::middleware::Middleware for GrpcProxy {
1236    fn handle(
1237        &self,
1238        request: &crate::request::Request,
1239        connection: &crate::server::ConnectionInfo,
1240        next: &dyn crate::application::Application,
1241    ) -> Result<crate::response::Response, String> {
1242        let ct = request
1243            .get_header("content-type".to_string())
1244            .map(|h| h.value.as_str())
1245            .unwrap_or("");
1246        if ct.starts_with("application/grpc") {
1247            self.inner.handle(request, connection, next)
1248        } else {
1249            next.execute(request, connection)
1250        }
1251    }
1252}
1253
1254// ── Backend::parse unit tests ─────────────────────────────────────────────────
1255
1256#[cfg(test)]
1257mod backend_parse_tests {
1258    use super::Backend;
1259
1260    fn parse(url: &str) -> Option<(String, u16, bool)> {
1261        Backend::parse(url).map(|b| (b.host, b.port, b.tls))
1262    }
1263
1264    #[test]
1265    fn bare_host_port() {
1266        assert_eq!(Some(("api.example.com".into(), 8080, false)), parse("api.example.com:8080"));
1267    }
1268
1269    #[test]
1270    fn http_scheme() {
1271        assert_eq!(Some(("backend".into(), 3000, false)), parse("http://backend:3000"));
1272    }
1273
1274    #[test]
1275    fn h2_scheme_plain() {
1276        assert_eq!(Some(("svc".into(), 50051, false)), parse("h2://svc:50051"));
1277    }
1278
1279    #[test]
1280    fn grpc_scheme_plain() {
1281        assert_eq!(Some(("svc".into(), 50051, false)), parse("grpc://svc:50051"));
1282    }
1283
1284    #[test]
1285    fn https_scheme_sets_tls_and_default_port() {
1286        assert_eq!(Some(("api.example.com".into(), 443, true)), parse("https://api.example.com"));
1287    }
1288
1289    #[test]
1290    fn https_scheme_explicit_port() {
1291        assert_eq!(Some(("api.example.com".into(), 8443, true)), parse("https://api.example.com:8443"));
1292    }
1293
1294    #[test]
1295    fn h2s_scheme_sets_tls() {
1296        assert_eq!(Some(("svc".into(), 443, true)), parse("h2s://svc"));
1297    }
1298
1299    #[test]
1300    fn h2s_scheme_explicit_port() {
1301        assert_eq!(Some(("svc".into(), 8443, true)), parse("h2s://svc:8443"));
1302    }
1303
1304    #[test]
1305    fn grpcs_scheme_sets_tls() {
1306        assert_eq!(Some(("grpc-svc".into(), 443, true)), parse("grpcs://grpc-svc"));
1307    }
1308
1309    #[test]
1310    fn grpcs_scheme_explicit_port() {
1311        assert_eq!(Some(("grpc-svc".into(), 50052, true)), parse("grpcs://grpc-svc:50052"));
1312    }
1313
1314    #[test]
1315    fn empty_host_returns_none() {
1316        assert_eq!(None, parse("https://"));
1317    }
1318
1319    #[test]
1320    fn bare_host_no_port_defaults_to_80() {
1321        assert_eq!(Some(("myhost".into(), 80, false)), parse("myhost"));
1322    }
1323}