0.34.6_repo_cleanup

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 09:31:15 +02:00
1 parent ce1a90397c
commit 133d2f0f91
273 files changed
+5969 -6926

No files matched your search

+658
View File
@@ -0,0 +1,658 @@
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 content_type_policy_denies_before_local_web_server_and_cache_and_updates_live() {
let directory = TempDir::new().unwrap();
let mut config = web_config(&directory, true);
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
enabled: true,
blocked: vec!["multipart/form-data".into(), "image/*".into()],
};
let fixture = Fixture::new(config).await;
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::OK);
assert_eq!(
fixture
.get(
"/style.css",
&[("content-type", "multipart/form-data; boundary=example")]
)
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
fixture
.get("/style.css", &[("content-type", "IMAGE/PNG")])
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
fixture
.send(
Method::POST,
"/style.css",
&[("content-type", "multipart/form-data; boundary=x")],
Body::from("sample")
)
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
fixture
.get("/style.css", &[("content-type", "text/plain")])
.await
.status,
StatusCode::OK
);
assert_eq!(
fixture
.get(
"/style.css",
&[("content-type", "application/json, multipart/form-data")]
)
.await
.status,
StatusCode::FORBIDDEN
);
fixture
.change(|config| config.vhosts[0].content_type_policy.enabled = false)
.await;
assert_eq!(
fixture
.get("/style.css", &[("content-type", "multipart/form-data")])
.await
.status,
StatusCode::OK
);
fixture.stop().await;
}
#[tokio::test]
async fn content_type_policy_denies_before_reverse_proxy_upstream_request() {
let origin = origin().await;
let mut config = config_for(origin.addr, false);
config.vhosts[0].content_type_policy = crate::vhost::ContentTypePolicy {
enabled: true,
blocked: vec!["multipart/form-data".into()],
};
let fixture = Fixture::new(config).await;
let blocked = fixture
.send(
Method::POST,
"/",
&[("content-type", "Multipart/Form-Data; boundary=abc")],
Body::from("--abc\r\n"),
)
.await;
assert_eq!(blocked.status, StatusCode::FORBIDDEN);
assert_eq!(origin.requests.load(Ordering::Relaxed), 0);
let allowed = fixture
.get("/", &[("content-type", "application/json")])
.await;
assert_eq!(allowed.status, StatusCode::OK);
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
fixture.stop().await;
}
#[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;
}
}
-5
View File
@@ -133,7 +133,6 @@ if let Some(ticket) = &cache_ticket {
.unwrap_or_else(|e| e.into_inner()) = "cache_miss";
}
} else if cached_revalidation.is_none() {
// Account a true cache miss even when the client requested validation.
let _ = state.cache.get_tiered(ticket, cache_settings).await;
state.cache.record(scope, &host, "MISS", "cache_miss");
miss_recorded = true;
@@ -199,10 +198,6 @@ if only_cached {
return response;
}
// Foreground fills are single-flight per base cache key. Range responses are
// never fill leaders because a 206 is not stored as a separate cache object.
// A follower can wake after a different Vary representation was published; in
// that case it competes for the next flight instead of stampeding the origin.
let mut fill_leader = None;
if handshake.is_none()
&& matches!(range_selection, crate::cache::RangeSelection::None)
+101
View File
@@ -0,0 +1,101 @@
mod h3_navigation_tests {
use super::*;
#[tokio::test]
async fn h3_bodyless_get_and_head_are_normalized_for_http1_navigation() {
use bytes::Bytes;
for method in [Method::GET, Method::HEAD] {
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(method.clone())
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
.await
.unwrap();
assert!(request.body().is_end_stream(), "{method}");
assert_eq!(request.body().size_hint().exact(), Some(0), "{method}");
}
}
#[tokio::test]
async fn h3_get_with_payload_retains_payload_for_http1_upstream() {
use bytes::Bytes;
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(
b"unexpected-but-valid",
))]);
let mut request = Request::builder()
.method(Method::GET)
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
.await
.unwrap();
let body = request.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"unexpected-but-valid"));
}
#[tokio::test]
async fn h3_stalled_cache_probe_bypasses_without_blocking_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(Method::GET)
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
let empty = tokio::time::timeout(
std::time::Duration::from_millis(750),
empty_for_cache(&mut request),
)
.await
.expect("bounded cache preflight")
.unwrap();
assert!(!empty, "unknown body is not cacheable");
assert_eq!(request.body().size_hint().exact(), None);
}
#[tokio::test]
async fn h3_cache_probe_replays_nonempty_get_without_dropping_bytes() {
use bytes::Bytes;
let stream =
futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"intact"))]);
let mut request = Request::builder()
.method(Method::GET)
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
assert!(!empty_for_cache(&mut request).await.unwrap());
assert_eq!(
request.into_body().collect().await.unwrap().to_bytes(),
Bytes::from_static(b"intact")
);
}
#[tokio::test]
async fn h3_stalled_empty_get_does_not_block_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(Method::GET)
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
tokio::time::timeout(
std::time::Duration::from_millis(750),
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11),
)
.await
.expect("bounded GET probe")
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
}
}
-7
View File
@@ -7,10 +7,6 @@
).await {
return fallback;
}
// Keep the upstream body as hyper::body::Incoming until the
// HTTP/2 WebSocket and regular response paths have adapted it.
// The opt-in application error page is applied to Response<Body>
// in success_prepare.rs, before transformation/cache admission.
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
include!("success_prepare.rs");
include!("success_finish.rs")
@@ -46,9 +42,6 @@
"upstream connection failed"
);
} else if graceful_h2_close {
// Correlated GOAWAY(NO_ERROR) is expected connection churn. Keep
// failed requests visible as 502/access-log entries, but avoid
// repeating their verbose diagnostic at DEBUG severity.
tracing::trace!(
backend = %active_backend.id,
origin = %active_backend.origin,
-16
View File
@@ -1,7 +1,4 @@
{
// Global host, profile, vhost and location access policies were already enforced
// before this source/cache path. Do not re-evaluate IP ACLs in isolation here:
// allow-only country entries and explicit IP allows form one policy scope.
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
exchange.set_source(if route.response.is_some() {
@@ -15,8 +12,6 @@ if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
}
});
}
// Local location responses intentionally still pass host/profile/location ACLs
// and the outer response-header/security pipeline, but never select/open an upstream.
if let Some(spec) = &route.response {
return local_location_response(spec, request.method());
}
@@ -125,9 +120,6 @@ let cache_settings = route
let transform_settings = route.frontend.as_ref().map(|f| &f.transform);
let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or("");
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
// The resolved route was already recorded before local response, LB and
// ACL decisions. Do not acquire the route mutex and copy its ID twice on
// every reverse-proxy request.
exchange.set_cache_enabled(cache_settings.active_for(route.cache));
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
}
@@ -169,8 +161,6 @@ let body_empty = if cache_enabled
} else {
cache_enabled && request.body().is_end_stream()
};
// Keep the original URI alive through forwarding: ordinary targets can be
// borrowed while route rewrites still allocate and are strictly validated.
let public_suffix: std::borrow::Cow<'_, str> = std::borrow::Cow::Borrowed(
routing_uri.path_and_query().map(|v| v.as_str()).unwrap_or("/")
);
@@ -247,9 +237,6 @@ if let Some(frontend) = &route.frontend {
crate::headers::apply(request.headers_mut(), &frontend.headers.request);
}
crate::headers::apply(request.headers_mut(), &route.headers.request);
// Canonical trusted metadata cannot be overwritten by custom request rules.
// A vhost may intentionally withhold only the visitor IP from the application;
// ACL/rate-limit/Tinyauth decisions above still use the authenticated client address.
context.apply_upstream(
request.headers_mut(),
route
@@ -289,9 +276,6 @@ let if_range = if effective_cacheable {
} 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 effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") {
-8
View File
@@ -1,7 +1,4 @@
{
// A backend 304 validates the stored representation. Merge the new
// metadata into the cached object and answer the client's original
// condition locally, just like Varnish's conditional delivery path.
if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
if let (Some(ticket), Some(cached)) =
(cache_ticket.as_ref(), cached_revalidation.as_ref())
@@ -39,9 +36,6 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
cache_settings.annotate_resident_age(response.headers_mut(), resident_age);
Some(response)
} else if state.cache.ticket_current(ticket) {
// The validation succeeded but the refreshed representation is no
// longer eligible for shared storage (for example new no-store).
// Serve it once, then discard the stored copy.
state.cache.discard(ticket);
let mut response = if !client_not_modified {
if let crate::cache::RangeSelection::Single(range) = range_selection {
@@ -91,8 +85,6 @@ 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 origin_plan.is_none()
|| (ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) {
-19
View File
@@ -1,5 +1,4 @@
{
// Only a real cache revalidation needs origin headers after response rules.
let origin_validation_headers = if revalidation_sent
&& response.status() == StatusCode::NOT_MODIFIED
{
@@ -112,9 +111,6 @@ if handshake.as_ref().is_some_and(|handshake| {
"RustProxy: HTTP/1.1 WebSocket upstream returned 2xx without switching protocols\n",
);
}
// Varnish built-in behavior treats non-GET/HEAD methods as pass: the
// response is never stored, but existing cached GET representations are
// not purged implicitly. Explicit cache purge remains an admin action.
let origin_plan = if cache_ticket.is_some()
&& !(ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie"))
{
@@ -181,9 +177,6 @@ let mut response = if h3_tunnel {
"RustProxy: WebSocket exchange state unavailable\n",
);
};
// Hyper cannot complete the client-side upgrade while the 101 body
// remains owned here. Drop it, then prove that OnUpgrade is ready
// BEFORE returning a successful RFC 9220 response to the H3 layer.
drop(body);
let stop_token = stop
.as_ref()
@@ -213,8 +206,6 @@ let mut response = if h3_tunnel {
}
};
exchange.websocket_phase("tunnel_ready");
// RFC 8441/9220 uses a successful 2xx response, never 101, and
// Sec-WebSocket-Accept is not part of the downstream handshake.
parts.status = StatusCode::OK;
parts.headers.remove("sec-websocket-accept");
parts.headers.remove("content-length");
@@ -338,21 +329,11 @@ let mut response = if h3_tunnel {
}
Response::from_parts(parts, Body::new(body))
};
// The upstream HTTP client returns Response<hyper::body::Incoming>. Adapt it
// before rendering a RustProxy error page, so HTTP/2 WebSocket tunnels can
// continue using the original Incoming body without type erasure.
// An internal named-location fallback has already won earlier in response.rs.
let intercept_origin_error = !route.internal && route.frontend.as_ref().is_some_and(|frontend| {
frontend.pages.intercept_statuses.contains(&response.status().as_u16())
});
// Mark every upstream response as application-supplied, even if this vhost
// does not intercept its status. The observer must not overwrite it merely
// because a global 404/5xx error page exists.
response = intercept_route_origin_error(response, route, &snapshot);
if intercept_origin_error {
// The earlier response rules modified the original origin headers;
// reapply them to the newly rendered page so the vhost's header policy
// remains effective. Do not allow a custom header rule to cache errors.
apply_response_rules(
response.headers_mut(),
&snapshot,
-16
View File
@@ -7,8 +7,6 @@ if let Some(handshake) = &handshake {
request.headers_mut().remove("content-length");
request.headers_mut().remove("transfer-encoding");
if websocket_upstream_h2 {
// Preserve CONNECT + :protocol=websocket for an explicit HTTP/2
// upstream. HTTP/1.1-only fields are forbidden on this hop.
request.headers_mut().remove("connection");
request.headers_mut().remove("upgrade");
request.headers_mut().remove("sec-websocket-key");
@@ -21,8 +19,6 @@ if let Some(handshake) = &handshake {
.headers_mut()
.insert("upgrade", HeaderValue::from_static("websocket"));
if !handshake.h1() {
// Extended CONNECT replaces the RFC 6455 proof headers on the
// downstream hop. Synthesize them only for an HTTP/1.1 backend.
*request.method_mut() = Method::GET;
request.headers_mut().remove("sec-websocket-key");
request.headers_mut().remove("sec-websocket-accept");
@@ -40,11 +36,8 @@ if let Some(handshake) = &handshake {
}
}
if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
// Enforce after all custom request-header rules; they cannot restore credentials.
auth.apply_upstream(request.headers_mut(), basic_identity);
}
// Transformation operates on the identity representation. Prefer identity from
// the origin, while still decoding a supported encoding if the origin ignores it.
if range_requested || transform_settings.is_some_and(|settings| settings.enabled) {
request.headers_mut().remove("accept-encoding");
}
@@ -54,16 +47,9 @@ if pending_revalidation {
revalidation_sent = cached.apply_validators(request.headers_mut());
}
}
// Varnish hashes HEAD with GET and turns a regular cacheable backend fetch
// into GET. The outer response middleware still strips the body for the
// original HEAD client, while a MISS can populate the shared GET object.
if request.method() == Method::HEAD && cache_ticket.is_some() {
*request.method_mut() = Method::GET;
}
// Save the pre-normalization headers only for replayable requests that may
// actually be retried. The common one-backend HTTP/1 route has no retry peer
// or H2 GOAWAY handling, so copying HeaderMap for every request was wasted.
// Never replay writes, streaming uploads, auth or WebSocket requests.
let retry_method = request.method().clone();
let retry_policy = route.pool.as_ref().map(|pool| &pool.settings.retry);
let pool_retry_possible = route.backend_id.is_none()
@@ -137,8 +123,6 @@ if websocket_upstream_h2 {
*request.body_mut() = websocket::wrap_h2_request_body(body, session.clone(), exchange);
h2_direct_session = Some(session);
}
// 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 = if cache_ticket.is_some() && cache_settings.proactive_refresh_enabled {
request.headers().clone()
-37
View File
@@ -1,6 +1,4 @@
{
// Web Server is an origin just like a reverse-proxy backend. It must pass
// through the same vhost cache policy, transform stage and response rules.
if handshake.is_some() {
return error(StatusCode::BAD_REQUEST,
"RustProxy: Web Server source does not support WebSocket upgrades\n");
@@ -30,20 +28,13 @@
exchange.set_cache_enabled(enabled);
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
}
// The authority is already validated by effective_host. HTTP/2 and HTTP/3
// requests commonly have :authority but no Host field, which otherwise
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
request.headers_mut().insert(HOST, original_host.clone());
let request_method = request.method().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()
.map(|item| item.as_str()).unwrap_or("/").to_owned();
// H2/H3 may expose an unknown-length empty stream before its final frame.
// Reuse the bounded probe that preserves any nonempty streaming body.
let empty_body = if enabled && (request_method == Method::GET || request_method == Method::HEAD) {
match empty_for_cache(&mut request).await {
Ok(empty) => empty,
@@ -62,8 +53,6 @@
&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 key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") {
let mut headers = request_headers.clone();
@@ -79,9 +68,6 @@
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
// the cached body or resetting X-Cache-Hits (same as Reverse Proxy).
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
} else { None };
@@ -91,10 +77,7 @@
if let Some(ticket) = &cache_ticket {
if !needs_validation {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
// Filesystem/PHP does not have the reverse-proxy background
// revalidator. Refresh stale representations synchronously.
if hit.stale {
// Fall through and fetch from the local origin.
} else {
let conditional = crate::cache::conditional_not_modified(
&request_headers, hit.response.headers());
@@ -130,15 +113,11 @@
return error(StatusCode::GATEWAY_TIMEOUT,
"RustProxy: only-if-cached request has no usable Web Server representation\n");
}
// 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) = if cache_ticket.is_some() {
web_cache_revalidation::web_origin_request_headers(
&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();
@@ -180,8 +159,6 @@
hit.response.extensions_mut().insert(StaticSourceResponse);
return intercept_route_origin_error(hit.response, route, &snapshot);
}
// A simultaneous PURGE/config change invalidated the ticket. Do
// not accidentally return an origin 304 without a reusable body.
return error(StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: Web Server cache entry invalidated during revalidation\n");
}
@@ -189,16 +166,10 @@
if cached_revalidation.is_some() && response.status() != StatusCode::NOT_MODIFIED
&& response.status().as_u16() < 500
{
// A changed response (including a 206, 404 or an uncacheable 200)
// supersedes the old object. Also discard when the old entry has no
// validators and therefore must be fetched unconditionally. A complete
// cacheable GET below can refill the entry.
if let Some(ticket) = cache_ticket.as_ref() {
state.cache.discard(ticket);
}
}
// Do not allow a later response-header rewrite to override a private,
// explicitly uncacheable PHP/CGI origin response.
let origin_headers = response.headers();
let origin_safe = cache_ticket.is_some() && {
let explicit_origin_policy = origin_headers.contains_key("cache-control")
@@ -221,14 +192,11 @@
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
if response.extensions().get::<crate::pages::PageServed>().is_some() {
// A vhost header rule must not make a replaced error cacheable.
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
}
response.extensions_mut().insert(ResponseRulesApplied);
response.extensions_mut().insert(StaticSourceResponse);
// Cache representation must reflect vhost response rules and transforms,
// not solely the Web Service's Cache-Control header.
let origin_eligible_plan = if origin_safe
&& !(ignore_cookie && crate::cache::varies_on(response.headers(), "cookie"))
{
@@ -288,11 +256,6 @@
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
// Register only AFTER the vhost response policy, cache and transformation
// decisions are final. Never feed synthetic transport placeholders into
// cache, compression or the transformation engine. The ordinary body
// remains the fallback on every other protocol/response and when the
// per-connection bridge has no capacity.
#[cfg(target_os = "linux")]
if !enabled && identity_request && request_method == Method::GET
&& !transform_settings.is_some_and(|settings| settings.enabled)