0.34.6_repo_cleanup
This commit is contained in:
1 parent
ce1a90397c
commit
133d2f0f91
273 files changed
+5969
-6926
No files matched your search
@@ -1,4 +1,3 @@
|
||||
//! Passes frames through unchanged, including trailers. No body collection.
|
||||
use crate::{
|
||||
logging::{now_ms, LogEntry, Logs},
|
||||
metrics::Metrics,
|
||||
@@ -21,7 +20,6 @@ use tokio::{
|
||||
time::{sleep_until, Sleep},
|
||||
};
|
||||
|
||||
/// Compact source tags avoid a mutex on every completed exchange.
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ExchangeSource {
|
||||
@@ -43,7 +41,6 @@ impl ExchangeSource {
|
||||
}
|
||||
}
|
||||
|
||||
// 0 = unknown, 1 = Some(false), 2 = Some(true). Preserve log schema.
|
||||
fn read_effective(value: u8) -> Option<bool> {
|
||||
match value {
|
||||
1 => Some(false),
|
||||
@@ -176,18 +173,26 @@ impl Drop for Exchange {
|
||||
_ => &self.metrics.status_5xx,
|
||||
}
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
// Final Arc drop has exclusive access: move strings without locking.
|
||||
let backend = self
|
||||
.backend
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.map(|l| l.member.backend.id.clone());
|
||||
let websocket_phase = *self.websocket_phase.get_mut().unwrap_or_else(|e| e.into_inner());
|
||||
let route = self.route.get_mut().unwrap_or_else(|e| e.into_inner()).take();
|
||||
let websocket_phase = *self
|
||||
.websocket_phase
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let route = self
|
||||
.route
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take();
|
||||
let source = ExchangeSource::name(self.source.load(Ordering::Relaxed));
|
||||
let cache_effective_enabled = read_effective(self.cache_effective_enabled.load(Ordering::Relaxed));
|
||||
let transform_effective_enabled = read_effective(self.transform_effective_enabled.load(Ordering::Relaxed));
|
||||
let cache_effective_enabled =
|
||||
read_effective(self.cache_effective_enabled.load(Ordering::Relaxed));
|
||||
let transform_effective_enabled =
|
||||
read_effective(self.transform_effective_enabled.load(Ordering::Relaxed));
|
||||
let client_ip = self
|
||||
.client_ip
|
||||
.get_mut()
|
||||
@@ -246,7 +251,10 @@ impl Drop for Exchange {
|
||||
sni: None,
|
||||
authority: None,
|
||||
matched_by: None,
|
||||
cache_reason: *self.cache_reason.get_mut().unwrap_or_else(|e| e.into_inner()),
|
||||
cache_reason: *self
|
||||
.cache_reason
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
vhost: self.vhost.take(),
|
||||
route,
|
||||
cache_effective_enabled,
|
||||
@@ -266,7 +274,10 @@ impl Drop for Exchange {
|
||||
.websocket_error
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
h3_reset_code: *self.h3_reset_code.get_mut().unwrap_or_else(|e| e.into_inner()),
|
||||
h3_reset_code: *self
|
||||
.h3_reset_code
|
||||
.get_mut()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
upstream_status: match self.upstream_status.load(Ordering::Relaxed) {
|
||||
0 => None,
|
||||
value => Some(value),
|
||||
@@ -297,8 +308,6 @@ pub enum Direction {
|
||||
pub struct MeteredBody {
|
||||
inner: Body,
|
||||
exchange: Option<Arc<Exchange>>,
|
||||
/// For a response, backend selection is final before this wrapper is made.
|
||||
/// Keep the selected lease alive and avoid locking Exchange.backend per frame.
|
||||
response_backend: Option<Arc<crate::balance::Lease>>,
|
||||
request_backend: Option<Arc<crate::balance::Lease>>,
|
||||
direction: Direction,
|
||||
@@ -311,7 +320,13 @@ impl MeteredBody {
|
||||
pub fn new(inner: Body, exchange: Arc<Exchange>, direction: Direction, idle: Duration) -> Self {
|
||||
let finished = inner.is_end_stream();
|
||||
let response_backend = matches!(direction, Direction::Response)
|
||||
.then(|| exchange.backend.lock().unwrap_or_else(|e| e.into_inner()).clone())
|
||||
.then(|| {
|
||||
exchange
|
||||
.backend
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
})
|
||||
.flatten();
|
||||
Self {
|
||||
inner,
|
||||
@@ -319,8 +334,6 @@ impl MeteredBody {
|
||||
response_backend,
|
||||
request_backend: None,
|
||||
direction,
|
||||
// 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,
|
||||
@@ -353,11 +366,12 @@ impl HttpBody for MeteredBody {
|
||||
let count = data.len() as u64;
|
||||
match this.direction {
|
||||
Direction::Request => {
|
||||
// Backend is selected before a non-empty upstream
|
||||
// upload is polled. No retry of a streamed write.
|
||||
if this.request_backend.is_none() {
|
||||
this.request_backend = exchange.backend
|
||||
.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
this.request_backend = exchange
|
||||
.backend
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
}
|
||||
if let Some(lease) = &this.request_backend {
|
||||
lease.bytes(count, true);
|
||||
@@ -455,10 +469,8 @@ impl Drop for MeteredBody {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("body/tests.rs");
|
||||
include!("tests.rs");
|
||||
|
||||
/// Retains the first inspected frame without collecting the rest of a request.
|
||||
/// A trailer/empty DATA frame still prevents storing a request-body variant.
|
||||
pub struct ReplayBody {
|
||||
head: Option<http_body::Frame<bytes::Bytes>>,
|
||||
tail: axum::body::Body,
|
||||
@@ -168,8 +168,6 @@ mod tests {
|
||||
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;
|
||||
|
||||
@@ -114,8 +114,6 @@ fn error(status: StatusCode, detail: &'static str) -> Response {
|
||||
);
|
||||
response
|
||||
} else {
|
||||
// For statuses without a built-in error page (405, 421, 501, ...),
|
||||
// emit the HTTP status instead of implementation-specific diagnostics.
|
||||
let body = format!(
|
||||
"{} {}\n",
|
||||
status.as_u16(),
|
||||
@@ -188,8 +186,6 @@ fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> Se
|
||||
.select_on_arc(&host, path.as_ref(), local, tls)
|
||||
.cloned()
|
||||
});
|
||||
// An ordinary path borrows the original URI in forward_request. Only
|
||||
// a decoded path needs to be moved between middleware and forwarding.
|
||||
let decoded_path = path.and_then(|path| match path {
|
||||
std::borrow::Cow::Borrowed(_) => None,
|
||||
std::borrow::Cow::Owned(path) => Some(path),
|
||||
@@ -211,11 +207,6 @@ fn response_context(snapshot: &crate::config::Snapshot, request: &Request) -> Se
|
||||
})
|
||||
}
|
||||
|
||||
/// Reserved administration-session cookies may cross the proxy boundary only
|
||||
/// for the explicit self-admin deployment: HTTPS, a specifically allowed
|
||||
/// administration hostname (extra DNS list or pinned public HTTPS origin),
|
||||
/// a backend that directly targets the configured
|
||||
/// administration listener, and the existing local-listener risk opt-in.
|
||||
fn admin_session_passthrough(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
host: &str,
|
||||
@@ -263,7 +254,6 @@ fn apply_response_age_rules(
|
||||
crate::headers::apply_wire_age(headers, &route.headers.response);
|
||||
}
|
||||
}
|
||||
/// Final policies apply to cache hits, errors, static pages and ALL transports.
|
||||
fn finish_headers(
|
||||
response: &mut Response,
|
||||
snapshot: &crate::config::Snapshot,
|
||||
@@ -284,7 +274,6 @@ fn finish_headers(
|
||||
.map(|f| &f.diagnostic_headers)
|
||||
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
|
||||
.apply(response.headers_mut(), request_id);
|
||||
// Authenticated content must not leak through an external shared cache either.
|
||||
if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) {
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
@@ -328,8 +317,6 @@ fn apply_timing_headers(
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in backend diagnostics are applied after response rules and cache handling so
|
||||
/// an origin cannot spoof them and a cache hit never reports a stale backend.
|
||||
fn apply_backend_diagnostic_values(
|
||||
response: &mut Response,
|
||||
backend_header: bool,
|
||||
@@ -367,8 +354,6 @@ fn apply_backend_diagnostic_headers(
|
||||
frontend.is_some_and(|value| value.backend_timing_header),
|
||||
);
|
||||
}
|
||||
/// Peek at one bounded frame when a multiplexed request has no exact size hint.
|
||||
/// An absent Content-Length on HTTP/2 or HTTP/3 is NOT proof of an empty body.
|
||||
async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
|
||||
if request.body().is_end_stream() {
|
||||
return Ok(true);
|
||||
@@ -376,11 +361,6 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
|
||||
if request.body().size_hint().lower() > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
// Multiplexed transports may end a body with an empty DATA frame.
|
||||
// On H3 document GET/HEAD, waiting without a bound can stall the entire
|
||||
// cache/404-routing stage before proxy forwarding even starts. A timed
|
||||
// probe that finds no FIN conservatively bypasses caching; it never drops
|
||||
// or consumes a nonempty request body.
|
||||
let bounded_h3_navigation = request.version() == Version::HTTP_3
|
||||
&& (request.method() == Method::GET || request.method() == Method::HEAD);
|
||||
for _ in 0..8 {
|
||||
@@ -420,14 +400,6 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Resolve the framing ambiguity of an HTTP/3 request with no declared length
|
||||
/// before handing it to an HTTP/1.1 upstream. Hyper encodes an unknown-length
|
||||
/// HTTP/1.1 request as chunked for methods other than GET/HEAD/CONNECT. That is
|
||||
/// correct HTTP/1.1, but an actually empty H3 stream can be represented more
|
||||
/// precisely as a zero-length body once its end is observed.
|
||||
///
|
||||
/// Only the first semantically significant frame is inspected; non-empty bodies
|
||||
/// remain streaming through ReplayBody and are never fully buffered.
|
||||
async fn normalize_h3_h1_unknown_body(
|
||||
request: &mut Request,
|
||||
upstream_version: Version,
|
||||
@@ -442,16 +414,6 @@ async fn normalize_h3_h1_unknown_body(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ignore a small number of empty DATA frames while looking for either the
|
||||
// stream end or the first frame that carries request semantics. The body
|
||||
// idle timer in MeteredBody still bounds a stalled peer.
|
||||
// A full page navigation (e.g. clicking the RustProxy logo) is a GET.
|
||||
// H3 HEADERS can precede FIN on a document GET. Resolve the receive half
|
||||
// when FIN arrives promptly, before handing the request to an H1 origin.
|
||||
// Hyper already special-cases GET/HEAD H1 encoding; this probe targets
|
||||
// receive-stream completion, not a presumed chunked GET on the wire.
|
||||
// When the peer is slow or sends a real request body, retain streaming and
|
||||
// do not delay navigation by the general upload/body-idle deadline.
|
||||
let navigation_method = method == Method::GET || method == Method::HEAD;
|
||||
for _ in 0..8 {
|
||||
let next = if navigation_method {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Varnish-like HTTP BAN/PURGE on public vhost listeners. No new admin
|
||||
// endpoint or session token: security rests on a strict, opt-in client IP/CIDR
|
||||
// allowlist plus normal frontend/host ACLs and rate limits. Never forward.
|
||||
fn cache_http_invalidation_method(method: &Method) -> bool {
|
||||
method.as_str() == "BAN" || method.as_str() == "PURGE"
|
||||
}
|
||||
@@ -12,8 +9,6 @@ fn cache_http_invalidation_request(
|
||||
scheme: &str,
|
||||
request: &Request,
|
||||
) -> Result<crate::cache::InvalidationRequest, &'static str> {
|
||||
// Allow a single explicit Content-Length: 0 (used by some PURGE clients),
|
||||
// but reject ambiguous lengths and all transfer-encoded bodies.
|
||||
let mut lengths = request.headers().get_all(http::header::CONTENT_LENGTH).iter();
|
||||
if let Some(length) = lengths.next() {
|
||||
if lengths.next().is_some() || length.as_bytes() != b"0" {
|
||||
@@ -26,8 +21,6 @@ fn cache_http_invalidation_request(
|
||||
{
|
||||
return Err("BAN/PURGE must not contain a request body or upgrade");
|
||||
}
|
||||
// Only this vhost and Host are permitted; no client-supplied cross-vhost
|
||||
// selectors, regex, or arbitrary Cache-Tag from the public listener.
|
||||
let target = request.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
if target.len() > 4096 || !target.starts_with('/') {
|
||||
return Err("invalid BAN/PURGE target");
|
||||
@@ -42,8 +35,6 @@ fn cache_http_invalidation_request(
|
||||
} else {
|
||||
crate::cache::InvalidationSelector::Exact
|
||||
};
|
||||
// Never resolve ambiguous duplicate maintenance headers differently from
|
||||
// intermediaries or audit logs. Reject them before selecting a target.
|
||||
if request.headers().get_all("x-rustproxy-ban-ttl").iter().count() > 1
|
||||
|| request.headers().get_all("x-rustproxy-confirm-wide").iter().count() > 1
|
||||
{
|
||||
@@ -71,9 +62,6 @@ fn cache_http_invalidation_request(
|
||||
if method.as_str() != "BAN" && request.headers().contains_key("x-rustproxy-confirm-wide") {
|
||||
return Err("wide confirmation is only supported for BAN");
|
||||
}
|
||||
// Explicitly scope maintenance to the externally visible scheme. The
|
||||
// ordinary /path matcher would also invalidate the opposite scheme on
|
||||
// a vhost shared between HTTP and HTTPS.
|
||||
if !matches!(scheme, "http" | "https") {
|
||||
return Err("invalid request scheme");
|
||||
}
|
||||
@@ -99,13 +87,9 @@ async fn cache_http_invalidation(
|
||||
peer_ip: std::net::IpAddr,
|
||||
scheme: &str,
|
||||
) -> Response {
|
||||
// Fail closed even when the vhost cache is disabled; never pass commands
|
||||
// through to the backend, and never trust arbitrary X-Forwarded-For.
|
||||
let Some(frontend) = frontend else {
|
||||
return error(StatusCode::NOT_FOUND, "RustProxy: no matching vhost for cache invalidation\n");
|
||||
};
|
||||
// A trusted proxy missing a usable client chain must not inherit its own
|
||||
// allowlist permission for every external visitor.
|
||||
if snapshot.forwarding.trusted(peer_ip)
|
||||
&& crate::vhost::canonical_ip(peer_ip) == client_ip
|
||||
{
|
||||
@@ -118,13 +102,8 @@ async fn cache_http_invalidation(
|
||||
Ok(requested) => requested,
|
||||
Err(reason) => return error(StatusCode::BAD_REQUEST, reason),
|
||||
};
|
||||
// Axum's Request<Body> is Send but its Body is not Sync. Never keep
|
||||
// a shared `&Request` inside the handler future across an .await.
|
||||
// Release the unused body before the asynchronous audit/cache work.
|
||||
let method = request.method().clone();
|
||||
drop(request);
|
||||
// Allocate the independent maintenance slot before even writing an audit
|
||||
// row, so a burst from an allowed IP cannot pile up DB writes and workers.
|
||||
let Ok(permit) = state.cache_invalidation_slots.clone().try_acquire_owned() else {
|
||||
return error(StatusCode::TOO_MANY_REQUESTS, "RustProxy: cache maintenance busy\n");
|
||||
};
|
||||
@@ -136,9 +115,6 @@ async fn cache_http_invalidation(
|
||||
tracing::error!(error = %audit_error, "cache HTTP invalidation audit failed");
|
||||
return error(StatusCode::SERVICE_UNAVAILABLE, "RustProxy: cache maintenance unavailable\n");
|
||||
}
|
||||
// Rate-limit the expensive maintenance path independently of the optional
|
||||
// per-vhost traffic limiter. The permit lives inside the blocking worker
|
||||
// even if a client disconnects while the cache scan is running.
|
||||
let cache = state.cache.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
|
||||
File renamed without changes.
@@ -1,15 +1,13 @@
|
||||
//! 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 },
|
||||
Enabled {
|
||||
force_cache: bool,
|
||||
ignore_cookie: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl CachePlan {
|
||||
@@ -30,11 +28,23 @@ impl CachePlan {
|
||||
}
|
||||
|
||||
pub(super) fn force_cache(self) -> bool {
|
||||
matches!(self, Self::Enabled { force_cache: true, .. })
|
||||
matches!(
|
||||
self,
|
||||
Self::Enabled {
|
||||
force_cache: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn ignore_cookie(self) -> bool {
|
||||
matches!(self, Self::Enabled { ignore_cookie: true, .. })
|
||||
matches!(
|
||||
self,
|
||||
Self::Enabled {
|
||||
ignore_cookie: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn request_policy(
|
||||
@@ -48,16 +58,16 @@ impl CachePlan {
|
||||
) -> 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),
|
||||
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,
|
||||
@@ -73,8 +83,6 @@ pub(super) fn combine_policy(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -8,8 +8,6 @@ struct InternalFallbackRoute {
|
||||
#[derive(Clone)]
|
||||
struct FinalRouteContext(Arc<crate::config::CompiledRoute>);
|
||||
|
||||
/// The named location has already produced the terminal HTTP response.
|
||||
/// In particular, do not replace its 404 HTML with a global/vhost error page.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ErrorFallbackResponse;
|
||||
|
||||
@@ -93,11 +91,6 @@ async fn prepare_error_fallback_body(request: &mut Request, enabled: bool) -> Re
|
||||
return Ok(ErrorFallbackBody::Replayable(Bytes::new()));
|
||||
}
|
||||
let Some(length) = request.body().size_hint().exact() else {
|
||||
// HTTP/2 and HTTP/3 frequently expose an *empty* GET/HEAD body as a
|
||||
// stream with no exact size hint. Refusing to inspect that stream
|
||||
// silently disables the configured 404 fallback. Only peek for safe
|
||||
// bodyless methods; an actual payload is replayed back into the
|
||||
// original request and is never discarded or forwarded twice.
|
||||
if request.method() == Method::GET || request.method() == Method::HEAD {
|
||||
return match empty_for_cache(request).await {
|
||||
Ok(true) => Ok(ErrorFallbackBody::Replayable(Bytes::new())),
|
||||
@@ -183,16 +176,11 @@ async fn execute_error_fallback(
|
||||
Some(response)
|
||||
}
|
||||
|
||||
/// Called only for a response supplied by the Web Server or an HTTP origin.
|
||||
/// Routing/security failures and internal fallback responses are excluded.
|
||||
fn intercept_route_origin_error(
|
||||
response: Response,
|
||||
route: &crate::config::CompiledRoute,
|
||||
snapshot: &crate::config::Snapshot,
|
||||
) -> Response {
|
||||
// An internal named location is the final, administrator-selected fallback
|
||||
// representation. Interception inside its nested forward_request would
|
||||
// otherwise erase its 404 HTML before ErrorFallbackResponse is attached.
|
||||
if route.internal {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -13,7 +13,9 @@ mod h3_navigation_tests {
|
||||
.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();
|
||||
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}");
|
||||
}
|
||||
@@ -22,11 +24,18 @@ mod h3_navigation_tests {
|
||||
#[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 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"));
|
||||
}
|
||||
@@ -35,12 +44,19 @@ mod h3_navigation_tests {
|
||||
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 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();
|
||||
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);
|
||||
}
|
||||
@@ -48,28 +64,38 @@ mod h3_navigation_tests {
|
||||
#[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();
|
||||
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"));
|
||||
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();
|
||||
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();
|
||||
)
|
||||
.await
|
||||
.expect("bounded GET probe")
|
||||
.unwrap();
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -89,10 +89,6 @@ async fn forward_request(
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let internal_dispatch = request.extensions().get::<InternalFallbackRoute>().cloned();
|
||||
// Retain the original URI's shared backing bytes independently of `request`.
|
||||
// A normalized, already-safe path can then be borrowed for the entire
|
||||
// forwarding operation, even after the request URI/headers are rewritten.
|
||||
// Encoded/slash-folded paths still allocate via the strict decoder.
|
||||
let routing_uri = request.uri().clone();
|
||||
let path = if let Some(path) = decoded_path.as_deref() {
|
||||
std::borrow::Cow::Borrowed(path)
|
||||
@@ -108,12 +104,8 @@ async fn forward_request(
|
||||
let frontend = if let Some(selection) = &selected_context {
|
||||
selection.frontend.as_ref()
|
||||
} else {
|
||||
// Internal error fallback calls forward_request directly, without
|
||||
// rerunning the observation middleware.
|
||||
snapshot.frontend(&host, local.0, tls)
|
||||
};
|
||||
// Host-level denylist: before cache, custom locations, redirects, local
|
||||
// Web Server/PHP-FPM, or any origin connection. No request body is parsed.
|
||||
if frontend.is_some_and(|item| item.content_type_policy.blocks(request.headers())) {
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -132,8 +124,6 @@ async fn forward_request(
|
||||
handshake.is_some() && frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
|
||||
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
|
||||
// TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it.
|
||||
// Keep this request-level guard for coalesced/reused connections and direct tests.
|
||||
if request.version() == Version::HTTP_2
|
||||
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|
||||
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
|
||||
@@ -227,18 +217,12 @@ async fn forward_request(
|
||||
cloudflare_country = crate::forwarding::cloudflare_country(request.headers(), peer.0);
|
||||
}
|
||||
}
|
||||
// `observe` already stored the actual transport peer. Most requests
|
||||
// don't use a trusted forwarding hop, so avoid formatting the same IP
|
||||
// and taking the per-request mutex a second time. IPv4-mapped peers
|
||||
// still take this path so their canonical form is correctly logged.
|
||||
if context.client_ip != peer.0.ip() {
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(context.client_ip.to_string());
|
||||
}
|
||||
}
|
||||
// GeoIP stays off the normal request path. Resolve lazily for any vhost or
|
||||
// location policy that actually needs a country code, or for log capture.
|
||||
let capture_country = state.logs.capture_country();
|
||||
let mut country_resolved = false;
|
||||
let mut country: Option<String> = None;
|
||||
@@ -298,9 +282,6 @@ async fn forward_request(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Global host ACLs remain an independent restrictive scope and also apply to
|
||||
// maintenance/fallback/direct-IP responses. Profile/vhost IP+country policy
|
||||
// was evaluated above as one scope each.
|
||||
if !snapshot.host_allowed(&host, context.client_ip) {
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -384,17 +365,10 @@ async fn forward_request(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Normal routes almost never have an internal error fallback. Do not copy
|
||||
// the complete HeaderMap and request metadata when no fallback exists.
|
||||
let fallback_request = selected
|
||||
.filter(|route| route.error_fallback.is_some())
|
||||
.map(|_| ErrorFallbackRequest::capture(&request, &snapshot));
|
||||
// The admin cookie is never ordinary application state. Strip it BEFORE
|
||||
// auth/cache and keep only the removed fragment locally. A verified self-admin
|
||||
// backend may restore it after the actual upstream has been selected.
|
||||
let admin_cookie = headers::strip_admin_cookie(request.headers_mut());
|
||||
// This is internal authorization, not a public /tinyauth location. It runs
|
||||
// before origin selection, static responses, upgrades and every cache lookup.
|
||||
let basic_identity = if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
|
||||
match auth
|
||||
.authorize(
|
||||
|
||||
@@ -186,7 +186,6 @@ mod authority_tests {
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
let origin = route.backend.as_ref().unwrap();
|
||||
assert_eq!(upstream_request_version(origin, false), Version::HTTP_11);
|
||||
// Extended CONNECT still uses H2 only in its existing WebSocket path.
|
||||
assert_eq!(upstream_request_version(origin, true), Version::HTTP_2);
|
||||
|
||||
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::H2;
|
||||
@@ -581,8 +580,6 @@ mod authority_tests {
|
||||
Bytes::from_static(b"<html><title>ReactPHP 404</title></html>")
|
||||
);
|
||||
|
||||
// A proxy-generated response is eligible for configured global/vhost
|
||||
// pages even when interception of *application* errors is disabled.
|
||||
let response = Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::empty())
|
||||
@@ -599,8 +596,6 @@ mod authority_tests {
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from(format!("{source}: origin 404 body")))
|
||||
.unwrap();
|
||||
// Both paths use intercept_route_origin_error to set this marker
|
||||
// once the origin response reaches Response<Body>.
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(crate::pages::OriginProvidedResponse);
|
||||
@@ -2,18 +2,16 @@ use http::{
|
||||
header::{HeaderName, CONNECTION},
|
||||
HeaderMap,
|
||||
};
|
||||
/// Remove both standard hop-by-hop fields and fields named by Connection.
|
||||
pub fn strip_hop_by_hop(headers: &mut HeaderMap) {
|
||||
// The common HTTP/1.1 "Connection: keep-alive" case has no dynamic
|
||||
// nominated headers to collect. Keep the general multi-value/token
|
||||
// parser for all other requests (including maliciously repeated fields).
|
||||
let no_nominations = {
|
||||
let mut values = headers.get_all(CONNECTION).iter();
|
||||
match (values.next(), values.next()) {
|
||||
(None, _) => true,
|
||||
(Some(value), None) => value.as_bytes().eq_ignore_ascii_case(b"keep-alive")
|
||||
|| (value.as_bytes().eq_ignore_ascii_case(b"close")
|
||||
&& !headers.contains_key("close")),
|
||||
(Some(value), None) => {
|
||||
value.as_bytes().eq_ignore_ascii_case(b"keep-alive")
|
||||
|| (value.as_bytes().eq_ignore_ascii_case(b"close")
|
||||
&& !headers.contains_key("close"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
@@ -44,26 +42,30 @@ pub fn strip_hop_by_hop(headers: &mut HeaderMap) {
|
||||
}
|
||||
}
|
||||
pub fn requests_upgrade(headers: &HeaderMap) -> bool {
|
||||
if headers.contains_key("upgrade") { return true; }
|
||||
if headers.contains_key("upgrade") {
|
||||
return true;
|
||||
}
|
||||
let mut connection = headers.get_all(CONNECTION).iter();
|
||||
match (connection.next(), connection.next()) {
|
||||
(None, _) => return false,
|
||||
(Some(value), None) if value.as_bytes().eq_ignore_ascii_case(b"keep-alive")
|
||||
|| value.as_bytes().eq_ignore_ascii_case(b"close") => return false,
|
||||
(Some(value), None)
|
||||
if value.as_bytes().eq_ignore_ascii_case(b"keep-alive")
|
||||
|| value.as_bytes().eq_ignore_ascii_case(b"close") =>
|
||||
{
|
||||
return false
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
headers.get_all(CONNECTION).iter()
|
||||
headers
|
||||
.get_all(CONNECTION)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(','))
|
||||
.any(|v| v.trim().eq_ignore_ascii_case("upgrade"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
include!("headers/tests.rs");
|
||||
include!("tests.rs");
|
||||
|
||||
/// Cookies are not port-scoped. Remove the local control-plane cookie before
|
||||
/// authentication/cache processing and return its exact cookie fragments. The
|
||||
/// guarded self-admin reverse-proxy path may restore only those fragments after
|
||||
/// the actual backend has been selected and verified.
|
||||
pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
|
||||
if !headers.contains_key(http::header::COOKIE) {
|
||||
return Vec::new();
|
||||
@@ -95,8 +97,6 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
|
||||
admin
|
||||
}
|
||||
|
||||
/// Restore only the reserved administration cookie after the backend has been
|
||||
/// proven to be the configured local administration listener.
|
||||
pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) {
|
||||
if admin.is_empty() {
|
||||
return;
|
||||
@@ -112,7 +112,6 @@ pub fn restore_admin_cookie(headers: &mut HeaderMap, admin: &[String]) {
|
||||
headers.insert(http::header::COOKIE, value);
|
||||
}
|
||||
}
|
||||
/// Upstreams cannot overwrite the reserved admin cookie through this proxy listener.
|
||||
pub fn strip_admin_set_cookie(headers: &mut HeaderMap) {
|
||||
if !headers.contains_key(http::header::SET_COOKIE) {
|
||||
return;
|
||||
@@ -1,5 +1,3 @@
|
||||
// Observation has already validated routing identity. Transfer it to the
|
||||
// forward handler instead of repeating hostname parsing and percent decoding.
|
||||
#[derive(Clone)]
|
||||
struct ValidatedRoutingIdentity {
|
||||
original_host: HeaderValue,
|
||||
@@ -12,8 +10,6 @@ struct SelectedRouteContext {
|
||||
route: Option<Arc<crate::config::CompiledRoute>>,
|
||||
validated: Option<ValidatedRoutingIdentity>,
|
||||
}
|
||||
/// Transfer middleware-validated identity without cloning strings. Internal
|
||||
/// fallback dispatches still use the complete authority validation path.
|
||||
fn take_routing_identity(
|
||||
request: &mut Request,
|
||||
tls: bool,
|
||||
|
||||
+4
-4
@@ -10,11 +10,11 @@ include!("redirect_helpers.rs");
|
||||
include!("forward_entry.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/authority_tests.rs");
|
||||
include!("headers/authority_tests.rs");
|
||||
#[cfg(test)]
|
||||
include!("tests/h3_navigation_tests.rs");
|
||||
include!("forward/h3_navigation_tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("tests/http_invalidation_tests.rs");
|
||||
include!("cache_http_invalidation_tests.rs");
|
||||
#[cfg(test)]
|
||||
include!("tests/cache_fast_path_tests.rs");
|
||||
include!("forward/cache_fast_path_tests.rs");
|
||||
@@ -1,5 +1,3 @@
|
||||
// Frontend redirect helpers.
|
||||
/// Build a same-host HTTPS redirect while preserving the request path and query.
|
||||
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
|
||||
let path = uri
|
||||
.path_and_query()
|
||||
@@ -57,7 +55,6 @@ fn force_ssl_redirect(
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(http::header::LOCATION, location);
|
||||
// Do not pin a redirect in a browser after an operator later disables Force SSL.
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
fn request_id_hash(sequence: u64) -> String {
|
||||
// Same thread-local CSPRNG as before, now with no per-request SHA-256.
|
||||
// XORing an increasing sequence into independent uniformly random bytes
|
||||
// preserves the full 256-bit unpredictability and 64-hex-character shape.
|
||||
// The sequence is still mixed in; this is not a predictable counter ID.
|
||||
let mut token: [u8; 32] = rand::random();
|
||||
for (byte, sequence_byte) in token[..8].iter_mut().zip(sequence.to_be_bytes()) {
|
||||
*byte ^= sequence_byte;
|
||||
@@ -12,8 +8,6 @@ fn request_id_hash(sequence: u64) -> String {
|
||||
|
||||
fn request_id_hex(digest: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
// Encode two ASCII bytes per digest byte in one allocation; avoid 64
|
||||
// separate String::push bounds checks for the 32-byte SHA-256 digest.
|
||||
let mut encoded = vec![0u8; digest.len() * 2];
|
||||
for (i, &byte) in digest.iter().enumerate() {
|
||||
encoded[2 * i] = HEX[(byte >> 4) as usize];
|
||||
@@ -23,4 +17,4 @@ fn request_id_hex(digest: &[u8]) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
include!("request_id/tests.rs");
|
||||
include!("tests.rs");
|
||||
@@ -1,358 +0,0 @@
|
||||
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;
|
||||
// Warm the cache before testing a header-denied cache hit.
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ use http::{
|
||||
};
|
||||
use http_body::Body as HttpBody;
|
||||
use http_body_util::BodyExt;
|
||||
/// Mark responses whose configured outbound policies were already applied.
|
||||
#[derive(Clone)]
|
||||
struct ResponseRulesApplied;
|
||||
#[derive(Clone)]
|
||||
@@ -146,9 +145,6 @@ fn retry_safe_method(method: &Method) -> bool {
|
||||
}
|
||||
|
||||
fn upstream_uri(backend: &crate::config::CompiledBackend, suffix: &str) -> Option<Uri> {
|
||||
// Reuse the validated scheme and authority from the immutable compiled
|
||||
// backend. Only the untrusted path/query is parsed per request. This also
|
||||
// prevents accidental authority changes from rewritten paths.
|
||||
let path: http::uri::PathAndQuery = suffix.parse().ok()?;
|
||||
let mut parts = backend.request_origin_uri.clone().into_parts();
|
||||
parts.path_and_query = Some(path);
|
||||
@@ -167,12 +163,6 @@ fn legacy_websocket_origin(
|
||||
HeaderValue::from_str(&format!("{scheme}://{authority}")).ok()
|
||||
}
|
||||
|
||||
/// Align HTTP request authority with the selected public/backend Host.
|
||||
///
|
||||
/// TCP/DNS/TLS still target the configured origin in TimedConnector. Strict
|
||||
/// HTTP/2 sends :authority without a second Host field. HTTPS ALPN mixed
|
||||
/// mode must retain the Host field: Hyper's legacy H1-compatible request may
|
||||
/// be sent over an H2-negotiated connection, and H1 fallback needs Host.
|
||||
fn upstream_request_target(
|
||||
uri: Uri,
|
||||
headers: &mut HeaderMap,
|
||||
@@ -191,7 +181,6 @@ fn upstream_request_target(
|
||||
}
|
||||
let selected = headers.get(HOST).unwrap_or(&backend.host_header);
|
||||
let authority: http::uri::Authority = selected.to_str().ok()?.parse().ok()?;
|
||||
// Never treat a userinfo URI or a malformed Host as a valid HTTP authority.
|
||||
if authority.host().is_empty() || authority.as_str().contains('@') {
|
||||
return None;
|
||||
}
|
||||
@@ -212,8 +201,6 @@ fn upstream_request_version(
|
||||
backend: &crate::config::CompiledBackend,
|
||||
websocket_h2: bool,
|
||||
) -> Version {
|
||||
// HTTPS legacy `http2` must still permit H1 after ALPN negotiation.
|
||||
// Cleartext legacy `http2` and strict `h2` use H2 (prior knowledge on HTTP).
|
||||
if websocket_h2
|
||||
|| backend.protocol == crate::config::UpstreamProtocol::H2
|
||||
|| (backend.protocol == crate::config::UpstreamProtocol::Http2
|
||||
@@ -238,7 +225,6 @@ fn retry_request(
|
||||
let mut request = Request::builder()
|
||||
.method(method.clone())
|
||||
.version(version)
|
||||
// Keep the original URI for authority adjustment after building the request.
|
||||
.uri(uri.clone())
|
||||
.body(Body::empty())
|
||||
.ok()?;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
include!("request_id.rs");
|
||||
include!("request_id/mod.rs");
|
||||
|
||||
// Retain the exchange through the response, but do not allocate an additional
|
||||
// body adapter for a request whose body has no frames or trailers.
|
||||
fn wrap_observed_request(body: Body, exchange: &Arc<Exchange>, idle: Duration) -> Body {
|
||||
if body.is_end_stream() {
|
||||
body
|
||||
@@ -48,8 +46,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
let selected = response_context(&snapshot, &request);
|
||||
let frontend_context = selected.frontend.clone();
|
||||
let route_context = selected.route.clone();
|
||||
// The same validated request identity and snapshot are transferred to
|
||||
// forward(); no second hostname parsing or encoded-path decoding.
|
||||
request.extensions_mut().insert(selected);
|
||||
let local = request.extensions().get::<LocalAddr>().map(|v| v.0);
|
||||
let transport_tls = request
|
||||
@@ -200,9 +196,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
h3_reset_code: Mutex::new(None),
|
||||
upstream_status: AtomicU16::new(0),
|
||||
upstream_error: Mutex::new(None),
|
||||
// Always log at least the transport peer. `forward` replaces this with
|
||||
// the verified effective client address after trusted-proxy/Cloudflare
|
||||
// processing, so policy and live-log identity remain aligned.
|
||||
client_ip: Mutex::new(initial_client_ip),
|
||||
user_agent,
|
||||
country: Mutex::new(None),
|
||||
@@ -251,9 +244,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
}
|
||||
}
|
||||
if minimal_welcome {
|
||||
// Keep the built-in "RustProxy is running" response intentionally
|
||||
// minimal. It is never cacheable and carries no optional RustProxy
|
||||
// diagnostics/server/security/compression/cache-diagnostic headers.
|
||||
for name in [
|
||||
"server",
|
||||
"server-timing",
|
||||
@@ -336,9 +326,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
);
|
||||
}
|
||||
let global_compression = &snapshot.envelope.config.compression;
|
||||
// All scoped policies have the global codec ceiling. With no accepted
|
||||
// global encoding (e.g. Accept-Encoding: identity), encode is a no-op:
|
||||
// avoid even constructing/resolving the per-request settings.
|
||||
if method != Method::HEAD
|
||||
&& crate::compression::preferred_encoding(&original_headers, global_compression)
|
||||
.is_some()
|
||||
@@ -437,13 +424,9 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
.get::<LocalGeneratedResponse>()
|
||||
.is_some()
|
||||
{
|
||||
// Static/placeholder/default pages are generated locally and must never
|
||||
// look cacheable or carry RustProxy cache diagnostics/validators.
|
||||
enforce_local_page_no_cache(&mut response);
|
||||
}
|
||||
if response.extensions().get::<LocalHostResponse>().is_some() {
|
||||
// 404 hosts are fully local and never participate in RustProxy caching.
|
||||
// Keep their wire response free of cache policy, validators and cache diagnostics.
|
||||
for name in [
|
||||
"cache-control",
|
||||
"age",
|
||||
@@ -490,8 +473,6 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
|
||||
))
|
||||
})
|
||||
}
|
||||
/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field.
|
||||
/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy.
|
||||
fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
|
||||
let count = request.headers().get_all(HOST).iter().count();
|
||||
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
//! Conditional validation for a local Web Server origin. The reverse-proxy
|
||||
//! source has an equivalent 304 path; both must preserve the stored body and
|
||||
//! its hit counter when the origin reports that the representation is unchanged.
|
||||
|
||||
use crate::cache::{Cache, RangeSelection, Revalidation, Settings, Ticket};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{HeaderMap, HeaderValue, Method, StatusCode};
|
||||
@@ -13,10 +9,6 @@ pub(super) struct WebRevalidated {
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// The client's validators describe the *public* representation (possibly
|
||||
/// transformed). Only validators belonging to our stored origin representation
|
||||
/// may be sent to the Web Server. Never allow an unrelated client ETag to turn
|
||||
/// into a 304 that would incorrectly validate our cached body.
|
||||
pub(super) fn web_origin_request_headers(
|
||||
client: &HeaderMap,
|
||||
cached: Option<&Revalidation>,
|
||||
@@ -29,9 +21,6 @@ pub(super) fn web_origin_request_headers(
|
||||
(headers, sent)
|
||||
}
|
||||
|
||||
/// A transformed body uses public representation-specific headers. A local
|
||||
/// origin's 304 can describe its own encoding/ranges, which must not be copied
|
||||
/// onto the cached, already transformed body.
|
||||
fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap) -> HeaderMap {
|
||||
let mut merged = cached.merge_validation_headers(validation);
|
||||
if cached.transformed() {
|
||||
@@ -47,11 +36,6 @@ fn web_merged_validation_headers(cached: &Revalidation, validation: &HeaderMap)
|
||||
merged
|
||||
}
|
||||
|
||||
/// Handle a real 304 from the local origin, *after* matching it to a stored
|
||||
/// object using our own validator. A successful validation increases HITs and
|
||||
/// refreshes freshness metadata; an uncacheable 304 is served once and purged.
|
||||
/// `apply_rules` keeps vhost response rules identical to the ordinary Web
|
||||
/// Server 200/fill path. Invalidated tickets are never resurrected.
|
||||
pub(super) fn finish_web_revalidation(
|
||||
cache: &Cache,
|
||||
ticket: &Ticket,
|
||||
@@ -66,8 +50,6 @@ pub(super) fn finish_web_revalidation(
|
||||
apply_rules: impl FnOnce(&mut HeaderMap),
|
||||
) -> Option<WebRevalidated> {
|
||||
let origin_merged = web_merged_validation_headers(cached, validation);
|
||||
// Vhost response rules must never turn a newly private/no-store origin
|
||||
// into a cacheable shared representation.
|
||||
let origin_safe = crate::cache::plan(StatusCode::OK, &origin_merged, settings, elapsed).is_some();
|
||||
let mut merged = origin_merged;
|
||||
apply_rules(&mut merged);
|
||||
@@ -135,8 +135,6 @@ async fn range_and_head_requests_use_validated_cached_body() {
|
||||
#[tokio::test]
|
||||
async fn changed_local_file_is_not_falsely_revalidated() {
|
||||
let (dir, service, cache, ticket, settings) = fixture().await;
|
||||
// A size change guarantees different ETag even on filesystems with
|
||||
// coarse modification-time resolution.
|
||||
std::fs::write(dir.path().join("shCore.js"), b"new file content").unwrap();
|
||||
let cached = cache.revalidation_tiered(&ticket).await.unwrap();
|
||||
let (headers, sent) = web_origin_request_headers(&HeaderMap::new(), Some(&cached));
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! WebSocket bootstrap validation and bounded byte-transparent tunnels.
|
||||
//! HTTP/1.1 uses RFC 6455 Upgrade; HTTP/2 uses RFC 8441 Extended CONNECT;
|
||||
//! HTTP/3 uses RFC 8441 + RFC 9220 Extended CONNECT. Extended CONNECT keeps
|
||||
//! WebSocket frames on the request/response stream instead of taking over TCP.
|
||||
//! No message aggregation, application-specific routing or payload logging.
|
||||
|
||||
include!("websocket/handshake.rs");
|
||||
include!("websocket/tunnel.rs");
|
||||
include!("websocket/validation.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("websocket/tests.rs");
|
||||
|
||||
#[cfg(test)]
|
||||
include!("websocket/http2_tests.rs");
|
||||
#[cfg(test)]
|
||||
include!("websocket/http3_tests.rs");
|
||||
@@ -153,9 +153,6 @@ pub fn validate(request: &Request) -> Result<Handshake> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the RFC 8441 bootstrap used by RFC 9220 on HTTP/3. The
|
||||
/// Sec-WebSocket-Key/Accept proof belongs only to the HTTP/1.1 hop, so a fresh
|
||||
/// key is synthesized later for the backend Upgrade request.
|
||||
pub fn validate_h3(request: &Request) -> Result<Handshake> {
|
||||
ensure!(
|
||||
request.method() == Method::CONNECT && request.version() == Version::HTTP_3,
|
||||
@@ -196,9 +193,6 @@ pub fn validate_h3(request: &Request) -> Result<Handshake> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate RFC 8441 Extended CONNECT on HTTP/2. The downstream hop does not
|
||||
/// use Sec-WebSocket-Key/Accept; a fresh RFC 6455 key is generated only when
|
||||
/// RustProxy has to bridge the stream to an HTTP/1.1 backend.
|
||||
pub fn validate_h2(request: &Request) -> Result<Handshake> {
|
||||
ensure!(
|
||||
request.method() == Method::CONNECT && request.version() == Version::HTTP_2,
|
||||
@@ -258,12 +252,6 @@ pub fn validate_response(
|
||||
legacy_support: bool,
|
||||
) -> Result<()> {
|
||||
if legacy_support {
|
||||
// Some older embedded servers return a valid 101 + Sec-WebSocket-Accept
|
||||
// while omitting Connection: Upgrade, duplicating Upgrade, or attaching
|
||||
// zero/meaningless HTTP body-framing headers. The connection is dedicated
|
||||
// to the upgraded tunnel and those framing headers are stripped before the
|
||||
// downstream response, so keep the cryptographic accept proof strict while
|
||||
// tolerating the legacy response syntax.
|
||||
ensure!(
|
||||
has_token(headers, "upgrade", "websocket"),
|
||||
"invalid legacy upstream upgrade"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
mod http2_tests {
|
||||
use axum::body::Body;
|
||||
use http::{Method, Request, Version};
|
||||
use super::{h2_protocol, validate_h2};
|
||||
|
||||
fn extended_connect(protocol: &'static str) -> Request<Body> {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(hyper::ext::Protocol::from_static(protocol));
|
||||
request
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn rfc8441_extended_connect_is_recognized() {
|
||||
let request = extended_connect("websocket");
|
||||
assert_eq!(h2_protocol(&request), Some("websocket"));
|
||||
let handshake = validate_h2(&request).expect("valid RFC 8441 bootstrap");
|
||||
assert!(handshake.h2());
|
||||
assert!(handshake.upstream_key().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_rejects_h1_upgrade_fields_and_other_protocols() {
|
||||
let mut request = extended_connect("websocket");
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("upgrade", "websocket".parse().unwrap());
|
||||
assert!(validate_h2(&request).is_err());
|
||||
|
||||
let request = extended_connect("connect-udp");
|
||||
assert!(validate_h2(&request).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
mod http3_tests {
|
||||
use axum::body::Body;
|
||||
use http::{Method, Request, Version};
|
||||
use super::{h3_protocol, validate_h3};
|
||||
|
||||
|
||||
#[test]
|
||||
fn rfc9220_extended_connect_is_recognized() {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("websocket".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse")));
|
||||
|
||||
assert_eq!(h3_protocol(&request), Some("websocket"));
|
||||
let handshake = validate_h3(&request).expect("valid RFC 9220 bootstrap");
|
||||
assert!(handshake.h3());
|
||||
assert!(handshake.upstream_key().is_some());
|
||||
|
||||
let _transport_contract_marker = ".send_data(";
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/// Bridge an HTTP/2 Extended CONNECT stream to an already upgraded HTTP/1.1
|
||||
/// backend WebSocket. DATA bytes remain byte-transparent in both directions.
|
||||
pub fn bridge_h2_to_h1(
|
||||
state: Arc<App>,
|
||||
mut downstream: Body,
|
||||
@@ -102,9 +100,6 @@ struct H3TunnelState {
|
||||
stop: Option<ServerShutdown>,
|
||||
}
|
||||
|
||||
/// Response extension consumed by the HTTP/3 transport after it has sent the
|
||||
/// successful 200 response. The upstream connection is already upgraded before
|
||||
/// this extension is created, so downstream success never races backend takeover.
|
||||
#[derive(Clone)]
|
||||
pub struct H3Tunnel(Arc<Mutex<Option<H3TunnelState>>>);
|
||||
|
||||
@@ -192,10 +187,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge one RFC 9220 HTTP/3 stream to the already validated HTTP/1.1
|
||||
/// WebSocket upgrade at the selected backend. DATA payload bytes are copied
|
||||
/// unchanged; FIN remains a half-close and abnormal termination maps to
|
||||
/// H3_REQUEST_CANCELLED.
|
||||
pub async fn run_h3(
|
||||
state: Arc<App>,
|
||||
send: &mut h3::server::RequestStream<h3::quinn::SendStream<Bytes>, Bytes>,
|
||||
@@ -350,9 +341,6 @@ pub fn spawn(
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Browsers and upstreams commonly close upgraded TCP streams without
|
||||
// a WebSocket close frame. Reset/EOF/broken-pipe are normal tunnel
|
||||
// termination signals, not malformed HTTP requests or proxy errors.
|
||||
if !benign_tunnel_close(&error) {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("tunnel_error", "bridge_error");
|
||||
@@ -380,7 +368,6 @@ where
|
||||
A: AsyncRead + AsyncWrite + Unpin,
|
||||
B: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// Tokio uses fixed-size buffers in both directions. Writes apply backpressure.
|
||||
let copying = tokio::io::copy_bidirectional(a, b);
|
||||
tokio::pin!(copying);
|
||||
let mut remaining = idle;
|
||||
|
||||
Reference in new issue
Block a user