v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
+11
-139
@@ -59,22 +59,13 @@ pub struct Exchange {
|
||||
impl Exchange {
|
||||
pub fn websocket_phase(&self, phase: &'static str) {
|
||||
if self.websocket_bootstrap.is_some() {
|
||||
*self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
}
|
||||
}
|
||||
pub fn websocket_error(&self, phase: &'static str, error: &'static str) {
|
||||
if self.websocket_bootstrap.is_some() {
|
||||
*self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self
|
||||
.websocket_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(error);
|
||||
*self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner()) = Some(phase);
|
||||
*self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(error);
|
||||
}
|
||||
}
|
||||
pub fn h3_reset(&self, code: &'static str) {
|
||||
@@ -138,29 +129,12 @@ impl Drop for Exchange {
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.map(|l| l.member.backend.id.clone());
|
||||
let websocket_phase = *self
|
||||
.websocket_phase
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let websocket_phase = *self.websocket_phase.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let route = self.route.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let cache_effective_enabled = *self
|
||||
.cache_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let transform_effective_enabled = *self
|
||||
.transform_effective_enabled
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let client_ip = self
|
||||
.client_ip
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let country = self
|
||||
.country
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let cache_effective_enabled = *self.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let transform_effective_enabled = *self.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let client_ip = self.client_ip.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let country = self.country.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let request_bytes = self.request_bytes.load(Ordering::Relaxed);
|
||||
let response_bytes = self.response_bytes.load(Ordering::Relaxed);
|
||||
let duration_ms = duration.as_millis() as u64;
|
||||
@@ -213,20 +187,13 @@ impl Drop for Exchange {
|
||||
transport: self.transport,
|
||||
websocket_bootstrap: self.websocket_bootstrap,
|
||||
websocket_phase,
|
||||
websocket_error: *self
|
||||
.websocket_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()),
|
||||
websocket_error: *self.websocket_error.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
h3_reset_code: *self.h3_reset_code.lock().unwrap_or_else(|e| e.into_inner()),
|
||||
upstream_status: match self.upstream_status.load(Ordering::Relaxed) {
|
||||
0 => None,
|
||||
value => Some(value),
|
||||
},
|
||||
upstream_error: self
|
||||
.upstream_error
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone(),
|
||||
upstream_error: self.upstream_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||
client_ip,
|
||||
user_agent: self.user_agent.clone(),
|
||||
country,
|
||||
@@ -367,102 +334,7 @@ impl Drop for MeteredBody {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
use tokio::sync::Semaphore;
|
||||
async fn observation() -> (Arc<Exchange>, Arc<Semaphore>) {
|
||||
let slots = Arc::new(Semaphore::new(1));
|
||||
let permit = slots.clone().acquire_owned().await.unwrap();
|
||||
let metrics = Arc::new(Metrics::default());
|
||||
metrics.active.store(1, Ordering::Relaxed);
|
||||
let exchange = Arc::new(Exchange {
|
||||
vhost_metrics: None,
|
||||
vhost: None,
|
||||
route: Mutex::new(None),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
id: 1,
|
||||
method: "POST".into(),
|
||||
host: "localhost".into(),
|
||||
path: "/echo".into(),
|
||||
http_version: "1.1",
|
||||
transport: "h1",
|
||||
websocket_bootstrap: None,
|
||||
websocket_phase: Mutex::new(None),
|
||||
websocket_error: Mutex::new(None),
|
||||
h3_reset_code: Mutex::new(None),
|
||||
upstream_status: AtomicU16::new(0),
|
||||
upstream_error: Mutex::new(None),
|
||||
client_ip: Mutex::new(Some("127.0.0.1".into())),
|
||||
user_agent: Some("RustProxy-Test/1.0".into()),
|
||||
country: Mutex::new(Some("PL".into())),
|
||||
started: Instant::now(),
|
||||
status: AtomicU16::new(200),
|
||||
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,
|
||||
logs: Arc::new(Logs::default()),
|
||||
traffic: crate::traffic::Analytics::start(tokio_util::sync::CancellationToken::new()),
|
||||
_permit: permit,
|
||||
});
|
||||
(exchange, slots)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn permit_and_metrics_cover_both_bodies() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let logs = exchange.logs.clone();
|
||||
let input = MeteredBody::new(
|
||||
Body::from("in"),
|
||||
exchange.clone(),
|
||||
Direction::Request,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
let output = MeteredBody::new(
|
||||
Body::from("out"),
|
||||
exchange.clone(),
|
||||
Direction::Response,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
drop(exchange);
|
||||
assert_eq!(
|
||||
input.collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"in")
|
||||
);
|
||||
assert_eq!(slots.available_permits(), 0);
|
||||
assert_eq!(
|
||||
output.collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"out")
|
||||
);
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(metrics.bytes_in.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(metrics.completed.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(logs.recent().len(), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn idle_body_fails_and_releases_capacity() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let pending = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let output = MeteredBody::new(
|
||||
Body::from_stream(pending),
|
||||
exchange,
|
||||
Direction::Response,
|
||||
Duration::from_millis(10),
|
||||
);
|
||||
assert!(output.collect().await.is_err());
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
include!("body/tests.rs");
|
||||
|
||||
/// Retains the first inspected frame without collecting the rest of a request.
|
||||
/// A trailer/empty DATA frame still prevents storing a request-body variant.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
use tokio::sync::Semaphore;
|
||||
async fn observation() -> (Arc<Exchange>, Arc<Semaphore>) {
|
||||
let slots = Arc::new(Semaphore::new(1));
|
||||
let permit = slots.clone().acquire_owned().await.unwrap();
|
||||
let metrics = Arc::new(Metrics::default());
|
||||
metrics.active.store(1, Ordering::Relaxed);
|
||||
let exchange = Arc::new(Exchange {
|
||||
vhost_metrics: None,
|
||||
vhost: None,
|
||||
route: Mutex::new(None),
|
||||
cache_effective_enabled: Mutex::new(None),
|
||||
transform_effective_enabled: Mutex::new(None),
|
||||
backend: Mutex::new(None),
|
||||
id: 1,
|
||||
method: "POST".into(),
|
||||
host: "localhost".into(),
|
||||
path: "/echo".into(),
|
||||
http_version: "1.1",
|
||||
transport: "h1",
|
||||
websocket_bootstrap: None,
|
||||
websocket_phase: Mutex::new(None),
|
||||
websocket_error: Mutex::new(None),
|
||||
h3_reset_code: Mutex::new(None),
|
||||
upstream_status: AtomicU16::new(0),
|
||||
upstream_error: Mutex::new(None),
|
||||
client_ip: Mutex::new(Some("127.0.0.1".into())),
|
||||
user_agent: Some("RustProxy-Test/1.0".into()),
|
||||
country: Mutex::new(Some("PL".into())),
|
||||
started: Instant::now(),
|
||||
status: AtomicU16::new(200),
|
||||
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,
|
||||
logs: Arc::new(Logs::default()),
|
||||
traffic: crate::traffic::Analytics::start(tokio_util::sync::CancellationToken::new()),
|
||||
_permit: permit,
|
||||
});
|
||||
(exchange, slots)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn permit_and_metrics_cover_both_bodies() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let logs = exchange.logs.clone();
|
||||
let input = MeteredBody::new(
|
||||
Body::from("in"),
|
||||
exchange.clone(),
|
||||
Direction::Request,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
let output = MeteredBody::new(
|
||||
Body::from("out"),
|
||||
exchange.clone(),
|
||||
Direction::Response,
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
drop(exchange);
|
||||
assert_eq!(
|
||||
input.collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"in")
|
||||
);
|
||||
assert_eq!(slots.available_permits(), 0);
|
||||
assert_eq!(
|
||||
output.collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"out")
|
||||
);
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(metrics.bytes_in.load(Ordering::Relaxed), 2);
|
||||
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(metrics.completed.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(logs.recent().len(), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn idle_body_fails_and_releases_capacity() {
|
||||
let (exchange, slots) = observation().await;
|
||||
let metrics = exchange.metrics.clone();
|
||||
let pending = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let output = MeteredBody::new(
|
||||
Body::from_stream(pending),
|
||||
exchange,
|
||||
Direction::Response,
|
||||
Duration::from_millis(10),
|
||||
);
|
||||
assert!(output.collect().await.is_err());
|
||||
assert_eq!(slots.available_permits(), 1);
|
||||
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
{
|
||||
if let Some(ticket) = &cache_ticket {
|
||||
if revalidation_reason.is_none() {
|
||||
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
|
||||
let conditional = crate::cache::conditional_not_modified(
|
||||
&client_conditionals,
|
||||
hit.response.headers(),
|
||||
);
|
||||
if !conditional {
|
||||
if let crate::cache::RangeSelection::Single(range) = range_selection {
|
||||
hit = hit.apply_range(range, if_range.as_ref());
|
||||
}
|
||||
}
|
||||
let range_hit = matches!(
|
||||
hit.response.status(),
|
||||
StatusCode::PARTIAL_CONTENT | StatusCode::RANGE_NOT_SATISFIABLE
|
||||
);
|
||||
let reason = if conditional {
|
||||
"cache_conditional_hit"
|
||||
} else if range_hit {
|
||||
"cache_range_hit"
|
||||
} else if hit.stale && hit.background_revalidate {
|
||||
"cache_stale_while_revalidate"
|
||||
} else if hit.stale {
|
||||
"cache_max_stale"
|
||||
} else {
|
||||
"cache_hit"
|
||||
};
|
||||
if hit.background_revalidate {
|
||||
if let (Some(cached), Some(guard)) = (
|
||||
cached_revalidation.clone(),
|
||||
cached_revalidation
|
||||
.as_ref()
|
||||
.and_then(|cached| state.cache.begin_background_revalidation(cached)),
|
||||
) {
|
||||
let refresh_context = CacheRefreshContext {
|
||||
state: state.clone(),
|
||||
snapshot: snapshot.clone(),
|
||||
backend: backend.clone(),
|
||||
cache_settings: cache_settings.clone(),
|
||||
transform: transform_settings.cloned(),
|
||||
uri: uri.clone(),
|
||||
public_suffix: public_suffix.clone(),
|
||||
host: host.clone(),
|
||||
path: path.clone(),
|
||||
scope: scope.to_owned(),
|
||||
timeout: frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout),
|
||||
local: local.0,
|
||||
tls,
|
||||
base_headers: request.headers().clone(),
|
||||
};
|
||||
let refresh_ticket = ticket.clone();
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
if refresh_cache_once(&refresh_context, refresh_ticket, cached).await {
|
||||
refresh_context.state.cache.record(
|
||||
&refresh_context.scope,
|
||||
&refresh_context.host,
|
||||
"HIT",
|
||||
"cache_stale_while_revalidate_refreshed",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if cache_settings.proactive_refresh_enabled
|
||||
&& !hit.stale
|
||||
&& hit.remaining_ttl > 0
|
||||
{
|
||||
schedule_proactive_refresh(
|
||||
CacheRefreshContext {
|
||||
state: state.clone(),
|
||||
snapshot: snapshot.clone(),
|
||||
backend: backend.clone(),
|
||||
cache_settings: cache_settings.clone(),
|
||||
transform: transform_settings.cloned(),
|
||||
uri: uri.clone(),
|
||||
public_suffix: public_suffix.clone(),
|
||||
host: host.clone(),
|
||||
path: path.clone(),
|
||||
scope: scope.to_owned(),
|
||||
timeout: frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout),
|
||||
local: local.0,
|
||||
tls,
|
||||
base_headers: request.headers().clone(),
|
||||
},
|
||||
ticket.clone(),
|
||||
hit.remaining_ttl,
|
||||
);
|
||||
}
|
||||
if conditional {
|
||||
hit.response = crate::cache::make_not_modified(hit.response);
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
}
|
||||
state.cache.record(scope, &host, "HIT", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
cache_settings.annotate_reason(
|
||||
hit.response.headers_mut(),
|
||||
"HIT",
|
||||
hit.hits,
|
||||
hit.remaining_ttl,
|
||||
reason,
|
||||
);
|
||||
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
|
||||
if hit.transformed {
|
||||
state.transform_metrics.record_cache_hit(scope);
|
||||
}
|
||||
cache_settings.annotate_resident_age(
|
||||
hit.response.headers_mut(),
|
||||
hit.resident_age_seconds,
|
||||
);
|
||||
hit.response.extensions_mut().insert(ResponseRulesApplied);
|
||||
return hit.response;
|
||||
}
|
||||
state.cache.record(scope, &host, "MISS", "cache_miss");
|
||||
miss_recorded = true;
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(2, Ordering::Relaxed);
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = "cache_miss";
|
||||
}
|
||||
} else if cached_revalidation.is_none() {
|
||||
// Account a true cache miss even when the client requested validation.
|
||||
let _ = state.cache.get_tiered(ticket, cache_settings).await;
|
||||
state.cache.record(scope, &host, "MISS", "cache_miss");
|
||||
miss_recorded = true;
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(2, Ordering::Relaxed);
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = "cache_miss";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.cache.bypass();
|
||||
let reason = effective_reason.unwrap_or("configuration_changed");
|
||||
state.cache.record(scope, &host, "BYPASS", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(3, Ordering::Relaxed);
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
}
|
||||
let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option<Response> {
|
||||
let cached = cached_revalidation.as_ref()?;
|
||||
let mut hit = state.cache.stale_if_error_hit(cached)?;
|
||||
if let crate::cache::RangeSelection::Single(range) = range_selection {
|
||||
hit = hit.apply_range(range, if_range.as_ref());
|
||||
}
|
||||
cache_settings.annotate_reason(
|
||||
hit.response.headers_mut(),
|
||||
"HIT",
|
||||
hit.hits,
|
||||
0,
|
||||
reason,
|
||||
);
|
||||
cache_settings.annotate_transformed(hit.response.headers_mut(), hit.transformed);
|
||||
if hit.transformed {
|
||||
state.transform_metrics.record_cache_hit(scope);
|
||||
}
|
||||
cache_settings.annotate_resident_age(
|
||||
hit.response.headers_mut(),
|
||||
hit.resident_age_seconds,
|
||||
);
|
||||
hit.response.extensions_mut().insert(ResponseRulesApplied);
|
||||
hit.response.extensions_mut().insert(OriginTiming(elapsed));
|
||||
hit.response
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(origin_backend.to_owned()));
|
||||
state.cache.record(scope, &host, "HIT", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
*exchange.cache_reason.lock().unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
Some(hit.response)
|
||||
};
|
||||
if only_cached {
|
||||
let mut response = error(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: no reusable cached response\n",
|
||||
);
|
||||
cache_settings.annotate(response.headers_mut(), "MISS", 0, 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Foreground fills are single-flight per base cache key. Range responses are
|
||||
// never fill leaders because a 206 is not stored as a separate cache object.
|
||||
// A follower can wake after a different Vary representation was published; in
|
||||
// that case it competes for the next flight instead of stampeding the origin.
|
||||
let mut fill_leader = None;
|
||||
if handshake.is_none()
|
||||
&& matches!(range_selection, crate::cache::RangeSelection::None)
|
||||
{
|
||||
if let Some(ticket) = cache_ticket.as_ref() {
|
||||
let mut collapse_rounds = 0u8;
|
||||
loop {
|
||||
match state.cache.claim_fill(ticket, cache_settings).await {
|
||||
crate::cache::FillClaim::Leader(guard) => {
|
||||
fill_leader = Some(guard);
|
||||
break;
|
||||
}
|
||||
crate::cache::FillClaim::Waited => {
|
||||
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
|
||||
let conditional = crate::cache::conditional_not_modified(
|
||||
&client_conditionals,
|
||||
hit.response.headers(),
|
||||
);
|
||||
let reason = if conditional {
|
||||
"cache_collapsed_conditional_hit"
|
||||
} else {
|
||||
"cache_collapsed_hit"
|
||||
};
|
||||
if cache_settings.proactive_refresh_enabled
|
||||
&& !hit.stale
|
||||
&& hit.remaining_ttl > 0
|
||||
{
|
||||
schedule_proactive_refresh(
|
||||
CacheRefreshContext {
|
||||
state: state.clone(),
|
||||
snapshot: snapshot.clone(),
|
||||
backend: backend.clone(),
|
||||
cache_settings: cache_settings.clone(),
|
||||
transform: transform_settings.cloned(),
|
||||
uri: uri.clone(),
|
||||
public_suffix: public_suffix.clone(),
|
||||
host: host.clone(),
|
||||
path: path.clone(),
|
||||
scope: scope.to_owned(),
|
||||
timeout: frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout),
|
||||
local: local.0,
|
||||
tls,
|
||||
base_headers: request.headers().clone(),
|
||||
},
|
||||
ticket.clone(),
|
||||
hit.remaining_ttl,
|
||||
);
|
||||
}
|
||||
if conditional {
|
||||
hit.response = crate::cache::make_not_modified(hit.response);
|
||||
}
|
||||
cache_settings.annotate_reason(
|
||||
hit.response.headers_mut(),
|
||||
"HIT",
|
||||
hit.hits,
|
||||
hit.remaining_ttl,
|
||||
reason,
|
||||
);
|
||||
cache_settings
|
||||
.annotate_transformed(hit.response.headers_mut(), hit.transformed);
|
||||
if hit.transformed {
|
||||
state.transform_metrics.record_cache_hit(scope);
|
||||
}
|
||||
cache_settings.annotate_resident_age(
|
||||
hit.response.headers_mut(),
|
||||
hit.resident_age_seconds,
|
||||
);
|
||||
hit.response.extensions_mut().insert(ResponseRulesApplied);
|
||||
state.cache.record(scope, &host, "HIT", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
return hit.response;
|
||||
}
|
||||
collapse_rounds = collapse_rounds.saturating_add(1);
|
||||
if collapse_rounds >= 8 {
|
||||
state.cache.record_reason(
|
||||
scope,
|
||||
&host,
|
||||
"cache_collapse_variant_limit",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
crate::cache::FillClaim::Timeout => {
|
||||
if matches!(
|
||||
cache_settings.collapse_timeout_fallback,
|
||||
crate::cache::CollapseTimeoutFallback::StaleIfAvailable
|
||||
) {
|
||||
if let Some(stale) =
|
||||
serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id)
|
||||
{
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
state.cache.record_reason(scope, &host, "cache_collapse_timeout_origin");
|
||||
break;
|
||||
}
|
||||
crate::cache::FillClaim::Disabled => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !admin_control_plane {
|
||||
headers::strip_admin_cookie(request.headers_mut());
|
||||
}
|
||||
if let Some(pool) = &route.pool {
|
||||
pool.strip_affinity(request.headers_mut());
|
||||
}
|
||||
if let Some(lease) = &backend_lease {
|
||||
lease.started();
|
||||
if let Some(exchange) = &exchange {
|
||||
*exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) = Some(lease.clone());
|
||||
}
|
||||
}
|
||||
(serve_stale, fill_leader)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
match upstream_result {
|
||||
Ok(Ok(mut response)) => {
|
||||
let (origin_validation_headers, origin_plan, origin_reason, mut response) =
|
||||
include!("success_prepare.rs");
|
||||
include!("success_finish.rs")
|
||||
}
|
||||
Ok(Err(cause)) => {
|
||||
if let Some(session) = h2_direct_session.take() {
|
||||
session.cancellation().cancel();
|
||||
}
|
||||
let elapsed = upstream_started.elapsed();
|
||||
let connect_failure = cause.is_connect();
|
||||
let detail = upstream_failure_detail(&cause, &active_backend, elapsed, connect_failure);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_request_failed", "upstream_http_error");
|
||||
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some(detail.clone());
|
||||
}
|
||||
if let Some(lease) = &active_lease {
|
||||
lease.error_detail(
|
||||
upstream_error_class(&cause, connect_failure),
|
||||
&detail,
|
||||
final_attempt_elapsed,
|
||||
);
|
||||
}
|
||||
if connect_failure {
|
||||
tracing::warn!(
|
||||
backend = %active_backend.id,
|
||||
origin = %active_backend.origin,
|
||||
request_origin = %active_backend.request_origin,
|
||||
tls_verify = active_backend.tls_verify,
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
error = %detail,
|
||||
"upstream connection failed"
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
backend = %active_backend.id,
|
||||
origin = %active_backend.origin,
|
||||
elapsed_ms = elapsed.as_millis(),
|
||||
error = %detail,
|
||||
"upstream HTTP request failed"
|
||||
);
|
||||
}
|
||||
let status = if connect_failure {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
let stale_class = classify_stale_error(&cause);
|
||||
if cache_settings.allows_stale_error_class(stale_class) {
|
||||
if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id) {
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
let mut response = error(
|
||||
status,
|
||||
if status == StatusCode::SERVICE_UNAVAILABLE {
|
||||
"RustProxy: upstream unavailable (DNS, TCP or TLS connection failed)\n"
|
||||
} else {
|
||||
"RustProxy: upstream did not provide a valid HTTP response\n"
|
||||
},
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
if status == StatusCode::SERVICE_UNAVAILABLE {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
}
|
||||
response
|
||||
}
|
||||
Err(_) => {
|
||||
if let Some(session) = h2_direct_session.take() {
|
||||
session.cancellation().cancel();
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_timeout", "header_timeout");
|
||||
*exchange.upstream_error.lock().unwrap_or_else(|e| e.into_inner()) = Some("response_header_timeout".into());
|
||||
}
|
||||
if let Some(lease) = &active_lease {
|
||||
lease.error_detail(
|
||||
"response_header_timeout",
|
||||
"response_header_timeout",
|
||||
final_attempt_elapsed,
|
||||
);
|
||||
}
|
||||
if cache_settings
|
||||
.allows_stale_error_class(crate::cache::StaleErrorClass::Timeout)
|
||||
{
|
||||
if let Some(stale) =
|
||||
serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id)
|
||||
{
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
error(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"RustProxy: upstream response deadline exceeded\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
// Both scopes run before DNS, TCP, TLS or reuse of an existing upstream connection.
|
||||
if !snapshot.host_allowed(&host, context.client_ip)
|
||||
|| !route
|
||||
.acl
|
||||
.as_ref()
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
|| !route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.and_then(|f| f.profile_acl.as_ref())
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
|| !route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.and_then(|f| f.acl.as_ref())
|
||||
.is_none_or(|acl| acl.allows(context.client_ip))
|
||||
{
|
||||
return error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"RustProxy: denied by access policy\n",
|
||||
);
|
||||
}
|
||||
// Local location responses intentionally still pass host/profile/location ACLs
|
||||
// and the outer response-header/security pipeline, but never select/open an upstream.
|
||||
if let Some(spec) = &route.response {
|
||||
return local_location_response(spec, request.method());
|
||||
}
|
||||
if let crate::config::CompiledSource::WebServer(service) = &route.source {
|
||||
if handshake.is_some() {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: Web Server source does not support WebSocket upgrades\n",
|
||||
);
|
||||
}
|
||||
let service_path = match route.rewritten_path(&path) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => path.clone(),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite path\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) = Some(false);
|
||||
}
|
||||
// Do not hold a shared reference to Request<Body> across await. Axum handlers
|
||||
// must return Send futures, while Body is not Sync. Clone only static-request
|
||||
// metadata needed by the filesystem source before entering the async server.
|
||||
let static_method = request.method().clone();
|
||||
let static_headers = request.headers().clone();
|
||||
let static_uri = request.uri().clone();
|
||||
let static_version = request.version();
|
||||
let static_body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
let mut response = crate::web_server::serve(
|
||||
service,
|
||||
static_method,
|
||||
static_headers,
|
||||
static_uri,
|
||||
&service_path,
|
||||
static_body,
|
||||
crate::web_server::RequestContext {
|
||||
client_ip: context.client_ip,
|
||||
client_port: Some(peer.0.port()),
|
||||
server_name: host.clone(),
|
||||
server_port: local.0.port(),
|
||||
tls,
|
||||
version: static_version,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
response.extensions_mut().insert(StaticSourceResponse);
|
||||
return response;
|
||||
}
|
||||
let (backend, backend_lease, affinity_cookie) = if let Some(pool) = &route.pool {
|
||||
let selection = match &route.backend_id {
|
||||
Some(id) => pool.select_member_wait(id).await,
|
||||
None => pool
|
||||
.select_wait(context.client_ip, request.headers(), tls)
|
||||
.await,
|
||||
};
|
||||
let selection = match selection {
|
||||
Ok(selection) => selection,
|
||||
Err(crate::balance::SelectionFailure::Unavailable) => {
|
||||
let mut response = error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"RustProxy: no healthy backend in this vhost pool\n",
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
return response;
|
||||
}
|
||||
Err(crate::balance::SelectionFailure::Capacity) => {
|
||||
let mut response = error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"RustProxy: backend concurrency limit reached\n",
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
return response;
|
||||
}
|
||||
};
|
||||
(
|
||||
selection.lease.member.backend.clone(),
|
||||
Some(selection.lease),
|
||||
selection.cookie,
|
||||
)
|
||||
} else {
|
||||
let Some(backend) = route.backend.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Reverse Proxy route has no backend\n",
|
||||
);
|
||||
};
|
||||
(backend, None, None)
|
||||
};
|
||||
let admin_control_plane = admin_session_passthrough(&snapshot, &host, tls, &backend);
|
||||
if admin_control_plane {
|
||||
headers::restore_admin_cookie(request.headers_mut(), &admin_cookie);
|
||||
}
|
||||
let cache_settings = route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.map(|f| &f.cache)
|
||||
.unwrap_or(&snapshot.envelope.config.cache);
|
||||
let transform_settings = route.frontend.as_ref().map(|f| &f.transform);
|
||||
let scope = route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or("");
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.route.lock().unwrap_or_else(|e| e.into_inner()) = Some(route.id.clone());
|
||||
*exchange.cache_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(cache_settings.active_for(route.cache));
|
||||
*exchange.transform_effective_enabled.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(transform_settings.is_some_and(|settings| settings.enabled));
|
||||
}
|
||||
let policy_reason = if admin_control_plane {
|
||||
Some("admin_control_plane")
|
||||
} else if frontend.is_some_and(|f| f.basic_auth.is_some()) {
|
||||
Some("basic_auth")
|
||||
} else if frontend.is_some_and(|f| f.tinyauth.is_some()) {
|
||||
Some("tinyauth")
|
||||
} else if route
|
||||
.pool
|
||||
.as_ref()
|
||||
.is_some_and(|pool| pool.cookie_affinity())
|
||||
{
|
||||
Some("sticky_cookie")
|
||||
} else if !cache_settings.enabled {
|
||||
Some("cache_disabled")
|
||||
} else if route.cache == Some(false) {
|
||||
Some("route_disabled")
|
||||
} else if handshake.is_some() {
|
||||
Some("request_upgrade")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cache_enabled = policy_reason.is_none();
|
||||
let body_empty = if cache_enabled && handshake.is_none() && request.method() == Method::GET {
|
||||
match empty_for_cache(&mut request).await {
|
||||
Ok(empty) => empty,
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.body().is_end_stream()
|
||||
};
|
||||
let ignore_static_cookie = cache_settings.ignore_cookie_for_path(&path);
|
||||
let original_policy = policy_reason
|
||||
.map(crate::cache::RequestPolicy::Bypass)
|
||||
.unwrap_or_else(|| {
|
||||
crate::cache::request_policy(
|
||||
request.method(),
|
||||
request.headers(),
|
||||
body_empty,
|
||||
&path,
|
||||
request.uri().query().is_some(),
|
||||
cache_settings,
|
||||
)
|
||||
});
|
||||
|
||||
let only_cached =
|
||||
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
|
||||
let public_suffix = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|v| v.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_owned();
|
||||
let origin_suffix = match route.rewritten_path(&path) {
|
||||
Ok(None) => public_suffix.clone(),
|
||||
Ok(Some(rewritten)) => {
|
||||
let Some(mut encoded) = encoded_origin_path(&rewritten) else {
|
||||
return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n");
|
||||
};
|
||||
if let Some(query) = request.uri().query() {
|
||||
encoded.push('?');
|
||||
encoded.push_str(query);
|
||||
}
|
||||
encoded
|
||||
}
|
||||
Err(_) => return error(StatusCode::BAD_REQUEST, "RustProxy: invalid rewritten request target\n"),
|
||||
};
|
||||
let Some(uri) = upstream_uri(&backend, &origin_suffix) else {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid request target\n",
|
||||
);
|
||||
};
|
||||
let exchange = request.extensions().get::<Arc<Exchange>>().cloned();
|
||||
let lease = request.extensions().get::<Arc<ConnectionLease>>().cloned();
|
||||
let stop = request.extensions().get::<ServerShutdown>().cloned();
|
||||
if handshake.is_some() {
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_phase("upstream_handshake");
|
||||
}
|
||||
}
|
||||
let downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h1()) {
|
||||
Some(hyper::upgrade::on(&mut request))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let h2_downstream = if handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& (backend.protocol != crate::config::UpstreamProtocol::Http2
|
||||
|| websocket_legacy_support)
|
||||
{
|
||||
Some(std::mem::replace(request.body_mut(), Body::empty()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
headers::strip_hop_by_hop(request.headers_mut());
|
||||
request.headers_mut().insert(
|
||||
HOST,
|
||||
if websocket_legacy_support {
|
||||
backend.host_header.clone()
|
||||
} else if backend.preserve_host {
|
||||
original_host.clone()
|
||||
} else {
|
||||
backend.host_header.clone()
|
||||
},
|
||||
);
|
||||
crate::headers::apply(request.headers_mut(), &snapshot.headers.request);
|
||||
if let Some(frontend) = &route.frontend {
|
||||
crate::headers::apply(request.headers_mut(), &frontend.headers.request);
|
||||
}
|
||||
crate::headers::apply(request.headers_mut(), &route.headers.request);
|
||||
// Canonical trusted metadata cannot be overwritten by custom request rules.
|
||||
// A vhost may intentionally withhold only the visitor IP from the application;
|
||||
// ACL/rate-limit/Tinyauth decisions above still use the authenticated client address.
|
||||
context.apply_upstream(
|
||||
request.headers_mut(),
|
||||
route
|
||||
.frontend
|
||||
.as_ref()
|
||||
.is_none_or(|frontend| frontend.forward_client_ip),
|
||||
);
|
||||
if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
|
||||
auth.strip_identity(request.headers_mut());
|
||||
}
|
||||
if let Some(identity) = identity {
|
||||
identity.apply(request.headers_mut());
|
||||
}
|
||||
if websocket_legacy_support {
|
||||
if let Some(upstream_host) = request.headers().get(HOST).cloned() {
|
||||
if let Some(origin) = legacy_websocket_origin(&backend.request_origin, &upstream_host) {
|
||||
request.headers_mut().insert("origin", origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
let range_selection = crate::cache::request_range(request.method(), request.headers());
|
||||
let if_range = request.headers().get("if-range").cloned();
|
||||
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
|
||||
let post_policy = crate::cache::request_policy(
|
||||
request.method(),
|
||||
request.headers(),
|
||||
body_empty,
|
||||
&path,
|
||||
request.uri().query().is_some(),
|
||||
cache_settings,
|
||||
);
|
||||
let effective_policy = match (original_policy, post_policy) {
|
||||
(crate::cache::RequestPolicy::Bypass(reason), _) => {
|
||||
crate::cache::RequestPolicy::Bypass(reason)
|
||||
}
|
||||
(_, crate::cache::RequestPolicy::Bypass(reason)) => {
|
||||
crate::cache::RequestPolicy::Bypass(reason)
|
||||
}
|
||||
(crate::cache::RequestPolicy::Revalidate(reason), _) => {
|
||||
crate::cache::RequestPolicy::Revalidate(reason)
|
||||
}
|
||||
(_, crate::cache::RequestPolicy::Revalidate(reason)) => {
|
||||
crate::cache::RequestPolicy::Revalidate(reason)
|
||||
}
|
||||
_ => crate::cache::RequestPolicy::Use,
|
||||
};
|
||||
let effective_reason = effective_policy.bypass_reason();
|
||||
let revalidation_reason = effective_policy.revalidation_reason();
|
||||
let effective_cacheable = effective_reason.is_none();
|
||||
|
||||
// Match Varnish-style cache identity: a load-balanced backend member must not
|
||||
// fragment the object cache. The vhost scope + canonical host + request target
|
||||
// already identify the public resource; Vary handles representation variants.
|
||||
let cache_route = route.id.as_str();
|
||||
let mut cache_key_headers = None;
|
||||
if ignore_static_cookie && request.headers().contains_key("cookie") {
|
||||
let mut headers = request.headers().clone();
|
||||
headers.remove("cookie");
|
||||
cache_key_headers = Some(headers);
|
||||
}
|
||||
let key_headers = cache_key_headers
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| request.headers());
|
||||
let cache_ticket = if effective_cacheable {
|
||||
state.cache.ticket_scoped(
|
||||
state.cache.revision(),
|
||||
route.frontend.as_ref().map(|f| f.id.as_str()).unwrap_or(""),
|
||||
context.scheme,
|
||||
original_host.to_str().unwrap_or(""),
|
||||
cache_route,
|
||||
&public_suffix,
|
||||
key_headers,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let client_conditionals = request.headers().clone();
|
||||
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
|
||||
state.cache.revalidation_tiered(ticket).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let freshness_revalidation_reason = cached_revalidation
|
||||
.as_ref()
|
||||
.and_then(|cached| cached.request_freshness_revalidation_reason(&client_conditionals));
|
||||
let pending_revalidation = revalidation_reason.is_some()
|
||||
|| freshness_revalidation_reason.is_some()
|
||||
|| cached_revalidation
|
||||
.as_ref()
|
||||
.is_some_and(|cached| !cached.reusable_for_request(&client_conditionals));
|
||||
let miss_recorded = false;
|
||||
(
|
||||
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
|
||||
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
|
||||
origin_suffix, uri, exchange, lease, stop, downstream, h2_downstream,
|
||||
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
|
||||
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
|
||||
pending_revalidation, miss_recorded,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
{
|
||||
// A backend 304 validates the stored representation. Merge the new
|
||||
// metadata into the cached object and answer the client's original
|
||||
// condition locally, just like Varnish's conditional delivery path.
|
||||
if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
|
||||
if let (Some(ticket), Some(cached)) =
|
||||
(cache_ticket.as_ref(), cached_revalidation.as_ref())
|
||||
{
|
||||
let merged = cached.merge_validation_headers(response.headers());
|
||||
let client_not_modified =
|
||||
crate::cache::conditional_not_modified(&client_conditionals, &merged);
|
||||
let mut revalidated = if let Some(mut hit) = state.cache.publish_revalidated(
|
||||
ticket.clone(),
|
||||
cached,
|
||||
merged.clone(),
|
||||
&origin_validation_headers,
|
||||
cache_settings,
|
||||
upstream_started.elapsed(),
|
||||
) {
|
||||
if !client_not_modified {
|
||||
if let crate::cache::RangeSelection::Single(range) = range_selection {
|
||||
hit = hit.apply_range(range, if_range.as_ref());
|
||||
}
|
||||
}
|
||||
let resident_age = hit.resident_age_seconds;
|
||||
let mut response = hit.response;
|
||||
cache_settings.annotate_reason(
|
||||
response.headers_mut(),
|
||||
"HIT",
|
||||
hit.hits,
|
||||
hit.remaining_ttl,
|
||||
"cache_revalidated",
|
||||
);
|
||||
cache_settings
|
||||
.annotate_transformed(response.headers_mut(), hit.transformed);
|
||||
if hit.transformed {
|
||||
state.transform_metrics.record_cache_hit(scope);
|
||||
}
|
||||
cache_settings.annotate_resident_age(response.headers_mut(), resident_age);
|
||||
Some(response)
|
||||
} else if state.cache.ticket_current(ticket) {
|
||||
// The validation succeeded but the refreshed representation is no
|
||||
// longer eligible for shared storage (for example new no-store).
|
||||
// Serve it once, then discard the stored copy.
|
||||
state.cache.discard(ticket);
|
||||
let mut response = if !client_not_modified {
|
||||
if let crate::cache::RangeSelection::Single(range) = range_selection {
|
||||
cached.response_with_range(merged, range, if_range.as_ref())
|
||||
} else {
|
||||
cached.response(merged)
|
||||
}
|
||||
} else {
|
||||
cached.response(merged)
|
||||
};
|
||||
cache_settings.annotate_reason(
|
||||
response.headers_mut(),
|
||||
"HIT",
|
||||
0,
|
||||
0,
|
||||
"cache_revalidated_uncached",
|
||||
);
|
||||
cache_settings
|
||||
.annotate_transformed(response.headers_mut(), cached.transformed());
|
||||
if cached.transformed() {
|
||||
state.transform_metrics.record_cache_hit(scope);
|
||||
}
|
||||
Some(response)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(mut revalidated) = revalidated.take() {
|
||||
if client_not_modified {
|
||||
revalidated = crate::cache::make_not_modified(revalidated);
|
||||
}
|
||||
revalidated.extensions_mut().insert(ResponseRulesApplied);
|
||||
revalidated
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(upstream_started.elapsed()));
|
||||
revalidated
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(active_backend.id.clone()));
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(1, Ordering::Relaxed);
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = "cache_revalidated";
|
||||
}
|
||||
state.cache.record(scope, &host, "HIT", "cache_revalidated");
|
||||
return revalidated;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both origin and transformed policies must be cacheable; hiding a
|
||||
// Set-Cookie/private header never authorizes storage of private content.
|
||||
let transformed_plan =
|
||||
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
|
||||
None
|
||||
} else {
|
||||
crate::cache::plan(
|
||||
response.status(),
|
||||
response.headers(),
|
||||
cache_settings,
|
||||
upstream_started.elapsed(),
|
||||
)
|
||||
};
|
||||
let outcome = if cache_ticket.is_some() {
|
||||
"MISS"
|
||||
} else {
|
||||
"BYPASS"
|
||||
};
|
||||
let reason = effective_reason
|
||||
.or(origin_reason)
|
||||
.or(revalidation_reason)
|
||||
.or(freshness_revalidation_reason)
|
||||
.unwrap_or_else(|| {
|
||||
if cache_ticket.is_some() && transformed_plan.is_none() {
|
||||
"response_headers_policy"
|
||||
} else if cache_ticket.is_some() {
|
||||
"cache_miss"
|
||||
} else {
|
||||
"configuration_changed"
|
||||
}
|
||||
});
|
||||
if outcome == "MISS" && !miss_recorded {
|
||||
state.cache.record(scope, &host, "MISS", reason);
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.cache_status.store(2, Ordering::Relaxed);
|
||||
}
|
||||
} else if outcome == "MISS" && reason != "cache_miss" {
|
||||
state.cache.record_reason(scope, &host, reason);
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = reason;
|
||||
}
|
||||
if let (Some(ticket), Some(plan), Some(transformed)) =
|
||||
(cache_ticket, origin_plan, transformed_plan)
|
||||
{
|
||||
let plan = if response
|
||||
.extensions()
|
||||
.get::<crate::transform::CanonicalIdentity>()
|
||||
.is_some()
|
||||
{
|
||||
plan.without_vary("accept-encoding")
|
||||
} else {
|
||||
plan
|
||||
};
|
||||
let plan = plan.restrict(transformed);
|
||||
let proactive_remaining_ttl = plan.remaining_ttl();
|
||||
let proactive_ticket = ticket.clone();
|
||||
response = state.cache.wrap_with_fill(
|
||||
response,
|
||||
ticket,
|
||||
plan,
|
||||
cache_settings,
|
||||
fill_leader.take(),
|
||||
);
|
||||
if cache_settings.proactive_refresh_enabled && proactive_remaining_ttl > 0 {
|
||||
schedule_proactive_refresh(
|
||||
CacheRefreshContext {
|
||||
state: state.clone(),
|
||||
snapshot: snapshot.clone(),
|
||||
backend: active_backend.clone(),
|
||||
cache_settings: cache_settings.clone(),
|
||||
transform: transform_settings.cloned(),
|
||||
uri: active_uri.clone(),
|
||||
public_suffix: public_suffix.clone(),
|
||||
host: host.clone(),
|
||||
path: path.clone(),
|
||||
scope: scope.to_owned(),
|
||||
timeout: frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout),
|
||||
local: local.0,
|
||||
tls,
|
||||
base_headers: proactive_base_headers.clone(),
|
||||
},
|
||||
proactive_ticket,
|
||||
proactive_remaining_ttl,
|
||||
);
|
||||
}
|
||||
}
|
||||
let transformed = response
|
||||
.extensions()
|
||||
.get::<crate::transform::Applied>()
|
||||
.is_some()
|
||||
|| response
|
||||
.extensions()
|
||||
.get::<crate::transform::CachedTransformed>()
|
||||
.is_some();
|
||||
cache_settings.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
|
||||
cache_settings.annotate_transformed(response.headers_mut(), transformed);
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
{
|
||||
let origin_validation_headers = response.headers().clone();
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange
|
||||
.upstream_status
|
||||
.store(response.status().as_u16(), Ordering::Relaxed);
|
||||
}
|
||||
if let Some(lease) = &active_lease {
|
||||
lease.response(
|
||||
response.status().as_u16(),
|
||||
final_attempt_elapsed,
|
||||
request_body_empty,
|
||||
);
|
||||
if retries > 0 && response.status().as_u16() < 500 {
|
||||
lease.retry_succeeded();
|
||||
}
|
||||
}
|
||||
if cache_settings.allows_stale_error_status(response.status()) {
|
||||
if let Some(stale) = serve_stale(
|
||||
"cache_stale_if_error",
|
||||
upstream_started.elapsed(),
|
||||
&active_backend.id,
|
||||
) {
|
||||
return stale;
|
||||
}
|
||||
}
|
||||
let h2_direct_tunnel = websocket_upstream_h2 && response.status().is_success();
|
||||
if websocket_upstream_h2 && response.status() == StatusCode::SWITCHING_PROTOCOLS {
|
||||
if let Some(session) = h2_direct_session.take() {
|
||||
session.cancellation().cancel();
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_rejected", "unexpected_h2_101");
|
||||
}
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: HTTP/2 WebSocket upstream returned HTTP/1.1 switching protocols\n",
|
||||
);
|
||||
}
|
||||
if h2_direct_tunnel {
|
||||
let Some(expected) = &handshake else {
|
||||
unreachable!("direct H2 WebSocket requires a handshake")
|
||||
};
|
||||
if websocket::validate_h2_response(response.headers(), expected).is_err() {
|
||||
if let Some(session) = h2_direct_session.take() {
|
||||
session.cancellation().cancel();
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_rejected", "invalid_h2_handshake");
|
||||
}
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: invalid upstream HTTP/2 WebSocket handshake\n",
|
||||
);
|
||||
}
|
||||
if let Some(session) = h2_direct_session.as_ref() {
|
||||
session.establish();
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_phase("upstream_2xx_received");
|
||||
}
|
||||
} else if websocket_upstream_h2 {
|
||||
if let Some(session) = h2_direct_session.take() {
|
||||
session.cancellation().cancel();
|
||||
}
|
||||
}
|
||||
let mut upgraded = if response.status() == StatusCode::SWITCHING_PROTOCOLS {
|
||||
let Some(expected) = &handshake else {
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: unsolicited upstream upgrade\n",
|
||||
);
|
||||
};
|
||||
if websocket::validate_response(
|
||||
response.headers(),
|
||||
expected,
|
||||
websocket_legacy_support,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_rejected", "invalid_101_handshake");
|
||||
}
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: invalid upstream WebSocket handshake\n",
|
||||
);
|
||||
}
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_phase("upstream_101_received");
|
||||
}
|
||||
Some(hyper::upgrade::on(&mut response))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if handshake.as_ref().is_some_and(|handshake| {
|
||||
handshake.h3() || (handshake.h2() && !websocket_upstream_h2)
|
||||
})
|
||||
&& response.status().is_success()
|
||||
&& response.status() != StatusCode::SWITCHING_PROTOCOLS
|
||||
{
|
||||
if let Some(exchange) = &exchange {
|
||||
exchange.websocket_error("upstream_rejected", "upstream_2xx_without_101");
|
||||
}
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: HTTP/1.1 WebSocket upstream returned 2xx without switching protocols\n",
|
||||
);
|
||||
}
|
||||
// Varnish built-in behavior treats non-GET/HEAD methods as pass: the
|
||||
// response is never stored, but existing cached GET representations are
|
||||
// not purged implicitly. Explicit cache purge remains an admin action.
|
||||
let origin_plan = if cache_ticket.is_some()
|
||||
&& !(ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie"))
|
||||
{
|
||||
crate::cache::plan(
|
||||
response.status(),
|
||||
response.headers(),
|
||||
cache_settings,
|
||||
upstream_started.elapsed(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| {
|
||||
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
|
||||
"response_vary_cookie"
|
||||
} else {
|
||||
crate::cache::response_reason(response.status(), response.headers())
|
||||
}
|
||||
});
|
||||
let origin_no_transform = crate::transform::no_transform(response.headers());
|
||||
let skip_compression = crate::compression::private_or_no_transform(response.headers());
|
||||
let origin_validators = crate::transform::OriginValidators {
|
||||
etag: response.headers().get("etag").cloned(),
|
||||
last_modified: response.headers().get("last-modified").cloned(),
|
||||
};
|
||||
let (mut parts, body) = response.into_parts();
|
||||
parts.extensions.insert(origin_validators);
|
||||
if skip_compression || range_requested {
|
||||
parts.extensions.insert(crate::compression::Skip);
|
||||
}
|
||||
headers::strip_hop_by_hop(&mut parts.headers);
|
||||
if websocket_legacy_support && handshake.is_some() {
|
||||
parts.headers.remove("content-length");
|
||||
parts.headers.remove("transfer-encoding");
|
||||
}
|
||||
apply_response_rules(
|
||||
&mut parts.headers,
|
||||
&snapshot,
|
||||
route.frontend.as_deref(),
|
||||
Some(route),
|
||||
admin_control_plane,
|
||||
);
|
||||
if let Some(pool) = &route.pool {
|
||||
pool.strip_affinity_response(&mut parts.headers);
|
||||
}
|
||||
if let Some(cookie) = active_affinity_cookie {
|
||||
parts.headers.append("set-cookie", cookie);
|
||||
}
|
||||
let h3_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h3())
|
||||
&& upgraded.is_some();
|
||||
let h2_bridge_tunnel = handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& !websocket_upstream_h2
|
||||
&& upgraded.is_some();
|
||||
let mut response = if h3_tunnel {
|
||||
let Some(upstream) = upgraded.take() else {
|
||||
unreachable!("checked above")
|
||||
};
|
||||
let Some(exchange) = exchange.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: WebSocket exchange state unavailable\n",
|
||||
);
|
||||
};
|
||||
// Hyper cannot complete the client-side upgrade while the 101 body
|
||||
// remains owned here. Drop it, then prove that OnUpgrade is ready
|
||||
// BEFORE returning a successful RFC 9220 response to the H3 layer.
|
||||
drop(body);
|
||||
let stop_token = stop
|
||||
.as_ref()
|
||||
.map(|value| value.0.clone())
|
||||
.unwrap_or_else(|| state.shutdown.clone());
|
||||
let upstream = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("upgrade_cancelled", "shutdown");
|
||||
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"RustProxy: WebSocket upgrade cancelled\n",
|
||||
);
|
||||
}
|
||||
result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result {
|
||||
Ok(Ok(upstream)) => upstream,
|
||||
_ => {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("upgrade_failed", "backend_on_upgrade");
|
||||
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: upstream WebSocket upgrade failed\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
exchange.websocket_phase("tunnel_ready");
|
||||
// RFC 8441/9220 uses a successful 2xx response, never 101, and
|
||||
// Sec-WebSocket-Accept is not part of the downstream handshake.
|
||||
parts.status = StatusCode::OK;
|
||||
parts.headers.remove("sec-websocket-accept");
|
||||
parts.headers.remove("content-length");
|
||||
parts.headers.remove("transfer-encoding");
|
||||
parts.extensions.insert(crate::compression::Skip);
|
||||
parts
|
||||
.extensions
|
||||
.insert(websocket::H3Tunnel::new(
|
||||
upstream,
|
||||
exchange,
|
||||
active_backend.origin.clone(),
|
||||
lease,
|
||||
stop,
|
||||
));
|
||||
Response::from_parts(parts, Body::empty())
|
||||
} else if h2_bridge_tunnel {
|
||||
let Some(upstream) = upgraded.take() else {
|
||||
unreachable!("checked above")
|
||||
};
|
||||
let Some(exchange) = exchange.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: WebSocket exchange state unavailable\n",
|
||||
);
|
||||
};
|
||||
let Some(downstream_body) = h2_downstream.take() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: HTTP/2 WebSocket request stream unavailable\n",
|
||||
);
|
||||
};
|
||||
drop(body);
|
||||
let stop_token = stop
|
||||
.as_ref()
|
||||
.map(|value| value.0.clone())
|
||||
.unwrap_or_else(|| state.shutdown.clone());
|
||||
let upstream = tokio::select! {
|
||||
_ = stop_token.cancelled() => {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("upgrade_cancelled", "shutdown");
|
||||
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"RustProxy: WebSocket upgrade cancelled\n",
|
||||
);
|
||||
}
|
||||
result = tokio::time::timeout(Duration::from_secs(10), upstream) => match result {
|
||||
Ok(Ok(upstream)) => upstream,
|
||||
_ => {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("upgrade_failed", "backend_on_upgrade");
|
||||
state.metrics.websocket_errors.fetch_add(1, Ordering::Relaxed);
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: upstream WebSocket upgrade failed\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
exchange.websocket_phase("tunnel_ready");
|
||||
parts.status = StatusCode::OK;
|
||||
for name in [
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-accept",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
] {
|
||||
parts.headers.remove(name);
|
||||
}
|
||||
parts.extensions.insert(crate::compression::Skip);
|
||||
let body = websocket::bridge_h2_to_h1(
|
||||
state.clone(),
|
||||
downstream_body,
|
||||
upstream,
|
||||
exchange,
|
||||
active_backend.origin.clone(),
|
||||
lease,
|
||||
stop,
|
||||
);
|
||||
Response::from_parts(parts, body)
|
||||
} else if h2_direct_tunnel {
|
||||
let Some(exchange) = exchange.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: WebSocket exchange state unavailable\n",
|
||||
);
|
||||
};
|
||||
let Some(session) = h2_direct_session.take() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: HTTP/2 WebSocket tunnel state unavailable\n",
|
||||
);
|
||||
};
|
||||
parts.headers.remove("content-length");
|
||||
parts.headers.remove("transfer-encoding");
|
||||
parts.extensions.insert(crate::compression::Skip);
|
||||
Response::from_parts(
|
||||
parts,
|
||||
websocket::wrap_h2_response_body(body, session, exchange),
|
||||
)
|
||||
} else {
|
||||
if let (Some(upstream), Some(downstream), Some(exchange)) =
|
||||
(upgraded.take(), downstream, exchange.clone())
|
||||
{
|
||||
parts
|
||||
.headers
|
||||
.insert("connection", HeaderValue::from_static("Upgrade"));
|
||||
parts
|
||||
.headers
|
||||
.insert("upgrade", HeaderValue::from_static("websocket"));
|
||||
websocket::spawn(
|
||||
state.clone(),
|
||||
downstream,
|
||||
upstream,
|
||||
exchange,
|
||||
active_backend.origin.clone(),
|
||||
lease,
|
||||
stop,
|
||||
);
|
||||
}
|
||||
Response::from_parts(parts, Body::new(body))
|
||||
};
|
||||
response.extensions_mut().insert(ResponseRulesApplied);
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginTiming(upstream_started.elapsed()));
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(OriginBackend(active_backend.id.clone()));
|
||||
if !range_requested && handshake.is_none() {
|
||||
if let Some(settings) = transform_settings.filter(|settings| settings.enabled) {
|
||||
let transform_started = Instant::now();
|
||||
response = match crate::transform::apply(
|
||||
response,
|
||||
&public_suffix,
|
||||
settings,
|
||||
origin_no_transform,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
state.transform_metrics.record_response(
|
||||
scope,
|
||||
&response,
|
||||
transform_started.elapsed(),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(cause) => {
|
||||
state
|
||||
.transform_metrics
|
||||
.record_error(scope, transform_started.elapsed());
|
||||
state.cache.record_reason(scope, &host, "transform_error");
|
||||
if let Some(exchange) = &exchange {
|
||||
*exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = "transform_error";
|
||||
}
|
||||
tracing::debug!(error = %cause, "static response transformation failed");
|
||||
return error(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"RustProxy: upstream response body could not be transformed\n",
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
(origin_validation_headers, origin_plan, origin_reason, response)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
let websocket_upstream_h2 = !websocket_legacy_support
|
||||
&& handshake.as_ref().is_some_and(|handshake| handshake.h2())
|
||||
&& backend.protocol == crate::config::UpstreamProtocol::Http2;
|
||||
if let Some(handshake) = &handshake {
|
||||
request.headers_mut().remove("content-length");
|
||||
request.headers_mut().remove("transfer-encoding");
|
||||
if websocket_upstream_h2 {
|
||||
// Preserve CONNECT + :protocol=websocket for an explicit HTTP/2
|
||||
// upstream. HTTP/1.1-only fields are forbidden on this hop.
|
||||
request.headers_mut().remove("connection");
|
||||
request.headers_mut().remove("upgrade");
|
||||
request.headers_mut().remove("sec-websocket-key");
|
||||
request.headers_mut().remove("sec-websocket-accept");
|
||||
} else {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("connection", HeaderValue::from_static("Upgrade"));
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("upgrade", HeaderValue::from_static("websocket"));
|
||||
if !handshake.h1() {
|
||||
// Extended CONNECT replaces the RFC 6455 proof headers on the
|
||||
// downstream hop. Synthesize them only for an HTTP/1.1 backend.
|
||||
*request.method_mut() = Method::GET;
|
||||
request.headers_mut().remove("sec-websocket-key");
|
||||
request.headers_mut().remove("sec-websocket-accept");
|
||||
if let Some(key) = handshake.upstream_key() {
|
||||
if let Ok(key) = HeaderValue::from_str(key) {
|
||||
request.headers_mut().insert("sec-websocket-key", key);
|
||||
} else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: failed to build WebSocket handshake\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
|
||||
// Enforce after all custom request-header rules; they cannot restore credentials.
|
||||
auth.apply_upstream(request.headers_mut(), basic_identity);
|
||||
}
|
||||
// Transformation operates on the identity representation. Prefer identity from
|
||||
// the origin, while still decoding a supported encoding if the origin ignores it.
|
||||
if range_requested || transform_settings.is_some_and(|settings| settings.enabled) {
|
||||
request.headers_mut().remove("accept-encoding");
|
||||
}
|
||||
let mut revalidation_sent = false;
|
||||
if pending_revalidation {
|
||||
if let Some(cached) = &cached_revalidation {
|
||||
revalidation_sent = cached.apply_validators(request.headers_mut());
|
||||
}
|
||||
}
|
||||
// Varnish hashes HEAD with GET and turns a regular cacheable backend fetch
|
||||
// into GET. The outer response middleware still strips the body for the
|
||||
// original HEAD client, while a MISS can populate the shared GET object.
|
||||
if request.method() == Method::HEAD && cache_ticket.is_some() {
|
||||
*request.method_mut() = Method::GET;
|
||||
}
|
||||
*request.uri_mut() = uri.clone();
|
||||
let upstream_version = if websocket_legacy_support && !websocket_upstream_h2 {
|
||||
Version::HTTP_11
|
||||
} else {
|
||||
upstream_request_version(&backend, websocket_upstream_h2)
|
||||
};
|
||||
if normalize_h3_h1_unknown_body(&mut request, upstream_version)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or incomplete request body\n",
|
||||
);
|
||||
}
|
||||
*request.version_mut() = upstream_version;
|
||||
let mut h2_direct_session = None;
|
||||
if websocket_upstream_h2 {
|
||||
let Some(exchange) = exchange.clone() else {
|
||||
return error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: WebSocket exchange state unavailable\n",
|
||||
);
|
||||
};
|
||||
let stop_token = stop
|
||||
.as_ref()
|
||||
.map(|value| value.0.clone())
|
||||
.unwrap_or_else(|| state.shutdown.clone());
|
||||
let session = state.tunnel_registry.register_pending(
|
||||
exchange.clone(),
|
||||
backend.origin.clone(),
|
||||
"h2-rfc8441-h2-upstream",
|
||||
stop_token,
|
||||
lease.clone(),
|
||||
);
|
||||
websocket::spawn_h2_idle_watch(state.clone(), &session);
|
||||
let body = std::mem::replace(request.body_mut(), Body::empty());
|
||||
*request.body_mut() = websocket::wrap_h2_request_body(body, session.clone(), exchange);
|
||||
h2_direct_session = Some(session);
|
||||
}
|
||||
// Do not classify a slow client upload as a slow origin response. Probes
|
||||
// independently measure origin liveness/latency with body-free requests.
|
||||
let request_body_empty = request.body().size_hint().exact() == Some(0);
|
||||
let proactive_base_headers = request.headers().clone();
|
||||
let response_header_timeout = frontend
|
||||
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
|
||||
.unwrap_or(state.header_timeout);
|
||||
let retry_headers = request.headers().clone();
|
||||
let retry_method = request.method().clone();
|
||||
let expected_backend_host = if backend.preserve_host {
|
||||
&original_host
|
||||
} else {
|
||||
&backend.host_header
|
||||
};
|
||||
let retry_host_follows_backend = request
|
||||
.headers()
|
||||
.get(HOST)
|
||||
.is_some_and(|value| value == expected_backend_host);
|
||||
let retry_policy = route.pool.as_ref().map(|pool| pool.settings.retry.clone());
|
||||
let retry_enabled = route.backend_id.is_none()
|
||||
&& !admin_control_plane
|
||||
&& handshake.is_none()
|
||||
&& request_body_empty
|
||||
&& retry_safe_method(&retry_method)
|
||||
&& retry_policy.as_ref().is_some_and(|policy| policy.max_retries > 0);
|
||||
let upstream_started = Instant::now();
|
||||
let mut attempt_started = Instant::now();
|
||||
let mut active_backend = backend.clone();
|
||||
let mut active_lease = backend_lease.clone();
|
||||
drop(backend_lease);
|
||||
let mut active_affinity_cookie = affinity_cookie.clone();
|
||||
let mut active_uri = uri.clone();
|
||||
let mut request_attempt = Some(request);
|
||||
let mut attempted_backends = Vec::<String>::new();
|
||||
let mut retries = 0u8;
|
||||
let upstream_result = loop {
|
||||
let client = if handshake.is_some() && !websocket_upstream_h2 {
|
||||
&active_backend.websocket_client
|
||||
} else {
|
||||
&active_backend.client
|
||||
};
|
||||
let result = tokio::time::timeout(
|
||||
response_header_timeout,
|
||||
client.request(request_attempt.take().expect("upstream request attempt missing")),
|
||||
)
|
||||
.await;
|
||||
let attempt_elapsed = attempt_started.elapsed();
|
||||
let Some(policy) = retry_policy.as_ref() else {
|
||||
break result;
|
||||
};
|
||||
let retry_reason = match &result {
|
||||
Ok(Ok(response)) if policy.retries_status(response.status().as_u16()) => {
|
||||
Some("http_status")
|
||||
}
|
||||
Ok(Err(cause)) if cause.is_connect() && policy.retry_connect_errors => Some("connect"),
|
||||
Err(_) if policy.retry_timeouts => Some("timeout"),
|
||||
_ => None,
|
||||
};
|
||||
if !retry_enabled || retries >= policy.max_retries || retry_reason.is_none() {
|
||||
break result;
|
||||
}
|
||||
attempted_backends.push(active_backend.id.clone());
|
||||
let Some(pool) = route.pool.as_ref() else {
|
||||
break result;
|
||||
};
|
||||
let selection = match pool
|
||||
.select_retry_wait(
|
||||
context.client_ip,
|
||||
&retry_headers,
|
||||
tls,
|
||||
&attempted_backends,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(selection) => selection,
|
||||
Err(crate::balance::SelectionFailure::Capacity) => {
|
||||
tracing::debug!(
|
||||
vhost = %pool.id,
|
||||
attempt = retries,
|
||||
"retry stopped because all remaining backends are at concurrency capacity"
|
||||
);
|
||||
break result;
|
||||
}
|
||||
Err(crate::balance::SelectionFailure::Unavailable) => break result,
|
||||
};
|
||||
let next_backend = selection.lease.member.backend.clone();
|
||||
let Some(next_uri) = upstream_uri(&next_backend, &origin_suffix) else {
|
||||
break result;
|
||||
};
|
||||
let Some(next_request) = retry_request(
|
||||
&retry_method,
|
||||
upstream_request_version(&next_backend, false),
|
||||
&retry_headers,
|
||||
&next_backend,
|
||||
&original_host,
|
||||
&origin_suffix,
|
||||
retry_host_follows_backend,
|
||||
) else {
|
||||
break result;
|
||||
};
|
||||
if let Some(lease) = &active_lease {
|
||||
match &result {
|
||||
Ok(Ok(response)) => {
|
||||
lease.response(response.status().as_u16(), attempt_elapsed, true)
|
||||
}
|
||||
Ok(Err(cause)) => {
|
||||
let connect_failure = cause.is_connect();
|
||||
let detail = upstream_failure_detail(
|
||||
cause,
|
||||
&active_backend,
|
||||
attempt_elapsed,
|
||||
connect_failure,
|
||||
);
|
||||
lease.error_detail(
|
||||
upstream_error_class(cause, connect_failure),
|
||||
&detail,
|
||||
attempt_elapsed,
|
||||
);
|
||||
}
|
||||
Err(_) => lease.error_detail(
|
||||
"response_header_timeout",
|
||||
"response_header_timeout",
|
||||
attempt_elapsed,
|
||||
),
|
||||
}
|
||||
lease.retry_attempted();
|
||||
}
|
||||
retries = retries.saturating_add(1);
|
||||
selection.lease.started();
|
||||
if let Some(exchange) = &exchange {
|
||||
*exchange.backend.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
Some(selection.lease.clone());
|
||||
}
|
||||
tracing::info!(
|
||||
previous_backend = %active_backend.id,
|
||||
backend = %next_backend.id,
|
||||
attempt = retries,
|
||||
reason = retry_reason.unwrap_or("unknown"),
|
||||
"retrying replay-safe request on another backend"
|
||||
);
|
||||
active_backend = next_backend;
|
||||
active_lease = Some(selection.lease);
|
||||
active_affinity_cookie = selection.cookie;
|
||||
active_uri = next_uri;
|
||||
request_attempt = Some(next_request);
|
||||
attempt_started = Instant::now();
|
||||
};
|
||||
let final_attempt_elapsed = attempt_started.elapsed();
|
||||
(
|
||||
websocket_upstream_h2, revalidation_sent, h2_direct_session, request_body_empty,
|
||||
proactive_base_headers, upstream_started, active_backend,
|
||||
active_lease, active_affinity_cookie, active_uri, retries, upstream_result,
|
||||
final_attempt_elapsed,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
async fn forward(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(peer): Extension<PeerAddr>,
|
||||
Extension(local): Extension<LocalAddr>,
|
||||
mut request: Request,
|
||||
) -> Response {
|
||||
if request.method() == Method::CONNECT
|
||||
&& !matches!(request.version(), Version::HTTP_2 | Version::HTTP_3)
|
||||
{
|
||||
return error(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"RustProxy: CONNECT is not supported\n",
|
||||
);
|
||||
}
|
||||
let tls = request
|
||||
.extensions()
|
||||
.get::<Transport>()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.tls;
|
||||
let Ok(original_host) = effective_host(&request, tls) else {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or conflicting request authority\n",
|
||||
);
|
||||
};
|
||||
let Ok(host) = original_host
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|v| request_host(v).ok())
|
||||
.ok_or(())
|
||||
else {
|
||||
return error(StatusCode::BAD_REQUEST, "RustProxy: invalid hostname\n");
|
||||
};
|
||||
let handshake = if request.method() == Method::CONNECT {
|
||||
let protocol = match request.version() {
|
||||
Version::HTTP_2 => websocket::h2_protocol(&request),
|
||||
Version::HTTP_3 => websocket::h3_protocol(&request),
|
||||
_ => None,
|
||||
};
|
||||
match (request.version(), protocol) {
|
||||
(Version::HTTP_2, Some("websocket")) => match websocket::validate_h2(&request) {
|
||||
Ok(handshake) => Some(handshake),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid HTTP/2 WebSocket Extended CONNECT\n",
|
||||
)
|
||||
}
|
||||
},
|
||||
(Version::HTTP_3, Some("websocket")) => match websocket::validate_h3(&request) {
|
||||
Ok(handshake) => Some(handshake),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid HTTP/3 WebSocket Extended CONNECT\n",
|
||||
)
|
||||
}
|
||||
},
|
||||
(_, Some(_)) => {
|
||||
return error(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"RustProxy: unsupported Extended CONNECT protocol\n",
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return error(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"RustProxy: CONNECT is not supported\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if headers::requests_upgrade(request.headers()) {
|
||||
match websocket::validate(&request) {
|
||||
Ok(handshake) => Some(handshake),
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid or unsupported WebSocket upgrade\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let snapshot = request
|
||||
.extensions()
|
||||
.get::<Arc<crate::config::Snapshot>>()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| state.config.load_full());
|
||||
let Ok(path) = crate::acl::routing_path(request.uri().path()) else {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: ambiguous request path\n",
|
||||
);
|
||||
};
|
||||
let frontend = snapshot.frontend(&host, local.0, tls);
|
||||
let websocket_legacy_support = handshake.is_some()
|
||||
&& frontend.is_some_and(|frontend| frontend.websocket_legacy_support);
|
||||
let redirect = snapshot.redirect_on(&host, &path, local.0, tls);
|
||||
let local_host = snapshot.not_found_host_on(&host, local.0, tls);
|
||||
// TLS ALPN normally prevents HTTP/2 from reaching a frontend that disabled it.
|
||||
// Keep this request-level guard for coalesced/reused connections and direct tests.
|
||||
if request.version() == Version::HTTP_2
|
||||
&& (frontend.is_some_and(|frontend| !frontend.http2_enabled)
|
||||
|| redirect.is_some_and(|(redirect, _)| !redirect.http2_enabled)
|
||||
|| local_host.is_some_and(|item| !item.http2_enabled))
|
||||
{
|
||||
return error(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"RustProxy: HTTP/2 is disabled for this vhost\n",
|
||||
);
|
||||
}
|
||||
if request.version() == Version::HTTP_3
|
||||
&& (frontend.is_some_and(|frontend| !frontend.http3_enabled)
|
||||
|| redirect.is_some_and(|(redirect, _)| !redirect.http3_enabled)
|
||||
|| local_host.is_some_and(|item| !item.http3_enabled))
|
||||
{
|
||||
return error(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"RustProxy: HTTP/3 is disabled for this vhost\n",
|
||||
);
|
||||
}
|
||||
if request.version() == Version::HTTP_2
|
||||
&& request.method() == Method::CONNECT
|
||||
&& websocket::h2_protocol(&request) == Some("websocket")
|
||||
&& frontend.is_none_or(|frontend| !frontend.websocket_rfc8441)
|
||||
{
|
||||
return error(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"RustProxy: RFC 8441 WebSocket transport is not enabled for this vhost\n",
|
||||
);
|
||||
}
|
||||
if request.version() == Version::HTTP_3
|
||||
&& request.method() == Method::CONNECT
|
||||
&& websocket::h3_protocol(&request) == Some("websocket")
|
||||
&& frontend.is_none_or(|frontend| !frontend.websocket_rfc9220)
|
||||
{
|
||||
return error(
|
||||
StatusCode::MISDIRECTED_REQUEST,
|
||||
"RustProxy: RFC 9220 WebSocket transport is not enabled for this vhost\n",
|
||||
);
|
||||
}
|
||||
if !tls {
|
||||
if let Some(response) = frontend.and_then(|frontend| force_ssl_redirect(frontend, &host, request.uri())) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
let mut context = match snapshot.forwarding.context(
|
||||
request.headers(),
|
||||
peer.0,
|
||||
local.0,
|
||||
tls,
|
||||
&original_host,
|
||||
request.uri(),
|
||||
) {
|
||||
Ok(context) => context,
|
||||
Err(_) => {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid forwarding context\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
if frontend.is_some_and(|frontend| frontend.cloudflare_client_ip) {
|
||||
if let Some(client) = crate::forwarding::cloudflare_client_ip(request.headers(), peer.0) {
|
||||
if context.override_client_ip(client).is_err() {
|
||||
return error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Cloudflare client address\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.client_ip.lock().unwrap_or_else(|e| e.into_inner()) = Some(context.client_ip.to_string());
|
||||
}
|
||||
if let Some(frontend) = frontend {
|
||||
// GeoIP is deliberately off the default request path. Resolve only when
|
||||
// a country policy/rate-limit requires it or the operator explicitly
|
||||
// enables country capture for access logs + traffic analytics.
|
||||
let capture_country = state.logs.capture_country();
|
||||
let country = (capture_country
|
||||
|| frontend.profile_country_access.needs_geoip()
|
||||
|| frontend.rate_limit.needs_geoip())
|
||||
.then(|| state.geoip.country(context.client_ip))
|
||||
.flatten();
|
||||
if capture_country {
|
||||
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
|
||||
*exchange.country.lock().unwrap_or_else(|e| e.into_inner()) = country.clone();
|
||||
}
|
||||
}
|
||||
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",
|
||||
)
|
||||
});
|
||||
};
|
||||
let (
|
||||
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
|
||||
transform_settings, scope, ignore_static_cookie, only_cached, public_suffix,
|
||||
origin_suffix, uri, exchange, lease, stop, downstream, mut h2_downstream,
|
||||
range_selection, if_range, range_requested, effective_reason, revalidation_reason,
|
||||
cache_ticket, client_conditionals, cached_revalidation, freshness_revalidation_reason,
|
||||
pending_revalidation, mut miss_recorded,
|
||||
) = include!("forward/route_cache.rs");
|
||||
|
||||
let (serve_stale, mut fill_leader) = include!("forward/cache_lookup.rs");
|
||||
|
||||
let (
|
||||
websocket_upstream_h2, revalidation_sent, mut h2_direct_session, request_body_empty,
|
||||
proactive_base_headers, upstream_started, active_backend,
|
||||
active_lease, active_affinity_cookie, active_uri, retries, upstream_result,
|
||||
final_attempt_elapsed,
|
||||
) = include!("forward/upstream.rs");
|
||||
|
||||
include!("forward/response.rs")
|
||||
}
|
||||
+2
-41
@@ -38,44 +38,7 @@ pub fn requests_upgrade(headers: &HeaderMap) -> bool {
|
||||
.any(|v| v.trim().eq_ignore_ascii_case("upgrade"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn removes_nominated_fields_and_preserves_cookies() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("connection", "keep-alive, X-Internal".parse().unwrap());
|
||||
h.insert("x-internal", "secret".parse().unwrap());
|
||||
h.insert("keep-alive", "timeout=5".parse().unwrap());
|
||||
h.append("set-cookie", "a=1".parse().unwrap());
|
||||
h.append("set-cookie", "b=2".parse().unwrap());
|
||||
strip_hop_by_hop(&mut h);
|
||||
assert!(!h.contains_key("x-internal"));
|
||||
assert!(!h.contains_key("connection"));
|
||||
assert_eq!(h.get_all("set-cookie").iter().count(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn upgrade_is_case_insensitive() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("connection", "keep-alive, Upgrade".parse().unwrap());
|
||||
assert!(requests_upgrade(&h));
|
||||
}
|
||||
#[test]
|
||||
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
"cookie",
|
||||
"a=1; rustproxy_session=secret; b=2".parse().unwrap(),
|
||||
);
|
||||
let admin = strip_admin_cookie(&mut h);
|
||||
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
|
||||
assert_eq!(admin, vec!["rustproxy_session=secret"]);
|
||||
restore_admin_cookie(&mut h, &admin);
|
||||
assert_eq!(
|
||||
h.get("cookie").unwrap(),
|
||||
"a=1; b=2; rustproxy_session=secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
include!("headers/tests.rs");
|
||||
|
||||
/// Cookies are not port-scoped. Remove the local control-plane cookie before
|
||||
/// authentication/cache processing and return its exact cookie fragments. The
|
||||
@@ -91,9 +54,7 @@ pub fn strip_admin_cookie(headers: &mut HeaderMap) -> Vec<String> {
|
||||
.flat_map(|h| h.split(';'))
|
||||
{
|
||||
let part = part.trim();
|
||||
let Some((name, _)) = part.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let Some((name, _)) = part.split_once('=') else { continue; };
|
||||
if name == crate::auth::COOKIE_NAME {
|
||||
admin.push(part.to_owned());
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn removes_nominated_fields_and_preserves_cookies() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("connection", "keep-alive, X-Internal".parse().unwrap());
|
||||
h.insert("x-internal", "secret".parse().unwrap());
|
||||
h.insert("keep-alive", "timeout=5".parse().unwrap());
|
||||
h.append("set-cookie", "a=1".parse().unwrap());
|
||||
h.append("set-cookie", "b=2".parse().unwrap());
|
||||
strip_hop_by_hop(&mut h);
|
||||
assert!(!h.contains_key("x-internal"));
|
||||
assert!(!h.contains_key("connection"));
|
||||
assert_eq!(h.get_all("set-cookie").iter().count(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn upgrade_is_case_insensitive() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("connection", "keep-alive, Upgrade".parse().unwrap());
|
||||
assert!(requests_upgrade(&h));
|
||||
}
|
||||
#[test]
|
||||
fn admin_cookie_can_be_restored_only_from_captured_fragment() {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("cookie", "a=1; rustproxy_session=secret; b=2".parse().unwrap());
|
||||
let admin = strip_admin_cookie(&mut h);
|
||||
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2");
|
||||
assert_eq!(admin, vec!["rustproxy_session=secret"]);
|
||||
restore_admin_cookie(&mut h, &admin);
|
||||
assert_eq!(h.get("cookie").unwrap(), "a=1; b=2; rustproxy_session=secret");
|
||||
}
|
||||
}
|
||||
+5
-3516
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,278 @@
|
||||
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.as_ref().unwrap(), false),
|
||||
Version::HTTP_2
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn h3_body_capable_methods_without_length_become_known_empty_for_h1() {
|
||||
use bytes::Bytes;
|
||||
for method in [Method::POST, Method::PUT, Method::PATCH, Method::DELETE] {
|
||||
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(method.clone())
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/api2/json/item")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
assert_eq!(request.body().size_hint().exact(), None, "{method}");
|
||||
|
||||
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(request.body().is_end_stream(), "{method}");
|
||||
assert_eq!(request.body().size_hint().exact(), Some(0), "{method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_delete_unknown_nonempty_body_is_replayed_not_buffered_away() {
|
||||
use bytes::Bytes;
|
||||
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(
|
||||
Bytes::from_static(b"payload"),
|
||||
)]);
|
||||
let mut request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/api2/json/item")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
|
||||
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let body = request.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::from_static(b"payload"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_unknown_body_is_untouched_for_http2_upstream() {
|
||||
use bytes::Bytes;
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/api2/json/item")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
|
||||
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_2)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_h3_unknown_body_is_untouched_for_http1_upstream() {
|
||||
use bytes::Bytes;
|
||||
for version in [Version::HTTP_11, Version::HTTP_2] {
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.version(version)
|
||||
.uri("https://example.test/api2/json/item")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
|
||||
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn h3_declared_delete_body_is_not_preconsumed() {
|
||||
use bytes::Bytes;
|
||||
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
|
||||
let mut request = Request::builder()
|
||||
.method(Method::DELETE)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/api2/json/item")
|
||||
.header(http::header::CONTENT_LENGTH, "7")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap();
|
||||
|
||||
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.headers()[http::header::CONTENT_LENGTH], "7");
|
||||
assert_eq!(request.body().size_hint().exact(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_policy_and_gateway_errors_use_html_pages() {
|
||||
for status in [
|
||||
StatusCode::FORBIDDEN,
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
] {
|
||||
let response = error(status, "internal detail\n");
|
||||
assert_eq!(response.status(), status);
|
||||
assert_eq!(
|
||||
response.headers().get("content-type").unwrap(),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
}
|
||||
let response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
|
||||
assert_ne!(
|
||||
response.headers().get("content-type").unwrap(),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn locally_generated_pages_remove_cache_metadata() {
|
||||
let mut response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
|
||||
for (name, value) in [
|
||||
("etag", "\"old\""),
|
||||
("x-cache", "HIT"),
|
||||
("x-cache-reason", "origin"),
|
||||
("cache-status", "Origin; hit"),
|
||||
] {
|
||||
response.headers_mut().insert(name, value.parse().unwrap());
|
||||
}
|
||||
enforce_local_page_no_cache(&mut response);
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
for name in ["etag", "x-cache", "x-cache-reason", "cache-status"] {
|
||||
assert!(!response.headers().contains_key(name), "{name}");
|
||||
}
|
||||
assert!(response.extensions().get::<LocalGeneratedResponse>().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn http2_uses_authority_and_rejects_conflicting_host() {
|
||||
let mut request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://app.localhost/test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(effective_host(&request, true).unwrap(), "app.localhost");
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(HOST, "different.localhost".parse().unwrap());
|
||||
assert!(effective_host(&request, true).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_websocket_origin_matches_upstream_scheme_and_host() {
|
||||
let origin = legacy_websocket_origin(
|
||||
"https://ilo.internal:443",
|
||||
&"10.0.0.5:443".parse().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(origin, "https://10.0.0.5:443");
|
||||
assert!(legacy_websocket_origin("not-an-origin", &"10.0.0.5".parse().unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_ssl_location_preserves_path_query_and_https_port() {
|
||||
let uri: Uri = "/api/items?x=1&next=%2Fhome".parse().unwrap();
|
||||
assert_eq!(
|
||||
force_ssl_location("app.example.test", &uri, 443).unwrap(),
|
||||
"https://app.example.test/api/items?x=1&next=%2Fhome"
|
||||
);
|
||||
assert_eq!(
|
||||
force_ssl_location("app.example.test", &uri, 8443).unwrap(),
|
||||
"https://app.example.test:8443/api/items?x=1&next=%2Fhome"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn http1_absolute_form_and_h2_wrong_scheme_are_rejected() {
|
||||
let request = Request::builder()
|
||||
.uri("http://app.localhost/")
|
||||
.header(HOST, "app.localhost")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(effective_host(&request, false).is_err());
|
||||
let request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("http://app.localhost/")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(effective_host(&request, true).is_err());
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct NestedDiagnosticError {
|
||||
source: std::io::Error,
|
||||
}
|
||||
impl std::fmt::Display for NestedDiagnosticError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("connector wrapper")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for NestedDiagnosticError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
Some(&self.source)
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn upstream_error_chain_keeps_nested_connector_cause() {
|
||||
let error = NestedDiagnosticError {
|
||||
source: std::io::Error::new(
|
||||
std::io::ErrorKind::ConnectionRefused,
|
||||
"connection refused by origin",
|
||||
),
|
||||
};
|
||||
let detail = format_error_chain(&error);
|
||||
assert!(detail.contains("connector wrapper"));
|
||||
assert!(detail.contains("connection refused by origin"));
|
||||
}
|
||||
#[test]
|
||||
fn upstream_error_detail_truncation_preserves_utf8_boundaries() {
|
||||
let mut detail = "ą".repeat(MAX_UPSTREAM_ERROR_DETAIL);
|
||||
truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL);
|
||||
assert!(detail.len() <= MAX_UPSTREAM_ERROR_DETAIL);
|
||||
assert!(std::str::from_utf8(detail.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
|
||||
let mut response = Response::new(Body::empty());
|
||||
response.headers_mut().insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
|
||||
response.headers_mut().insert("x-rust-proxy-backend-time", HeaderValue::from_static("legacy-spoof"));
|
||||
response.headers_mut().insert("x-rustproxy-backend-time", HeaderValue::from_static("999ms"));
|
||||
apply_backend_diagnostic_values(&mut response, false, false);
|
||||
assert!(response.headers().get("x-rust-proxy-backend").is_none());
|
||||
assert!(response.headers().get("x-rust-proxy-backend-time").is_none());
|
||||
assert!(response.headers().get("x-rustproxy-backend-time").is_none());
|
||||
|
||||
response.extensions_mut().insert(OriginBackend("origin-a".into()));
|
||||
response.extensions_mut().insert(OriginTiming(Duration::from_millis(12)));
|
||||
apply_backend_diagnostic_values(&mut response, true, true);
|
||||
assert_eq!(response.headers().get("x-rust-proxy-backend").unwrap(), "origin-a");
|
||||
assert_eq!(response.headers().get("x-rustproxy-backend-time").unwrap(), "12.00ms");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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, USER_AGENT},
|
||||
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)]
|
||||
struct StaticSourceResponse;
|
||||
#[derive(Clone, Copy)]
|
||||
struct RedirectResponse { http3_enabled: bool }
|
||||
#[derive(Clone, Copy)]
|
||||
struct LocalHostResponse { http3_enabled: bool }
|
||||
#[derive(Clone, Copy)]
|
||||
struct OriginTiming(Duration);
|
||||
#[derive(Clone)]
|
||||
struct OriginBackend(String);
|
||||
use std::{
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU16, AtomicU64, Ordering},
|
||||
Arc, Mutex,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub fn router(state: Arc<App>) -> Router {
|
||||
Router::new()
|
||||
.fallback(forward)
|
||||
.layer(middleware::from_fn_with_state(state.clone(), observe))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::acme::challenge,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
const MAX_UPSTREAM_ERROR_DETAIL: usize = 2048;
|
||||
|
||||
fn truncate_utf8(value: &mut String, max_bytes: usize) {
|
||||
if value.len() <= max_bytes {
|
||||
return;
|
||||
}
|
||||
let mut end = max_bytes;
|
||||
while end > 0 && !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value.truncate(end);
|
||||
}
|
||||
|
||||
fn format_error_chain(error: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut parts = Vec::new();
|
||||
let mut current = Some(error);
|
||||
for _ in 0..12 {
|
||||
let Some(item) = current else { break };
|
||||
let message = item.to_string();
|
||||
if !message.is_empty() && parts.last() != Some(&message) {
|
||||
parts.push(message);
|
||||
}
|
||||
current = item.source();
|
||||
}
|
||||
if current.is_some() {
|
||||
parts.push("<error chain truncated>".into());
|
||||
}
|
||||
parts.join(" -> ")
|
||||
}
|
||||
|
||||
fn classify_stale_error(error: &(dyn std::error::Error + 'static)) -> crate::cache::StaleErrorClass {
|
||||
let mut current = Some(error);
|
||||
let mut text = String::new();
|
||||
for _ in 0..12 {
|
||||
let Some(item) = current else { break };
|
||||
if let Some(io) = item.downcast_ref::<std::io::Error>() {
|
||||
if io.kind() == std::io::ErrorKind::TimedOut {
|
||||
return crate::cache::StaleErrorClass::Timeout;
|
||||
}
|
||||
}
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&item.to_string().to_ascii_lowercase());
|
||||
current = item.source();
|
||||
}
|
||||
if text.contains("tls")
|
||||
|| text.contains("certificate")
|
||||
|| text.contains("cert verify")
|
||||
|| text.contains("handshake")
|
||||
{
|
||||
crate::cache::StaleErrorClass::Tls
|
||||
} else if text.contains("dns")
|
||||
|| text.contains("name resolution")
|
||||
|| text.contains("resolve host")
|
||||
|| text.contains("lookup address")
|
||||
{
|
||||
crate::cache::StaleErrorClass::Dns
|
||||
} else if text.contains("timed out") || text.contains("timeout") {
|
||||
crate::cache::StaleErrorClass::Timeout
|
||||
} else {
|
||||
crate::cache::StaleErrorClass::Connection
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream_error_class(error: &(dyn std::error::Error + 'static), connect_failure: bool) -> &'static str {
|
||||
match classify_stale_error(error) {
|
||||
crate::cache::StaleErrorClass::Dns => "dns",
|
||||
crate::cache::StaleErrorClass::Tls => "tls",
|
||||
crate::cache::StaleErrorClass::Timeout => "response_header_timeout",
|
||||
crate::cache::StaleErrorClass::Connection if connect_failure => "connect",
|
||||
crate::cache::StaleErrorClass::Connection => "io_error",
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_safe_method(method: &Method) -> bool {
|
||||
method == Method::GET || method == Method::HEAD || method == Method::OPTIONS
|
||||
}
|
||||
|
||||
fn upstream_uri(backend: &crate::config::CompiledBackend, suffix: &str) -> Option<Uri> {
|
||||
format!("{}{suffix}", backend.request_origin).parse().ok()
|
||||
}
|
||||
|
||||
fn legacy_websocket_origin(
|
||||
request_origin: &str,
|
||||
upstream_host: &HeaderValue,
|
||||
) -> Option<HeaderValue> {
|
||||
let scheme = request_origin.split_once("://")?.0;
|
||||
let authority = upstream_host.to_str().ok()?.trim();
|
||||
if authority.is_empty() {
|
||||
return None;
|
||||
}
|
||||
HeaderValue::from_str(&format!("{scheme}://{authority}")).ok()
|
||||
}
|
||||
|
||||
fn upstream_request_version(
|
||||
backend: &crate::config::CompiledBackend,
|
||||
websocket_h2: bool,
|
||||
) -> Version {
|
||||
if websocket_h2 || backend.protocol == crate::config::UpstreamProtocol::Http2 {
|
||||
Version::HTTP_2
|
||||
} else {
|
||||
Version::HTTP_11
|
||||
}
|
||||
}
|
||||
|
||||
fn retry_request(
|
||||
method: &Method,
|
||||
version: Version,
|
||||
headers: &HeaderMap,
|
||||
backend: &crate::config::CompiledBackend,
|
||||
original_host: &HeaderValue,
|
||||
origin_suffix: &str,
|
||||
host_follows_backend: bool,
|
||||
) -> Option<Request> {
|
||||
let uri = upstream_uri(backend, origin_suffix)?;
|
||||
let mut request = Request::builder()
|
||||
.method(method.clone())
|
||||
.version(version)
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.ok()?;
|
||||
*request.headers_mut() = headers.clone();
|
||||
if host_follows_backend {
|
||||
request.headers_mut().insert(
|
||||
HOST,
|
||||
if backend.preserve_host {
|
||||
original_host.clone()
|
||||
} else {
|
||||
backend.host_header.clone()
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(request)
|
||||
}
|
||||
|
||||
fn upstream_failure_detail<E>(
|
||||
cause: &E,
|
||||
backend: &crate::config::CompiledBackend,
|
||||
elapsed: Duration,
|
||||
connect_failure: bool,
|
||||
) -> String
|
||||
where
|
||||
E: std::error::Error + std::fmt::Debug + 'static,
|
||||
{
|
||||
let phase = if connect_failure { "connect" } else { "http" };
|
||||
let chain = format_error_chain(cause);
|
||||
let debug = format!("{cause:?}");
|
||||
let mut detail = format!(
|
||||
"phase={phase}; origin={}; request_origin={}; tls_verify={}; elapsed_ms={}; error_chain={chain}",
|
||||
backend.origin,
|
||||
backend.request_origin,
|
||||
backend.tls_verify,
|
||||
elapsed.as_millis(),
|
||||
);
|
||||
if !debug.is_empty() && debug != cause.to_string() {
|
||||
detail.push_str("; debug=");
|
||||
detail.push_str(&debug);
|
||||
}
|
||||
detail = detail
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { ' ' } else { ch })
|
||||
.collect();
|
||||
truncate_utf8(&mut detail, MAX_UPSTREAM_ERROR_DETAIL);
|
||||
detail
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CacheRefreshContext {
|
||||
state: Arc<App>,
|
||||
snapshot: Arc<crate::config::Snapshot>,
|
||||
backend: Arc<crate::config::CompiledBackend>,
|
||||
cache_settings: crate::cache::Settings,
|
||||
transform: Option<crate::transform::Settings>,
|
||||
uri: Uri,
|
||||
public_suffix: String,
|
||||
host: String,
|
||||
path: String,
|
||||
scope: String,
|
||||
timeout: Duration,
|
||||
local: std::net::SocketAddr,
|
||||
tls: bool,
|
||||
base_headers: http::HeaderMap,
|
||||
}
|
||||
|
||||
async fn refresh_cache_once(
|
||||
context: &CacheRefreshContext,
|
||||
ticket: crate::cache::Ticket,
|
||||
cached: crate::cache::Revalidation,
|
||||
) -> bool {
|
||||
if !context.state.cache.ticket_current(&ticket) {
|
||||
return false;
|
||||
}
|
||||
let mut refresh_headers = context.base_headers.clone();
|
||||
for name in [
|
||||
"if-none-match",
|
||||
"if-modified-since",
|
||||
"if-match",
|
||||
"if-unmodified-since",
|
||||
"if-range",
|
||||
"range",
|
||||
] {
|
||||
refresh_headers.remove(name);
|
||||
}
|
||||
if !cached.apply_validators(&mut refresh_headers) {
|
||||
return false;
|
||||
}
|
||||
if context
|
||||
.transform
|
||||
.as_ref()
|
||||
.is_some_and(|settings| settings.enabled)
|
||||
{
|
||||
refresh_headers.remove("accept-encoding");
|
||||
}
|
||||
let mut refresh = Request::new(Body::empty());
|
||||
*refresh.method_mut() = Method::GET;
|
||||
*refresh.uri_mut() = context.uri.clone();
|
||||
*refresh.version_mut() = Version::HTTP_11;
|
||||
*refresh.headers_mut() = refresh_headers;
|
||||
let started = Instant::now();
|
||||
let Ok(Ok(origin)) = tokio::time::timeout(
|
||||
context.timeout,
|
||||
context.backend.client.request(refresh),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let origin_validation_headers = origin.headers().clone();
|
||||
let Some(route) = context.snapshot.select_on(
|
||||
&context.host,
|
||||
&context.path,
|
||||
context.local,
|
||||
context.tls,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
if origin.status() == StatusCode::NOT_MODIFIED {
|
||||
let mut validation_headers = origin_validation_headers.clone();
|
||||
headers::strip_hop_by_hop(&mut validation_headers);
|
||||
apply_response_rules(
|
||||
&mut validation_headers,
|
||||
&context.snapshot,
|
||||
route.frontend.as_deref(),
|
||||
Some(route),
|
||||
false,
|
||||
);
|
||||
if let Some(pool) = &route.pool {
|
||||
pool.strip_affinity_response(&mut validation_headers);
|
||||
}
|
||||
let merged = cached.merge_validation_headers(&validation_headers);
|
||||
return context
|
||||
.state
|
||||
.cache
|
||||
.publish_revalidated(
|
||||
ticket,
|
||||
&cached,
|
||||
merged,
|
||||
&origin_validation_headers,
|
||||
&context.cache_settings,
|
||||
started.elapsed(),
|
||||
)
|
||||
.is_some();
|
||||
}
|
||||
if origin.status() != StatusCode::OK {
|
||||
return false;
|
||||
}
|
||||
let Some(origin_plan) = crate::cache::plan(
|
||||
origin.status(),
|
||||
origin.headers(),
|
||||
&context.cache_settings,
|
||||
started.elapsed(),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let origin_no_transform = crate::transform::no_transform(origin.headers());
|
||||
let skip_compression = crate::compression::private_or_no_transform(origin.headers());
|
||||
let origin_validators = crate::transform::OriginValidators {
|
||||
etag: origin.headers().get("etag").cloned(),
|
||||
last_modified: origin.headers().get("last-modified").cloned(),
|
||||
};
|
||||
let (mut parts, body) = origin.into_parts();
|
||||
parts.extensions.insert(origin_validators);
|
||||
if skip_compression {
|
||||
parts.extensions.insert(crate::compression::Skip);
|
||||
}
|
||||
headers::strip_hop_by_hop(&mut parts.headers);
|
||||
apply_response_rules(
|
||||
&mut parts.headers,
|
||||
&context.snapshot,
|
||||
route.frontend.as_deref(),
|
||||
Some(route),
|
||||
false,
|
||||
);
|
||||
if let Some(pool) = &route.pool {
|
||||
pool.strip_affinity_response(&mut parts.headers);
|
||||
}
|
||||
let mut refreshed = Response::from_parts(parts, Body::new(body));
|
||||
refreshed.extensions_mut().insert(ResponseRulesApplied);
|
||||
if let Some(settings) = context
|
||||
.transform
|
||||
.as_ref()
|
||||
.filter(|settings| settings.enabled)
|
||||
{
|
||||
let transform_started = Instant::now();
|
||||
let Ok(transformed) = crate::transform::apply(
|
||||
refreshed,
|
||||
&context.public_suffix,
|
||||
settings,
|
||||
origin_no_transform,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
context
|
||||
.state
|
||||
.transform_metrics
|
||||
.record_error(&context.scope, transform_started.elapsed());
|
||||
context
|
||||
.state
|
||||
.cache
|
||||
.record_reason(&context.scope, &context.host, "transform_error");
|
||||
return false;
|
||||
};
|
||||
context.state.transform_metrics.record_response(
|
||||
&context.scope,
|
||||
&transformed,
|
||||
transform_started.elapsed(),
|
||||
);
|
||||
refreshed = transformed;
|
||||
}
|
||||
let canonical_identity = refreshed
|
||||
.extensions()
|
||||
.get::<crate::transform::CanonicalIdentity>()
|
||||
.is_some();
|
||||
let Some(transformed_plan) = crate::cache::plan(
|
||||
refreshed.status(),
|
||||
refreshed.headers(),
|
||||
&context.cache_settings,
|
||||
started.elapsed(),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let origin_plan = if canonical_identity {
|
||||
origin_plan.without_vary("accept-encoding")
|
||||
} else {
|
||||
origin_plan
|
||||
};
|
||||
let verify_ticket = ticket.clone();
|
||||
let refreshed = context.state.cache.wrap_with_fill(
|
||||
refreshed,
|
||||
ticket,
|
||||
origin_plan.restrict(transformed_plan),
|
||||
&context.cache_settings,
|
||||
None,
|
||||
);
|
||||
if http_body_util::BodyExt::collect(refreshed.into_body())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
context
|
||||
.state
|
||||
.cache
|
||||
.revalidation_tiered(&verify_ticket)
|
||||
.await
|
||||
.is_some()
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let snapshot = state.config.load_full();
|
||||
let (frontend_context, route_context) = response_context(&snapshot, &request);
|
||||
let local = request.extensions().get::<LocalAddr>().map(|v| v.0);
|
||||
let transport_tls = request
|
||||
.extensions()
|
||||
.get::<Transport>()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.tls;
|
||||
request.extensions_mut().insert(snapshot.clone());
|
||||
let original_headers = request.headers().clone();
|
||||
let method = request.method().clone();
|
||||
let is_head = method == Method::HEAD;
|
||||
let ws_attempt = request
|
||||
.headers()
|
||||
.get("upgrade")
|
||||
.is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket"))
|
||||
|| (request.version() == Version::HTTP_2
|
||||
&& request.method() == Method::CONNECT
|
||||
&& websocket::h2_protocol(&request) == Some("websocket"))
|
||||
|| (request.version() == Version::HTTP_3
|
||||
&& request.method() == Method::CONNECT
|
||||
&& websocket::h3_protocol(&request) == Some("websocket"));
|
||||
if request.version() == Version::HTTP_11 {
|
||||
state.metrics.http1_requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if request.version() == Version::HTTP_2 {
|
||||
state.metrics.http2_requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if request.version() == Version::HTTP_3 {
|
||||
state.metrics.http3_requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let id = state.metrics.requests.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if let Some(frontend) = &frontend_context {
|
||||
frontend.metrics.requests.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let Ok(permit) = state.inflight.clone().try_acquire_owned() else {
|
||||
state
|
||||
.metrics
|
||||
.rejected_requests
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
if let Some(frontend) = &frontend_context {
|
||||
frontend.metrics.rejected.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let mut response = error(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"RustProxy: in-flight limit reached\n",
|
||||
);
|
||||
if let Some(page) = frontend_context
|
||||
.as_ref()
|
||||
.and_then(|f| f.pages.errors.get(&503))
|
||||
.or_else(|| snapshot.envelope.config.pages.errors.get(&503))
|
||||
{
|
||||
crate::pages::replace_error(&mut response, page);
|
||||
}
|
||||
if is_head {
|
||||
*response.body_mut() = Body::empty();
|
||||
}
|
||||
apply_response_rules(
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
false,
|
||||
);
|
||||
finish_headers(
|
||||
&mut response,
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
local,
|
||||
transport_tls,
|
||||
id,
|
||||
&state.active_listeners,
|
||||
);
|
||||
return response;
|
||||
};
|
||||
state.metrics.active.fetch_add(1, Ordering::Relaxed);
|
||||
if let Some(frontend) = &frontend_context {
|
||||
frontend.metrics.active.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
let host = request
|
||||
.headers()
|
||||
.get(HOST)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or_else(|| request.uri().authority().map(|v| v.as_str()).unwrap_or(""))
|
||||
.chars()
|
||||
.take(253)
|
||||
.collect();
|
||||
let initial_client_ip = request
|
||||
.extensions()
|
||||
.get::<PeerAddr>()
|
||||
.map(|peer| peer.0.ip().to_string());
|
||||
let user_agent = state.logs.capture_user_agent().then(|| {
|
||||
request
|
||||
.headers()
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.chars().take(512).collect::<String>())
|
||||
}).flatten();
|
||||
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),
|
||||
user_agent,
|
||||
country: Mutex::new(None),
|
||||
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(),
|
||||
traffic: state.traffic.clone(),
|
||||
_permit: permit,
|
||||
});
|
||||
if let Ok(value) = HeaderValue::from_str(&id.to_string()) {
|
||||
request.headers_mut().insert("x-request-id", value);
|
||||
}
|
||||
request.extensions_mut().insert(exchange.clone());
|
||||
let request_send_timeout = frontend_context
|
||||
.as_ref()
|
||||
.and_then(|frontend| frontend.proxy_timeouts.send_timeout())
|
||||
.unwrap_or(state.body_idle_timeout);
|
||||
let request = request.map(|body| {
|
||||
Body::new(MeteredBody::new(
|
||||
body,
|
||||
exchange.clone(),
|
||||
Direction::Request,
|
||||
request_send_timeout,
|
||||
))
|
||||
});
|
||||
let mut response = next.run(request).await;
|
||||
let minimal_welcome = response.extensions().get::<crate::pages::MinimalWelcome>().is_some();
|
||||
let code = response.status().as_u16();
|
||||
let custom_error = frontend_context
|
||||
.as_ref()
|
||||
.and_then(|f| f.pages.errors.get(&code))
|
||||
.or_else(|| snapshot.envelope.config.pages.errors.get(&code));
|
||||
if !minimal_welcome
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.is_none()
|
||||
{
|
||||
if let Some(page) = custom_error {
|
||||
crate::pages::replace_error(&mut response, page);
|
||||
}
|
||||
}
|
||||
if minimal_welcome {
|
||||
// Keep the built-in "RustProxy is running" response intentionally
|
||||
// minimal. It is never cacheable and carries no optional RustProxy
|
||||
// diagnostics/server/security/compression/cache-diagnostic headers.
|
||||
for name in [
|
||||
"server", "server-timing", "alt-svc", "x-rustproxy-request-id",
|
||||
"x-rustproxy-revision", "x-rust-proxy-backend",
|
||||
"x-rust-proxy-backend-time", "x-rustproxy-backend-time", "x-cache", "x-cache-hits",
|
||||
"x-cache-status", "x-cache-resident-age", "content-encoding", "vary",
|
||||
"strict-transport-security", "content-security-policy",
|
||||
"permissions-policy", "referrer-policy", "x-frame-options",
|
||||
"x-content-type-options",
|
||||
] {
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
} else {
|
||||
if response
|
||||
.extensions()
|
||||
.get::<ResponseRulesApplied>()
|
||||
.is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.is_none()
|
||||
{
|
||||
apply_response_rules(
|
||||
response.headers_mut(),
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
route_context.as_ref(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
apply_timing_headers(&mut response, frontend_context.as_deref(), &exchange);
|
||||
apply_backend_diagnostic_headers(&mut response, frontend_context.as_deref());
|
||||
finish_headers(
|
||||
&mut response,
|
||||
&snapshot,
|
||||
frontend_context.as_deref(),
|
||||
local,
|
||||
transport_tls,
|
||||
id,
|
||||
&state.active_listeners,
|
||||
);
|
||||
if response.extensions().get::<RedirectResponse>().is_some_and(|redirect| !redirect.http3_enabled) {
|
||||
response.headers_mut().remove("alt-svc");
|
||||
}
|
||||
if response.extensions().get::<LocalHostResponse>().is_some_and(|item| !item.http3_enabled) {
|
||||
response.headers_mut().remove("alt-svc");
|
||||
}
|
||||
response = crate::compression::encode(
|
||||
response,
|
||||
&original_headers,
|
||||
&method,
|
||||
&snapshot.envelope.config.compression,
|
||||
)
|
||||
.await;
|
||||
if let Some(encoding) = response
|
||||
.extensions()
|
||||
.get::<crate::compression::AppliedEncoding>()
|
||||
.copied()
|
||||
{
|
||||
state.metrics.record_compression(encoding);
|
||||
}
|
||||
if response
|
||||
.extensions()
|
||||
.get::<ResponseRulesApplied>()
|
||||
.is_none()
|
||||
&& response.extensions().get::<RedirectResponse>().is_none()
|
||||
&& response.extensions().get::<LocalHostResponse>().is_none()
|
||||
&& response.extensions().get::<crate::pages::PageServed>().is_none()
|
||||
&& response.extensions().get::<LocalGeneratedResponse>().is_none()
|
||||
&& response.extensions().get::<StaticSourceResponse>().is_none()
|
||||
&& response
|
||||
.extensions()
|
||||
.get::<crate::tinyauth::AuthenticationFailure>()
|
||||
.is_none()
|
||||
{
|
||||
let outcome = match exchange.cache_status.load(Ordering::Relaxed) {
|
||||
1 => "HIT",
|
||||
2 => "MISS",
|
||||
_ => "BYPASS",
|
||||
};
|
||||
let cache = frontend_context
|
||||
.as_ref()
|
||||
.map(|f| &f.cache)
|
||||
.unwrap_or(&snapshot.envelope.config.cache);
|
||||
let reason = *exchange
|
||||
.cache_reason
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
cache.annotate_reason(response.headers_mut(), outcome, 0, 0, reason);
|
||||
let transformed = response
|
||||
.extensions()
|
||||
.get::<crate::transform::Applied>()
|
||||
.is_some()
|
||||
|| response
|
||||
.extensions()
|
||||
.get::<crate::transform::CachedTransformed>()
|
||||
.is_some();
|
||||
cache.annotate_transformed(response.headers_mut(), transformed);
|
||||
}
|
||||
}
|
||||
if response.extensions().get::<crate::pages::PageServed>().is_some()
|
||||
|| response.extensions().get::<LocalGeneratedResponse>().is_some()
|
||||
{
|
||||
// Static/placeholder/default pages are generated locally and must never
|
||||
// look cacheable or carry RustProxy cache diagnostics/validators.
|
||||
enforce_local_page_no_cache(&mut response);
|
||||
}
|
||||
if response.extensions().get::<LocalHostResponse>().is_some() {
|
||||
// 404 hosts are fully local and never participate in RustProxy caching.
|
||||
// Keep their wire response free of cache policy, validators and cache diagnostics.
|
||||
for name in [
|
||||
"cache-control",
|
||||
"age",
|
||||
"expires",
|
||||
"pragma",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"cdn-cache-control",
|
||||
"surrogate-control",
|
||||
"x-cache",
|
||||
"x-cache-hits",
|
||||
"x-cache-status",
|
||||
"x-cache-resident-age",
|
||||
"x-cache-reason",
|
||||
"cache-status",
|
||||
] {
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
}
|
||||
exchange
|
||||
.status
|
||||
.store(response.status().as_u16(), Ordering::Relaxed);
|
||||
let ws_success = response.status() == StatusCode::SWITCHING_PROTOCOLS
|
||||
|| response.extensions().get::<websocket::H3Tunnel>().is_some();
|
||||
if ws_attempt && !ws_success {
|
||||
state
|
||||
.metrics
|
||||
.websocket_handshake_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if is_head {
|
||||
*response.body_mut() = Body::empty();
|
||||
}
|
||||
let response_read_timeout = frontend_context
|
||||
.as_ref()
|
||||
.and_then(|frontend| frontend.proxy_timeouts.read_timeout())
|
||||
.unwrap_or(state.body_idle_timeout);
|
||||
response.map(|body| {
|
||||
Body::new(MeteredBody::new(
|
||||
body,
|
||||
exchange,
|
||||
Direction::Response,
|
||||
response_read_timeout,
|
||||
))
|
||||
})
|
||||
}
|
||||
/// Build a same-host HTTPS redirect while preserving the request path and query.
|
||||
fn force_ssl_location(host: &str, uri: &Uri, port: u16) -> Option<HeaderValue> {
|
||||
let path = uri.path_and_query().map(|value| value.as_str()).unwrap_or("/");
|
||||
let location = if port == 443 {
|
||||
format!("https://{host}{path}")
|
||||
} else {
|
||||
format!("https://{host}:{port}{path}")
|
||||
};
|
||||
HeaderValue::from_str(&location).ok()
|
||||
}
|
||||
|
||||
fn force_ssl_redirect(
|
||||
frontend: &crate::config::CompiledFrontend,
|
||||
host: &str,
|
||||
uri: &Uri,
|
||||
) -> Option<Response> {
|
||||
if !frontend.force_ssl {
|
||||
return None;
|
||||
}
|
||||
let location = force_ssl_location(host, uri, frontend.https_redirect_port?)?;
|
||||
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
|
||||
response.headers_mut().insert(http::header::LOCATION, location);
|
||||
// Do not pin a redirect in a browser after an operator later disables Force SSL.
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-control", HeaderValue::from_static("no-store"));
|
||||
Some(response)
|
||||
}
|
||||
|
||||
/// HTTP/2 and HTTP/3 authority is a pseudo-header, not necessarily a Host field.
|
||||
/// HTTP/1 absolute-form remains forbidden: this is not a forward proxy.
|
||||
fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
|
||||
let count = request.headers().get_all(HOST).iter().count();
|
||||
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
|
||||
let authority = request.uri().authority().ok_or(())?;
|
||||
if request.uri().scheme_str() != Some(if tls { "https" } else { "http" }) || count > 1 {
|
||||
return Err(());
|
||||
}
|
||||
if let Some(host) = request.headers().get(HOST) {
|
||||
if !host
|
||||
.to_str()
|
||||
.map_err(|_| ())?
|
||||
.eq_ignore_ascii_case(authority.as_str())
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
HeaderValue::from_str(authority.as_str()).map_err(|_| ())
|
||||
} else {
|
||||
if request.uri().scheme().is_some() || request.uri().authority().is_some() || count != 1 {
|
||||
return Err(());
|
||||
}
|
||||
request.headers().get(HOST).cloned().ok_or(())
|
||||
}
|
||||
}
|
||||
+4
-1469
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,418 @@
|
||||
use super::body::Exchange;
|
||||
use crate::{
|
||||
runtime::App,
|
||||
server::{ConnectionLease, ServerShutdown},
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::{body::Body, extract::Request};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use bytes::{Buf, Bytes};
|
||||
use h3::error::Code;
|
||||
use http::{HeaderMap, Method, Version};
|
||||
use http_body::{Body as HttpBody, Frame, SizeHint};
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::upgrade::{OnUpgrade, Upgraded};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use sha1::{Digest, Sha1};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
future::Future,
|
||||
io,
|
||||
pin::Pin,
|
||||
sync::{
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
Arc, Mutex, Weak,
|
||||
},
|
||||
task::{Context, Poll},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
fn has_token(headers: &HeaderMap, name: &str, token: &str) -> bool {
|
||||
headers
|
||||
.get_all(name)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(','))
|
||||
.any(|v| v.trim().eq_ignore_ascii_case(token))
|
||||
}
|
||||
fn single<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str> {
|
||||
ensure!(
|
||||
headers.get_all(name).iter().count() == 1,
|
||||
"WebSocket header must occur once"
|
||||
);
|
||||
Ok(headers
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing WebSocket header"))?
|
||||
.to_str()?)
|
||||
}
|
||||
pub struct Handshake {
|
||||
pub accept: String,
|
||||
upstream_key: Option<String>,
|
||||
protocols: Vec<String>,
|
||||
bootstrap: Bootstrap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Bootstrap {
|
||||
Http1,
|
||||
Http2,
|
||||
Http3,
|
||||
}
|
||||
|
||||
impl Handshake {
|
||||
pub fn h1(&self) -> bool {
|
||||
self.bootstrap == Bootstrap::Http1
|
||||
}
|
||||
pub fn h2(&self) -> bool {
|
||||
self.bootstrap == Bootstrap::Http2
|
||||
}
|
||||
pub fn h3(&self) -> bool {
|
||||
self.bootstrap == Bootstrap::Http3
|
||||
}
|
||||
|
||||
pub fn upstream_key(&self) -> Option<&str> {
|
||||
self.upstream_key.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
fn protocols(headers: &HeaderMap) -> Result<Vec<String>> {
|
||||
let protocols = headers
|
||||
.get_all("sec-websocket-protocol")
|
||||
.iter()
|
||||
.map(|v| v.to_str())
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flat_map(|v| v.split(','))
|
||||
.map(|v| v.trim().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
ensure!(
|
||||
protocols.len() <= 32 && protocols.iter().all(|v| !v.is_empty()),
|
||||
"invalid WebSocket protocols"
|
||||
);
|
||||
Ok(protocols)
|
||||
}
|
||||
|
||||
fn accept_for_key(key: &str) -> String {
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(key.as_bytes());
|
||||
hasher.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
||||
STANDARD.encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn validate(request: &Request) -> Result<Handshake> {
|
||||
ensure!(
|
||||
request.method() == Method::GET && request.version() == Version::HTTP_11,
|
||||
"WebSocket requires HTTP/1.1 GET"
|
||||
);
|
||||
ensure!(
|
||||
single(request.headers(), "upgrade")?.eq_ignore_ascii_case("websocket")
|
||||
&& has_token(request.headers(), "connection", "upgrade"),
|
||||
"invalid WebSocket upgrade"
|
||||
);
|
||||
ensure!(
|
||||
single(request.headers(), "sec-websocket-version")? == "13",
|
||||
"WebSocket version must be 13"
|
||||
);
|
||||
let key = single(request.headers(), "sec-websocket-key")?;
|
||||
ensure!(
|
||||
key.len() == 24 && STANDARD.decode(key)?.len() == 16,
|
||||
"invalid WebSocket key"
|
||||
);
|
||||
ensure!(
|
||||
!request.headers().contains_key("transfer-encoding"),
|
||||
"WebSocket handshake cannot contain a transfer body"
|
||||
);
|
||||
ensure!(
|
||||
request.headers().get_all("content-length").iter().count() <= 1
|
||||
&& request
|
||||
.headers()
|
||||
.get("content-length")
|
||||
.is_none_or(|v| v == "0"),
|
||||
"WebSocket handshake cannot contain a body"
|
||||
);
|
||||
for value in request.headers().get_all("connection").iter() {
|
||||
for field in value.to_str()?.split(',') {
|
||||
ensure!(
|
||||
!field
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("sec-websocket-"),
|
||||
"Connection must not nominate WebSocket handshake fields"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Handshake {
|
||||
accept: accept_for_key(key),
|
||||
upstream_key: None,
|
||||
protocols: protocols(request.headers())?,
|
||||
bootstrap: Bootstrap::Http1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the RFC 8441 bootstrap used by RFC 9220 on HTTP/3. The
|
||||
/// Sec-WebSocket-Key/Accept proof belongs only to the HTTP/1.1 hop, so a fresh
|
||||
/// key is synthesized later for the backend Upgrade request.
|
||||
pub fn validate_h3(request: &Request) -> Result<Handshake> {
|
||||
ensure!(
|
||||
request.method() == Method::CONNECT && request.version() == Version::HTTP_3,
|
||||
"HTTP/3 WebSocket requires Extended CONNECT"
|
||||
);
|
||||
ensure!(
|
||||
request
|
||||
.extensions()
|
||||
.get::<h3::ext::Protocol>()
|
||||
.is_some_and(|protocol| protocol.as_str() == "websocket"),
|
||||
"Extended CONNECT protocol is not websocket"
|
||||
);
|
||||
for name in [
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-accept",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
] {
|
||||
ensure!(
|
||||
!request.headers().contains_key(name),
|
||||
"HTTP/1.1-only WebSocket field on HTTP/3"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
single(request.headers(), "sec-websocket-version")? == "13",
|
||||
"WebSocket version must be 13"
|
||||
);
|
||||
let mut nonce = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = STANDARD.encode(nonce);
|
||||
Ok(Handshake {
|
||||
accept: accept_for_key(&key),
|
||||
upstream_key: Some(key),
|
||||
protocols: protocols(request.headers())?,
|
||||
bootstrap: Bootstrap::Http3,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate RFC 8441 Extended CONNECT on HTTP/2. The downstream hop does not
|
||||
/// use Sec-WebSocket-Key/Accept; a fresh RFC 6455 key is generated only when
|
||||
/// RustProxy has to bridge the stream to an HTTP/1.1 backend.
|
||||
pub fn validate_h2(request: &Request) -> Result<Handshake> {
|
||||
ensure!(
|
||||
request.method() == Method::CONNECT && request.version() == Version::HTTP_2,
|
||||
"HTTP/2 WebSocket requires Extended CONNECT"
|
||||
);
|
||||
ensure!(
|
||||
request
|
||||
.extensions()
|
||||
.get::<hyper::ext::Protocol>()
|
||||
.is_some_and(|protocol| protocol.as_str() == "websocket"),
|
||||
"Extended CONNECT protocol is not websocket"
|
||||
);
|
||||
for name in [
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-accept",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
] {
|
||||
ensure!(
|
||||
!request.headers().contains_key(name),
|
||||
"HTTP/1.1-only WebSocket field on HTTP/2"
|
||||
);
|
||||
}
|
||||
ensure!(
|
||||
single(request.headers(), "sec-websocket-version")? == "13",
|
||||
"WebSocket version must be 13"
|
||||
);
|
||||
let mut nonce = [0u8; 16];
|
||||
OsRng.fill_bytes(&mut nonce);
|
||||
let key = STANDARD.encode(nonce);
|
||||
Ok(Handshake {
|
||||
accept: accept_for_key(&key),
|
||||
upstream_key: Some(key),
|
||||
protocols: protocols(request.headers())?,
|
||||
bootstrap: Bootstrap::Http2,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn h2_protocol(request: &Request) -> Option<&str> {
|
||||
request
|
||||
.extensions()
|
||||
.get::<hyper::ext::Protocol>()
|
||||
.map(|protocol| protocol.as_str())
|
||||
}
|
||||
|
||||
pub fn h3_protocol(request: &Request) -> Option<&str> {
|
||||
request
|
||||
.extensions()
|
||||
.get::<h3::ext::Protocol>()
|
||||
.map(|protocol| protocol.as_str())
|
||||
}
|
||||
pub fn validate_response(
|
||||
headers: &HeaderMap,
|
||||
expected: &Handshake,
|
||||
legacy_support: bool,
|
||||
) -> Result<()> {
|
||||
if legacy_support {
|
||||
// Some older embedded servers return a valid 101 + Sec-WebSocket-Accept
|
||||
// while omitting Connection: Upgrade, duplicating Upgrade, or attaching
|
||||
// zero/meaningless HTTP body-framing headers. The connection is dedicated
|
||||
// to the upgraded tunnel and those framing headers are stripped before the
|
||||
// downstream response, so keep the cryptographic accept proof strict while
|
||||
// tolerating the legacy response syntax.
|
||||
ensure!(
|
||||
has_token(headers, "upgrade", "websocket"),
|
||||
"invalid legacy upstream upgrade"
|
||||
);
|
||||
if headers.contains_key("content-length") {
|
||||
for value in headers.get_all("content-length").iter() {
|
||||
ensure!(
|
||||
value.to_str()?.trim() == "0",
|
||||
"legacy 101 response declared a non-zero body"
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ensure!(
|
||||
single(headers, "upgrade")?.eq_ignore_ascii_case("websocket")
|
||||
&& has_token(headers, "connection", "upgrade"),
|
||||
"invalid upstream upgrade"
|
||||
);
|
||||
ensure!(
|
||||
!headers.contains_key("transfer-encoding")
|
||||
&& !headers.contains_key("content-length"),
|
||||
"101 response must not include body framing"
|
||||
);
|
||||
for value in headers.get_all("connection").iter() {
|
||||
for field in value.to_str()?.split(',') {
|
||||
ensure!(
|
||||
!field
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.starts_with("sec-websocket-"),
|
||||
"upstream nominated a protected handshake field"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
single(headers, "sec-websocket-accept")? == expected.accept,
|
||||
"upstream WebSocket accept mismatch"
|
||||
);
|
||||
if headers.contains_key("sec-websocket-protocol") {
|
||||
let selected = single(headers, "sec-websocket-protocol")?;
|
||||
ensure!(
|
||||
expected.protocols.iter().any(|v| v == selected),
|
||||
"upstream chose an unoffered subprotocol"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_h2_response(headers: &HeaderMap, expected: &Handshake) -> Result<()> {
|
||||
for name in [
|
||||
"connection",
|
||||
"upgrade",
|
||||
"sec-websocket-key",
|
||||
"sec-websocket-accept",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
] {
|
||||
ensure!(
|
||||
!headers.contains_key(name),
|
||||
"HTTP/1.1-only WebSocket field on HTTP/2 response"
|
||||
);
|
||||
}
|
||||
if headers.contains_key("sec-websocket-protocol") {
|
||||
let selected = single(headers, "sec-websocket-protocol")?;
|
||||
ensure!(
|
||||
expected.protocols.iter().any(|value| value == selected),
|
||||
"upstream chose an unoffered subprotocol"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
struct MeteredIo<T> {
|
||||
inner: T,
|
||||
exchange: Arc<Exchange>,
|
||||
session: Arc<TunnelSession>,
|
||||
client_to_backend: bool,
|
||||
started: Instant,
|
||||
activity: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
fn record_bytes(
|
||||
exchange: &Exchange,
|
||||
bytes: u64,
|
||||
client_to_backend: bool,
|
||||
activity: &AtomicU64,
|
||||
started: Instant,
|
||||
) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
exchange.backend_bytes(bytes, client_to_backend);
|
||||
activity.store(started.elapsed().as_millis() as u64, Ordering::Relaxed);
|
||||
if client_to_backend {
|
||||
exchange
|
||||
.metrics
|
||||
.websocket_bytes_in
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
exchange.request_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
} else {
|
||||
exchange
|
||||
.metrics
|
||||
.websocket_bytes_out
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
exchange.response_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncRead + Unpin> AsyncRead for MeteredIo<T> {
|
||||
fn poll_read(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
let before = buf.filled().len();
|
||||
let result = Pin::new(&mut this.inner).poll_read(cx, buf);
|
||||
if let Poll::Ready(Ok(())) = &result {
|
||||
let bytes = (buf.filled().len() - before) as u64;
|
||||
record_bytes(
|
||||
&this.exchange,
|
||||
bytes,
|
||||
this.client_to_backend,
|
||||
&this.activity,
|
||||
this.started,
|
||||
);
|
||||
this.session.record(bytes, this.client_to_backend);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
impl<T: AsyncWrite + Unpin> AsyncWrite for MeteredIo<T> {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_write(cx, bytes)
|
||||
}
|
||||
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_flush(cx)
|
||||
}
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Pin::new(&mut self.get_mut().inner).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum TunnelTransport {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
fn request() -> Request {
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.version(Version::HTTP_11)
|
||||
.header("upgrade", "websocket")
|
||||
.header("connection", "Upgrade")
|
||||
.header("sec-websocket-version", "13")
|
||||
.header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
fn h3_request() -> Request {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_3)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("websocket".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static websocket H3 protocol token must parse")));
|
||||
request
|
||||
}
|
||||
fn h2_request() -> Request {
|
||||
let mut request = Request::builder()
|
||||
.method(Method::CONNECT)
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://example.test/socket")
|
||||
.header("sec-websocket-version", "13")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(hyper::ext::Protocol::from_static("websocket"));
|
||||
request
|
||||
}
|
||||
#[test]
|
||||
fn rfc_handshake_vector() {
|
||||
let handshake = validate(&request()).unwrap();
|
||||
assert_eq!(handshake.accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
|
||||
}
|
||||
#[test]
|
||||
fn rfc9220_bootstrap_uses_extended_connect_without_h1_proof_headers() {
|
||||
let request = h3_request();
|
||||
let handshake = validate_h3(&request).unwrap();
|
||||
assert!(handshake.h3());
|
||||
let key = handshake.upstream_key().expect("backend nonce");
|
||||
assert_eq!(STANDARD.decode(key).unwrap().len(), 16);
|
||||
assert!(!request.headers().contains_key("connection"));
|
||||
assert!(!request.headers().contains_key("upgrade"));
|
||||
assert!(!request.headers().contains_key("sec-websocket-key"));
|
||||
assert!(!request.headers().contains_key("sec-websocket-accept"));
|
||||
|
||||
let mut backend = HeaderMap::new();
|
||||
backend.insert("connection", "Upgrade".parse().unwrap());
|
||||
backend.insert("upgrade", "websocket".parse().unwrap());
|
||||
backend.insert("sec-websocket-accept", accept_for_key(key).parse().unwrap());
|
||||
assert!(validate_response(&backend, &handshake, false).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn rfc8441_h2_bootstrap_uses_extended_connect_without_h1_proof_headers() {
|
||||
let request = h2_request();
|
||||
let handshake = validate_h2(&request).unwrap();
|
||||
assert!(handshake.h2());
|
||||
assert!(!handshake.h1());
|
||||
let key = handshake.upstream_key().expect("HTTP/1 bridge nonce");
|
||||
assert_eq!(STANDARD.decode(key).unwrap().len(), 16);
|
||||
assert!(!request.headers().contains_key("connection"));
|
||||
assert!(!request.headers().contains_key("upgrade"));
|
||||
assert!(!request.headers().contains_key("sec-websocket-key"));
|
||||
|
||||
let mut h2_response = HeaderMap::new();
|
||||
h2_response.insert("sec-websocket-protocol", "chat".parse().unwrap());
|
||||
assert!(validate_h2_response(&h2_response, &handshake).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rfc8441_h2_response_rejects_h1_framing_fields() {
|
||||
let handshake = validate_h2(&h2_request()).unwrap();
|
||||
for (name, value) in [("connection", "close"), ("upgrade", "websocket"), ("content-length", "0")] {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
value.parse().unwrap(),
|
||||
);
|
||||
assert!(validate_h2_response(&headers, &handshake).is_err(), "accepted forbidden response {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunnel_transport_metrics_classify_frontend_protocol() {
|
||||
assert!(matches!(TunnelTransport::from_protocol("h1-upgrade"), TunnelTransport::H1));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h1-upstream"), TunnelTransport::H2));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h2-rfc8441-h2-upstream"), TunnelTransport::H2));
|
||||
assert!(matches!(TunnelTransport::from_protocol("h3-rfc9220"), TunnelTransport::H3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_h2_rejects_h1_only_fields_and_wrong_protocol() {
|
||||
for (name, value) in [
|
||||
("connection", "Upgrade"),
|
||||
("upgrade", "websocket"),
|
||||
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
("sec-websocket-accept", "ignored"),
|
||||
("content-length", "0"),
|
||||
("transfer-encoding", "chunked"),
|
||||
] {
|
||||
let mut request = h2_request();
|
||||
request.headers_mut().insert(
|
||||
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
value.parse().unwrap(),
|
||||
);
|
||||
assert!(validate_h2(&request).is_err(), "accepted forbidden {name}");
|
||||
}
|
||||
let mut request = h2_request();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert(hyper::ext::Protocol::from_static("connect-udp"));
|
||||
assert!(validate_h2(&request).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn rfc9220_rejects_h1_only_fields_and_non_websocket_protocols() {
|
||||
for (name, value) in [
|
||||
("connection", "Upgrade"),
|
||||
("upgrade", "websocket"),
|
||||
("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
("sec-websocket-accept", "ignored"),
|
||||
("content-length", "0"),
|
||||
("transfer-encoding", "chunked"),
|
||||
] {
|
||||
let mut request = h3_request();
|
||||
request.headers_mut().insert(
|
||||
http::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
value.parse().unwrap(),
|
||||
);
|
||||
assert!(validate_h3(&request).is_err(), "accepted forbidden {name}");
|
||||
}
|
||||
let mut request = h3_request();
|
||||
request
|
||||
.extensions_mut()
|
||||
.insert("connect-udp".parse::<h3::ext::Protocol>().unwrap_or_else(|_| panic!("static connect-udp H3 protocol token must parse")));
|
||||
assert!(validate_h3(&request).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_or_body_bearing_upgrade_fails() {
|
||||
let mut req = request();
|
||||
req.headers_mut()
|
||||
.insert("content-length", "1".parse().unwrap());
|
||||
assert!(validate(&req).is_err());
|
||||
let mut req = request();
|
||||
req.headers_mut()
|
||||
.insert("sec-websocket-key", "invalid".parse().unwrap());
|
||||
assert!(validate(&req).is_err());
|
||||
let mut req = request();
|
||||
*req.version_mut() = Version::HTTP_2;
|
||||
assert!(validate(&req).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_upstream_response_accepts_old_101_syntax_but_keeps_accept_proof_strict() {
|
||||
let expected = validate(&request()).unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append("upgrade", "websocket".parse().unwrap());
|
||||
headers.append("upgrade", "WebSocket".parse().unwrap());
|
||||
headers.insert("content-length", "0".parse().unwrap());
|
||||
headers.insert("transfer-encoding", "chunked".parse().unwrap());
|
||||
headers.insert("sec-websocket-accept", expected.accept.parse().unwrap());
|
||||
assert!(validate_response(&headers, &expected, false).is_err());
|
||||
assert!(validate_response(&headers, &expected, true).is_ok());
|
||||
|
||||
headers.insert("content-length", "1".parse().unwrap());
|
||||
assert!(validate_response(&headers, &expected, true).is_err());
|
||||
headers.insert("content-length", "0".parse().unwrap());
|
||||
headers.insert("sec-websocket-accept", "wrong".parse().unwrap());
|
||||
assert!(validate_response(&headers, &expected, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_tcp_close_is_not_a_websocket_proxy_error() {
|
||||
for kind in [
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
io::ErrorKind::ConnectionReset,
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
io::ErrorKind::BrokenPipe,
|
||||
io::ErrorKind::NotConnected,
|
||||
] {
|
||||
assert!(benign_tunnel_close(&io::Error::new(kind, "closed")));
|
||||
}
|
||||
assert!(!benign_tunnel_close(&io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"idle timeout"
|
||||
)));
|
||||
}
|
||||
#[test]
|
||||
fn upstream_cannot_select_unoffered_protocol() {
|
||||
let expected = validate(&request()).unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("connection", "Upgrade".parse().unwrap());
|
||||
headers.insert("upgrade", "websocket".parse().unwrap());
|
||||
headers.insert("sec-websocket-accept", expected.accept.parse().unwrap());
|
||||
assert!(validate_response(&headers, &expected, false).is_ok());
|
||||
headers.insert("sec-websocket-protocol", "unoffered".parse().unwrap());
|
||||
assert!(validate_response(&headers, &expected, false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
impl TunnelTransport {
|
||||
fn from_protocol(protocol: &str) -> Self {
|
||||
if protocol.starts_with("h2-") {
|
||||
Self::H2
|
||||
} else if protocol.starts_with("h3-") {
|
||||
Self::H3
|
||||
} else {
|
||||
Self::H1
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(self, metrics: &crate::metrics::Metrics) {
|
||||
match self {
|
||||
Self::H1 => {
|
||||
metrics.websocket_h1_total.fetch_add(1, Ordering::Relaxed);
|
||||
metrics.websocket_h1_active.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Self::H2 => {
|
||||
metrics.websocket_h2_total.fetch_add(1, Ordering::Relaxed);
|
||||
metrics.websocket_h2_active.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Self::H3 => {
|
||||
metrics.websocket_h3_total.fetch_add(1, Ordering::Relaxed);
|
||||
metrics.websocket_h3_active.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_active(self, metrics: &crate::metrics::Metrics) {
|
||||
match self {
|
||||
Self::H1 => {
|
||||
metrics.websocket_h1_active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
Self::H2 => {
|
||||
metrics.websocket_h2_active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
Self::H3 => {
|
||||
metrics.websocket_h3_active.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveTunnel {
|
||||
exchange: Arc<Exchange>,
|
||||
started: Instant,
|
||||
transport: TunnelTransport,
|
||||
established: AtomicBool,
|
||||
}
|
||||
impl ActiveTunnel {
|
||||
fn establish(&self) {
|
||||
if self
|
||||
.established
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
{
|
||||
self.exchange.websocket_phase("tunnel_started");
|
||||
self.exchange
|
||||
.metrics
|
||||
.websocket_total
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.exchange
|
||||
.metrics
|
||||
.websocket_active
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
self.transport.increment(&self.exchange.metrics);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_established(&self) -> bool {
|
||||
self.established.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
impl Drop for ActiveTunnel {
|
||||
fn drop(&mut self) {
|
||||
if !self.is_established() {
|
||||
return;
|
||||
}
|
||||
self.exchange
|
||||
.metrics
|
||||
.websocket_active
|
||||
.fetch_sub(1, Ordering::Relaxed);
|
||||
self.transport.decrement_active(&self.exchange.metrics);
|
||||
self.exchange
|
||||
.metrics
|
||||
.websocket_duration_us
|
||||
.fetch_add(self.started.elapsed().as_micros() as u64, Ordering::Relaxed);
|
||||
self.exchange
|
||||
.metrics
|
||||
.websocket_closed
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TunnelInfo {
|
||||
pub id: u64,
|
||||
pub client: String,
|
||||
pub host: String,
|
||||
pub upstream: String,
|
||||
pub protocol: String,
|
||||
pub duration_ms: u64,
|
||||
pub rx_bytes: u64,
|
||||
pub tx_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TunnelRegistry {
|
||||
entries: Mutex<BTreeMap<u64, Weak<TunnelSession>>>,
|
||||
}
|
||||
|
||||
pub struct TunnelSession {
|
||||
registry: Weak<TunnelRegistry>,
|
||||
id: u64,
|
||||
client: String,
|
||||
host: String,
|
||||
upstream: String,
|
||||
protocol: String,
|
||||
started: Instant,
|
||||
rx_bytes: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
activity: AtomicU64,
|
||||
cancel: CancellationToken,
|
||||
_active: ActiveTunnel,
|
||||
_lease: Option<Arc<ConnectionLease>>,
|
||||
}
|
||||
|
||||
impl TunnelRegistry {
|
||||
fn register_inner(
|
||||
self: &Arc<Self>,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream: String,
|
||||
protocol: &str,
|
||||
stop: CancellationToken,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
established: bool,
|
||||
) -> Arc<TunnelSession> {
|
||||
let started = Instant::now();
|
||||
let transport = TunnelTransport::from_protocol(protocol);
|
||||
let client = {
|
||||
exchange
|
||||
.client_ip
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.unwrap_or_else(|| "-".into())
|
||||
};
|
||||
let session = Arc::new(TunnelSession {
|
||||
registry: Arc::downgrade(self),
|
||||
id: exchange.id,
|
||||
client,
|
||||
host: exchange.host.clone(),
|
||||
upstream,
|
||||
protocol: protocol.to_owned(),
|
||||
started,
|
||||
rx_bytes: AtomicU64::new(0),
|
||||
tx_bytes: AtomicU64::new(0),
|
||||
activity: AtomicU64::new(0),
|
||||
cancel: stop.child_token(),
|
||||
_active: ActiveTunnel {
|
||||
exchange,
|
||||
started,
|
||||
transport,
|
||||
established: AtomicBool::new(false),
|
||||
},
|
||||
_lease: lease,
|
||||
});
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.insert(session.id, Arc::downgrade(&session));
|
||||
if established {
|
||||
session.establish();
|
||||
}
|
||||
session
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
self: &Arc<Self>,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream: String,
|
||||
protocol: &str,
|
||||
stop: CancellationToken,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
) -> Arc<TunnelSession> {
|
||||
self.register_inner(exchange, upstream, protocol, stop, lease, true)
|
||||
}
|
||||
|
||||
pub fn register_pending(
|
||||
self: &Arc<Self>,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream: String,
|
||||
protocol: &str,
|
||||
stop: CancellationToken,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
) -> Arc<TunnelSession> {
|
||||
self.register_inner(exchange, upstream, protocol, stop, lease, false)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<TunnelInfo> {
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut result = Vec::with_capacity(entries.len());
|
||||
entries.retain(|_, weak| {
|
||||
if let Some(session) = weak.upgrade() {
|
||||
if session._active.is_established() {
|
||||
result.push(session.info());
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
pub fn kill(&self, id: u64) -> Option<TunnelInfo> {
|
||||
let session = self
|
||||
.entries
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get(&id)
|
||||
.and_then(Weak::upgrade)?;
|
||||
let info = session.info();
|
||||
session.cancel.cancel();
|
||||
Some(info)
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelSession {
|
||||
pub fn establish(&self) {
|
||||
self._active.establish();
|
||||
}
|
||||
|
||||
fn info(&self) -> TunnelInfo {
|
||||
TunnelInfo {
|
||||
id: self.id,
|
||||
client: self.client.clone(),
|
||||
host: self.host.clone(),
|
||||
upstream: self.upstream.clone(),
|
||||
protocol: self.protocol.clone(),
|
||||
duration_ms: self.started.elapsed().as_millis().min(u64::MAX as u128) as u64,
|
||||
rx_bytes: self.rx_bytes.load(Ordering::Relaxed),
|
||||
tx_bytes: self.tx_bytes.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn record(&self, bytes: u64, client_to_backend: bool) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
if client_to_backend {
|
||||
self.rx_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
} else {
|
||||
self.tx_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
self.activity.store(
|
||||
self.started.elapsed().as_millis().min(u64::MAX as u128) as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn cancellation(&self) -> CancellationToken {
|
||||
self.cancel.clone()
|
||||
}
|
||||
|
||||
fn inactive_for(&self) -> Duration {
|
||||
self.started
|
||||
.elapsed()
|
||||
.saturating_sub(Duration::from_millis(self.activity.load(Ordering::Relaxed)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TunnelSession {
|
||||
fn drop(&mut self) {
|
||||
if let Some(registry) = self.registry.upgrade() {
|
||||
let mut entries = registry.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if entries
|
||||
.get(&self.id)
|
||||
.and_then(Weak::upgrade)
|
||||
.is_none_or(|session| std::ptr::eq(session.as_ref(), self))
|
||||
{
|
||||
entries.remove(&self.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_session_bytes(
|
||||
exchange: &Exchange,
|
||||
session: &TunnelSession,
|
||||
bytes: u64,
|
||||
client_to_backend: bool,
|
||||
) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
exchange.backend_bytes(bytes, client_to_backend);
|
||||
if client_to_backend {
|
||||
exchange
|
||||
.metrics
|
||||
.websocket_bytes_in
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
exchange.request_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
} else {
|
||||
exchange
|
||||
.metrics
|
||||
.websocket_bytes_out
|
||||
.fetch_add(bytes, Ordering::Relaxed);
|
||||
exchange.response_bytes.fetch_add(bytes, Ordering::Relaxed);
|
||||
}
|
||||
session.record(bytes, client_to_backend);
|
||||
}
|
||||
|
||||
struct H2MeteredBody<B> {
|
||||
inner: Pin<Box<B>>,
|
||||
session: Arc<TunnelSession>,
|
||||
exchange: Arc<Exchange>,
|
||||
client_to_backend: bool,
|
||||
cancelled: Pin<Box<dyn Future<Output = ()> + Send>>,
|
||||
}
|
||||
|
||||
impl<B> H2MeteredBody<B>
|
||||
where
|
||||
B: HttpBody<Data = Bytes> + Send + 'static,
|
||||
{
|
||||
fn new(
|
||||
inner: B,
|
||||
session: Arc<TunnelSession>,
|
||||
exchange: Arc<Exchange>,
|
||||
client_to_backend: bool,
|
||||
) -> Self {
|
||||
let cancelled = session.cancellation().cancelled_owned();
|
||||
Self {
|
||||
inner: Box::pin(inner),
|
||||
session,
|
||||
exchange,
|
||||
client_to_backend,
|
||||
cancelled: Box::pin(cancelled),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<B> HttpBody for H2MeteredBody<B>
|
||||
where
|
||||
B: HttpBody<Data = Bytes> + Send + 'static,
|
||||
{
|
||||
type Data = Bytes;
|
||||
type Error = B::Error;
|
||||
|
||||
fn poll_frame(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
|
||||
if self.cancelled.as_mut().poll(cx).is_ready() {
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
match self.inner.as_mut().poll_frame(cx) {
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
if let Some(data) = frame.data_ref() {
|
||||
record_session_bytes(
|
||||
&self.exchange,
|
||||
&self.session,
|
||||
data.len() as u64,
|
||||
self.client_to_backend,
|
||||
);
|
||||
}
|
||||
Poll::Ready(Some(Ok(frame)))
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_end_stream(&self) -> bool {
|
||||
self.inner.as_ref().get_ref().is_end_stream()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> SizeHint {
|
||||
self.inner.as_ref().get_ref().size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wrap_h2_request_body(
|
||||
body: Body,
|
||||
session: Arc<TunnelSession>,
|
||||
exchange: Arc<Exchange>,
|
||||
) -> Body {
|
||||
Body::new(H2MeteredBody::new(body, session, exchange, true))
|
||||
}
|
||||
|
||||
pub fn wrap_h2_response_body(
|
||||
body: hyper::body::Incoming,
|
||||
session: Arc<TunnelSession>,
|
||||
exchange: Arc<Exchange>,
|
||||
) -> Body {
|
||||
Body::new(H2MeteredBody::new(body, session, exchange, false))
|
||||
}
|
||||
|
||||
pub fn spawn_h2_idle_watch(state: Arc<App>, session: &Arc<TunnelSession>) {
|
||||
let weak = Arc::downgrade(session);
|
||||
let idle = state.websocket_idle_timeout;
|
||||
let tracker = state.tunnels.clone();
|
||||
tracker.spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(idle).await;
|
||||
let Some(session) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
if session.inactive_for() >= idle {
|
||||
session._active.exchange.failed.store(true, Ordering::Relaxed);
|
||||
session
|
||||
._active
|
||||
.exchange
|
||||
.websocket_error("tunnel_error", "idle_timeout");
|
||||
session
|
||||
._active
|
||||
.exchange
|
||||
.metrics
|
||||
.websocket_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
session.cancel.cancel();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/// Bridge an HTTP/2 Extended CONNECT stream to an already upgraded HTTP/1.1
|
||||
/// backend WebSocket. DATA bytes remain byte-transparent in both directions.
|
||||
pub fn bridge_h2_to_h1(
|
||||
state: Arc<App>,
|
||||
mut downstream: Body,
|
||||
upstream: Upgraded,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream_label: String,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
) -> Body {
|
||||
let stop = stop.map(|value| value.0).unwrap_or_else(|| state.shutdown.clone());
|
||||
let session = state.tunnel_registry.register(
|
||||
exchange.clone(),
|
||||
upstream_label,
|
||||
"h2-rfc8441-h1-upstream",
|
||||
stop,
|
||||
lease,
|
||||
);
|
||||
spawn_h2_idle_watch(state.clone(), &session);
|
||||
let upstream = TokioIo::new(upstream);
|
||||
let (upstream_read, mut upstream_write) = tokio::io::split(upstream);
|
||||
|
||||
let request_session = session.clone();
|
||||
let request_exchange = exchange.clone();
|
||||
let tracker = state.tunnels.clone();
|
||||
tracker.spawn(async move {
|
||||
let cancel = request_session.cancellation();
|
||||
loop {
|
||||
let frame = tokio::select! {
|
||||
_ = cancel.cancelled() => return,
|
||||
frame = downstream.frame() => frame,
|
||||
};
|
||||
match frame {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
if !data.is_empty() {
|
||||
if upstream_write.write_all(&data).await.is_err() {
|
||||
return;
|
||||
}
|
||||
record_session_bytes(
|
||||
&request_exchange,
|
||||
&request_session,
|
||||
data.len() as u64,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(_)) => return,
|
||||
None => {
|
||||
let _ = upstream_write.shutdown().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = futures_util::stream::unfold(
|
||||
(upstream_read, session, exchange),
|
||||
|(mut upstream_read, session, exchange)| async move {
|
||||
let mut buffer = vec![0u8; 16 * 1024];
|
||||
let cancel = session.cancellation();
|
||||
let read = tokio::select! {
|
||||
_ = cancel.cancelled() => return None,
|
||||
read = upstream_read.read(&mut buffer) => read,
|
||||
};
|
||||
match read {
|
||||
Ok(0) => None,
|
||||
Ok(read) => {
|
||||
buffer.truncate(read);
|
||||
record_session_bytes(&exchange, &session, read as u64, false);
|
||||
Some((Ok::<Bytes, io::Error>(Bytes::from(buffer)), (upstream_read, session, exchange)))
|
||||
}
|
||||
Err(error) => Some((Err(error), (upstream_read, session, exchange))),
|
||||
}
|
||||
},
|
||||
);
|
||||
Body::from_stream(stream)
|
||||
}
|
||||
fn benign_tunnel_close(error: &io::Error) -> bool {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::UnexpectedEof
|
||||
| io::ErrorKind::ConnectionReset
|
||||
| io::ErrorKind::ConnectionAborted
|
||||
| io::ErrorKind::BrokenPipe
|
||||
| io::ErrorKind::NotConnected
|
||||
)
|
||||
}
|
||||
|
||||
struct H3TunnelState {
|
||||
upstream: Upgraded,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream_label: String,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
}
|
||||
|
||||
/// Response extension consumed by the HTTP/3 transport after it has sent the
|
||||
/// successful 200 response. The upstream connection is already upgraded before
|
||||
/// this extension is created, so downstream success never races backend takeover.
|
||||
#[derive(Clone)]
|
||||
pub struct H3Tunnel(Arc<Mutex<Option<H3TunnelState>>>);
|
||||
|
||||
impl H3Tunnel {
|
||||
pub fn new(
|
||||
upstream: Upgraded,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream_label: String,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
) -> Self {
|
||||
Self(Arc::new(Mutex::new(Some(H3TunnelState {
|
||||
upstream,
|
||||
exchange,
|
||||
upstream_label,
|
||||
lease,
|
||||
stop,
|
||||
}))))
|
||||
}
|
||||
|
||||
fn take(&self) -> Option<H3TunnelState> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
}
|
||||
|
||||
fn h3_io_error(error: impl std::fmt::Display) -> io::Error {
|
||||
io::Error::other(error.to_string())
|
||||
}
|
||||
|
||||
async fn h3_client_to_backend<W>(
|
||||
receive: &mut h3::server::RequestStream<h3::quinn::RecvStream, Bytes>,
|
||||
upstream: &mut W,
|
||||
exchange: &Exchange,
|
||||
activity: &AtomicU64,
|
||||
started: Instant,
|
||||
session: &TunnelSession,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
W: AsyncWrite + Unpin,
|
||||
{
|
||||
while let Some(mut data) = receive.recv_data().await.map_err(h3_io_error)? {
|
||||
let bytes = data.copy_to_bytes(data.remaining());
|
||||
if !bytes.is_empty() {
|
||||
upstream.write_all(&bytes).await?;
|
||||
record_bytes(exchange, bytes.len() as u64, true, activity, started);
|
||||
session.record(bytes.len() as u64, true);
|
||||
}
|
||||
}
|
||||
if receive
|
||||
.recv_trailers()
|
||||
.await
|
||||
.map_err(h3_io_error)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"WebSocket Extended CONNECT must not end with HTTP trailers",
|
||||
));
|
||||
}
|
||||
upstream.shutdown().await
|
||||
}
|
||||
|
||||
async fn backend_to_h3<R>(
|
||||
upstream: &mut R,
|
||||
send: &mut h3::server::RequestStream<h3::quinn::SendStream<Bytes>, Bytes>,
|
||||
exchange: &Exchange,
|
||||
activity: &AtomicU64,
|
||||
started: Instant,
|
||||
session: &TunnelSession,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
R: AsyncRead + Unpin,
|
||||
{
|
||||
let mut buffer = [0u8; 16 * 1024];
|
||||
loop {
|
||||
let read = upstream.read(&mut buffer).await?;
|
||||
if read == 0 {
|
||||
send.finish().await.map_err(h3_io_error)?;
|
||||
return Ok(());
|
||||
}
|
||||
send.send_data(Bytes::copy_from_slice(&buffer[..read]))
|
||||
.await
|
||||
.map_err(h3_io_error)?;
|
||||
record_bytes(exchange, read as u64, false, activity, started);
|
||||
session.record(read as u64, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge one RFC 9220 HTTP/3 stream to the already validated HTTP/1.1
|
||||
/// WebSocket upgrade at the selected backend. DATA payload bytes are copied
|
||||
/// unchanged; FIN remains a half-close and abnormal termination maps to
|
||||
/// H3_REQUEST_CANCELLED.
|
||||
pub async fn run_h3(
|
||||
state: Arc<App>,
|
||||
send: &mut h3::server::RequestStream<h3::quinn::SendStream<Bytes>, Bytes>,
|
||||
receive: &mut h3::server::RequestStream<h3::quinn::RecvStream, Bytes>,
|
||||
tunnel: H3Tunnel,
|
||||
) -> io::Result<()> {
|
||||
let Some(H3TunnelState {
|
||||
upstream,
|
||||
exchange,
|
||||
upstream_label,
|
||||
lease,
|
||||
stop,
|
||||
}) = tunnel.take()
|
||||
else {
|
||||
send.stop_stream(Code::H3_REQUEST_CANCELLED);
|
||||
receive.stop_sending(Code::H3_REQUEST_CANCELLED);
|
||||
return Err(io::Error::other("HTTP/3 WebSocket tunnel already consumed"));
|
||||
};
|
||||
let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone());
|
||||
let started = Instant::now();
|
||||
let activity = Arc::new(AtomicU64::new(0));
|
||||
let session = state.tunnel_registry.register(
|
||||
exchange.clone(),
|
||||
upstream_label,
|
||||
"h3-rfc9220",
|
||||
stop,
|
||||
lease,
|
||||
);
|
||||
let stop = session.cancellation();
|
||||
let upstream = TokioIo::new(upstream);
|
||||
let (mut upstream_read, mut upstream_write) = tokio::io::split(upstream);
|
||||
let result = {
|
||||
let copying = async {
|
||||
tokio::try_join!(
|
||||
h3_client_to_backend(receive, &mut upstream_write, &exchange, &activity, started, &session),
|
||||
backend_to_h3(&mut upstream_read, send, &exchange, &activity, started, &session)
|
||||
)?;
|
||||
Ok::<(), io::Error>(())
|
||||
};
|
||||
tokio::pin!(copying);
|
||||
let mut remaining = state.websocket_idle_timeout;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut copying => break result,
|
||||
_ = stop.cancelled() => break Err(io::Error::new(io::ErrorKind::ConnectionAborted, "WebSocket tunnel shutdown")),
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
let inactive = started.elapsed().saturating_sub(Duration::from_millis(activity.load(Ordering::Relaxed)));
|
||||
if inactive >= state.websocket_idle_timeout {
|
||||
break Err(io::Error::new(io::ErrorKind::TimedOut, "WebSocket idle timeout"));
|
||||
}
|
||||
remaining = state.websocket_idle_timeout - inactive;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Err(error) = &result {
|
||||
send.stop_stream(Code::H3_REQUEST_CANCELLED);
|
||||
receive.stop_sending(Code::H3_REQUEST_CANCELLED);
|
||||
exchange.h3_reset("H3_REQUEST_CANCELLED");
|
||||
if !benign_tunnel_close(error) {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("tunnel_error", "h3_bridge_error");
|
||||
state
|
||||
.metrics
|
||||
.websocket_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
exchange.websocket_phase("tunnel_closed");
|
||||
}
|
||||
} else {
|
||||
exchange.websocket_phase("tunnel_closed");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn(
|
||||
state: Arc<App>,
|
||||
downstream: OnUpgrade,
|
||||
upstream: OnUpgrade,
|
||||
exchange: Arc<Exchange>,
|
||||
upstream_label: String,
|
||||
lease: Option<Arc<ConnectionLease>>,
|
||||
stop: Option<ServerShutdown>,
|
||||
) {
|
||||
let tracker = state.tunnels.clone();
|
||||
tracker.spawn(async move {
|
||||
exchange.websocket_phase("upgrade_wait");
|
||||
let stop = stop.map(|v| v.0).unwrap_or_else(|| state.shutdown.clone());
|
||||
let upgraded = tokio::select! {
|
||||
_ = stop.cancelled() => return,
|
||||
result = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
tokio::try_join!(downstream, upstream)
|
||||
}) => result,
|
||||
};
|
||||
let (downstream, upstream) = match upgraded {
|
||||
Ok(Ok(streams)) => streams,
|
||||
_ => {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("upgrade_failed", "on_upgrade");
|
||||
state
|
||||
.metrics
|
||||
.websocket_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let started = Instant::now();
|
||||
let activity = Arc::new(AtomicU64::new(0));
|
||||
let session = state.tunnel_registry.register(
|
||||
exchange.clone(),
|
||||
upstream_label,
|
||||
"h1-upgrade",
|
||||
stop,
|
||||
lease,
|
||||
);
|
||||
let stop = session.cancellation();
|
||||
let mut downstream = MeteredIo {
|
||||
inner: TokioIo::new(downstream),
|
||||
exchange: exchange.clone(),
|
||||
session: session.clone(),
|
||||
client_to_backend: true,
|
||||
started,
|
||||
activity: activity.clone(),
|
||||
};
|
||||
let mut upstream = MeteredIo {
|
||||
inner: TokioIo::new(upstream),
|
||||
exchange: exchange.clone(),
|
||||
session: session.clone(),
|
||||
client_to_backend: false,
|
||||
started,
|
||||
activity: activity.clone(),
|
||||
};
|
||||
if let Err(error) = tunnel(
|
||||
&mut downstream,
|
||||
&mut upstream,
|
||||
stop,
|
||||
activity,
|
||||
started,
|
||||
state.websocket_idle_timeout,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// Browsers and upstreams commonly close upgraded TCP streams without
|
||||
// a WebSocket close frame. Reset/EOF/broken-pipe are normal tunnel
|
||||
// termination signals, not malformed HTTP requests or proxy errors.
|
||||
if !benign_tunnel_close(&error) {
|
||||
exchange.failed.store(true, Ordering::Relaxed);
|
||||
exchange.websocket_error("tunnel_error", "bridge_error");
|
||||
state
|
||||
.metrics
|
||||
.websocket_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
exchange.websocket_phase("tunnel_closed");
|
||||
}
|
||||
} else {
|
||||
exchange.websocket_phase("tunnel_closed");
|
||||
}
|
||||
});
|
||||
}
|
||||
async fn tunnel<A, B>(
|
||||
a: &mut A,
|
||||
b: &mut B,
|
||||
stop: CancellationToken,
|
||||
activity: Arc<AtomicU64>,
|
||||
started: Instant,
|
||||
idle: Duration,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
A: AsyncRead + AsyncWrite + Unpin,
|
||||
B: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
// Tokio uses fixed-size buffers in both directions. Writes apply backpressure.
|
||||
let copying = tokio::io::copy_bidirectional(a, b);
|
||||
tokio::pin!(copying);
|
||||
let mut remaining = idle;
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = &mut copying => return result.map(|_| ()),
|
||||
_ = stop.cancelled() => return Ok(()),
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
let inactive = started.elapsed().saturating_sub(Duration::from_millis(activity.load(Ordering::Relaxed)));
|
||||
if inactive >= idle { return Err(io::Error::new(io::ErrorKind::TimedOut, "WebSocket idle timeout")); }
|
||||
remaining = idle - inactive;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user