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 method_policy_denial(policy: &crate::vhost::MethodPolicy, method: &Method) -> Option { if policy.allows(method) { return None; } let mut response = error( StatusCode::METHOD_NOT_ALLOWED, "RustProxy: HTTP method denied by access policy\n", ); if let Some(value) = policy.allow_header() { response.headers_mut().insert(http::header::ALLOW, value); } Some(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_arc(&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, a specifically allowed /// administration hostname (extra DNS list or pinned public HTTPS origin), /// a backend that directly targets the configured /// administration listener, and the existing local-listener risk opt-in. fn admin_session_passthrough( snapshot: &crate::config::Snapshot, host: &str, tls: bool, backend: &crate::config::CompiledBackend, ) -> bool { tls && backend.accept_local_listener_risk && backend.targets_admin_listener && snapshot .envelope .config .administration .explicitly_allowed_panel_host(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); } } fn apply_response_age_rules( headers: &mut http::HeaderMap, snapshot: &crate::config::Snapshot, frontend: Option<&crate::config::CompiledFrontend>, route: Option<&crate::config::CompiledRoute>, ) { crate::headers::apply_wire_age(headers, &snapshot.headers.response); if let Some(frontend) = frontend { crate::headers::apply_wire_age(headers, &frontend.headers.response); } if let Some(route) = route { crate::headers::apply_wire_age(headers, &route.headers.response); } } /// Final policies apply to cache hits, errors, static pages and ALL transports. fn finish_headers( response: &mut Response, snapshot: &crate::config::Snapshot, frontend: Option<&crate::config::CompiledFrontend>, local: Option, tls: bool, request_id: &str, 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(), request_id); // Authenticated content must not leak through an external shared cache either. if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) { response.headers_mut().insert( "cache-control", 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-rustproxy-backend"); 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. // On H3 document GET/HEAD, waiting without a bound can stall the entire // cache/404-routing stage before proxy forwarding even starts. A timed // probe that finds no FIN conservatively bypasses caching; it never drops // or consumes a nonempty request body. let bounded_h3_navigation = request.version() == Version::HTTP_3 && (request.method() == Method::GET || request.method() == Method::HEAD); for _ in 0..8 { let next = if bounded_h3_navigation { match tokio::time::timeout( std::time::Duration::from_millis(35), request.body_mut().frame(), ).await { Ok(frame) => frame, Err(_) => return Ok(false), } } else { request.body_mut().frame().await }; match next { 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::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. // A full page navigation (e.g. clicking the RustProxy logo) is a GET. // H3 HEADERS can precede FIN on a document GET. Resolve the receive half // when FIN arrives promptly, before handing the request to an H1 origin. // Hyper already special-cases GET/HEAD H1 encoding; this probe targets // receive-stream completion, not a presumed chunked GET on the wire. // When the peer is slow or sends a real request body, retain streaming and // do not delay navigation by the general upload/body-idle deadline. let navigation_method = method == Method::GET || method == Method::HEAD; for _ in 0..8 { let next = if navigation_method { match tokio::time::timeout( std::time::Duration::from_millis(35), request.body_mut().frame(), ).await { Ok(frame) => frame, Err(_) => return Ok(()), } } else { request.body_mut().frame().await }; match next { 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(()) }