Files
RustProxy/src/proxy/mod.rs
T
2026-10-03 00:27:09 +01:00

3329 lines
130 KiB
Rust

pub mod body;
pub mod headers;
pub mod websocket;
use crate::{
config::request_host,
runtime::App,
server::{ConnectionLease, LocalAddr, PeerAddr, ServerShutdown, Transport},
};
use axum::{
body::Body,
extract::{Request, State},
middleware::{self, Next},
response::{IntoResponse, Response},
Extension, Router,
};
use body::{Direction, Exchange, MeteredBody};
use http::{
header::{HeaderValue, HOST},
HeaderMap, Method, StatusCode, Uri, Version,
};
use http_body::Body as HttpBody;
use http_body_util::BodyExt;
#[derive(Clone)]
struct ResponseRulesApplied;
#[derive(Clone)]
struct LocalGeneratedResponse;
#[derive(Clone, Copy)]
struct RedirectResponse { http3_enabled: bool }
#[derive(Clone, Copy)]
struct LocalHostResponse { http3_enabled: bool }
#[derive(Clone, Copy)]
struct OriginTiming(Duration);
#[derive(Clone)]
struct OriginBackend(String);
use std::{
sync::{
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
Arc, Mutex,
},
time::{Duration, Instant},
};
pub fn router(state: Arc<App>) -> Router {
Router::new()
.fallback(forward)
.layer(middleware::from_fn_with_state(state.clone(), observe))
.layer(middleware::from_fn_with_state(
state.clone(),
crate::acme::challenge,
))
.with_state(state)
}
const MAX_UPSTREAM_ERROR_DETAIL: usize = 2048;
fn truncate_utf8(value: &mut String, max_bytes: usize) {
if value.len() <= max_bytes {
return;
}
let mut end = max_bytes;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
}
fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
let mut parts = Vec::new();
let mut current = Some(error);
for _ in 0..12 {
let Some(item) = current else { break };
let message = item.to_string();
if !message.is_empty() && parts.last() != Some(&message) {
parts.push(message);
}
current = item.source();
}
if current.is_some() {
parts.push("<error chain truncated>".into());
}
parts.join(" -> ")
}
fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cache::StaleErrorClass {
let mut current = Some(error);
let mut text = String::new();
for _ in 0..12 {
let Some(item) = current else { break };
if let Some(io) = item.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::TimedOut {
return crate::cache::StaleErrorClass::Timeout;
}
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(&item.to_string().to_ascii_lowercase());
current = item.source();
}
if text.contains("tls")
|| text.contains("certificate")
|| text.contains("cert verify")
|| text.contains("handshake")
{
crate::cache::StaleErrorClass::Tls
} else if text.contains("dns")
|| text.contains("name resolution")
|| text.contains("resolve host")
|| text.contains("lookup address")
{
crate::cache::StaleErrorClass::Dns
} else if text.contains("timed out") || text.contains("timeout") {
crate::cache::StaleErrorClass::Timeout
} else {
crate::cache::StaleErrorClass::Connection
}
}
fn upstream_error_class(error: &(dyn std::error::Error + 'static), connect_failure: bool) -> &'static str {
match classify_stale_error(error) {
crate::cache::StaleErrorClass::Dns => "dns",
crate::cache::StaleErrorClass::Tls => "tls",
crate::cache::StaleErrorClass::Timeout => "response_header_timeout",
crate::cache::StaleErrorClass::Connection if connect_failure => "connect",
crate::cache::StaleErrorClass::Connection => "io_error",
}
}
fn retry_safe_method(method: &Method) -> bool {
method == Method::GET || method == Method::HEAD || method == Method::OPTIONS
}
fn upstream_uri(backend: &crate::config::CompiledBackend, suffix: &str) -> Option<Uri> {
format!("{}{suffix}", backend.request_origin).parse().ok()
}
fn legacy_websocket_origin(
request_origin: &str,
upstream_host: &HeaderValue,
) -> Option<HeaderValue> {
let scheme = request_origin.split_once("://")?.0;
let authority = upstream_host.to_str().ok()?.trim();
if authority.is_empty() {
return None;
}
HeaderValue::from_str(&format!("{scheme}://{authority}")).ok()
}
fn upstream_request_version(
backend: &crate::config::CompiledBackend,
websocket_h2: bool,
) -> Version {
if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::Http2 {
Version::HTTP_2
} else {
Version::HTTP_11
}
}
fn retry_request(
method: &Method,
version: Version,
headers: &HeaderMap,
backend: &crate::config::CompiledBackend,
original_host: &HeaderValue,
origin_suffix: &str,
host_follows_backend: bool,
) -> Option<Request> {
let uri = upstream_uri(backend, origin_suffix)?;
let mut request = Request::builder()
.method(method.clone())
.version(version)
.uri(uri)
.body(Body::empty())
.ok()?;
*request.headers_mut() = headers.clone();
if host_follows_backend {
request.headers_mut().insert(
HOST,
if backend.preserve_host {
original_host.clone()
} else {
backend.host_header.clone()
},
);
}
Some(request)
}
fn upstream_failure_detail<E>(
cause: &E,
backend: &crate::config::CompiledBackend,
elapsed: Duration,
connect_failure: bool,
) -> String
where
E: std::error::Error + std::fmt::Debug + 'static,
{
let phase = if connect_failure { "connect" } else { "http" };
let chain = format_error_chain(cause);
let debug = format!("{cause:?}");
let mut detail = format!(
"phase={phase}; origin={}; request_origin={}; tls_verify={}; elapsed_ms={}; error_chain={chain}",
backend.origin,
backend.request_origin,
backend.tls_verify,
elapsed.as_millis(),
);
if !debug.is_empty() && debug != cause.to_string() {
detail.push_str("; debug=");
detail.push_str(&debug);
}
detail = detail
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect();
truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL);
detail
}
#[derive(Clone)]
struct CacheRefreshContext {
state: Arc<App>,
snapshot: Arc<crate::config::Snapshot>,
backend: Arc<crate::config::CompiledBackend>,
cache_settings: crate::cache::Settings,
transform: Option<crate::transform::Settings>,
uri: Uri,
public_suffix: String,
host: String,
path: String,
scope: String,
timeout: Duration,
local: std::net::SocketAddr,
tls: bool,
base_headers: http::HeaderMap,
}
async fn refresh_cache_once(
context: &CacheRefreshContext,
ticket: crate::cache::Ticket,
cached: crate::cache::Revalidation,
) -> bool {
if !context.state.cache.ticket_current(&ticket) {
return false;
}
let mut refresh_headers = context.base_headers.clone();
for name in [
"if-none-match",
"if-modified-since",
"if-match",
"if-unmodified-since",
"if-range",
"range",
] {
refresh_headers.remove(name);
}
if !cached.apply_validators(&mut refresh_headers) {
return false;
}
if context
.transform
.as_ref()
.is_some_and(|settings| settings.enabled)
{
refresh_headers.remove("accept-encoding");
}
let mut refresh = Request::new(Body::empty());
*refresh.method_mut() = Method::GET;
*refresh.uri_mut() = context.uri.clone();
*refresh.version_mut() = Version::HTTP_11;
*refresh.headers_mut() = refresh_headers;
let started = Instant::now();
let Ok(Ok(origin)) = tokio::time::timeout(
context.timeout,
context.backend.client.request(refresh),
)
.await
else {
return false;
};
let origin_validation_headers = origin.headers().clone();
let Some(route) = context.snapshot.select_on(
&context.host,
&context.path,
context.local,
context.tls,
) else {
return false;
};
if origin.status() == StatusCode::NOT_MODIFIED {
let mut validation_headers = origin_validation_headers.clone();
headers::strip_hop_by_hop(&mut validation_headers);
apply_response_rules(
&mut validation_headers,
&context.snapshot,
route.frontend.as_deref(),
Some(route),
false,
);
if let Some(pool) = &route.pool {
pool.strip_affinity_response(&mut validation_headers);
}
let merged = cached.merge_validation_headers(&validation_headers);
return context
.state
.cache
.publish_revalidated(
ticket,
&cached,
merged,
&origin_validation_headers,
&context.cache_settings,
started.elapsed(),
)
.is_some();
}
if origin.status() != StatusCode::OK {
return false;
}
let Some(origin_plan) = crate::cache::plan(
origin.status(),
origin.headers(),
&context.cache_settings,
started.elapsed(),
) else {
return false;
};
let origin_no_transform = crate::transform::no_transform(origin.headers());
let skip_compression = crate::compression::private_or_no_transform(origin.headers());
let origin_validators = crate::transform::OriginValidators {
etag: origin.headers().get("etag").cloned(),
last_modified: origin.headers().get("last-modified").cloned(),
};
let (mut parts, body) = origin.into_parts();
parts.extensions.insert(origin_validators);
if skip_compression {
parts.extensions.insert(crate::compression::Skip);
}
headers::strip_hop_by_hop(&mut parts.headers);
apply_response_rules(
&mut parts.headers,
&context.snapshot,
route.frontend.as_deref(),
Some(route),
false,
);
if let Some(pool) = &route.pool {
pool.strip_affinity_response(&mut parts.headers);
}
let mut refreshed = Response::from_parts(parts, Body::new(body));
refreshed.extensions_mut().insert(ResponseRulesApplied);
if let Some(settings) = context
.transform
.as_ref()
.filter(|settings| settings.enabled)
{
let transform_started = Instant::now();
let Ok(transformed) = crate::transform::apply(
refreshed,
&context.public_suffix,
settings,
origin_no_transform,
)
.await
else {
context
.state
.transform_metrics
.record_error(&context.scope, transform_started.elapsed());
context
.state
.cache
.record_reason(&context.scope, &context.host, "transform_error");
return false;
};
context.state.transform_metrics.record_response(
&context.scope,
&transformed,
transform_started.elapsed(),
);
refreshed = transformed;
}
let canonical_identity = refreshed
.extensions()
.get::<crate::transform::CanonicalIdentity>()
.is_some();
let Some(transformed_plan) = crate::cache::plan(
refreshed.status(),
refreshed.headers(),
&context.cache_settings,
started.elapsed(),
) else {
return false;
};
let origin_plan = if canonical_identity {
origin_plan.without_vary("accept-encoding")
} else {
origin_plan
};
let verify_ticket = ticket.clone();
let refreshed = context.state.cache.wrap_with_fill(
refreshed,
ticket,
origin_plan.restrict(transformed_plan),
&context.cache_settings,
None,
);
if http_body_util::BodyExt::collect(refreshed.into_body())
.await
.is_err()
{
return false;
}
context
.state
.cache
.revalidation_tiered(&verify_ticket)
.await
.is_some()
}
fn schedule_proactive_refresh(
context: CacheRefreshContext,
ticket: crate::cache::Ticket,
remaining_ttl: u64,
) {
let Some(schedule_guard) = context
.state
.cache
.begin_proactive_schedule(&ticket, &context.cache_settings)
else {
return;
};
let threshold = context
.cache_settings
.proactive_refresh_before_expiry_seconds;
tokio::spawn(async move {
let _schedule_guard = schedule_guard;
let mut delay = remaining_ttl.saturating_sub(threshold).max(1);
let mut awaiting_initial_publish = true;
let mut initial_publish_retries = 0u8;
loop {
if delay > 0 {
tokio::time::sleep(Duration::from_secs(delay)).await;
}
if !context.state.cache.ticket_current(&ticket) {
return;
}
let Some(cached) = context.state.cache.revalidation_tiered(&ticket).await else {
if awaiting_initial_publish && initial_publish_retries < 60 {
initial_publish_retries = initial_publish_retries.saturating_add(1);
delay = 1;
continue;
}
return;
};
awaiting_initial_publish = false;
let Some(inflight_guard) = context
.state
.cache
.try_begin_proactive_refresh(&context.cache_settings)
else {
delay = 5;
continue;
};
let Some(revalidation_guard) = context
.state
.cache
.begin_background_revalidation(&cached)
else {
drop(inflight_guard);
delay = 1;
continue;
};
let refreshed = refresh_cache_once(&context, ticket.clone(), cached).await;
drop(revalidation_guard);
drop(inflight_guard);
if !refreshed {
return;
}
context.state.cache.record(
&context.scope,
&context.host,
"HIT",
"cache_proactive_refreshed",
);
let Some(next) = context.state.cache.revalidation_tiered(&ticket).await else {
return;
};
let remaining = next.remaining_ttl();
if remaining == 0 {
return;
}
delay = remaining.saturating_sub(threshold).max(1);
}
});
}
fn enforce_local_page_no_cache(response: &mut Response) {
for name in [
"age",
"expires",
"pragma",
"etag",
"last-modified",
"cdn-cache-control",
"surrogate-control",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"cache-status",
] {
response.headers_mut().remove(name);
}
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
}
fn error(status: StatusCode, message: &'static str) -> Response {
let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) {
let mut response = (status, html).into_response();
response.headers_mut().insert(
"content-type",
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response
} else {
(status, message).into_response()
};
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response.extensions_mut().insert(LocalGeneratedResponse);
response
}
fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Method) -> Response {
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD;
let mut response = Response::builder()
.status(status)
.body(if body_allowed { Body::from(spec.body.clone()) } else { Body::empty() })
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response());
if let Ok(value) = HeaderValue::from_str(&spec.content_type) {
response.headers_mut().insert("content-type", value);
}
response
}
fn encoded_origin_path(path: &str) -> Option<String> {
let mut url = url::Url::parse("http://rustproxy.invalid/").ok()?;
url.set_path(path);
Some(url.path().to_owned())
}
fn response_context(
snapshot: &crate::config::Snapshot,
request: &Request,
) -> (
Option<Arc<crate::config::CompiledFrontend>>,
Option<crate::config::CompiledRoute>,
) {
let tls = request
.extensions()
.get::<Transport>()
.copied()
.unwrap_or_default()
.tls;
let context = (|| {
let local = request.extensions().get::<LocalAddr>()?.0;
let authority = effective_host(request, tls).ok()?;
let host = request_host(authority.to_str().ok()?).ok()?;
let frontend = snapshot.frontend(&host, local, tls).cloned();
let route = crate::acl::routing_path(request.uri().path())
.ok()
.and_then(|path| snapshot.select_on(&host, &path, local, tls).cloned());
Some((frontend, route))
})();
context.unwrap_or((None, None))
}
/// Reserved administration-session cookies may cross the proxy boundary only
/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public
/// administration hostname, 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,
tls: bool,
backend: &crate::config::CompiledBackend,
) -> bool {
tls
&& backend.accept_local_listener_risk
&& backend.targets_admin_listener
&& snapshot
.envelope
.config
.administration
.allowed_hosts
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(host))
}
fn apply_response_rules(
headers: &mut http::HeaderMap,
snapshot: &crate::config::Snapshot,
frontend: Option<&crate::config::CompiledFrontend>,
route: Option<&crate::config::CompiledRoute>,
preserve_admin_session: bool,
) {
crate::headers::apply(headers, &snapshot.headers.response);
if let Some(frontend) = frontend {
crate::headers::apply(headers, &frontend.headers.response);
}
if let Some(route) = route {
crate::headers::apply(headers, &route.headers.response);
}
if !preserve_admin_session {
headers::strip_admin_set_cookie(headers);
}
}
/// Final policies apply to cache hits, errors, static pages and ALL transports.
fn finish_headers(
response: &mut Response,
snapshot: &crate::config::Snapshot,
frontend: Option<&crate::config::CompiledFrontend>,
local: Option<std::net::SocketAddr>,
tls: bool,
id: u64,
active: &[crate::vhost::Listener],
) {
if let Some(frontend) = frontend {
frontend.security.apply(response.headers_mut(), tls);
}
frontend
.map(|f| &f.server_header)
.unwrap_or(&snapshot.envelope.config.server_header)
.apply(response.headers_mut());
frontend
.map(|f| &f.diagnostic_headers)
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
.apply(response.headers_mut(), 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",
HeaderValue::from_static("private, no-store"),
);
}
if frontend.is_some_and(|frontend| !frontend.http3_enabled) {
response.headers_mut().remove("alt-svc");
} else {
crate::http3::advertise(response.headers_mut(), local, tls, active);
}
}
fn apply_timing_headers(
response: &mut Response,
frontend: Option<&crate::config::CompiledFrontend>,
exchange: &Exchange,
) {
if !frontend.is_some_and(|frontend| frontend.timing_headers) {
return;
}
let total_ms = exchange.started.elapsed().as_secs_f64() * 1000.0;
let mut values = vec![format!("proxy;dur={total_ms:.2}")];
if let Some(origin) = response.extensions().get::<OriginTiming>() {
values.push(format!("origin;dur={:.2}", origin.0.as_secs_f64() * 1000.0));
}
let cache = match exchange.cache_status.load(Ordering::Relaxed) {
1 => Some("HIT"),
2 => Some("MISS"),
3 => Some("BYPASS"),
_ => None,
};
if let Some(cache) = cache {
values.push(format!("cache;desc=\"{cache}\""));
}
response.headers_mut().remove("server-timing");
if let Ok(value) = HeaderValue::from_str(&values.join(", ")) {
response.headers_mut().insert("server-timing", value);
}
}
/// 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,
backend_timing_header: bool,
) {
response.headers_mut().remove("x-rust-proxy-backend");
response.headers_mut().remove("x-rust-proxy-backend-time");
response.headers_mut().remove("x-rustproxy-backend-time");
if backend_header {
if let Some(origin) = response.extensions().get::<OriginBackend>() {
if let Ok(value) = HeaderValue::from_str(&origin.0) {
response.headers_mut().insert("x-rust-proxy-backend", value);
}
}
}
if backend_timing_header {
if let Some(origin) = response.extensions().get::<OriginTiming>() {
let value = format!("{:.2}ms", origin.0.as_secs_f64() * 1000.0);
if let Ok(value) = HeaderValue::from_str(&value) {
response
.headers_mut()
.insert("x-rustproxy-backend-time", value);
}
}
}
}
fn apply_backend_diagnostic_headers(
response: &mut Response,
frontend: Option<&crate::config::CompiledFrontend>,
) {
apply_backend_diagnostic_values(
response,
frontend.is_some_and(|value| value.backend_header),
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);
}
if request.body().size_hint().lower() > 0 {
return Ok(false);
}
// Multiplexed transports may end a body with an empty DATA frame.
for _ in 0..8 {
match request.body_mut().frame().await {
None => {
*request.body_mut() = Body::empty();
return Ok(true);
}
Some(Err(error)) => return Err(error),
Some(Ok(frame)) => {
if frame.data_ref().is_some_and(|data| data.is_empty()) {
if request.body().is_end_stream() {
*request.body_mut() = Body::empty();
return Ok(true);
}
continue;
}
let tail = std::mem::replace(request.body_mut(), Body::empty());
*request.body_mut() = Body::new(body::ReplayBody::new(frame, tail));
return Ok(false);
}
}
}
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,
) -> Result<(), axum::Error> {
let method = request.method();
if request.version() != Version::HTTP_3
|| upstream_version != Version::HTTP_11
|| method == Method::GET
|| method == Method::HEAD
|| method == Method::CONNECT
|| request.headers().contains_key(http::header::CONTENT_LENGTH)
|| request.body().size_hint().exact().is_some()
{
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.
for _ in 0..8 {
match request.body_mut().frame().await {
None => {
*request.body_mut() = Body::empty();
return Ok(());
}
Some(Err(error)) => return Err(error),
Some(Ok(frame)) => {
if frame.data_ref().is_some_and(|data| data.is_empty()) {
if request.body().is_end_stream() {
*request.body_mut() = Body::empty();
return Ok(());
}
continue;
}
let tail = std::mem::replace(request.body_mut(), Body::empty());
*request.body_mut() = Body::new(body::ReplayBody::new(frame, tail));
return Ok(());
}
}
}
Ok(())
}
async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
let snapshot = state.config.load_full();
let (frontend_context, route_context) = response_context(&snapshot, &request);
let local = request.extensions().get::<LocalAddr>().map(|v| v.0);
let transport_tls = request
.extensions()
.get::<Transport>()
.copied()
.unwrap_or_default()
.tls;
request.extensions_mut().insert(snapshot.clone());
let original_headers = request.headers().clone();
let method = request.method().clone();
let is_head = method == Method::HEAD;
let ws_attempt = request
.headers()
.get("upgrade")
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket"))
|| (request.version() == Version::HTTP_2
&& request.method() == Method::CONNECT
&& websocket::h2_protocol(&request) == Some("websocket"))
|| (request.version() == Version::HTTP_3
&& request.method() == Method::CONNECT
&& websocket::h3_protocol(&request) == Some("websocket"));
if request.version() == Version::HTTP_11 {
state.metrics.http1_requests.fetch_add(1, Ordering::Relaxed);
}
if request.version() == Version::HTTP_2 {
state.metrics.http2_requests.fetch_add(1, Ordering::Relaxed);
}
if request.version() == Version::HTTP_3 {
state.metrics.http3_requests.fetch_add(1, Ordering::Relaxed);
}
let id = state.metrics.requests.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(frontend) = &frontend_context {
frontend.metrics.requests.fetch_add(1, Ordering::Relaxed);
}
let Ok(permit) = state.inflight.clone().try_acquire_owned() else {
state
.metrics
.rejected_requests
.fetch_add(1, Ordering::Relaxed);
if let Some(frontend) = &frontend_context {
frontend.metrics.rejected.fetch_add(1, Ordering::Relaxed);
}
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: in-flight limit reached\n",
);
if let Some(page) = frontend_context
.as_ref()
.and_then(|f| f.pages.errors.get(&503))
.or_else(|| snapshot.envelope.config.pages.errors.get(&503))
{
crate::pages::replace_error(&mut response, page);
}
if is_head {
*response.body_mut() = Body::empty();
}
apply_response_rules(
response.headers_mut(),
&snapshot,
frontend_context.as_deref(),
route_context.as_ref(),
false,
);
finish_headers(
&mut response,
&snapshot,
frontend_context.as_deref(),
local,
transport_tls,
id,
&state.active_listeners,
);
return response;
};
state.metrics.active.fetch_add(1, Ordering::Relaxed);
if let Some(frontend) = &frontend_context {
frontend.metrics.active.fetch_add(1, Ordering::Relaxed);
}
let host = request
.headers()
.get(HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or_else(|| request.uri().authority().map(|v| v.as_str()).unwrap_or(""))
.chars()
.take(253)
.collect();
let initial_client_ip = request
.extensions()
.get::<PeerAddr>()
.map(|peer| peer.0.ip().to_string());
let (http_version, transport) = match request.version() {
Version::HTTP_10 => ("1.0", "h1"),
Version::HTTP_11 => ("1.1", "h1"),
Version::HTTP_2 => ("2", "h2"),
Version::HTTP_3 => ("3", "h3"),
_ => ("unknown", "unknown"),
};
let websocket_bootstrap = ws_attempt.then_some(match request.version() {
Version::HTTP_2 => "h2_extended_connect",
Version::HTTP_3 => "h3_extended_connect",
_ => "h1_upgrade",
});
let exchange = Arc::new(Exchange {
vhost_metrics: frontend_context.as_ref().map(|f| f.metrics.clone()),
vhost: frontend_context.as_ref().map(|f| f.id.clone()),
route: Mutex::new(None),
cache_effective_enabled: Mutex::new(None),
transform_effective_enabled: Mutex::new(None),
backend: Mutex::new(None),
id,
method: request.method().to_string(),
host,
path: request.uri().path().chars().take(256).collect(),
http_version,
transport,
websocket_bootstrap,
websocket_phase: Mutex::new(websocket_bootstrap.map(|_| "request_received")),
websocket_error: Mutex::new(None),
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),
started: Instant::now(),
status: AtomicU16::new(500),
cache_status: AtomicU16::new(0),
cache_reason: Mutex::new("not_proxy_response"),
failed: AtomicBool::new(false),
aborted: AtomicBool::new(false),
request_bytes: AtomicU64::new(0),
response_bytes: AtomicU64::new(0),
metrics: state.metrics.clone(),
logs: state.logs.clone(),
_permit: permit,
});
if let Ok(value) = HeaderValue::from_str(&id.to_string()) {
request.headers_mut().insert("x-request-id", value);
}
request.extensions_mut().insert(exchange.clone());
let request_send_timeout = frontend_context
.as_ref()
.and_then(|frontend| frontend.proxy_timeouts.send_timeout())
.unwrap_or(state.body_idle_timeout);
let request = request.map(|body| {
Body::new(MeteredBody::new(
body,
exchange.clone(),
Direction::Request,
request_send_timeout,
))
});
let mut response = next.run(request).await;
let minimal_welcome = response.extensions().get::<crate::pages::MinimalWelcome>().is_some();
let code = response.status().as_u16();
let custom_error = frontend_context
.as_ref()
.and_then(|f| f.pages.errors.get(&code))
.or_else(|| snapshot.envelope.config.pages.errors.get(&code));
if !minimal_welcome
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.is_none()
{
if let Some(page) = custom_error {
crate::pages::replace_error(&mut response, page);
}
}
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", "alt-svc", "x-rustproxy-request-id",
"x-rustproxy-revision", "x-rust-proxy-backend",
"x-rust-proxy-backend-time", "x-rustproxy-backend-time", "x-cache", "x-cache-hits",
"x-cache-status", "x-cache-resident-age", "content-encoding", "vary",
"strict-transport-security", "content-security-policy",
"permissions-policy", "referrer-policy", "x-frame-options",
"x-content-type-options",
] {
response.headers_mut().remove(name);
}
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
} else {
if response
.extensions()
.get::<ResponseRulesApplied>()
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.is_none()
{
apply_response_rules(
response.headers_mut(),
&snapshot,
frontend_context.as_deref(),
route_context.as_ref(),
false,
);
}
apply_timing_headers(&mut response, frontend_context.as_deref(), &exchange);
apply_backend_diagnostic_headers(&mut response, frontend_context.as_deref());
finish_headers(
&mut response,
&snapshot,
frontend_context.as_deref(),
local,
transport_tls,
id,
&state.active_listeners,
);
if response.extensions().get::<RedirectResponse>().is_some_and(|redirect| !redirect.http3_enabled) {
response.headers_mut().remove("alt-svc");
}
if response.extensions().get::<LocalHostResponse>().is_some_and(|item| !item.http3_enabled) {
response.headers_mut().remove("alt-svc");
}
response = crate::compression::encode(
response,
&original_headers,
&method,
&snapshot.envelope.config.compression,
)
.await;
if let Some(encoding) = response
.extensions()
.get::<crate::compression::AppliedEncoding>()
.copied()
{
state.metrics.record_compression(encoding);
}
if response
.extensions()
.get::<ResponseRulesApplied>()
.is_none()
&& response.extensions().get::<RedirectResponse>().is_none()
&& response.extensions().get::<LocalHostResponse>().is_none()
&& response.extensions().get::<crate::pages::PageServed>().is_none()
&& response.extensions().get::<LocalGeneratedResponse>().is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.is_none()
{
let outcome = match exchange.cache_status.load(Ordering::Relaxed) {
1 => "HIT",
2 => "MISS",
_ => "BYPASS",
};
let cache = frontend_context
.as_ref()
.map(|f| &f.cache)
.unwrap_or(&snapshot.envelope.config.cache);
let reason = *exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner());
cache.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
let transformed = response
.extensions()
.get::<crate::transform::Applied>()
.is_some()
|| response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache.annotate_transformed(response.headers_mut(), transformed);
}
}
if response.extensions().get::<crate::pages::PageServed>().is_some()
|| response.extensions().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",
"expires",
"pragma",
"etag",
"last-modified",
"cdn-cache-control",
"surrogate-control",
"x-cache",
"x-cache-hits",
"x-cache-status",
"x-cache-resident-age",
"x-cache-reason",
"cache-status",
] {
response.headers_mut().remove(name);
}
}
exchange
.status
.store(response.status().as_u16(), Ordering::Relaxed);
let ws_success = response.status() == StatusCode::SWITCHING_PROTOCOLS
|| response.extensions().get::<websocket::H3Tunnel>().is_some();
if ws_attempt && !ws_success {
state
.metrics
.websocket_handshake_errors
.fetch_add(1, Ordering::Relaxed);
}
if is_head {
*response.body_mut() = Body::empty();
}
let response_read_timeout = frontend_context
.as_ref()
.and_then(|frontend| frontend.proxy_timeouts.read_timeout())
.unwrap_or(state.body_idle_timeout);
response.map(|body| {
Body::new(MeteredBody::new(
body,
exchange,
Direction::Response,
response_read_timeout,
))
})
}
/// 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().map(|value| value.as_str()).unwrap_or("/");
let location = if port == 443 {
format!("https://{host}{path}")
} else {
format!("https://{host}:{port}{path}")
};
HeaderValue::from_str(&location).ok()
}
fn force_ssl_redirect(
frontend: &crate::config::CompiledFrontend,
host: &str,
uri: &Uri,
) -> Option<Response> {
if !frontend.force_ssl {
return None;
}
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
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"));
Some(response)
}
/// 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) {
let authority = request.uri().authority().ok_or(())?;
if request.uri().scheme_str() != Some(if tls { "https" } else { "http" }) || count > 1 {
return Err(());
}
if let Some(host) = request.headers().get(HOST) {
if !host
.to_str()
.map_err(|_| ())?
.eq_ignore_ascii_case(authority.as_str())
{
return Err(());
}
}
HeaderValue::from_str(authority.as_str()).map_err(|_| ())
} else {
if request.uri().scheme().is_some() || request.uri().authority().is_some() || count != 1 {
return Err(());
}
request.headers().get(HOST).cloned().ok_or(())
}
}
async fn forward(
State(state): State<Arc<App>>,
Extension(peer): Extension<PeerAddr>,
Extension(local): Extension<LocalAddr>,
mut request: Request,
) -> Response {
if request.method() == Method::CONNECT
&& !matches!(request.version(), Version::HTTP_2 | Version::HTTP_3)
{
return error(
StatusCode::NOT_IMPLEMENTED,
"RustProxy: CONNECT is not supported\n",
);
}
let tls = request
.extensions()
.get::<Transport>()
.copied()
.unwrap_or_default()
.tls;
let Ok(original_host) = effective_host(&request, tls) else {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or conflicting request authority\n",
);
};
let Ok(host) = original_host
.to_str()
.ok()
.and_then(|v| request_host(v).ok())
.ok_or(())
else {
return error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n");
};
let handshake = if request.method() == Method::CONNECT {
let protocol = match request.version() {
Version::HTTP_2 => websocket::h2_protocol(&request),
Version::HTTP_3 => websocket::h3_protocol(&request),
_ => None,
};
match (request.version(), protocol) {
(Version::HTTP_2, Some("websocket")) => match websocket::validate_h2(&request) {
Ok(handshake) => Some(handshake),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid HTTP/2 WebSocket Extended CONNECT\n",
)
}
},
(Version::HTTP_3, Some("websocket")) => match websocket::validate_h3(&request) {
Ok(handshake) => Some(handshake),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid HTTP/3 WebSocket Extended CONNECT\n",
)
}
},
(_, Some(_)) => {
return error(
StatusCode::NOT_IMPLEMENTED,
"RustProxy: unsupported Extended CONNECT protocol\n",
)
}
_ => {
return error(
StatusCode::NOT_IMPLEMENTED,
"RustProxy: CONNECT is not supported\n",
)
}
}
} else if headers::requests_upgrade(request.headers()) {
match websocket::validate(&request) {
Ok(handshake) => Some(handshake),
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or unsupported WebSocket upgrade\n",
)
}
}
} else {
None
};
let snapshot = request
.extensions()
.get::<Arc<crate::config::Snapshot>>()
.cloned()
.unwrap_or_else(|| state.config.load_full());
let Ok(path) = crate::acl::routing_path(request.uri().path()) else {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: ambiguous request path\n",
);
};
let frontend = snapshot.frontend(&host, local.0, tls);
let websocket_legacy_support = 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)
|| local_host.is_some_and(|item| !item.http2_enabled))
{
return error(
StatusCode::MISDIRECTED_REQUEST,
"RustProxy: HTTP/2 is disabled for this vhost\n",
);
}
if request.version() == Version::HTTP_3
&& (frontend.is_some_and(|frontend| !frontend.http3_enabled)
|| redirect.is_some_and(|(redirect, _)| !redirect.http3_enabled)
|| local_host.is_some_and(|item| !item.http3_enabled))
{
return error(
StatusCode::MISDIRECTED_REQUEST,
"RustProxy: HTTP/3 is disabled for this vhost\n",
);
}
if request.version() == Version::HTTP_2
&& request.method() == Method::CONNECT
&& websocket::h2_protocol(&request) == Some("websocket")
&& frontend.is_none_or(|frontend| !frontend.websocket_rfc8441)
{
return error(
StatusCode::MISDIRECTED_REQUEST,
"RustProxy: RFC 8441 WebSocket transport is not enabled for this vhost\n",
);
}
if request.version() == Version::HTTP_3
&& request.method() == Method::CONNECT
&& websocket::h3_protocol(&request) == Some("websocket")
&& frontend.is_none_or(|frontend| !frontend.websocket_rfc9220)
{
return error(
StatusCode::MISDIRECTED_REQUEST,
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
);
}
if !tls {
if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) {
return response;
}
}
let mut context = match snapshot.forwarding.context(
request.headers(),
peer.0,
local.0,
tls,
&original_host,
request.uri(),
) {
Ok(context) => context,
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid forwarding context\n",
)
}
};
if frontend.is_some_and(|frontend| frontend.cloudflare_client_ip) {
if let Some(client) = crate::forwarding::cloudflare_client_ip(request.headers(), peer.0) {
if context.override_client_ip(client).is_err() {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid Cloudflare client address\n",
);
}
}
}
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());
}
if let Some(frontend) = frontend {
let country = state.geoip.country(context.client_ip);
if !frontend.profile_country_access.allows(country.as_deref())
|| !frontend.rate_limit.country_allowed(country.as_deref())
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: country access policy denied this request\n",
);
}
if let Err(limited) = state.rate_limiter.check(
snapshot.envelope.revision,
&frontend.id,
context.client_ip,
country.as_deref(),
&frontend.rate_limit,
) {
let mut response = error(
StatusCode::TOO_MANY_REQUESTS,
"RustProxy: request rate limit exceeded\n",
);
if let Ok(value) = HeaderValue::from_str(&limited.retry_after_seconds.to_string()) {
response.headers_mut().insert("retry-after", value);
}
return response;
}
}
// Enforce host ACLs even for maintenance/fallback/direct-IP responses.
if !snapshot.host_allowed(&host, context.client_ip)
|| !frontend
.and_then(|f| f.profile_acl.as_ref())
.is_none_or(|acl| acl.allows(context.client_ip))
|| !frontend
.and_then(|f| f.acl.as_ref())
.is_none_or(|acl| acl.allows(context.client_ip))
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by access policy\n",
);
}
if let Some((_redirect, rule)) = redirect {
let authority = original_host.to_str().unwrap_or(&host);
let Some(mut response) = rule.response(authority, tls, request.uri()) else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: redirect target could not be rendered\n",
);
};
response.extensions_mut().insert(RedirectResponse { http3_enabled: _redirect.http3_enabled });
return response;
}
if let Some(item) = local_host {
let mut response = item.page.serve(request.method(), StatusCode::NOT_FOUND);
response.extensions_mut().insert(LocalHostResponse { http3_enabled: item.http3_enabled });
return response;
}
let selected = snapshot.select_on(&host, &path, local.0, tls);
if !selected
.and_then(|r| r.acl.as_ref())
.is_none_or(|acl| acl.allows(context.client_ip))
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by access policy\n",
);
}
// 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(
request.headers_mut(),
tls,
context.client_ip,
state.basic_workers.clone(),
&state.basic_limits,
)
.await
{
Ok(identity) => identity,
Err(response) => return response,
}
} else {
None
};
if handshake.is_some() && frontend.is_some_and(|f| !f.websockets) {
return error(
StatusCode::FORBIDDEN,
"RustProxy: WebSocket access is disabled for this host\n",
);
}
let identity = if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
auth.strip_identity(request.headers_mut());
match auth
.authorize(
request.headers(),
request.method(),
&context,
&state.shutdown,
)
.await
{
Ok(identity) => Some(identity),
Err(response) => return response,
}
} else {
None
};
if crate::pages::literal_ip_authority(original_host.to_str().unwrap_or("")) {
if let Some(page) = &snapshot.envelope.config.pages.direct_ip {
return page.serve(request.method(), StatusCode::OK);
}
}
if let Some(page) = frontend.and_then(|f| f.pages.response.as_ref()) {
return page.serve(request.method(), StatusCode::OK);
}
let Some(route) = selected else {
let page = match frontend {
Some(frontend) => frontend.pages.fallback.as_ref(),
None => snapshot.envelope.config.pages.unknown_host.as_ref(),
};
return page
.map(|page| page.serve(request.method(), StatusCode::NOT_FOUND))
.unwrap_or_else(|| {
error(
StatusCode::NOT_FOUND,
"RustProxy: no route for this host/path\n",
)
});
};
// Both scopes run before DNS, TCP, TLS or reuse of an existing upstream connection.
if !snapshot.host_allowed(&host, context.client_ip)
|| !route
.acl
.as_ref()
.is_none_or(|acl| acl.allows(context.client_ip))
|| !route
.frontend
.as_ref()
.and_then(|f| f.profile_acl.as_ref())
.is_none_or(|acl| acl.allows(context.client_ip))
|| !route
.frontend
.as_ref()
.and_then(|f| f.acl.as_ref())
.is_none_or(|acl| acl.allows(context.client_ip))
{
return error(
StatusCode::FORBIDDEN,
"RustProxy: denied by access policy\n",
);
}
// 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());
}
let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool {
let selection = match &route.backend_id {
Some(id) => pool.select_member_wait(id).await,
None => pool
.select_wait(context.client_ip, request.headers(), tls)
.await,
};
let selection = match selection {
Ok(selection) => selection,
Err(crate::balance::SelectionFailure::Unavailable) => {
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: no healthy backend in this vhost pool\n",
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
return response;
}
Err(crate::balance::SelectionFailure::Capacity) => {
let mut response = error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: backend concurrency limit reached\n",
);
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
return response;
}
};
(
selection.lease.member.backend.clone(),
Some(selection.lease),
selection.cookie,
)
} else {
(route.backend.clone(), None, None)
};
let admin_control_plane = admin_session_passthrough(&snapshot, &host, tls, &backend);
if admin_control_plane {
headers::restore_admin_cookie(request.headers_mut(), &admin_cookie);
}
let cache_settings = route
.frontend
.as_ref()
.map(|f| &f.cache)
.unwrap_or(&snapshot.envelope.config.cache);
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>>() {
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
Some(cache_settings.active_for(route.cache));
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
Some(transform_settings.is_some_and(|settings| settings.enabled));
}
let policy_reason = if admin_control_plane {
Some("admin_control_plane")
} else if frontend.is_some_and(|f| f.basic_auth.is_some()) {
Some("basic_auth")
} else if frontend.is_some_and(|f| f.tinyauth.is_some()) {
Some("tinyauth")
} else if route
.pool
.as_ref()
.is_some_and(|pool| pool.cookie_affinity())
{
Some("sticky_cookie")
} else if !cache_settings.enabled {
Some("cache_disabled")
} else if route.cache == Some(false) {
Some("route_disabled")
} else if handshake.is_some() {
Some("request_upgrade")
} else {
None
};
let cache_enabled = policy_reason.is_none();
let body_empty = if cache_enabled && handshake.is_none() && request.method() == Method::GET {
match empty_for_cache(&mut request).await {
Ok(empty) => empty,
Err(_) => {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or incomplete request body\n",
)
}
}
} else {
request.body().is_end_stream()
};
let ignore_static_cookie = cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
});
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
let public_suffix = request
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let origin_suffix = match route.rewritten_path(&path) {
Ok(None) => public_suffix.clone(),
Ok(Some(rewritten)) => {
let Some(mut encoded) = encoded_origin_path(&rewritten) else {
return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n");
};
if let Some(query) = request.uri().query() {
encoded.push('?');
encoded.push_str(query);
}
encoded
}
Err(_) => return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n"),
};
let Some(uri) = upstream_uri(&backend, &origin_suffix) else {
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid request target\n",
);
};
let exchange = request.extensions().get::<Arc<Exchange>>().cloned();
let lease = request.extensions().get::<Arc<ConnectionLease>>().cloned();
let stop = request.extensions().get::<ServerShutdown>().cloned();
if handshake.is_some() {
if let Some(exchange) = &exchange {
exchange.websocket_phase("upstream_handshake");
}
}
let downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h1()) {
Some(hyper::upgrade::on(&mut request))
} else {
None
};
let mut h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2())
&& (backend.protocol != crate::config::UpstreamProtocol::Http2
|| websocket_legacy_support)
{
Some(std::mem::replace(request.body_mut(), Body::empty()))
} else {
None
};
headers::strip_hop_by_hop(request.headers_mut());
request.headers_mut().insert(
HOST,
if websocket_legacy_support {
backend.host_header.clone()
} else if backend.preserve_host {
original_host.clone()
} else {
backend.host_header.clone()
},
);
crate::headers::apply(request.headers_mut(), &snapshot.headers.request);
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
.frontend
.as_ref()
.is_none_or(|frontend| frontend.forward_client_ip),
);
if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
auth.strip_identity(request.headers_mut());
}
if let Some(identity) = identity {
identity.apply(request.headers_mut());
}
if websocket_legacy_support {
if let Some(upstream_host) = request.headers().get(HOST).cloned() {
if let Some(origin) = legacy_websocket_origin(&backend.request_origin, &upstream_host) {
request.headers_mut().insert("origin", origin);
}
}
}
let range_selection = crate::cache::request_range(request.method(), request.headers());
let if_range = request.headers().get("if-range").cloned();
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let post_policy = crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
);
let effective_policy = match (original_policy, post_policy) {
(crate::cache::RequestPolicy::Bypass(reason), _) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(_, crate::cache::RequestPolicy::Bypass(reason)) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(crate::cache::RequestPolicy::Revalidate(reason), _) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
(_, crate::cache::RequestPolicy::Revalidate(reason)) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
_ => crate::cache::RequestPolicy::Use,
};
let effective_reason = effective_policy.bypass_reason();
let revalidation_reason = effective_policy.revalidation_reason();
let effective_cacheable = effective_reason.is_none();
// Match Varnish-style cache identity: a load-balanced backend member must not
// fragment the object cache. The vhost scope + canonical host + request target
// already identify the public resource; Vary handles representation variants.
let cache_route = route.id.as_str();
let mut cache_key_headers = None;
if ignore_static_cookie && request.headers().contains_key("cookie") {
let mut headers = request.headers().clone();
headers.remove("cookie");
cache_key_headers = Some(headers);
}
let key_headers = cache_key_headers
.as_ref()
.unwrap_or_else(|| request.headers());
let cache_ticket = if effective_cacheable {
state.cache.ticket_scoped(
state.cache.revision(),
route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""),
context.scheme,
original_host.to_str().unwrap_or(""),
cache_route,
&public_suffix,
key_headers,
)
} else {
None
};
let client_conditionals = request.headers().clone();
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
} else {
None
};
let freshness_revalidation_reason = cached_revalidation
.as_ref()
.and_then(|cached| cached.request_freshness_revalidation_reason(&client_conditionals));
let pending_revalidation = revalidation_reason.is_some()
|| freshness_revalidation_reason.is_some()
|| cached_revalidation
.as_ref()
.is_some_and(|cached| !cached.reusable_for_request(&client_conditionals));
let mut miss_recorded = false;
if let Some(ticket) = &cache_ticket {
if revalidation_reason.is_none() {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
let conditional = crate::cache::conditional_not_modified(
&client_conditionals,
hit.response.headers(),
);
if !conditional {
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
}
let range_hit = matches!(
hit.response.status(),
StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE
);
let reason = if conditional {
"cache_conditional_hit"
} else if range_hit {
"cache_range_hit"
} else if hit.stale && hit.background_revalidate {
"cache_stale_while_revalidate"
} else if hit.stale {
"cache_max_stale"
} else {
"cache_hit"
};
if hit.background_revalidate {
if let (Some(cached), Some(guard)) = (
cached_revalidation.clone(),
cached_revalidation
.as_ref()
.and_then(|cached| state.cache.begin_background_revalidation(cached)),
) {
let refresh_context = CacheRefreshContext {
state: state.clone(),
snapshot: snapshot.clone(),
backend: backend.clone(),
cache_settings: cache_settings.clone(),
transform: transform_settings.cloned(),
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout),
local: local.0,
tls,
base_headers: request.headers().clone(),
};
let refresh_ticket = ticket.clone();
tokio::spawn(async move {
let _guard = guard;
if refresh_cache_once(&refresh_context, refresh_ticket, cached).await {
refresh_context.state.cache.record(
&refresh_context.scope,
&refresh_context.host,
"HIT",
"cache_stale_while_revalidate_refreshed",
);
}
});
}
}
if cache_settings.proactive_refresh_enabled
&& !hit.stale
&& hit.remaining_ttl > 0
{
schedule_proactive_refresh(
CacheRefreshContext {
state: state.clone(),
snapshot: snapshot.clone(),
backend: backend.clone(),
cache_settings: cache_settings.clone(),
transform: transform_settings.cloned(),
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout),
local: local.0,
tls,
base_headers: request.headers().clone(),
},
ticket.clone(),
hit.remaining_ttl,
);
}
if conditional {
hit.response = crate::cache::make_not_modified(hit.response);
}
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
}
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
cache_settings.annotate_reason(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings.annotate_resident_age(
hit.response.headers_mut(),
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
return hit.response;
}
state.cache.record(scope, &host, "MISS", "cache_miss");
miss_recorded = true;
if let Some(exchange) = &exchange {
exchange.cache_status.store(2, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.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;
if let Some(exchange) = &exchange {
exchange.cache_status.store(2, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = "cache_miss";
}
}
} else {
state.cache.bypass();
let reason = effective_reason.unwrap_or("configuration_changed");
state.cache.record(scope, &host, "BYPASS", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(3, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
}
let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option<Response> {
let cached = cached_revalidation.as_ref()?;
let mut hit = state.cache.stale_if_error_hit(cached)?;
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
cache_settings.annotate_reason(
hit.response.headers_mut(),
"HIT",
hit.hits,
0,
reason,
);
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings.annotate_resident_age(
hit.response.headers_mut(),
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
hit.response.extensions_mut().insert(OriginTiming(elapsed));
hit.response
.extensions_mut()
.insert(OriginBackend(origin_backend.to_owned()));
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
}
Some(hit.response)
};
if only_cached {
let mut response = error(
StatusCode::GATEWAY_TIMEOUT,
"RustProxy: no reusable cached response\n",
);
cache_settings.annotate(response.headers_mut(), "MISS", 0, 0);
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)
{
if let Some(ticket) = cache_ticket.as_ref() {
let mut collapse_rounds = 0u8;
loop {
match state.cache.claim_fill(ticket, cache_settings).await {
crate::cache::FillClaim::Leader(guard) => {
fill_leader = Some(guard);
break;
}
crate::cache::FillClaim::Waited => {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
let conditional = crate::cache::conditional_not_modified(
&client_conditionals,
hit.response.headers(),
);
let reason = if conditional {
"cache_collapsed_conditional_hit"
} else {
"cache_collapsed_hit"
};
if cache_settings.proactive_refresh_enabled
&& !hit.stale
&& hit.remaining_ttl > 0
{
schedule_proactive_refresh(
CacheRefreshContext {
state: state.clone(),
snapshot: snapshot.clone(),
backend: backend.clone(),
cache_settings: cache_settings.clone(),
transform: transform_settings.cloned(),
uri: uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout),
local: local.0,
tls,
base_headers: request.headers().clone(),
},
ticket.clone(),
hit.remaining_ttl,
);
}
if conditional {
hit.response = crate::cache::make_not_modified(hit.response);
}
cache_settings.annotate_reason(
hit.response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
reason,
);
cache_settings
.annotate_transformed(hit.response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
cache_settings.annotate_resident_age(
hit.response.headers_mut(),
hit.resident_age_seconds,
);
hit.response.extensions_mut().insert(ResponseRulesApplied);
state.cache.record(scope, &host, "HIT", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
return hit.response;
}
collapse_rounds = collapse_rounds.saturating_add(1);
if collapse_rounds >= 8 {
state.cache.record_reason(
scope,
&host,
"cache_collapse_variant_limit",
);
break;
}
}
crate::cache::FillClaim::Timeout => {
if matches!(
cache_settings.collapse_timeout_fallback,
crate::cache::CollapseTimeoutFallback::StaleIfAvailable
) {
if let Some(stale) =
serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id)
{
return stale;
}
}
state.cache.record_reason(scope, &host, "cache_collapse_timeout_origin");
break;
}
crate::cache::FillClaim::Disabled => break,
}
}
}
}
if !admin_control_plane {
headers::strip_admin_cookie(request.headers_mut());
}
if let Some(pool) = &route.pool {
pool.strip_affinity(request.headers_mut());
}
if let Some(lease) = &backend_lease {
lease.started();
if let Some(exchange) = &exchange {
*exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) = Some(lease.clone());
}
}
let websocket_upstream_h2 = !websocket_legacy_support
&& handshake.as_ref().is_some_and(|handshake| handshake.h2())
&& backend.protocol == crate::config::UpstreamProtocol::Http2;
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");
request.headers_mut().remove("sec-websocket-accept");
} else {
request
.headers_mut()
.insert("connection", HeaderValue::from_static("Upgrade"));
request
.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");
if let Some(key) = handshake.upstream_key() {
if let Ok(key) = HeaderValue::from_str(key) {
request.headers_mut().insert("sec-websocket-key", key);
} else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: failed to build WebSocket handshake\n",
);
}
}
}
}
}
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");
}
let mut revalidation_sent = false;
if pending_revalidation {
if let Some(cached) = &cached_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;
}
*request.uri_mut() = uri.clone();
let upstream_version = if websocket_legacy_support && !websocket_upstream_h2 {
Version::HTTP_11
} else {
upstream_request_version(&backend, websocket_upstream_h2)
};
if normalize_h3_h1_unknown_body(&mut request, upstream_version)
.await
.is_err()
{
return error(
StatusCode::BAD_REQUEST,
"RustProxy: invalid or incomplete request body\n",
);
}
*request.version_mut() = upstream_version;
let mut h2_direct_session = None;
if websocket_upstream_h2 {
let Some(exchange) = exchange.clone() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: WebSocket exchange state unavailable\n",
);
};
let stop_token = stop
.as_ref()
.map(|value| value.0.clone())
.unwrap_or_else(|| state.shutdown.clone());
let session = state.tunnel_registry.register_pending(
exchange.clone(),
backend.origin.clone(),
"h2-rfc8441-h2-upstream",
stop_token,
lease.clone(),
);
websocket::spawn_h2_idle_watch(state.clone(), &session);
let body = std::mem::replace(request.body_mut(), Body::empty());
*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 = request.headers().clone();
let response_header_timeout = frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout);
let retry_headers = request.headers().clone();
let retry_method = request.method().clone();
let expected_backend_host = if backend.preserve_host {
&original_host
} else {
&backend.host_header
};
let retry_host_follows_backend = request
.headers()
.get(HOST)
.is_some_and(|value| value == expected_backend_host);
let retry_policy = route.pool.as_ref().map(|pool| pool.settings.retry.clone());
let retry_enabled = route.backend_id.is_none()
&& !admin_control_plane
&& handshake.is_none()
&& request_body_empty
&& retry_safe_method(&retry_method)
&& retry_policy.as_ref().is_some_and(|policy| policy.max_retries > 0);
let upstream_started = Instant::now();
let mut attempt_started = Instant::now();
let mut active_backend = backend.clone();
let mut active_lease = backend_lease.clone();
drop(backend_lease);
let mut active_affinity_cookie = affinity_cookie.clone();
let mut active_uri = uri.clone();
let mut request_attempt = Some(request);
let mut attempted_backends = Vec::<String>::new();
let mut retries = 0u8;
let upstream_result = loop {
let client = if handshake.is_some() && !websocket_upstream_h2 {
&active_backend.websocket_client
} else {
&active_backend.client
};
let result = tokio::time::timeout(
response_header_timeout,
client.request(request_attempt.take().expect("upstream request attempt missing")),
)
.await;
let attempt_elapsed = attempt_started.elapsed();
let Some(policy) = retry_policy.as_ref() else {
break result;
};
let retry_reason = match &result {
Ok(Ok(response)) if policy.retries_status(response.status().as_u16()) => {
Some("http_status")
}
Ok(Err(cause)) if cause.is_connect() && policy.retry_connect_errors => Some("connect"),
Err(_) if policy.retry_timeouts => Some("timeout"),
_ => None,
};
if !retry_enabled || retries >= policy.max_retries || retry_reason.is_none() {
break result;
}
attempted_backends.push(active_backend.id.clone());
let Some(pool) = route.pool.as_ref() else {
break result;
};
let selection = match pool
.select_retry_wait(
context.client_ip,
&retry_headers,
tls,
&attempted_backends,
)
.await
{
Ok(selection) => selection,
Err(crate::balance::SelectionFailure::Capacity) => {
tracing::debug!(
vhost = %pool.id,
attempt = retries,
"retry stopped because all remaining backends are at concurrency capacity"
);
break result;
}
Err(crate::balance::SelectionFailure::Unavailable) => break result,
};
let next_backend = selection.lease.member.backend.clone();
let Some(next_uri) = upstream_uri(&next_backend, &origin_suffix) else {
break result;
};
let Some(next_request) = retry_request(
&retry_method,
upstream_request_version(&next_backend, false),
&retry_headers,
&next_backend,
&original_host,
&origin_suffix,
retry_host_follows_backend,
) else {
break result;
};
if let Some(lease) = &active_lease {
match &result {
Ok(Ok(response)) => {
lease.response(response.status().as_u16(), attempt_elapsed, true)
}
Ok(Err(cause)) => {
let connect_failure = cause.is_connect();
let detail = upstream_failure_detail(
cause,
&active_backend,
attempt_elapsed,
connect_failure,
);
lease.error_detail(
upstream_error_class(cause, connect_failure),
&detail,
attempt_elapsed,
);
}
Err(_) => lease.error_detail(
"response_header_timeout",
"response_header_timeout",
attempt_elapsed,
),
}
lease.retry_attempted();
}
retries = retries.saturating_add(1);
selection.lease.started();
if let Some(exchange) = &exchange {
*exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) =
Some(selection.lease.clone());
}
tracing::info!(
previous_backend = %active_backend.id,
backend = %next_backend.id,
attempt = retries,
reason = retry_reason.unwrap_or("unknown"),
"retrying replay-safe request on another backend"
);
active_backend = next_backend;
active_lease = Some(selection.lease);
active_affinity_cookie = selection.cookie;
active_uri = next_uri;
request_attempt = Some(next_request);
attempt_started = Instant::now();
};
let final_attempt_elapsed = attempt_started.elapsed();
match upstream_result {
Ok(Ok(mut response)) => {
let origin_validation_headers = response.headers().clone();
if let Some(exchange) = &exchange {
exchange
.upstream_status
.store(response.status().as_u16(), Ordering::Relaxed);
}
if let Some(lease) = &active_lease {
lease.response(
response.status().as_u16(),
final_attempt_elapsed,
request_body_empty,
);
if retries > 0 && response.status().as_u16() < 500 {
lease.retry_succeeded();
}
}
if cache_settings.allows_stale_error_status(response.status()) {
if let Some(stale) = serve_stale(
"cache_stale_if_error",
upstream_started.elapsed(),
&active_backend.id,
) {
return stale;
}
}
let h2_direct_tunnel = websocket_upstream_h2 && response.status().is_success();
if websocket_upstream_h2 && response.status() == StatusCode::SWITCHING_PROTOCOLS {
if let Some(session) = h2_direct_session.take() {
session.cancellation().cancel();
}
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_rejected", "unexpected_h2_101");
}
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: HTTP/2 WebSocket upstream returned HTTP/1.1 switching protocols\n",
);
}
if h2_direct_tunnel {
let Some(expected) = &handshake else {
unreachable!("direct H2 WebSocket requires a handshake")
};
if websocket::validate_h2_response(response.headers(), expected).is_err() {
if let Some(session) = h2_direct_session.take() {
session.cancellation().cancel();
}
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_rejected", "invalid_h2_handshake");
}
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: invalid upstream HTTP/2 WebSocket handshake\n",
);
}
if let Some(session) = h2_direct_session.as_ref() {
session.establish();
}
if let Some(exchange) = &exchange {
exchange.websocket_phase("upstream_2xx_received");
}
} else if websocket_upstream_h2 {
if let Some(session) = h2_direct_session.take() {
session.cancellation().cancel();
}
}
let mut upgraded = if response.status() == StatusCode::SWITCHING_PROTOCOLS {
let Some(expected) = &handshake else {
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: unsolicited upstream upgrade\n",
);
};
if websocket::validate_response(
response.headers(),
expected,
websocket_legacy_support,
)
.is_err()
{
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_rejected", "invalid_101_handshake");
}
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: invalid upstream WebSocket handshake\n",
);
}
if let Some(exchange) = &exchange {
exchange.websocket_phase("upstream_101_received");
}
Some(hyper::upgrade::on(&mut response))
} else {
None
};
if handshake.as_ref().is_some_and(|handshake| {
handshake.h3() || (handshake.h2() && !websocket_upstream_h2)
})
&& response.status().is_success()
&& response.status() != StatusCode::SWITCHING_PROTOCOLS
{
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_rejected", "upstream_2xx_without_101");
}
return error(
StatusCode::BAD_GATEWAY,
"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"))
{
crate::cache::plan(
response.status(),
response.headers(),
cache_settings,
upstream_started.elapsed(),
)
} else {
None
};
let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| {
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
"response_vary_cookie"
} else {
crate::cache::response_reason(response.status(), response.headers())
}
});
let origin_no_transform = crate::transform::no_transform(response.headers());
let skip_compression = crate::compression::private_or_no_transform(response.headers());
let origin_validators = crate::transform::OriginValidators {
etag: response.headers().get("etag").cloned(),
last_modified: response.headers().get("last-modified").cloned(),
};
let (mut parts, body) = response.into_parts();
parts.extensions.insert(origin_validators);
if skip_compression || range_requested {
parts.extensions.insert(crate::compression::Skip);
}
headers::strip_hop_by_hop(&mut parts.headers);
if websocket_legacy_support && handshake.is_some() {
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
}
apply_response_rules(
&mut parts.headers,
&snapshot,
route.frontend.as_deref(),
Some(route),
admin_control_plane,
);
if let Some(pool) = &route.pool {
pool.strip_affinity_response(&mut parts.headers);
}
if let Some(cookie) = active_affinity_cookie {
parts.headers.append("set-cookie", cookie);
}
let h3_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h3())
&& upgraded.is_some();
let h2_bridge_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h2())
&& !websocket_upstream_h2
&& upgraded.is_some();
let mut response = if h3_tunnel {
let Some(upstream) = upgraded.take() else {
unreachable!("checked above")
};
let Some(exchange) = exchange.clone() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"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()
.map(|value| value.0.clone())
.unwrap_or_else(|| state.shutdown.clone());
let upstream = tokio::select! {
_ = stop_token.cancelled() => {
exchange.failed.store(true, Ordering::Relaxed);
exchange.websocket_error("upgrade_cancelled", "shutdown");
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
return error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: WebSocket upgrade cancelled\n",
);
}
result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result {
Ok(Ok(upstream)) => upstream,
_ => {
exchange.failed.store(true, Ordering::Relaxed);
exchange.websocket_error("upgrade_failed", "backend_on_upgrade");
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: upstream WebSocket upgrade failed\n",
);
}
}
};
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");
parts.headers.remove("transfer-encoding");
parts.extensions.insert(crate::compression::Skip);
parts
.extensions
.insert(websocket::H3Tunnel::new(
upstream,
exchange,
active_backend.origin.clone(),
lease,
stop,
));
Response::from_parts(parts, Body::empty())
} else if h2_bridge_tunnel {
let Some(upstream) = upgraded.take() else {
unreachable!("checked above")
};
let Some(exchange) = exchange.clone() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: WebSocket exchange state unavailable\n",
);
};
let Some(downstream_body) = h2_downstream.take() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: HTTP/2 WebSocket request stream unavailable\n",
);
};
drop(body);
let stop_token = stop
.as_ref()
.map(|value| value.0.clone())
.unwrap_or_else(|| state.shutdown.clone());
let upstream = tokio::select! {
_ = stop_token.cancelled() => {
exchange.failed.store(true, Ordering::Relaxed);
exchange.websocket_error("upgrade_cancelled", "shutdown");
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
return error(
StatusCode::SERVICE_UNAVAILABLE,
"RustProxy: WebSocket upgrade cancelled\n",
);
}
result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result {
Ok(Ok(upstream)) => upstream,
_ => {
exchange.failed.store(true, Ordering::Relaxed);
exchange.websocket_error("upgrade_failed", "backend_on_upgrade");
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: upstream WebSocket upgrade failed\n",
);
}
}
};
exchange.websocket_phase("tunnel_ready");
parts.status = StatusCode::OK;
for name in [
"connection",
"upgrade",
"sec-websocket-accept",
"content-length",
"transfer-encoding",
] {
parts.headers.remove(name);
}
parts.extensions.insert(crate::compression::Skip);
let body = websocket::bridge_h2_to_h1(
state.clone(),
downstream_body,
upstream,
exchange,
active_backend.origin.clone(),
lease,
stop,
);
Response::from_parts(parts, body)
} else if h2_direct_tunnel {
let Some(exchange) = exchange.clone() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: WebSocket exchange state unavailable\n",
);
};
let Some(session) = h2_direct_session.take() else {
return error(
StatusCode::INTERNAL_SERVER_ERROR,
"RustProxy: HTTP/2 WebSocket tunnel state unavailable\n",
);
};
parts.headers.remove("content-length");
parts.headers.remove("transfer-encoding");
parts.extensions.insert(crate::compression::Skip);
Response::from_parts(
parts,
websocket::wrap_h2_response_body(body, session, exchange),
)
} else {
if let (Some(upstream), Some(downstream), Some(exchange)) =
(upgraded.take(), downstream, exchange.clone())
{
parts
.headers
.insert("connection", HeaderValue::from_static("Upgrade"));
parts
.headers
.insert("upgrade", HeaderValue::from_static("websocket"));
websocket::spawn(
state.clone(),
downstream,
upstream,
exchange,
active_backend.origin.clone(),
lease,
stop,
);
}
Response::from_parts(parts, Body::new(body))
};
response.extensions_mut().insert(ResponseRulesApplied);
response
.extensions_mut()
.insert(OriginTiming(upstream_started.elapsed()));
response
.extensions_mut()
.insert(OriginBackend(active_backend.id.clone()));
if !range_requested && handshake.is_none() {
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
let transform_started = Instant::now();
response = match crate::transform::apply(
response,
&public_suffix,
settings,
origin_no_transform,
)
.await
{
Ok(response) => {
state.transform_metrics.record_response(
scope,
&response,
transform_started.elapsed(),
);
response
}
Err(cause) => {
state
.transform_metrics
.record_error(scope, transform_started.elapsed());
state.cache.record_reason(scope, &host, "transform_error");
if let Some(exchange) = &exchange {
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = "transform_error";
}
tracing::debug!(error = %cause, "static response transformation failed");
return error(
StatusCode::BAD_GATEWAY,
"RustProxy: upstream response body could not be transformed\n",
);
}
};
}
}
// 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())
{
let merged = cached.merge_validation_headers(response.headers());
let client_not_modified =
crate::cache::conditional_not_modified(&client_conditionals, &merged);
let mut revalidated = if let Some(mut hit) = state.cache.publish_revalidated(
ticket.clone(),
cached,
merged.clone(),
&origin_validation_headers,
cache_settings,
upstream_started.elapsed(),
) {
if !client_not_modified {
if let crate::cache::RangeSelection::Single(range) = range_selection {
hit = hit.apply_range(range, if_range.as_ref());
}
}
let resident_age = hit.resident_age_seconds;
let mut response = hit.response;
cache_settings.annotate_reason(
response.headers_mut(),
"HIT",
hit.hits,
hit.remaining_ttl,
"cache_revalidated",
);
cache_settings
.annotate_transformed(response.headers_mut(), hit.transformed);
if hit.transformed {
state.transform_metrics.record_cache_hit(scope);
}
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 {
cached.response_with_range(merged, range, if_range.as_ref())
} else {
cached.response(merged)
}
} else {
cached.response(merged)
};
cache_settings.annotate_reason(
response.headers_mut(),
"HIT",
0,
0,
"cache_revalidated_uncached",
);
cache_settings
.annotate_transformed(response.headers_mut(), cached.transformed());
if cached.transformed() {
state.transform_metrics.record_cache_hit(scope);
}
Some(response)
} else {
None
};
if let Some(mut revalidated) = revalidated.take() {
if client_not_modified {
revalidated = crate::cache::make_not_modified(revalidated);
}
revalidated.extensions_mut().insert(ResponseRulesApplied);
revalidated
.extensions_mut()
.insert(OriginTiming(upstream_started.elapsed()));
revalidated
.extensions_mut()
.insert(OriginBackend(active_backend.id.clone()));
if let Some(exchange) = &exchange {
exchange.cache_status.store(1, Ordering::Relaxed);
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = "cache_revalidated";
}
state.cache.record(scope, &host, "HIT", "cache_revalidated");
return revalidated;
}
}
}
// Both origin and transformed policies must be cacheable; hiding a
// Set-Cookie/private header never authorizes storage of private content.
let transformed_plan =
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
None
} else {
crate::cache::plan(
response.status(),
response.headers(),
cache_settings,
upstream_started.elapsed(),
)
};
let outcome = if cache_ticket.is_some() {
"MISS"
} else {
"BYPASS"
};
let reason = effective_reason
.or(origin_reason)
.or(revalidation_reason)
.or(freshness_revalidation_reason)
.unwrap_or_else(|| {
if cache_ticket.is_some() && transformed_plan.is_none() {
"response_headers_policy"
} else if cache_ticket.is_some() {
"cache_miss"
} else {
"configuration_changed"
}
});
if outcome == "MISS" && !miss_recorded {
state.cache.record(scope, &host, "MISS", reason);
if let Some(exchange) = &exchange {
exchange.cache_status.store(2, Ordering::Relaxed);
}
} else if outcome == "MISS" && reason != "cache_miss" {
state.cache.record_reason(scope, &host, reason);
}
if let Some(exchange) = &exchange {
*exchange
.cache_reason
.lock()
.unwrap_or_else(|e| e.into_inner()) = reason;
}
if let (Some(ticket), Some(plan), Some(transformed)) =
(cache_ticket, origin_plan, transformed_plan)
{
let plan = if response
.extensions()
.get::<crate::transform::CanonicalIdentity>()
.is_some()
{
plan.without_vary("accept-encoding")
} else {
plan
};
let plan = plan.restrict(transformed);
let proactive_remaining_ttl = plan.remaining_ttl();
let proactive_ticket = ticket.clone();
response = state.cache.wrap_with_fill(
response,
ticket,
plan,
cache_settings,
fill_leader.take(),
);
if cache_settings.proactive_refresh_enabled && proactive_remaining_ttl > 0 {
schedule_proactive_refresh(
CacheRefreshContext {
state: state.clone(),
snapshot: snapshot.clone(),
backend: active_backend.clone(),
cache_settings: cache_settings.clone(),
transform: transform_settings.cloned(),
uri: active_uri.clone(),
public_suffix: public_suffix.clone(),
host: host.clone(),
path: path.clone(),
scope: scope.to_owned(),
timeout: frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout),
local: local.0,
tls,
base_headers: proactive_base_headers.clone(),
},
proactive_ticket,
proactive_remaining_ttl,
);
}
}
let transformed = response
.extensions()
.get::<crate::transform::Applied>()
.is_some()
|| response
.extensions()
.get::<crate::transform::CachedTransformed>()
.is_some();
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
cache_settings.annotate_transformed(response.headers_mut(), transformed);
response
}
Ok(Err(cause)) => {
if let Some(session) = h2_direct_session.take() {
session.cancellation().cancel();
}
let elapsed = upstream_started.elapsed();
let connect_failure = cause.is_connect();
let detail = upstream_failure_detail(&cause, &active_backend, elapsed, connect_failure);
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_request_failed", "upstream_http_error");
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(detail.clone());
}
if let Some(lease) = &active_lease {
lease.error_detail(
upstream_error_class(&cause, connect_failure),
&detail,
final_attempt_elapsed,
);
}
if connect_failure {
tracing::warn!(
backend = %active_backend.id,
origin = %active_backend.origin,
request_origin = %active_backend.request_origin,
tls_verify = active_backend.tls_verify,
elapsed_ms = elapsed.as_millis(),
error = %detail,
"upstream connection failed"
);
} else {
tracing::debug!(
backend = %active_backend.id,
origin = %active_backend.origin,
elapsed_ms = elapsed.as_millis(),
error = %detail,
"upstream HTTP request failed"
);
}
let status = if connect_failure {
StatusCode::SERVICE_UNAVAILABLE
} else {
StatusCode::BAD_GATEWAY
};
let stale_class = classify_stale_error(&cause);
if cache_settings.allows_stale_error_class(stale_class) {
if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id) {
return stale;
}
}
let mut response = error(
status,
if status == StatusCode::SERVICE_UNAVAILABLE {
"RustProxy: upstream unavailable (DNS, TCP or TLS connection failed)\n"
} else {
"RustProxy: upstream did not provide a valid HTTP response\n"
},
);
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
if status == StatusCode::SERVICE_UNAVAILABLE {
response
.headers_mut()
.insert("retry-after", HeaderValue::from_static("1"));
}
response
}
Err(_) => {
if let Some(session) = h2_direct_session.take() {
session.cancellation().cancel();
}
if let Some(exchange) = &exchange {
exchange.websocket_error("upstream_timeout", "header_timeout");
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some("response_header_timeout".into());
}
if let Some(lease) = &active_lease {
lease.error_detail(
"response_header_timeout",
"response_header_timeout",
final_attempt_elapsed,
);
}
if cache_settings
.allows_stale_error_class(crate::cache::StaleErrorClass::Timeout)
{
if let Some(stale) =
serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id)
{
return stale;
}
}
error(
StatusCode::GATEWAY_TIMEOUT,
"RustProxy: upstream response deadline exceeded\n",
)
}
}
}
#[cfg(test)]
mod authority_tests {
use super::*;
fn validation_policy() -> crate::config::ValidationPolicy {
crate::config::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(),
}
}
#[test]
fn retry_is_limited_to_safe_methods() {
for method in [Method::GET, Method::HEAD, Method::OPTIONS] {
assert!(retry_safe_method(&method));
}
for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
assert!(!retry_safe_method(&method));
}
}
#[test]
fn explicit_http2_backend_uses_http2_request_version() {
let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].backends[0].protocol = crate::config::UpstreamProtocol::Http2;
let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap();
let route = snapshot.select("localhost", "/").unwrap();
assert_eq!(
upstream_request_version(&route.backend, false),
Version::HTTP_2
);
}
#[tokio::test]
async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() {
use bytes::Bytes;
for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(method.clone())
.version(Version::HTTP_3)
.uri("https://example.test/api2/json/item")
.body(Body::from_stream(stream))
.unwrap();
assert_eq!(request.body().size_hint().exact(), None, "{method}");
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
.await
.unwrap();
assert!(request.body().is_end_stream(), "{method}");
assert_eq!(request.body().size_hint().exact(), Some(0), "{method}");
}
}
#[tokio::test]
async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() {
use bytes::Bytes;
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
Bytes::from_static(b"payload"),
)]);
let mut request = Request::builder()
.method(Method::DELETE)
.version(Version::HTTP_3)
.uri("https://example.test/api2/json/item")
.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"payload"));
}
#[tokio::test]
async fn h3_unknown_body_is_untouched_for_http2_upstream() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(Method::DELETE)
.version(Version::HTTP_3)
.uri("https://example.test/api2/json/item")
.body(Body::from_stream(stream))
.unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_2)
.await
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
}
#[tokio::test]
async fn non_h3_unknown_body_is_untouched_for_http1_upstream() {
use bytes::Bytes;
for version in [Version::HTTP_11, Version::HTTP_2] {
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(Method::DELETE)
.version(version)
.uri("https://example.test/api2/json/item")
.body(Body::from_stream(stream))
.unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
.await
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
}
}
#[tokio::test]
async fn h3_declared_delete_body_is_not_preconsumed() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(Method::DELETE)
.version(Version::HTTP_3)
.uri("https://example.test/api2/json/item")
.header(http::header::CONTENT_LENGTH, "7")
.body(Body::from_stream(stream))
.unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
.await
.unwrap();
assert_eq!(request.headers()[http::header::CONTENT_LENGTH], "7");
assert_eq!(request.body().size_hint().exact(), None);
}
#[test]
fn generated_policy_and_gateway_errors_use_html_pages() {
for status in [
StatusCode::FORBIDDEN,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::GATEWAY_TIMEOUT,
] {
let response = error(status, "internal detail\n");
assert_eq!(response.status(), status);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
}
let response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
assert_ne!(
response.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
}
#[test]
fn locally_generated_pages_remove_cache_metadata() {
let mut response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
for (name, value) in [
("etag", "\"old\""),
("x-cache", "HIT"),
("x-cache-reason", "origin"),
("cache-status", "Origin; hit"),
] {
response.headers_mut().insert(name, value.parse().unwrap());
}
enforce_local_page_no_cache(&mut response);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
assert!(!response.headers().contains_key(name), "{name}");
}
assert!(response.extensions().get::<LocalGeneratedResponse>().is_some());
}
#[test]
fn http2_uses_authority_and_rejects_conflicting_host() {
let mut request = Request::builder()
.version(Version::HTTP_2)
.uri("https://app.localhost/test")
.body(Body::empty())
.unwrap();
assert_eq!(effective_host(&request, true).unwrap(), "app.localhost");
request
.headers_mut()
.insert(HOST, "different.localhost".parse().unwrap());
assert!(effective_host(&request, true).is_err());
}
#[test]
fn legacy_websocket_origin_matches_upstream_scheme_and_host() {
let origin = legacy_websocket_origin(
"https://ilo.internal:443",
&"10.0.0.5:443".parse().unwrap(),
)
.unwrap();
assert_eq!(origin, "https://10.0.0.5:443");
assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none());
}
#[test]
fn force_ssl_location_preserves_path_query_and_https_port() {
let uri: Uri = "/api/items?x=1&next=%2Fhome".parse().unwrap();
assert_eq!(
force_ssl_location("app.example.test", &uri, 443).unwrap(),
"https://app.example.test/api/items?x=1&next=%2Fhome"
);
assert_eq!(
force_ssl_location("app.example.test", &uri, 8443).unwrap(),
"https://app.example.test:8443/api/items?x=1&next=%2Fhome"
);
}
#[test]
fn http1_absolute_form_and_h2_wrong_scheme_are_rejected() {
let request = Request::builder()
.uri("http://app.localhost/")
.header(HOST, "app.localhost")
.body(Body::empty())
.unwrap();
assert!(effective_host(&request, false).is_err());
let request = Request::builder()
.version(Version::HTTP_2)
.uri("http://app.localhost/")
.body(Body::empty())
.unwrap();
assert!(effective_host(&request, true).is_err());
}
#[derive(Debug)]
struct NestedDiagnosticError {
source: std::io::Error,
}
impl std::fmt::Display for NestedDiagnosticError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("connector wrapper")
}
}
impl std::error::Error for NestedDiagnosticError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
#[test]
fn upstream_error_chain_keeps_nested_connector_cause() {
let error = NestedDiagnosticError {
source: std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"connection refused by origin",
),
};
let detail = format_error_chain(&error);
assert!(detail.contains("connector wrapper"));
assert!(detail.contains("connection refused by origin"));
}
#[test]
fn upstream_error_detail_truncation_preserves_utf8_boundaries() {
let mut detail = "ą".repeat(MAX_UPSTREAM_ERROR_DETAIL);
truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL);
assert!(detail.len() <= MAX_UPSTREAM_ERROR_DETAIL);
assert!(std::str::from_utf8(detail.as_bytes()).is_ok());
}
#[test]
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
let mut response = Response::new(Body::empty());
response.headers_mut().insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
response.headers_mut().insert("x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof"));
response.headers_mut().insert("x-rustproxy-backend-time", HeaderValue::from_static("999ms"));
apply_backend_diagnostic_values(&mut response, false, false);
assert!(response.headers().get("x-rust-proxy-backend").is_none());
assert!(response.headers().get("x-rust-proxy-backend-time").is_none());
assert!(response.headers().get("x-rustproxy-backend-time").is_none());
response.extensions_mut().insert(OriginBackend("origin-a".into()));
response.extensions_mut().insert(OriginTiming(Duration::from_millis(12)));
apply_backend_diagnostic_values(&mut response, true, true);
assert_eq!(response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a");
assert_eq!(response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms");
}
}