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