Skip to main content

ferryman_edge/
proxy.rs

1//! Per-request handler. Looks up the routing table, rebuilds the URI for
2//! the chosen upstream, strips hop-by-hop headers, forwards via the shared
3//! hyper client, records metrics, and turns transport errors / 502-504 into
4//! circuit-breaker trips.
5//!
6//! Body buffering is cfg-gated:
7//!   - `default`: collect the body once into a `Full<Bytes>`. Lower code
8//!     complexity, faster for typical JSON.
9//!   - `boxed_body`: forward streamed via `BoxBody`. Lower steady-state
10//!     allocations for large payloads.
11//!
12//! Numbers from spec: boxed ≈ +200µs at 10 MB; collected ≈ +80µs at 1 KB
13//! but allocates ~req_size. Default off — defended in README.
14//!
15//! Auth (JWT verify + rate limit + `x-ferryman-tenant` stamping) happens in
16//! `lib.rs` before a request reaches `handle` — by the time we're here the
17//! caller is authenticated and within quota.
18
19use ferryman_edge_core::{Limits, SharedTable};
20use http::{HeaderMap, HeaderValue};
21use http_body_util::{BodyExt, Full, LengthLimitError, Limited};
22use hyper::body::Bytes;
23use hyper::body::Incoming;
24use hyper::{Request, Response};
25use hyper_util::client::legacy::connect::HttpConnector;
26use hyper_util::client::legacy::Client;
27use std::net::IpAddr;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::Arc;
30use std::time::Duration;
31
32#[cfg(feature = "boxed_body")]
33pub type BoxErr = Box<dyn std::error::Error + Send + Sync>;
34
35#[cfg(not(feature = "boxed_body"))]
36pub type Body = Full<Bytes>;
37#[cfg(feature = "boxed_body")]
38pub type Body = http_body_util::combinators::BoxBody<Bytes, BoxErr>;
39
40const HOP_BY_HOP_HEADERS: &[&str] = &[
41    "connection",
42    "keep-alive",
43    "proxy-connection",
44    "te",
45    "trailer",
46    "transfer-encoding",
47    "upgrade",
48    "proxy-authenticate",
49    "proxy-authorization",
50];
51
52/// Strip the standard hop-by-hop headers plus anything named in the
53/// `Connection` header (RFC 9110 §7.6.1) — applied to both the outbound
54/// request and the inbound-from-upstream response.
55pub(crate) fn strip_hop_by_hop(headers: &mut HeaderMap) {
56    let mut extra: Vec<String> = Vec::new();
57    for v in headers.get_all(http::header::CONNECTION) {
58        if let Ok(s) = v.to_str() {
59            extra.extend(
60                s.split(',')
61                    .map(|p| p.trim().to_ascii_lowercase())
62                    .filter(|p| !p.is_empty()),
63            );
64        }
65    }
66    for name in HOP_BY_HOP_HEADERS {
67        headers.remove(*name);
68    }
69    for name in extra {
70        headers.remove(name.as_str());
71    }
72}
73
74/// This is the edge: whatever forwarding headers the client sent are
75/// untrusted, so replace them with the peer address instead of appending.
76fn set_forwarded(headers: &mut HeaderMap, ip: IpAddr) {
77    headers.remove("forwarded");
78    headers.remove("x-real-ip");
79    headers.insert(
80        "x-forwarded-for",
81        HeaderValue::from_str(&ip.to_string()).expect("an IP is a valid header value"),
82    );
83    headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
84}
85
86#[cfg(not(feature = "boxed_body"))]
87pub(crate) fn text_body(bytes: Bytes) -> Body {
88    Full::new(bytes)
89}
90
91#[cfg(feature = "boxed_body")]
92pub(crate) fn text_body(bytes: Bytes) -> Body {
93    Full::new(bytes)
94        .map_err(|never: std::convert::Infallible| -> BoxErr { match never {} })
95        .boxed()
96}
97
98fn plain(status: u16, msg: &'static [u8]) -> anyhow::Result<Response<Body>> {
99    metrics::counter!("ferryman_requests_total", "status" => status.to_string()).increment(1);
100    Ok(Response::builder()
101        .status(status)
102        .body(text_body(Bytes::from_static(msg)))?)
103}
104
105/// True if `path` could be read as a dot segment (`.`/`..`) by a normalising
106/// upstream, under the variants exercised by the tests
107/// below. Detection only; the forwarded path is never rewritten.
108fn bad_path(path: &str) -> bool {
109    let b = path.as_bytes();
110    let (mut start, mut i) = (0, 0);
111    while i <= b.len() {
112        let sep = match b[i..] {
113            [] => Some(0),
114            [b'/' | b'\\', ..] => Some(1),
115            [b'%', b'2', b'f' | b'F', ..] | [b'%', b'5', b'c' | b'C', ..] => Some(3),
116            [b'%', b'0', b'0', ..] | [b'%', b'u' | b'U', ..] => return true,
117            [b'%', b'2', b'5', b'2', b'e' | b'E' | b'f' | b'F', ..]
118            | [b'%', b'2', b'5', b'5', b'c' | b'C', ..] => return true,
119            _ => None,
120        };
121        match sep {
122            Some(n) => {
123                if dot_piece(&b[start..i]) {
124                    return true;
125                }
126                i += n.max(1);
127                start = i;
128            }
129            None => i += 1,
130        }
131    }
132    false
133}
134
135/// `.` or `..` once parameters and encodings handled by `bad_path` are
136/// accounted for.
137fn dot_piece(piece: &[u8]) -> bool {
138    let end = piece.iter().position(|&c| c == b';').unwrap_or(piece.len());
139    let b = &piece[..end];
140    let (mut i, mut dots) = (0, 0);
141    while i < b.len() {
142        match b[i..] {
143            [b'.', ..] => i += 1,
144            [b'%', b'2', b'e' | b'E', ..] => i += 3,
145            _ => return false,
146        }
147        dots += 1;
148    }
149    matches!(dots, 1 | 2)
150}
151
152/// True when a client-request failure was caused by *our* side of the
153/// exchange — the inbound body hit the size cap or the client went away
154/// mid-upload (hyper reports both as a user body error) — rather than by
155/// the upstream. Those must not trip the upstream's breaker, or any
156/// authenticated client could open it for every tenant.
157fn is_client_body_error(e: &(dyn std::error::Error + 'static)) -> bool {
158    error_chain(e).any(|c| {
159        c.is::<LengthLimitError>()
160            || c.downcast_ref::<hyper::Error>()
161                .is_some_and(|h| h.is_user())
162    })
163}
164
165fn error_chain<'a>(
166    e: &'a (dyn std::error::Error + 'static),
167) -> impl Iterator<Item = &'a (dyn std::error::Error + 'static)> {
168    std::iter::successors(Some(e), |c| c.source())
169}
170
171/// True for a protocol-upgrade request (`Upgrade` other than `h2c`) or CONNECT.
172///
173/// Upgrades (WebSocket etc.) need both hops spliced together, which this
174/// proxy doesn't do; say so instead of forwarding a mangled plain GET.
175/// `h2c` is exempt: servers may ignore it (RFC 9110 §7.8), and clients
176/// like curl --http2 or Java's HttpClient send it on every plain request.
177/// Plain CONNECT (HTTP/1 or h2) is caught here; h2 extended CONNECT
178/// (`:protocol`) is refused by the h2 layer itself, which doesn't enable it.
179///
180/// Must be called *before* `strip_hop_by_hop`, which removes `Upgrade`.
181pub(crate) fn wants_upgrade<B>(req: &Request<B>) -> bool {
182    req.method() == http::Method::CONNECT
183        || req
184            .headers()
185            .get(http::header::UPGRADE)
186            .is_some_and(|v| !v.as_bytes().eq_ignore_ascii_case(b"h2c"))
187}
188
189/// Handle a single inbound request. Hop-by-hop headers are expected to be
190/// stripped by the caller (before it stamps `x-ferryman-tenant`); this entry
191/// point detects upgrades from whatever headers are still present.
192pub async fn handle(
193    table: SharedTable,
194    client: Client<HttpConnector, Body>,
195    req: Request<Incoming>,
196    peer_ip: IpAddr,
197) -> Result<Response<Body>, anyhow::Error> {
198    handle_with(table, client, req, peer_ip, &Limits::default()).await
199}
200
201/// `handle` with explicit [`Limits`] (body cap, body-read and upstream
202/// timeouts). Limits should come from `parse_config` (or satisfy its ranges):
203/// a 0 timeout makes every request time out immediately.
204pub async fn handle_with(
205    table: SharedTable,
206    client: Client<HttpConnector, Body>,
207    req: Request<Incoming>,
208    peer_ip: IpAddr,
209    limits: &Limits,
210) -> Result<Response<Body>, anyhow::Error> {
211    let upgrade = wants_upgrade(&req);
212    handle_checked(table, client, req, peer_ip, upgrade, limits).await
213}
214
215/// `handle` with the upgrade check done by the caller, who saw the headers
216/// before they were stripped.
217pub(crate) async fn handle_checked(
218    table: SharedTable,
219    client: Client<HttpConnector, Body>,
220    req: Request<Incoming>,
221    peer_ip: IpAddr,
222    upgrade: bool,
223    limits: &Limits,
224) -> Result<Response<Body>, anyhow::Error> {
225    let max_body = limits.max_request_body_bytes as usize;
226    let started = std::time::Instant::now();
227    let snapshot = table.load();
228    let path = req.uri().path().to_string();
229
230    // Must stay before `RouteTable::lookup`: lookup can admit this request as
231    // the breaker's single half-open probe, and a 400 would never report back.
232    if bad_path(&path) {
233        return plain(400, b"bad path");
234    }
235
236    // Also before `lookup`, for the same reason: a 501 never reports back, so
237    // it must not hold the half-open probe slot. Route-agnostic by design.
238    if upgrade {
239        return plain(501, b"protocol upgrades are not supported");
240    }
241
242    // Fast rejection for a declared oversized body. `forward_body` below is
243    // the backstop for chunked uploads that lie about (or omit) it.
244    if req
245        .headers()
246        .get(http::header::CONTENT_LENGTH)
247        .and_then(|v| v.to_str().ok())
248        .and_then(|s| s.parse::<u64>().ok())
249        .is_some_and(|len| len > limits.max_request_body_bytes)
250    {
251        return plain(413, b"payload too large");
252    }
253
254    // Deal with the client's body *before* route lookup: lookup may admit
255    // this request as the breaker's half-open probe, and a probe that ends
256    // in a client-side 408/413/400 would never report back. The body read
257    // has its own deadline so a slow uploader can't eat into (and then be
258    // blamed as) the upstream's time budget.
259    let (mut parts, body) = req.into_parts();
260    let body_timeout = Duration::from_secs(limits.request_body_timeout_secs);
261    let (fwd_body, upload_done) =
262        match tokio::time::timeout(body_timeout, forward_body(body, max_body)).await {
263            Ok(Ok(b)) => b,
264            Ok(Err(e)) if e.downcast_ref::<LengthLimitError>().is_some() => {
265                return plain(413, b"payload too large");
266            }
267            Ok(Err(_)) => return plain(400, b"request body error"),
268            Err(_) => return plain(408, b"request body timeout"),
269        };
270
271    let upstream = match snapshot.lookup(&path) {
272        Some(u) => u.clone(),
273        // A prefix matched but its upstream's breaker is open: 503.
274        None if snapshot.has_prefix(&path) => return plain(503, b"upstream unavailable"),
275        None => return plain(404, b"no route"),
276    };
277
278    // Rebuild URI: upstream scheme+authority + original path+query. Inbound
279    // HTTP/2 requests carry the proxy's own scheme/authority in `parts.uri`,
280    // so this is always a full rebuild, never a patch.
281    let mut up_parts = upstream.uri.clone().into_parts();
282    up_parts.path_and_query = parts.uri.path_and_query().cloned();
283    parts.uri = http::Uri::from_parts(up_parts)?;
284    // hyper-util's legacy Client rejects an HTTP/2-versioned request over
285    // an HTTP/1 connection (UserUnsupportedVersion) — upstreams here are
286    // plain http://, so always downgrade.
287    parts.version = http::Version::HTTP_11;
288    if let Some(authority) = upstream.uri.authority() {
289        parts.headers.insert(
290            http::header::HOST,
291            HeaderValue::from_str(authority.as_str())?,
292        );
293    }
294    set_forwarded(&mut parts.headers, peer_ip);
295
296    // The upstream's budget starts now: round trip plus (collected mode)
297    // the response body.
298    // Unvalidated library limits must not panic: fall back to ~30 years out.
299    let now = tokio::time::Instant::now();
300    let deadline = now
301        .checked_add(Duration::from_secs(limits.upstream_timeout_secs))
302        .unwrap_or_else(|| now + Duration::from_secs(86_400 * 365 * 30));
303    let fwd = Request::from_parts(parts, fwd_body);
304
305    // host:port, so two upstreams on one host stay distinct series.
306    let host = upstream
307        .uri
308        .authority()
309        .map_or_else(String::new, |a| a.to_string());
310    let resp = match tokio::time::timeout_at(deadline, client.request(fwd)).await {
311        Ok(Ok(resp)) => resp,
312        Ok(Err(e)) if is_client_body_error(&e) => {
313            let too_large = error_chain(&e).any(|c| c.is::<LengthLimitError>());
314            return if too_large {
315                plain(413, b"payload too large")
316            } else {
317                plain(400, b"request body error")
318            };
319        }
320        Ok(Err(e)) => {
321            tracing::warn!(upstream = %host, error = %e, "upstream request failed");
322            upstream.mark_failed();
323            metrics::counter!("ferryman_requests_total", "status" => "502", "upstream" => host)
324                .increment(1);
325            return Ok(Response::builder()
326                .status(502)
327                .body(text_body(Bytes::from_static(b"bad gateway")))?);
328        }
329        Err(_) => {
330            // In streaming mode the upload runs inside this deadline; only
331            // blame the upstream if the client had finished sending.
332            if upload_done.load(Ordering::Acquire) {
333                upstream.mark_failed();
334            }
335            metrics::counter!("ferryman_requests_total", "status" => "504", "upstream" => host)
336                .increment(1);
337            return Ok(Response::builder()
338                .status(504)
339                .body(text_body(Bytes::from_static(b"upstream timeout")))?);
340        }
341    };
342
343    let status = resp.status();
344    let (mut resp_parts, resp_body) = resp.into_parts();
345    strip_hop_by_hop(&mut resp_parts.headers);
346    // Don't echo the upstream's HTTP version (e.g. an HTTP/1.0 upstream)
347    // back to the client; hyper picks the wire version.
348    resp_parts.version = http::Version::default();
349
350    #[cfg(not(feature = "boxed_body"))]
351    let out_body: Body = match tokio::time::timeout_at(deadline, resp_body.collect()).await {
352        Ok(Ok(collected)) => Full::new(collected.to_bytes()),
353        Ok(Err(_)) | Err(_) => {
354            upstream.mark_failed();
355            metrics::counter!("ferryman_requests_total", "status" => "502", "upstream" => host)
356                .increment(1);
357            return Ok(Response::builder()
358                .status(502)
359                .body(text_body(Bytes::from_static(b"bad gateway")))?);
360        }
361    };
362    #[cfg(feature = "boxed_body")]
363    let out_body: Body = resp_body.map_err(Into::into).boxed();
364
365    // Only gateway-class 5xx mean "this upstream is unhealthy"; a 500 is an
366    // application bug on one request and must not blackhole the whole route
367    // for a cooldown.
368    if matches!(status.as_u16(), 502..=504) {
369        upstream.mark_failed();
370    } else {
371        upstream.mark_success();
372    }
373    metrics::histogram!("ferryman_request_duration_seconds", "upstream" => host.clone())
374        .record(started.elapsed().as_secs_f64());
375    metrics::counter!(
376        "ferryman_requests_total",
377        "status" => status.as_u16().to_string(),
378        "upstream" => host
379    )
380    .increment(1);
381
382    Ok(Response::from_parts(resp_parts, out_body))
383}
384
385// ----- Body forwarding strategies ------------------------------------------
386//
387// Cargo feature `boxed_body` enables streaming forwarding (low alloc, good for
388// big payloads). Default off => collect body once (smaller code, faster for
389// JSON < 256 KB). Hiring-panel question to anticipate: "why is the default
390// off?" — see README "Design tradeoffs".
391
392/// Whether the client finished sending its body. Collected mode always has
393/// by the time the upstream is dialed; streaming mode flips it when the
394/// body reaches end-of-stream.
395type UploadDone = Arc<AtomicBool>;
396
397#[cfg(not(feature = "boxed_body"))]
398async fn forward_body(body: Incoming, max_body: usize) -> anyhow::Result<(Body, UploadDone)> {
399    match Limited::new(body, max_body).collect().await {
400        Ok(collected) => Ok((
401            Full::new(collected.to_bytes()),
402            Arc::new(AtomicBool::new(true)),
403        )),
404        Err(e) => match e.downcast::<LengthLimitError>() {
405            Ok(too_large) => Err(anyhow::Error::new(*too_large)),
406            Err(other) => Err(anyhow::anyhow!("{other}")),
407        },
408    }
409}
410
411// A chunked upload with no Content-Length that exceeds the body cap is cut
412// mid-stream by `Limited`; `is_client_body_error` maps that to 413 (or 400
413// for a client disconnect) and keeps it off the breaker.
414#[cfg(feature = "boxed_body")]
415async fn forward_body(body: Incoming, max_body: usize) -> anyhow::Result<(Body, UploadDone)> {
416    let inner = Limited::new(body, max_body);
417    // hyper never polls a body that already reports end-of-stream (e.g. a
418    // GET), so seed the flag rather than waiting for a poll.
419    let done: UploadDone = Arc::new(AtomicBool::new(hyper::body::Body::is_end_stream(&inner)));
420    let body = TrackEnd {
421        inner,
422        done: done.clone(),
423    };
424    Ok((body.boxed(), done))
425}
426
427/// Body wrapper that records when the inner body reaches end-of-stream.
428#[cfg(feature = "boxed_body")]
429struct TrackEnd<B> {
430    inner: B,
431    done: UploadDone,
432}
433
434#[cfg(feature = "boxed_body")]
435impl<B: hyper::body::Body + Unpin> hyper::body::Body for TrackEnd<B> {
436    type Data = B::Data;
437    type Error = B::Error;
438
439    fn poll_frame(
440        mut self: std::pin::Pin<&mut Self>,
441        cx: &mut std::task::Context<'_>,
442    ) -> std::task::Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
443        let polled = std::pin::Pin::new(&mut self.inner).poll_frame(cx);
444        if matches!(polled, std::task::Poll::Ready(None)) || self.inner.is_end_stream() {
445            self.done.store(true, Ordering::Release);
446        }
447        polled
448    }
449
450    fn is_end_stream(&self) -> bool {
451        self.inner.is_end_stream()
452    }
453
454    fn size_hint(&self) -> hyper::body::SizeHint {
455        self.inner.size_hint()
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::{bad_path, wants_upgrade};
462
463    #[test]
464    fn upgrade_detection() {
465        let get = |u: Option<&str>| {
466            let mut b = http::Request::get("/");
467            if let Some(u) = u {
468                b = b.header("upgrade", u);
469            }
470            b.body(()).unwrap()
471        };
472        assert!(wants_upgrade(
473            &http::Request::connect("example.com:443").body(()).unwrap()
474        ));
475        assert!(wants_upgrade(&get(Some("h2c, websocket"))));
476        assert!(wants_upgrade(&get(Some("websocket"))));
477        assert!(!wants_upgrade(&get(Some("h2c"))));
478        assert!(!wants_upgrade(&get(Some("H2C"))));
479        assert!(!wants_upgrade(&get(None)));
480    }
481
482    #[test]
483    fn dot_segments_are_rejected() {
484        for p in [
485            "/svc-a/../svc-b",
486            "/svc-a/./x",
487            "/svc-a/%2e%2e/svc-b",
488            "/svc-a/%2E/x",
489            "/api/../admin",
490            "/api/%2e%2e/admin",
491            "/api/..%2fadmin",
492            "/api/./../admin",
493            "/api/..",
494            "/api/%2E%2E/x",
495            "/api/%2e%2E/x",
496            "/api/.%2e/x",
497            "/api/..;/admin",
498            "/api/.;x/y",
499            "/api/%2e%2e;/x",
500            "/api/..%5cx",
501            "/api/..%5Cx",
502            "/api/a\\..\\b",
503            "/api/%2e/x",
504            "/..",
505            "/api/a%2f..",
506            "/api/%2e%2e%2f",
507            "/api/..%00",
508            "/api/.%00.",
509            "/api/%u002e%u002e",
510            "/api/%U002e",
511            "/api/%252e%252e",
512            "/api/%252E",
513            "/api/%252f",
514            "/api/%252F",
515            "/api/%255c",
516            "/api/a%00b",
517            "/api/a%5c..",
518            "/api/a%5C..%5Cb",
519        ] {
520            assert!(bad_path(p), "{p}");
521        }
522    }
523
524    #[test]
525    fn legitimate_paths_are_allowed() {
526        for p in [
527            "/svc-a/x",
528            "/svc-a/.hidden",
529            "/svc-a/a..b",
530            "/a..b/",
531            "/.well-known/acme",
532            "/file.tar.gz",
533            "/api/v1.2/x",
534            "/",
535            "/api/...",
536            "/api/a;..",
537            "/api/%2e%2e%2e/x",
538            "/api/%41/x",
539            "/api/.a/x",
540            "/api/a%2/",
541            "/api/v4/projects/group%2Fproject",
542            "/api/queues/%2F/q",
543            "/@scope%2fpkg",
544            "/%2F",
545            "/api/%25",
546            "/api/100%25",
547            "/api/%c0%ae",
548        ] {
549            assert!(!bad_path(p), "{p}");
550        }
551    }
552}