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) -> 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("".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::() { 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 { format!("{}{suffix}", backend.request_origin).parse().ok() } fn legacy_websocket_origin( request_origin: &str, upstream_host: &HeaderValue, ) -> Option { 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 { 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( 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, snapshot: Arc, backend: Arc, cache_settings: crate::cache::Settings, transform: Option, 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::() .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 { 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>, Option, ) { let tls = request .extensions() .get::() .copied() .unwrap_or_default() .tls; let context = (|| { let local = request.extensions().get::()?.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, 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::() { 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::() { 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::() { 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 { 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>, 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::().map(|v| v.0); let transport_tls = request .extensions() .get::() .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::() .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::().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::() .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::() .is_none() && response .extensions() .get::() .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::().is_some_and(|redirect| !redirect.http3_enabled) { response.headers_mut().remove("alt-svc"); } if response.extensions().get::().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::() .copied() { state.metrics.record_compression(encoding); } if response .extensions() .get::() .is_none() && response.extensions().get::().is_none() && response.extensions().get::().is_none() && response.extensions().get::().is_none() && response.extensions().get::().is_none() && response .extensions() .get::() .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::() .is_some() || response .extensions() .get::() .is_some(); cache.annotate_transformed(response.headers_mut(), transformed); } } if response.extensions().get::().is_some() || response.extensions().get::().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::().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::().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 { 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 { 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 { 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>, Extension(peer): Extension, Extension(local): Extension, 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::() .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::>() .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::>() { *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::>() { *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::>().cloned(); let lease = request.extensions().get::>().cloned(); let stop = request.extensions().get::().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 { 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::::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::() .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::() .is_some() || response .extensions() .get::() .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::>(); 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::>(); 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::>(); 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::>(); 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::().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"); } }