v0.33.8
This commit is contained in:
1 parent
6b937aaf85
commit
87c8393d86
79 files changed
+6727
-297
No files matched your search
+17
-7
@@ -18,7 +18,7 @@ use std::{
|
||||
};
|
||||
use tokio::{
|
||||
sync::OwnedSemaphorePermit,
|
||||
time::{sleep, Sleep},
|
||||
time::{sleep_until, Sleep},
|
||||
};
|
||||
|
||||
pub struct Exchange {
|
||||
@@ -263,7 +263,8 @@ pub struct MeteredBody {
|
||||
inner: Body,
|
||||
exchange: Option<Arc<Exchange>>,
|
||||
direction: Direction,
|
||||
timer: Pin<Box<Sleep>>,
|
||||
timer: Option<Pin<Box<Sleep>>>,
|
||||
deadline: tokio::time::Instant,
|
||||
idle: Duration,
|
||||
finished: bool,
|
||||
}
|
||||
@@ -274,13 +275,17 @@ impl MeteredBody {
|
||||
inner,
|
||||
exchange: Some(exchange),
|
||||
direction,
|
||||
timer: Box::pin(sleep(idle)),
|
||||
// Ready/empty bodies never need a timer allocation. Keep the
|
||||
// original deadline so a delayed first poll still times out.
|
||||
timer: None,
|
||||
deadline: tokio::time::Instant::now() + idle,
|
||||
idle,
|
||||
finished,
|
||||
}
|
||||
}
|
||||
fn finish(&mut self) {
|
||||
self.finished = true;
|
||||
self.timer.take();
|
||||
self.exchange.take();
|
||||
}
|
||||
}
|
||||
@@ -318,11 +323,13 @@ impl HttpBody for MeteredBody {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.timer
|
||||
.as_mut()
|
||||
.reset(tokio::time::Instant::now() + this.idle);
|
||||
if this.inner.is_end_stream() {
|
||||
this.finish();
|
||||
} else {
|
||||
this.deadline = tokio::time::Instant::now() + this.idle;
|
||||
if let Some(timer) = this.timer.as_mut() {
|
||||
timer.as_mut().reset(this.deadline);
|
||||
}
|
||||
}
|
||||
Poll::Ready(Some(Ok(frame)))
|
||||
}
|
||||
@@ -342,7 +349,10 @@ impl HttpBody for MeteredBody {
|
||||
Poll::Ready(None)
|
||||
}
|
||||
Poll::Pending => {
|
||||
if this.timer.as_mut().poll(cx).is_ready() {
|
||||
let timer = this
|
||||
.timer
|
||||
.get_or_insert_with(|| Box::pin(sleep_until(this.deadline)));
|
||||
if timer.as_mut().poll(cx).is_ready() {
|
||||
if let Some(exchange) = &this.exchange {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.backend_error(match this.direction {
|
||||
|
||||
@@ -95,4 +95,134 @@ mod tests {
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_and_empty_bodies_keep_accounting_without_timer_allocations() {
|
||||
for payload in ["", "ready response"] {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let logs = exchange.logs.clone();
|
||||
let mut body = MeteredBody::new(
|
||||
Body::from(payload),
|
||||
exchange,
|
||||
Direction::Response,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
assert!(body.timer.is_none());
|
||||
assert_eq!(slots.available_permits(), 0);
|
||||
let mut received = Vec::new();
|
||||
while let Some(frame) = body.frame().await {
|
||||
received.extend_from_slice(frame.unwrap().data_ref().unwrap());
|
||||
assert!(body.timer.is_none());
|
||||
}
|
||||
assert_eq!(received, payload.as_bytes());
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.completed.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
metrics.bytes_out.load(Ordering::Relaxed),
|
||||
payload.len() as u64
|
||||
);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(logs.recent().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_first_poll_does_not_extend_the_original_idle_deadline() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let pending = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut body = MeteredBody::new(
|
||||
Body::from_stream(pending),
|
||||
exchange,
|
||||
Direction::Request,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
// Move the saved initial deadline into the past without wall-clock
|
||||
// sleeps: the first Pending must create an already-expired timer.
|
||||
body.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
|
||||
assert!(body.timer.is_none());
|
||||
let result = tokio::time::timeout(Duration::from_millis(200), body.frame()).await;
|
||||
assert!(result.unwrap().unwrap().is_err());
|
||||
assert!(body.timer.is_none());
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn progress_resets_a_lazy_timer_and_trailers_pass_unchanged() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let (sender, receiver) =
|
||||
tokio::sync::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(4);
|
||||
let stream = futures_util::stream::unfold(receiver, |mut receiver| async {
|
||||
receiver.recv().await.map(|frame| (frame, receiver))
|
||||
});
|
||||
let mut body = MeteredBody::new(
|
||||
Body::new(http_body_util::StreamBody::new(stream)),
|
||||
exchange,
|
||||
Direction::Response,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert!(futures_util::poll!(body.frame()).is_pending());
|
||||
assert!(body.timer.is_some());
|
||||
let original_deadline = body.deadline;
|
||||
sender
|
||||
.send(Ok(Frame::data(Bytes::from_static(b"part"))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().data_ref().unwrap(),
|
||||
"part"
|
||||
);
|
||||
assert!(body.deadline >= original_deadline);
|
||||
assert_eq!(body.timer.as_ref().unwrap().deadline(), body.deadline);
|
||||
let mut trailers = http::HeaderMap::new();
|
||||
trailers.insert("x-checksum", "complete".parse().unwrap());
|
||||
sender
|
||||
.send(Ok(Frame::trailers(trailers.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(sender);
|
||||
assert_eq!(
|
||||
body.frame().await.unwrap().unwrap().trailers_ref(),
|
||||
Some(&trailers)
|
||||
);
|
||||
assert!(body.frame().await.is_none());
|
||||
assert!(body.timer.is_none());
|
||||
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 4);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_and_read_errors_keep_their_original_outcomes() {
|
||||
for cancel in [true, false] {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let logs = exchange.logs.clone();
|
||||
let inner = if cancel {
|
||||
Body::from_stream(futures_util::stream::pending::<Result<Bytes, std::io::Error>>())
|
||||
} else {
|
||||
Body::from_stream(futures_util::stream::once(async {
|
||||
Err::<Bytes, _>(std::io::Error::other("test read error"))
|
||||
}))
|
||||
};
|
||||
let mut body =
|
||||
MeteredBody::new(inner, exchange, Direction::Response, Duration::from_secs(1));
|
||||
if cancel {
|
||||
assert!(futures_util::poll!(body.frame()).is_pending());
|
||||
drop(body);
|
||||
} else {
|
||||
assert!(body.frame().await.unwrap().is_err());
|
||||
assert!(body.timer.is_none());
|
||||
}
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(
|
||||
logs.recent()[0].outcome,
|
||||
if cancel { "cancelled" } else { "body_error" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,7 @@ fn apply_backend_diagnostic_values(
|
||||
backend_header: bool,
|
||||
backend_timing_header: bool,
|
||||
) {
|
||||
response.headers_mut().remove("x-rustproxy-backend");
|
||||
response.headers_mut().remove("x-rust-proxy-backend");
|
||||
response.headers_mut().remove("x-rust-proxy-backend-time");
|
||||
response.headers_mut().remove("x-rustproxy-backend-time");
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Request-local cache decisions derived from the already validated route.
|
||||
//! A disabled plan retains its diagnostic reason without preparing cache-only
|
||||
//! policy, cookie matching or request metadata. Access and origin policy stay
|
||||
//! in the common forwarding path and must never depend on this plan.
|
||||
|
||||
use crate::cache::{RequestPolicy, Settings};
|
||||
use http::{HeaderMap, Method};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) enum CachePlan {
|
||||
Disabled(&'static str),
|
||||
Enabled { force_cache: bool, ignore_cookie: bool },
|
||||
}
|
||||
|
||||
impl CachePlan {
|
||||
pub(super) fn new(
|
||||
disabled_reason: Option<&'static str>,
|
||||
settings: &Settings,
|
||||
path: &str,
|
||||
force_match: impl FnOnce() -> bool,
|
||||
) -> Self {
|
||||
if let Some(reason) = disabled_reason {
|
||||
return Self::Disabled(reason);
|
||||
}
|
||||
let force_cache = force_match();
|
||||
Self::Enabled {
|
||||
force_cache,
|
||||
ignore_cookie: !force_cache && settings.ignore_cookie_for_path(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn force_cache(self) -> bool {
|
||||
matches!(self, Self::Enabled { force_cache: true, .. })
|
||||
}
|
||||
|
||||
pub(super) fn ignore_cookie(self) -> bool {
|
||||
matches!(self, Self::Enabled { ignore_cookie: true, .. })
|
||||
}
|
||||
|
||||
pub(super) fn request_policy(
|
||||
self,
|
||||
method: &Method,
|
||||
headers: &HeaderMap,
|
||||
body_empty: bool,
|
||||
path: &str,
|
||||
has_query: bool,
|
||||
settings: &Settings,
|
||||
) -> RequestPolicy {
|
||||
match self {
|
||||
Self::Disabled(reason) => RequestPolicy::Bypass(reason),
|
||||
Self::Enabled { force_cache: true, .. } =>
|
||||
crate::cache::forced_request_policy(method, headers, body_empty),
|
||||
Self::Enabled { .. } =>
|
||||
crate::cache::request_policy(method, headers, body_empty, path, has_query, settings),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header rules may only make a request less cacheable. An original bypass is
|
||||
/// final, so there is no reason to parse the rewritten headers in that case.
|
||||
pub(super) fn combine_policy(
|
||||
original: RequestPolicy,
|
||||
rewritten: impl FnOnce() -> RequestPolicy,
|
||||
) -> RequestPolicy {
|
||||
if matches!(original, RequestPolicy::Bypass(_)) {
|
||||
return original;
|
||||
}
|
||||
match (original, rewritten()) {
|
||||
(_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
|
||||
(RequestPolicy::Revalidate(reason), _) => RequestPolicy::Revalidate(reason),
|
||||
(_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
|
||||
_ => RequestPolicy::Use,
|
||||
}
|
||||
}
|
||||
|
||||
/// Origin Range semantics also disable transformation/compression when the
|
||||
/// cache is off. Only a cache-eligible request needs byte-range parsing.
|
||||
pub(super) fn range_selection(
|
||||
cache_eligible: bool,
|
||||
method: &Method,
|
||||
headers: &HeaderMap,
|
||||
) -> crate::cache::RangeSelection {
|
||||
if cache_eligible {
|
||||
crate::cache::request_range(method, headers)
|
||||
} else if headers.contains_key("range") {
|
||||
crate::cache::RangeSelection::Unsupported
|
||||
} else {
|
||||
crate::cache::RangeSelection::None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,113 @@
|
||||
use super::*;
|
||||
use crate::cache::RangeSelection;
|
||||
use http::HeaderValue;
|
||||
|
||||
fn settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
static_ignore_cookies: true,
|
||||
static_extensions: vec!["css".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_headers() {
|
||||
let settings = settings();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", HeaderValue::from_static("private"));
|
||||
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number"));
|
||||
for reason in [
|
||||
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
|
||||
"tinyauth", "sticky_cookie", "request_upgrade",
|
||||
] {
|
||||
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
|
||||
panic!("disabled cache must not match force-cache rules")
|
||||
});
|
||||
assert!(!plan.force_cache());
|
||||
assert!(!plan.ignore_cookie());
|
||||
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
|
||||
assert_eq!(policy, RequestPolicy::Bypass(reason));
|
||||
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_on_keeps_all_existing_request_policy_outcomes() {
|
||||
let settings = settings();
|
||||
for forced in [false, true] {
|
||||
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
|
||||
for (name, value) in [
|
||||
("x-irrelevant", "yes"), ("cache-control", "no-store"),
|
||||
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
|
||||
("cache-control", "min-fresh=broken"), ("authorization", "private"),
|
||||
("cookie", "session=private"), ("range", "bytes=0-2"),
|
||||
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
|
||||
("if-match", "\"etag\""), ("upgrade", "websocket"),
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
|
||||
for method in [Method::GET, Method::HEAD, Method::POST] {
|
||||
for body_empty in [false, true] {
|
||||
let expected = if forced {
|
||||
crate::cache::forced_request_policy(&method, &headers, body_empty)
|
||||
} else {
|
||||
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings)
|
||||
};
|
||||
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_header_rules_cannot_relax_bypass_or_revalidation() {
|
||||
let cases = [
|
||||
RequestPolicy::Use,
|
||||
RequestPolicy::Revalidate("request_no_cache"),
|
||||
RequestPolicy::Revalidate("request_max_age"),
|
||||
RequestPolicy::Bypass("request_authorization"),
|
||||
RequestPolicy::Bypass("request_no_store"),
|
||||
];
|
||||
for original in cases {
|
||||
for rewritten in cases {
|
||||
let expected = match (original, rewritten) {
|
||||
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
|
||||
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
|
||||
_ => RequestPolicy::Use,
|
||||
};
|
||||
assert_eq!(combine_policy(original, || rewritten), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
|
||||
for method in [Method::GET, Method::HEAD, Method::POST] {
|
||||
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); }
|
||||
headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
|
||||
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\""));
|
||||
let original = headers.clone();
|
||||
let baseline = crate::cache::request_range(&method, &headers);
|
||||
assert_eq!(range_selection(true, &method, &headers), baseline);
|
||||
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
|
||||
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_cache_keeps_cookie_variants_and_disabled_cookie_matching_has_no_effect() {
|
||||
let settings = settings();
|
||||
let ordinary = CachePlan::new(None, &settings, "/style.css", || false);
|
||||
assert!(ordinary.ignore_cookie());
|
||||
let forced = CachePlan::new(None, &settings, "/style.css", || true);
|
||||
assert!(forced.force_cache());
|
||||
assert!(!forced.ignore_cookie());
|
||||
let disabled = CachePlan::new(Some("route_disabled"), &settings, "/style.css", || true);
|
||||
assert!(!disabled.force_cache());
|
||||
assert!(!disabled.ignore_cookie());
|
||||
}
|
||||
@@ -167,7 +167,7 @@ let body_empty = if cache_enabled
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.body().is_end_stream()
|
||||
cache_enabled && request.body().is_end_stream()
|
||||
};
|
||||
let public_suffix = request
|
||||
.uri()
|
||||
@@ -175,29 +175,18 @@ let public_suffix = request
|
||||
.map(|v| v.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_owned();
|
||||
let force_cache = policy_reason.is_none()
|
||||
&& cache_settings.active_for(route.cache)
|
||||
&& route
|
||||
let cache_plan = cache_plan::CachePlan::new(policy_reason, cache_settings, &path, || {
|
||||
cache_settings.active_for(route.cache) && route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix));
|
||||
let ignore_static_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
|
||||
let original_policy = policy_reason
|
||||
.map(crate::cache::RequestPolicy::Bypass)
|
||||
.unwrap_or_else(|| {
|
||||
if force_cache {
|
||||
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
|
||||
} else {
|
||||
crate::cache::request_policy(
|
||||
request.method(),
|
||||
request.headers(),
|
||||
body_empty,
|
||||
&path,
|
||||
request.uri().query().is_some(),
|
||||
cache_settings,
|
||||
)
|
||||
}
|
||||
});
|
||||
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix))
|
||||
});
|
||||
let force_cache = cache_plan.force_cache();
|
||||
let ignore_static_cookie = cache_plan.ignore_cookie();
|
||||
let original_policy = cache_plan.request_policy(
|
||||
request.method(), request.headers(), body_empty, &path,
|
||||
request.uri().query().is_some(), cache_settings,
|
||||
);
|
||||
|
||||
let only_cached =
|
||||
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
|
||||
@@ -282,48 +271,31 @@ if websocket_legacy_support {
|
||||
}
|
||||
}
|
||||
}
|
||||
let range_selection = crate::cache::request_range(request.method(), request.headers());
|
||||
let if_range = request.headers().get("if-range").cloned();
|
||||
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
|
||||
let post_policy = if force_cache {
|
||||
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
|
||||
} else {
|
||||
crate::cache::request_policy(
|
||||
request.method(),
|
||||
request.headers(),
|
||||
body_empty,
|
||||
&path,
|
||||
request.uri().query().is_some(),
|
||||
cache_settings,
|
||||
let effective_policy = cache_plan::combine_policy(original_policy, || {
|
||||
cache_plan.request_policy(
|
||||
request.method(), request.headers(), body_empty, &path,
|
||||
request.uri().query().is_some(), cache_settings,
|
||||
)
|
||||
};
|
||||
});
|
||||
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
|
||||
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
|
||||
let effective_policy = match (original_policy, post_policy) {
|
||||
(crate::cache::RequestPolicy::Bypass(reason), _) => {
|
||||
crate::cache::RequestPolicy::Bypass(reason)
|
||||
}
|
||||
(_, crate::cache::RequestPolicy::Bypass(reason)) => {
|
||||
crate::cache::RequestPolicy::Bypass(reason)
|
||||
}
|
||||
(crate::cache::RequestPolicy::Revalidate(reason), _) => {
|
||||
crate::cache::RequestPolicy::Revalidate(reason)
|
||||
}
|
||||
(_, crate::cache::RequestPolicy::Revalidate(reason)) => {
|
||||
crate::cache::RequestPolicy::Revalidate(reason)
|
||||
}
|
||||
_ => crate::cache::RequestPolicy::Use,
|
||||
};
|
||||
let effective_reason = effective_policy.bypass_reason();
|
||||
let revalidation_reason = effective_policy.revalidation_reason();
|
||||
let effective_cacheable = effective_reason.is_none();
|
||||
let range_selection = cache_plan::range_selection(
|
||||
effective_cacheable, request.method(), request.headers(),
|
||||
);
|
||||
let if_range = if effective_cacheable {
|
||||
request.headers().get("if-range").cloned()
|
||||
} else { None };
|
||||
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
|
||||
|
||||
// Match Varnish-style cache identity: a load-balanced backend member must not
|
||||
// fragment the object cache. The vhost scope + canonical host + request target
|
||||
// already identify the public resource; Vary handles representation variants.
|
||||
let cache_route = route.id.as_str();
|
||||
let mut cache_key_headers = None;
|
||||
if ignore_static_cookie && request.headers().contains_key("cookie") {
|
||||
if effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") {
|
||||
let mut headers = request.headers().clone();
|
||||
headers.remove("cookie");
|
||||
cache_key_headers = Some(headers);
|
||||
@@ -344,7 +316,9 @@ let cache_ticket = if effective_cacheable {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let client_conditionals = request.headers().clone();
|
||||
let client_conditionals = if cache_ticket.is_some() {
|
||||
request.headers().clone()
|
||||
} else { HeaderMap::new() };
|
||||
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
|
||||
state.cache.revalidation_tiered(ticket).await
|
||||
} else {
|
||||
|
||||
@@ -94,7 +94,8 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
|
||||
// Both origin and transformed policies must be cacheable; hiding a
|
||||
// Set-Cookie/private header never authorizes storage of private content.
|
||||
let transformed_plan =
|
||||
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
|
||||
if origin_plan.is_none()
|
||||
|| (ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) {
|
||||
None
|
||||
} else {
|
||||
crate::cache::plan(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
let origin_validation_headers = response.headers().clone();
|
||||
// Only a real cache revalidation needs origin headers after response rules.
|
||||
let origin_validation_headers = if revalidation_sent
|
||||
&& response.status() == StatusCode::NOT_MODIFIED
|
||||
{
|
||||
response.headers().clone()
|
||||
} else { HeaderMap::new() };
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange
|
||||
.upstream_status
|
||||
|
||||
@@ -119,7 +119,9 @@ if websocket_upstream_h2 {
|
||||
// Do not classify a slow client upload as a slow origin response. Probes
|
||||
// independently measure origin liveness/latency with body-free requests.
|
||||
let request_body_empty = request.body().size_hint().exact() == Some(0);
|
||||
let proactive_base_headers = request.headers().clone();
|
||||
let proactive_base_headers = if cache_ticket.is_some() && cache_settings.proactive_refresh_enabled {
|
||||
request.headers().clone()
|
||||
} else { HeaderMap::new() };
|
||||
let response_header_timeout = frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout);
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
|
||||
request.headers_mut().insert(HOST, original_host.clone());
|
||||
let request_method = request.method().clone();
|
||||
let request_headers = request.headers().clone();
|
||||
// This branch owns local-origin delivery. Move its headers instead of
|
||||
// cloning them; the request remains only for exchange/body extensions.
|
||||
let mut request_headers = std::mem::take(request.headers_mut());
|
||||
let request_uri = request.uri().clone();
|
||||
let request_version = request.version();
|
||||
let public_suffix = request_uri.path_and_query()
|
||||
@@ -41,32 +43,33 @@
|
||||
Err(_) => return error(StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n"),
|
||||
}
|
||||
} else { request.body().is_end_stream() };
|
||||
let force_cache = enabled && route.frontend.as_ref().is_some_and(|frontend|
|
||||
frontend.force_cache.matches(&path, &public_suffix));
|
||||
let policy = if enabled {
|
||||
if force_cache {
|
||||
crate::cache::forced_request_policy(&request_method, &request_headers, empty_body)
|
||||
} else {
|
||||
crate::cache::request_policy(
|
||||
&request_method, &request_headers, empty_body, &path,
|
||||
request_uri.query().is_some(), cache_settings)
|
||||
}
|
||||
} else {
|
||||
crate::cache::RequestPolicy::Bypass("cache_disabled")
|
||||
};
|
||||
} else { enabled && request.body().is_end_stream() };
|
||||
let cache_plan = cache_plan::CachePlan::new(
|
||||
(!enabled).then_some("cache_disabled"), cache_settings, &path,
|
||||
|| route.frontend.as_ref().is_some_and(|frontend|
|
||||
frontend.force_cache.matches(&path, &public_suffix)),
|
||||
);
|
||||
let force_cache = cache_plan.force_cache();
|
||||
let ignore_cookie = cache_plan.ignore_cookie();
|
||||
let policy = cache_plan.request_policy(
|
||||
&request_method, &request_headers, empty_body, &path,
|
||||
request_uri.query().is_some(), cache_settings,
|
||||
);
|
||||
// HEAD can use an existing GET representation but may not fill a cache
|
||||
// entry with its empty downstream body.
|
||||
let eligible = enabled && policy.bypass_reason().is_none();
|
||||
let mut key_headers = request_headers.clone();
|
||||
let ignore_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
|
||||
if ignore_cookie { key_headers.remove("cookie"); }
|
||||
let key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") {
|
||||
let mut headers = request_headers.clone();
|
||||
headers.remove("cookie");
|
||||
Some(headers)
|
||||
} else { None };
|
||||
let cache_ticket = if eligible {
|
||||
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
|
||||
original_host.to_str().unwrap_or(""), &route.id, &public_suffix, &key_headers)
|
||||
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
|
||||
key_headers.as_ref().unwrap_or(&request_headers))
|
||||
} else { None };
|
||||
let range = crate::cache::request_range(&request_method, &request_headers);
|
||||
let if_range = request_headers.get("if-range").cloned();
|
||||
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
|
||||
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
|
||||
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
|
||||
// Unlike the original Web Server path, retain stale and client-forced
|
||||
// revalidation candidates. A local 304 can refresh them without replacing
|
||||
@@ -122,9 +125,14 @@
|
||||
// Neither the Request nor the streaming Body is held across a borrowed
|
||||
// await: the filesystem/PHP source owns the body for its full lifetime.
|
||||
let static_body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let (origin_request_headers, revalidation_sent) =
|
||||
let (origin_request_headers, revalidation_sent) = if cache_ticket.is_some() {
|
||||
web_cache_revalidation::web_origin_request_headers(
|
||||
&request_headers, cached_revalidation.as_ref());
|
||||
&request_headers, cached_revalidation.as_ref())
|
||||
} else {
|
||||
// No cache result can need the original conditionals later. Preserve
|
||||
// all origin HTTP semantics by handing over the untouched header map.
|
||||
(std::mem::take(&mut request_headers), false)
|
||||
};
|
||||
let started = Instant::now();
|
||||
let response = crate::web_server::serve(service, request_method.clone(),
|
||||
origin_request_headers, request_uri, &service_path, static_body,
|
||||
@@ -181,25 +189,27 @@
|
||||
state.cache.discard(ticket);
|
||||
}
|
||||
}
|
||||
let origin_headers = response.headers().clone();
|
||||
let original_plan = crate::cache::plan(response.status(), &origin_headers,
|
||||
cache_settings, started.elapsed());
|
||||
// Do not allow a later response-header rewrite to override a private,
|
||||
// explicitly uncacheable PHP/CGI origin response.
|
||||
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|
||||
|| origin_headers.contains_key("expires")
|
||||
|| origin_headers.contains_key("set-cookie")
|
||||
|| origin_headers.contains_key("vary");
|
||||
let origin_safe = !explicit_origin_policy || original_plan.is_some();
|
||||
let no_transform = crate::transform::no_transform(&origin_headers);
|
||||
let skip_compression = crate::compression::no_transform(&origin_headers);
|
||||
let origin_headers = response.headers();
|
||||
let origin_safe = cache_ticket.is_some() && {
|
||||
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|
||||
|| origin_headers.contains_key("expires")
|
||||
|| origin_headers.contains_key("set-cookie")
|
||||
|| origin_headers.contains_key("vary");
|
||||
!explicit_origin_policy || crate::cache::plan(response.status(), origin_headers,
|
||||
cache_settings, started.elapsed()).is_some()
|
||||
};
|
||||
let no_transform = crate::transform::no_transform(origin_headers);
|
||||
let skip_compression = crate::compression::no_transform(origin_headers);
|
||||
let origin_validators = crate::transform::OriginValidators {
|
||||
etag: origin_headers.get("etag").cloned(),
|
||||
last_modified: origin_headers.get("last-modified").cloned(),
|
||||
};
|
||||
if skip_compression || range_requested {
|
||||
response.extensions_mut().insert(crate::compression::Skip);
|
||||
}
|
||||
response.extensions_mut().insert(crate::transform::OriginValidators {
|
||||
etag: origin_headers.get("etag").cloned(),
|
||||
last_modified: origin_headers.get("last-modified").cloned(),
|
||||
});
|
||||
response.extensions_mut().insert(origin_validators);
|
||||
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
|
||||
Some(route), false);
|
||||
if response.extensions().get::<crate::pages::PageServed>().is_some() {
|
||||
@@ -271,4 +281,4 @@
|
||||
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
|
||||
cache_settings.annotate_transformed(response.headers_mut(), transformed);
|
||||
response
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
include!("types.rs");
|
||||
include!("error_fallback.rs");
|
||||
include!("cache_helpers.rs");
|
||||
mod cache_plan;
|
||||
mod web_cache_revalidation;
|
||||
include!("cache_http_invalidation.rs");
|
||||
include!("upstream_helpers.rs");
|
||||
@@ -14,3 +15,5 @@ include!("tests/h3_navigation_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/http_invalidation_tests.rs");
|
||||
#[cfg(test)]
|
||||
include!("tests/cache_fast_path_tests.rs");
|
||||
@@ -0,0 +1,26 @@
|
||||
fn request_id_hash(sequence: u64) -> String {
|
||||
use rand::TryRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut nonce = [0u8; 32];
|
||||
let mut rng = rand::rngs::SysRng;
|
||||
rng.try_fill_bytes(&mut nonce)
|
||||
.expect("secure operating system random source unavailable");
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(sequence.to_be_bytes());
|
||||
hash.update(nonce);
|
||||
request_id_hex(&hash.finalize())
|
||||
}
|
||||
|
||||
fn request_id_hex(digest: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut encoded = String::with_capacity(digest.len() * 2);
|
||||
for &byte in digest {
|
||||
encoded.push(HEX[(byte >> 4) as usize] as char);
|
||||
encoded.push(HEX[(byte & 0x0f) as usize] as char);
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("request_id/tests.rs");
|
||||
@@ -0,0 +1,58 @@
|
||||
mod request_id_tests {
|
||||
use super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[test]
|
||||
fn hexadecimal_encoding_matches_previous_sha256_display() {
|
||||
for byte in 0u8..=255 {
|
||||
let digest = Sha256::digest([byte; 32]);
|
||||
assert_eq!(request_id_hex(&digest), format!("{digest:x}"));
|
||||
}
|
||||
assert_eq!(
|
||||
request_id_hex(&[0, 1, 15, 16, 127, 128, 254, 255]),
|
||||
"00010f107f80feff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_ids_keep_their_64_hexadecimal_format_and_randomness() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for _ in 0..512 {
|
||||
let id = request_id_hash(1);
|
||||
assert_eq!(id.len(), 64);
|
||||
assert!(id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
|
||||
assert!(
|
||||
seen.insert(id),
|
||||
"fixed sequence must still use independent OS entropy"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "explicit release-mode microbenchmark; not an acceptance threshold"]
|
||||
fn benchmark_request_id_formatting() {
|
||||
use std::hint::black_box;
|
||||
const ITERATIONS: u32 = 200_000;
|
||||
let digest = Sha256::digest(b"same SHA256 digest for both formatters");
|
||||
for round in 0..7 {
|
||||
for candidate in if round % 2 == 0 {
|
||||
[false, true]
|
||||
} else {
|
||||
[true, false]
|
||||
} {
|
||||
let start = Instant::now();
|
||||
for _ in 0..ITERATIONS {
|
||||
let value = if candidate {
|
||||
request_id_hex(black_box(&digest))
|
||||
} else {
|
||||
format!("{:x}", black_box(&digest))
|
||||
};
|
||||
black_box(value);
|
||||
}
|
||||
println!("{{\"round\":{round},\"candidate\":{candidate},\"iterations\":{ITERATIONS},\"ns_per_op\":{}}}", start.elapsed().as_nanos() as f64 / ITERATIONS as f64);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,7 @@ mod authority_tests {
|
||||
#[test]
|
||||
fn generated_policy_and_gateway_errors_use_html_pages() {
|
||||
for status in [
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
@@ -231,11 +232,6 @@ mod authority_tests {
|
||||
);
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
}
|
||||
let response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
|
||||
assert_ne!(
|
||||
response.headers().get("content-type").unwrap(),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn locally_generated_pages_remove_cache_metadata() {
|
||||
@@ -344,6 +340,7 @@ mod authority_tests {
|
||||
#[test]
|
||||
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
|
||||
@@ -356,6 +353,7 @@ mod authority_tests {
|
||||
HeaderValue::from_static("999ms"),
|
||||
);
|
||||
apply_backend_diagnostic_values(&mut response, false, false);
|
||||
assert!(response.headers().get("x-rustproxy-backend").is_none());
|
||||
assert!(response.headers().get("x-rust-proxy-backend").is_none());
|
||||
assert!(response
|
||||
.headers()
|
||||
@@ -369,7 +367,9 @@ mod authority_tests {
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(Duration::from_millis(12)));
|
||||
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
|
||||
apply_backend_diagnostic_values(&mut response, true, true);
|
||||
assert!(response.headers().get("x-rustproxy-backend").is_none());
|
||||
assert_eq!(
|
||||
response.headers().get("x-rust-proxy-backend").unwrap(),
|
||||
"origin-a"
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
mod cache_fast_path_tests {
|
||||
use super::*;
|
||||
use crate::{auth::Auth, config::{self, Config, Update, ValidationPolicy}, db::Store};
|
||||
use std::net::SocketAddr;
|
||||
use tempfile::TempDir;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n";
|
||||
|
||||
struct Fixture { app: Arc<App>, _directory: TempDir }
|
||||
struct Received { status: StatusCode, headers: HeaderMap, body: Bytes }
|
||||
|
||||
impl Fixture {
|
||||
async fn new(config: Config) -> Self {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let policy = ValidationPolicy {
|
||||
tls_addr: None, cert_dir: None, data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
};
|
||||
let store = Store::open(&directory.path().join("db"), &config).await.unwrap();
|
||||
let auth = Auth::new(store.clone(), directory.path().to_owned()).await.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let app = App::new(store, snapshot, policy, auth, 32,
|
||||
Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(30));
|
||||
Self { app, _directory: directory }
|
||||
}
|
||||
|
||||
async fn send(&self, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
|
||||
send(self.app.clone(), method, path, headers, body).await
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received {
|
||||
self.send(Method::GET, path, headers, Body::empty()).await
|
||||
}
|
||||
|
||||
async fn change(&self, edit: impl FnOnce(&mut Config)) {
|
||||
let current = self.app.config.load_full();
|
||||
let mut config = current.envelope.config.clone();
|
||||
edit(&mut config);
|
||||
self.app.update(Update { expected_revision: current.envelope.revision, config }).await.unwrap();
|
||||
}
|
||||
|
||||
async fn stop(self) {
|
||||
self.app.shutdown.cancel();
|
||||
self.app.tunnels.close();
|
||||
self.app.tunnels.wait().await;
|
||||
self.app.store.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(app: Arc<App>, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
|
||||
let mut builder = Request::builder().method(method).uri(path)
|
||||
.header(HOST, "localhost").header(USER_AGENT, "cache-plan-regression/1.0");
|
||||
for (name, value) in headers { builder = builder.header(*name, *value); }
|
||||
let mut request = builder.body(body).unwrap();
|
||||
request.extensions_mut().insert(PeerAddr("192.0.2.50:45678".parse().unwrap()));
|
||||
request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap()));
|
||||
let response = router(app).oneshot(request).await.unwrap();
|
||||
let (parts, body) = response.into_parts();
|
||||
let body = body.collect().await.unwrap().to_bytes();
|
||||
Received { status: parts.status, headers: parts.headers, body }
|
||||
}
|
||||
|
||||
fn config_for(origin: SocketAddr, enabled: bool) -> Config {
|
||||
let mut config = config::demo_config(origin);
|
||||
config.cache.enabled = enabled;
|
||||
config.cache.expose_status = true;
|
||||
config.cache.expose_hits = true;
|
||||
config.cache.expose_cache_status = true;
|
||||
config.cache.expose_only_hits = false;
|
||||
config.cache.static_ignore_cookies = false;
|
||||
config
|
||||
}
|
||||
|
||||
fn web_config(directory: &TempDir, enabled: bool) -> Config {
|
||||
std::fs::write(directory.path().join("style.css"), CSS).unwrap();
|
||||
let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled);
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(), name: "Site".into(), document_root: directory.path().to_owned(),
|
||||
cache_control: Some("public, max-age=600".into()), ..Default::default()
|
||||
});
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "site".into() };
|
||||
config.vhosts[0].backends.clear();
|
||||
config.vhosts[0].routes.clear();
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let fixture = Fixture::new(web_config(&directory, false)).await;
|
||||
let first = fixture.get("/style.css", &[]).await;
|
||||
assert_eq!(first.status, StatusCode::OK);
|
||||
assert_eq!(first.body, CSS);
|
||||
assert!(!first.headers.contains_key("x-cache"));
|
||||
let etag = first.headers["etag"].to_str().unwrap();
|
||||
let head = fixture.send(Method::HEAD, "/style.css", &[], Body::empty()).await;
|
||||
assert_eq!(head.status, StatusCode::OK);
|
||||
assert_eq!(head.headers["content-length"], CSS.len().to_string());
|
||||
assert!(head.body.is_empty());
|
||||
let partial = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]).await;
|
||||
assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(partial.body, &CSS[..4]);
|
||||
let mismatch = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", "\"different\"")]).await;
|
||||
assert_eq!(mismatch.status, StatusCode::OK);
|
||||
assert_eq!(mismatch.body, CSS);
|
||||
let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await;
|
||||
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
|
||||
assert!(conditional.body.is_empty());
|
||||
let invalid = fixture.get("/style.css", &[("range", "bytes=99999-")]).await;
|
||||
assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(invalid.headers["content-range"], format!("bytes */{}", CSS.len()));
|
||||
let only_cached = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await;
|
||||
assert_eq!(only_cached.body, CSS);
|
||||
let logs = fixture.app.logs.recent();
|
||||
assert_eq!(logs.len(), 7);
|
||||
assert!(logs.iter().all(|entry| entry.cache_reason == "cache_disabled"));
|
||||
assert!(logs.iter().all(|entry| entry.request_id.len() == 64));
|
||||
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7);
|
||||
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(fixture.app.logs.stats().queue_dropped, 0);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let fixture = Fixture::new(web_config(&directory, false)).await;
|
||||
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
|
||||
fixture.change(|config| config.cache.enabled = true).await;
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
|
||||
let hit = fixture.get("/style.css", &[]).await;
|
||||
assert_eq!(hit.headers["x-cache"], "HIT");
|
||||
assert_eq!(hit.body, CSS);
|
||||
fixture.change(|config| config.vhosts[0].routes.push(crate::vhost::PathRoute {
|
||||
id: "uncached".into(), path_prefix: "/style.css".into(), cache: Some(false),
|
||||
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
|
||||
..Default::default()
|
||||
})).await;
|
||||
let bypass = fixture.get("/style.css", &[]).await;
|
||||
assert_eq!(bypass.headers["x-cache"], "BYPASS");
|
||||
assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled");
|
||||
fixture.change(|config| {
|
||||
config.vhosts[0].routes.clear();
|
||||
config.vhosts[0].cache.enabled = Some(false);
|
||||
}).await;
|
||||
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
|
||||
fixture.change(|config| config.vhosts[0].cache.enabled = None).await;
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "HIT");
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_transform_remains_independent_of_disabled_cache_and_range() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
let mut config = web_config(&directory, false);
|
||||
config.transform.enabled = true;
|
||||
config.vhosts[0].transform.enabled = true;
|
||||
let fixture = Fixture::new(config).await;
|
||||
let normal = fixture.get("/style.css", &[]).await;
|
||||
assert_eq!(normal.status, StatusCode::OK);
|
||||
assert!(normal.body.len() < CSS.len(), "transform must remain active with cache off");
|
||||
let range = fixture.get("/style.css", &[("range", "bytes=0-3"), ("accept-encoding", "gzip")]).await;
|
||||
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(range.body, &CSS[..4]);
|
||||
assert!(!range.headers.contains_key("content-encoding"));
|
||||
fixture.change(|config| config.vhosts[0].transform.enabled = false).await;
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.body, CSS);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cache_fast_paths_keep_global_and_location_access_control() {
|
||||
let directory = TempDir::new().unwrap();
|
||||
for enabled in [false, true] {
|
||||
let mut config = web_config(&directory, enabled);
|
||||
let deny = crate::acl::Acl { default: crate::acl::Action::Deny, rules: vec![] };
|
||||
config.host_policies.push(config::HostPolicy { hosts: vec!["localhost".into()], acl: deny.clone() });
|
||||
let fixture = Fixture::new(config).await;
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
|
||||
fixture.change(|config| {
|
||||
config.host_policies.clear();
|
||||
config.vhosts[0].routes.push(crate::vhost::PathRoute {
|
||||
id: "private".into(), path_prefix: "/style.css".into(), acl: Some(deny),
|
||||
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
|
||||
..Default::default()
|
||||
});
|
||||
}).await;
|
||||
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
|
||||
fixture.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
struct Origin { addr: SocketAddr, requests: Arc<AtomicU64>, task: tokio::task::JoinHandle<()> }
|
||||
impl Drop for Origin { fn drop(&mut self) { self.task.abort(); } }
|
||||
|
||||
async fn origin() -> Origin {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let requests = Arc::new(AtomicU64::new(0));
|
||||
let count = requests.clone();
|
||||
let app = Router::new().fallback(move |request: Request| {
|
||||
let count = count.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::Relaxed);
|
||||
let (parts, body) = request.into_parts();
|
||||
if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; }
|
||||
let input = body.collect().await.unwrap().to_bytes();
|
||||
let variant = parts.headers.get("x-variant").and_then(|v| v.to_str().ok()).unwrap_or("default");
|
||||
let output = if parts.method == Method::POST { input } else { Bytes::from(format!("origin-{variant}")) };
|
||||
let mut response = Response::new(Body::from(output.clone()));
|
||||
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\""));
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static(
|
||||
if parts.uri.path() == "/private" { "private, max-age=600" } else { "public, max-age=600" }));
|
||||
response.headers_mut().insert("vary", HeaderValue::from_static("X-Variant"));
|
||||
response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain"));
|
||||
for name in ["range", "if-range", "if-none-match", "cookie", "authorization", "x-request-id"] {
|
||||
if let Some(value) = parts.headers.get(name) {
|
||||
let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap();
|
||||
response.headers_mut().insert(echoed, value.clone());
|
||||
}
|
||||
}
|
||||
if parts.headers.get("if-none-match").is_some_and(|v| v == "\"origin-v1\"") {
|
||||
*response.status_mut() = StatusCode::NOT_MODIFIED;
|
||||
*response.body_mut() = Body::empty();
|
||||
} else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2")
|
||||
&& parts.headers.get("if-range").is_none_or(|v| v == "\"origin-v1\"") {
|
||||
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
|
||||
response.headers_mut().insert("content-range", format!("bytes 0-2/{}", output.len()).parse().unwrap());
|
||||
*response.body_mut() = Body::from(output.slice(..3));
|
||||
}
|
||||
response
|
||||
}
|
||||
});
|
||||
let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
|
||||
Origin { addr, requests, task }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() {
|
||||
let origin = origin().await;
|
||||
let fixture = Fixture::new(config_for(origin.addr, false)).await;
|
||||
let range = fixture.get("/inspect", &[("range", "bytes=0-2"), ("if-range", "\"origin-v1\""),
|
||||
("cookie", "session=one"), ("authorization", "Bearer example")]).await;
|
||||
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(range.body, "ori");
|
||||
assert_eq!(range.headers["x-seen-range"], "bytes=0-2");
|
||||
assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\"");
|
||||
assert_eq!(range.headers["x-seen-cookie"], "session=one");
|
||||
assert_eq!(range.headers["x-seen-authorization"], "Bearer example");
|
||||
assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64);
|
||||
assert!(!range.headers.contains_key("x-cache"));
|
||||
let conditional = fixture.get("/inspect", &[("if-none-match", "\"origin-v1\"")]).await;
|
||||
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
|
||||
assert!(conditional.body.is_empty());
|
||||
let input = futures_util::stream::iter([
|
||||
Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")),
|
||||
Ok(Bytes::from_static(b"content")),
|
||||
]);
|
||||
let post = fixture.send(Method::POST, "/echo", &[], Body::from_stream(input)).await;
|
||||
assert_eq!(post.status, StatusCode::OK);
|
||||
assert_eq!(post.body, "streamed content");
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 3);
|
||||
assert!(fixture.app.logs.recent().iter().all(|entry| entry.cache_reason == "cache_disabled"));
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() {
|
||||
let origin = origin().await;
|
||||
let fixture = Fixture::new(config_for(origin.addr, true)).await;
|
||||
for variant in ["a", "b"] {
|
||||
assert_eq!(fixture.get("/vary", &[("x-variant", variant)]).await.headers["x-cache"], "MISS");
|
||||
}
|
||||
let hit = fixture.get("/vary", &[("x-variant", "a")]).await;
|
||||
assert_eq!(hit.headers["x-cache"], "HIT");
|
||||
assert_eq!(hit.body, "origin-a");
|
||||
let range = fixture.get("/vary", &[("x-variant", "a"), ("range", "bytes=0-2"), ("if-range", "\"origin-v1\"")]).await;
|
||||
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(range.headers["x-cache"], "HIT");
|
||||
assert_eq!(range.body, "ori");
|
||||
let validated = fixture.get("/vary", &[("x-variant", "a"), ("cache-control", "no-cache")]).await;
|
||||
assert_eq!(validated.status, StatusCode::OK);
|
||||
assert_eq!(validated.headers["x-cache"], "HIT");
|
||||
assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated");
|
||||
assert_eq!(validated.body, "origin-a");
|
||||
let conditional = fixture.get("/vary", &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")]).await;
|
||||
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
|
||||
let bypass = fixture.get("/vary", &[("authorization", "Bearer example")]).await;
|
||||
assert_eq!(bypass.headers["x-cache-reason"], "request_authorization");
|
||||
for _ in 0..2 {
|
||||
assert_eq!(fixture.get("/private", &[]).await.headers["x-cache"], "MISS");
|
||||
}
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 6);
|
||||
fixture.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() {
|
||||
let origin = origin().await;
|
||||
let fixture = Fixture::new(config_for(origin.addr, true)).await;
|
||||
let mut requests = tokio::task::JoinSet::new();
|
||||
for _ in 0..12 {
|
||||
let app = fixture.app.clone();
|
||||
requests.spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await });
|
||||
}
|
||||
while let Some(result) = requests.join_next().await {
|
||||
let response = result.unwrap();
|
||||
assert_eq!(response.status, StatusCode::OK);
|
||||
assert_eq!(response.body, "origin-default");
|
||||
}
|
||||
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12);
|
||||
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
|
||||
fixture.stop().await;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,4 @@
|
||||
fn request_id_hash(sequence: u64) -> String {
|
||||
use rand::TryRng;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut nonce = [0u8; 32];
|
||||
let mut rng = rand::rngs::SysRng;
|
||||
rng.try_fill_bytes(&mut nonce)
|
||||
.expect("secure operating system random source unavailable");
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(sequence.to_be_bytes());
|
||||
hash.update(nonce);
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
include!("request_id.rs");
|
||||
|
||||
fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool {
|
||||
!minimal_welcome
|
||||
@@ -43,7 +31,13 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.unwrap_or_default()
|
||||
.tls;
|
||||
request.extensions_mut().insert(snapshot.clone());
|
||||
let original_headers = request.headers().clone();
|
||||
let original_headers = if snapshot.envelope.config.compression.enabled()
|
||||
&& request.method() != Method::HEAD
|
||||
{
|
||||
crate::compression::request_headers(request.headers())
|
||||
} else {
|
||||
HeaderMap::new()
|
||||
};
|
||||
let method = request.method().clone();
|
||||
let is_head = method == Method::HEAD;
|
||||
let ws_attempt = request
|
||||
@@ -242,6 +236,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
"x-rustproxy-request-id",
|
||||
"x-rustproxy-revision",
|
||||
"x-rust-proxy-backend",
|
||||
"x-rustproxy-backend",
|
||||
"x-rust-proxy-backend-time",
|
||||
"x-rustproxy-backend-time",
|
||||
"x-cache",
|
||||
|
||||
Reference in new issue
Block a user