v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,328 @@
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
(serve_stale, fill_leader)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
match upstream_result {
|
||||
Ok(Ok(mut response)) => {
|
||||
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
|
||||
include!("success_prepare.rs");
|
||||
include!("success_finish.rs")
|
||||
}
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
// 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());
|
||||
}
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
if handshake.is_some() {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: Web Server source does not support WebSocket upgrades\n",
|
||||
);
|
||||
}
|
||||
let service_path = match route.rewritten_path(&path) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => path.clone(),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite path\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
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(false);
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
|
||||
}
|
||||
// Do not hold a shared reference to Request<Body> across await. Axum handlers
|
||||
// must return Send futures, while Body is not Sync. Clone only static-request
|
||||
// metadata needed by the filesystem source before entering the async server.
|
||||
let static_method = request.method().clone();
|
||||
let static_headers = request.headers().clone();
|
||||
let static_uri = request.uri().clone();
|
||||
let static_version = request.version();
|
||||
let static_body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let mut response = crate::web_server::serve(
|
||||
service,
|
||||
static_method,
|
||||
static_headers,
|
||||
static_uri,
|
||||
&service_path,
|
||||
static_body,
|
||||
crate::web_server::RequestContext {
|
||||
client_ip: context.client_ip,
|
||||
client_port: Some(peer.0.port()),
|
||||
server_name: host.clone(),
|
||||
server_port: local.0.port(),
|
||||
tls,
|
||||
version: static_version,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
return response;
|
||||
}
|
||||
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 {
|
||||
let Some(backend) = route.backend.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Reverse Proxy route has no backend\n",
|
||||
);
|
||||
};
|
||||
(backend, 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 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 miss_recorded = false;
|
||||
(
|
||||
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
|
||||
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
|
||||
origin_suffix, uri, exchange, lease, stop, downstream, h2_downstream,
|
||||
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
|
||||
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
|
||||
pending_revalidation, miss_recorded,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
{
|
||||
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",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
(origin_validation_headers, origin_plan, origin_reason, response)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
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();
|
||||
(
|
||||
websocket_upstream_h2, revalidation_sent, h2_direct_session, request_body_empty,
|
||||
proactive_base_headers, upstream_started, active_backend,
|
||||
active_lease, active_affinity_cookie, active_uri, retries, upstream_result,
|
||||
final_attempt_elapsed,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user