v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,390 @@
|
||||
fn schedule_proactive_refresh(
|
||||
context: CacheRefreshContext,
|
||||
ticket: crate::cache::Ticket,
|
||||
remaining_ttl: u64,
|
||||
) {
|
||||
let Some(schedule_guard) = context
|
||||
.state
|
||||
.cache
|
||||
.begin_proactive_schedule(&ticket, &context.cache_settings)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let threshold = context
|
||||
.cache_settings
|
||||
.proactive_refresh_before_expiry_seconds;
|
||||
tokio::spawn(async move {
|
||||
let _schedule_guard = schedule_guard;
|
||||
let mut delay = remaining_ttl.saturating_sub(threshold).max(1);
|
||||
let mut awaiting_initial_publish = true;
|
||||
let mut initial_publish_retries = 0u8;
|
||||
loop {
|
||||
if delay > 0 {
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
}
|
||||
if !context.state.cache.ticket_current(&ticket) {
|
||||
return;
|
||||
}
|
||||
let Some(cached) = context.state.cache.revalidation_tiered(&ticket).await else {
|
||||
if awaiting_initial_publish && initial_publish_retries < 60 {
|
||||
initial_publish_retries = initial_publish_retries.saturating_add(1);
|
||||
delay = 1;
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
};
|
||||
awaiting_initial_publish = false;
|
||||
let Some(inflight_guard) = context
|
||||
.state
|
||||
.cache
|
||||
.try_begin_proactive_refresh(&context.cache_settings)
|
||||
else {
|
||||
delay = 5;
|
||||
continue;
|
||||
};
|
||||
let Some(revalidation_guard) = context
|
||||
.state
|
||||
.cache
|
||||
.begin_background_revalidation(&cached)
|
||||
else {
|
||||
drop(inflight_guard);
|
||||
delay = 1;
|
||||
continue;
|
||||
};
|
||||
let refreshed = refresh_cache_once(&context, ticket.clone(), cached).await;
|
||||
drop(revalidation_guard);
|
||||
drop(inflight_guard);
|
||||
if !refreshed {
|
||||
return;
|
||||
}
|
||||
context.state.cache.record(
|
||||
&context.scope,
|
||||
&context.host,
|
||||
"HIT",
|
||||
"cache_proactive_refreshed",
|
||||
);
|
||||
let Some(next) = context.state.cache.revalidation_tiered(&ticket).await else {
|
||||
return;
|
||||
};
|
||||
let remaining = next.remaining_ttl();
|
||||
if remaining == 0 {
|
||||
return;
|
||||
}
|
||||
delay = remaining.saturating_sub(threshold).max(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn enforce_local_page_no_cache(response: &mut Response) {
|
||||
for name in [
|
||||
"age",
|
||||
"expires",
|
||||
"pragma",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"cdn-cache-control",
|
||||
"surrogate-control",
|
||||
"x-cache",
|
||||
"x-cache-hits",
|
||||
"x-cache-status",
|
||||
"x-cache-resident-age",
|
||||
"x-cache-reason",
|
||||
"cache-status",
|
||||
] {
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
}
|
||||
|
||||
fn error(status: StatusCode, message: &'static str) -> Response {
|
||||
let mut response = if let Some(html) = crate::pages::builtin_gateway_error(status) {
|
||||
let mut response = (status, html).into_response();
|
||||
response.headers_mut().insert(
|
||||
"content-type",
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response
|
||||
} else {
|
||||
(status, message).into_response()
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
response.extensions_mut().insert(LocalGeneratedResponse);
|
||||
response
|
||||
}
|
||||
|
||||
fn local_location_response(spec: &crate::vhost::LocationResponse, method: &Method) -> Response {
|
||||
let status = StatusCode::from_u16(spec.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body_allowed = !matches!(status.as_u16(), 100..=199 | 204 | 304) && *method != Method::HEAD;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.body(if body_allowed { Body::from(spec.body.clone()) } else { Body::empty() })
|
||||
.unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, Body::empty()).into_response());
|
||||
if let Ok(value) = HeaderValue::from_str(&spec.content_type) {
|
||||
response.headers_mut().insert("content-type", value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn encoded_origin_path(path: &str) -> Option<String> {
|
||||
let mut url = url::Url::parse("http://rustproxy.invalid/").ok()?;
|
||||
url.set_path(path);
|
||||
Some(url.path().to_owned())
|
||||
}
|
||||
fn response_context(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
request: &Request,
|
||||
) -> (
|
||||
Option<Arc<crate::config::CompiledFrontend>>,
|
||||
Option<crate::config::CompiledRoute>,
|
||||
) {
|
||||
let tls = request
|
||||
.extensions()
|
||||
.get::<Transport>()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.tls;
|
||||
let context = (|| {
|
||||
let local = request.extensions().get::<LocalAddr>()?.0;
|
||||
let authority = effective_host(request, tls).ok()?;
|
||||
let host = request_host(authority.to_str().ok()?).ok()?;
|
||||
let frontend = snapshot.frontend(&host, local, tls).cloned();
|
||||
let route = crate::acl::routing_path(request.uri().path())
|
||||
.ok()
|
||||
.and_then(|path| snapshot.select_on(&host, &path, local, tls).cloned());
|
||||
Some((frontend, route))
|
||||
})();
|
||||
context.unwrap_or((None, None))
|
||||
}
|
||||
|
||||
/// Reserved administration-session cookies may cross the proxy boundary only
|
||||
/// for the explicit self-admin deployment: HTTPS, an explicitly allowed public
|
||||
/// administration hostname, a backend that directly targets the configured
|
||||
/// administration listener, and the existing local-listener risk opt-in.
|
||||
fn admin_session_passthrough(
|
||||
snapshot: &crate::config::Snapshot,
|
||||
host: &str,
|
||||
tls: bool,
|
||||
backend: &crate::config::CompiledBackend,
|
||||
) -> bool {
|
||||
tls
|
||||
&& backend.accept_local_listener_risk
|
||||
&& backend.targets_admin_listener
|
||||
&& snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.allowed_hosts
|
||||
.iter()
|
||||
.any(|allowed| allowed.eq_ignore_ascii_case(host))
|
||||
}
|
||||
|
||||
fn apply_response_rules(
|
||||
headers: &mut http::HeaderMap,
|
||||
snapshot: &crate::config::Snapshot,
|
||||
frontend: Option<&crate::config::CompiledFrontend>,
|
||||
route: Option<&crate::config::CompiledRoute>,
|
||||
preserve_admin_session: bool,
|
||||
) {
|
||||
crate::headers::apply(headers, &snapshot.headers.response);
|
||||
if let Some(frontend) = frontend {
|
||||
crate::headers::apply(headers, &frontend.headers.response);
|
||||
}
|
||||
if let Some(route) = route {
|
||||
crate::headers::apply(headers, &route.headers.response);
|
||||
}
|
||||
if !preserve_admin_session {
|
||||
headers::strip_admin_set_cookie(headers);
|
||||
}
|
||||
}
|
||||
/// Final policies apply to cache hits, errors, static pages and ALL transports.
|
||||
fn finish_headers(
|
||||
response: &mut Response,
|
||||
snapshot: &crate::config::Snapshot,
|
||||
frontend: Option<&crate::config::CompiledFrontend>,
|
||||
local: Option<std::net::SocketAddr>,
|
||||
tls: bool,
|
||||
id: u64,
|
||||
active: &[crate::vhost::Listener],
|
||||
) {
|
||||
if let Some(frontend) = frontend {
|
||||
frontend.security.apply(response.headers_mut(), tls);
|
||||
}
|
||||
frontend
|
||||
.map(|f| &f.server_header)
|
||||
.unwrap_or(&snapshot.envelope.config.server_header)
|
||||
.apply(response.headers_mut());
|
||||
frontend
|
||||
.map(|f| &f.diagnostic_headers)
|
||||
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
|
||||
.apply(response.headers_mut(), id);
|
||||
// Authenticated content must not leak through an external shared cache either.
|
||||
if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) {
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store"),
|
||||
);
|
||||
}
|
||||
if frontend.is_some_and(|frontend| !frontend.http3_enabled) {
|
||||
response.headers_mut().remove("alt-svc");
|
||||
} else {
|
||||
crate::http3::advertise(response.headers_mut(), local, tls, active);
|
||||
}
|
||||
}
|
||||
fn apply_timing_headers(
|
||||
response: &mut Response,
|
||||
frontend: Option<&crate::config::CompiledFrontend>,
|
||||
exchange: &Exchange,
|
||||
) {
|
||||
if !frontend.is_some_and(|frontend| frontend.timing_headers) {
|
||||
return;
|
||||
}
|
||||
let total_ms = exchange.started.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut values = vec![format!("proxy;dur={total_ms:.2}")];
|
||||
if let Some(origin) = response.extensions().get::<OriginTiming>() {
|
||||
values.push(format!("origin;dur={:.2}", origin.0.as_secs_f64() * 1000.0));
|
||||
}
|
||||
let cache = match exchange.cache_status.load(Ordering::Relaxed) {
|
||||
1 => Some("HIT"),
|
||||
2 => Some("MISS"),
|
||||
3 => Some("BYPASS"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(cache) = cache {
|
||||
values.push(format!("cache;desc=\"{cache}\""));
|
||||
}
|
||||
response.headers_mut().remove("server-timing");
|
||||
if let Ok(value) = HeaderValue::from_str(&values.join(", ")) {
|
||||
response.headers_mut().insert("server-timing", value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in backend diagnostics are applied after response rules and cache handling so
|
||||
/// an origin cannot spoof them and a cache hit never reports a stale backend.
|
||||
fn apply_backend_diagnostic_values(
|
||||
response: &mut Response,
|
||||
backend_header: bool,
|
||||
backend_timing_header: bool,
|
||||
) {
|
||||
response.headers_mut().remove("x-rust-proxy-backend");
|
||||
response.headers_mut().remove("x-rust-proxy-backend-time");
|
||||
response.headers_mut().remove("x-rustproxy-backend-time");
|
||||
if backend_header {
|
||||
if let Some(origin) = response.extensions().get::<OriginBackend>() {
|
||||
if let Ok(value) = HeaderValue::from_str(&origin.0) {
|
||||
response.headers_mut().insert("x-rust-proxy-backend", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if backend_timing_header {
|
||||
if let Some(origin) = response.extensions().get::<OriginTiming>() {
|
||||
let value = format!("{:.2}ms", origin.0.as_secs_f64() * 1000.0);
|
||||
if let Ok(value) = HeaderValue::from_str(&value) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-rustproxy-backend-time", value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn apply_backend_diagnostic_headers(
|
||||
response: &mut Response,
|
||||
frontend: Option<&crate::config::CompiledFrontend>,
|
||||
) {
|
||||
apply_backend_diagnostic_values(
|
||||
response,
|
||||
frontend.is_some_and(|value| value.backend_header),
|
||||
frontend.is_some_and(|value| value.backend_timing_header),
|
||||
);
|
||||
}
|
||||
/// Peek at one bounded frame when a multiplexed request has no exact size hint.
|
||||
/// An absent Content-Length on HTTP/2 or HTTP/3 is NOT proof of an empty body.
|
||||
async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
|
||||
if request.body().is_end_stream() {
|
||||
return Ok(true);
|
||||
}
|
||||
if request.body().size_hint().lower() > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
// Multiplexed transports may end a body with an empty DATA frame.
|
||||
for _ in 0..8 {
|
||||
match request.body_mut().frame().await {
|
||||
None => {
|
||||
*request.body_mut() = Body::empty();
|
||||
return Ok(true);
|
||||
}
|
||||
Some(Err(error)) => return Err(error),
|
||||
Some(Ok(frame)) => {
|
||||
if frame.data_ref().is_some_and(|data| data.is_empty()) {
|
||||
if request.body().is_end_stream() {
|
||||
*request.body_mut() = Body::empty();
|
||||
return Ok(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let tail = std::mem::replace(request.body_mut(), Body::empty());
|
||||
*request.body_mut() = Body::new(body::ReplayBody::new(frame, tail));
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Resolve the framing ambiguity of an HTTP/3 request with no declared length
|
||||
/// before handing it to an HTTP/1.1 upstream. Hyper encodes an unknown-length
|
||||
/// HTTP/1.1 request as chunked for methods other than GET/HEAD/CONNECT. That is
|
||||
/// correct HTTP/1.1, but an actually empty H3 stream can be represented more
|
||||
/// precisely as a zero-length body once its end is observed.
|
||||
///
|
||||
/// Only the first semantically significant frame is inspected; non-empty bodies
|
||||
/// remain streaming through ReplayBody and are never fully buffered.
|
||||
async fn normalize_h3_h1_unknown_body(
|
||||
request: &mut Request,
|
||||
upstream_version: Version,
|
||||
) -> Result<(), axum::Error> {
|
||||
let method = request.method();
|
||||
if request.version() != Version::HTTP_3
|
||||
|| upstream_version != Version::HTTP_11
|
||||
|| method == Method::GET
|
||||
|| method == Method::HEAD
|
||||
|| method == Method::CONNECT
|
||||
|| request.headers().contains_key(http::header::CONTENT_LENGTH)
|
||||
|| request.body().size_hint().exact().is_some()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Ignore a small number of empty DATA frames while looking for either the
|
||||
// stream end or the first frame that carries request semantics. The body
|
||||
// idle timer in MeteredBody still bounds a stalled peer.
|
||||
for _ in 0..8 {
|
||||
match request.body_mut().frame().await {
|
||||
None => {
|
||||
*request.body_mut() = Body::empty();
|
||||
return Ok(());
|
||||
}
|
||||
Some(Err(error)) => return Err(error),
|
||||
Some(Ok(frame)) => {
|
||||
if frame.data_ref().is_some_and(|data| data.is_empty()) {
|
||||
if request.body().is_end_stream() {
|
||||
*request.body_mut() = Body::empty();
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let tail = std::mem::replace(request.body_mut(), Body::empty());
|
||||
*request.body_mut() = Body::new(body::ReplayBody::new(frame, tail));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user