Files
RustProxy/src/upstream/client.rs
T
2026-10-06 23:08:43 +02:00

364 lines
12 KiB
Rust

use super::{
public_roots, roots, AddressPolicy, BackendCertificateVerificationDisabled, TimedConnector,
CONNECT_TIMEOUT,
};
use crate::config::{Backend, UpstreamProtocol};
use anyhow::{Context, Result};
use arc_swap::ArcSwap;
use axum::body::Body;
use http::{Request, Response};
use hyper::body::Incoming;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::{
client::legacy::{Client, Error as ClientError},
rt::{TokioExecutor, TokioTimer},
};
use rustls::ClientConfig;
use std::{
sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc, OnceLock,
},
time::{Duration, Instant},
};
static MONOTONIC_START: OnceLock<Instant> = OnceLock::new();
type RawProxyClient = Client<super::TimedConnector, Body>;
fn monotonic_millis() -> u64 {
MONOTONIC_START
.get_or_init(Instant::now)
.elapsed()
.as_millis()
.min(u64::MAX as u128) as u64
}
#[derive(Clone, PartialEq, Eq)]
struct ClientFactory {
backend: Backend,
policy: AddressPolicy,
websocket: bool,
keepalive: crate::balance::Keepalive,
timeout: Duration,
}
struct RotatingClient {
current: ArcSwap<RawProxyClient>,
factory: ClientFactory,
generation: AtomicU64,
generation_requests: AtomicU64,
generation_started_ms: AtomicU64,
rotations: AtomicU64,
rotating: AtomicBool,
}
/// Backend HTTP client with bounded generation lifetime.
///
/// Hyper reuses a client/pool for efficiency. Under sustained traffic that also
/// makes connection/TLS/buffer high-water allocations live as long as the
/// compiled vhost snapshot. Recycling only this client generation bounds that
/// lifetime without reloading the vhost or touching cache/configuration state.
#[derive(Clone)]
pub struct ProxyClient {
inner: Arc<RotatingClient>,
}
#[derive(Debug, Clone, Copy)]
pub struct ClientRotationStats {
pub generation: u64,
pub generation_requests: u64,
pub generation_age_seconds: u64,
pub rotations: u64,
}
impl std::fmt::Debug for ProxyClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ProxyClient")
.field("rotation", &self.rotation_stats())
.finish_non_exhaustive()
}
}
impl ProxyClient {
fn new(current: RawProxyClient, factory: ClientFactory) -> Self {
Self {
inner: Arc::new(RotatingClient {
current: ArcSwap::from_pointee(current),
factory,
generation: AtomicU64::new(0),
generation_requests: AtomicU64::new(0),
generation_started_ms: AtomicU64::new(monotonic_millis()),
rotations: AtomicU64::new(0),
rotating: AtomicBool::new(false),
}),
}
}
pub async fn request(&self, request: Request<Body>) -> Result<Response<Incoming>, ClientError> {
let client = self.inner.client_for_request();
client.request(request).await
}
pub fn rotation_stats(&self) -> ClientRotationStats {
self.inner.stats()
}
/// Whether a newly compiled backend client has exactly the same connection
/// policy and lifecycle settings as this one. Compatible clients can be
/// retained across unrelated snapshot rebuilds so generation age and
/// rotation counters are not restarted from zero.
pub(crate) fn reusable_with(&self, other: &Self) -> bool {
self.inner.factory == other.inner.factory
}
/// Recycle an aged connection pool even when no user request arrives.
pub(crate) fn maintain_rotation(&self) {
self.inner.maintain_rotation();
}
}
impl RotatingClient {
fn stats(&self) -> ClientRotationStats {
let now = monotonic_millis();
ClientRotationStats {
generation: self.generation.load(Ordering::Relaxed),
generation_requests: self.generation_requests.load(Ordering::Relaxed),
generation_age_seconds: now
.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed))
/ 1_000,
rotations: self.rotations.load(Ordering::Relaxed),
}
}
fn client_for_request(&self) -> Arc<RawProxyClient> {
let observed_generation = self.generation.load(Ordering::Acquire);
let requests = self
.generation_requests
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
let now = monotonic_millis();
let age_ms = now.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed));
let request_due = self.factory.keepalive.pool_recycle_requests > 0
&& requests > self.factory.keepalive.pool_recycle_requests;
let age_due = self.factory.keepalive.pool_recycle_seconds > 0
&& age_ms
>= self
.factory
.keepalive
.pool_recycle_seconds
.saturating_mul(1_000);
if request_due || age_due {
self.rotate(
observed_generation,
if request_due { "requests" } else { "age" },
true,
);
}
self.current.load_full()
}
fn maintain_rotation(&self) {
self.maintain_rotation_at(monotonic_millis());
}
fn maintain_rotation_at(&self, now_ms: u64) {
let recycle_seconds = self.factory.keepalive.pool_recycle_seconds;
if recycle_seconds == 0 {
return;
}
let age_ms = now_ms.saturating_sub(self.generation_started_ms.load(Ordering::Relaxed));
if age_ms >= recycle_seconds.saturating_mul(1_000) {
self.rotate(self.generation.load(Ordering::Acquire), "age", false);
}
}
fn rotate(
&self,
observed_generation: u64,
reason: &'static str,
triggering_request: bool,
) {
if self.generation.load(Ordering::Acquire) != observed_generation
|| self
.rotating
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return;
}
if self.generation.load(Ordering::Acquire) != observed_generation {
self.rotating.store(false, Ordering::Release);
return;
}
match build_raw_client(&self.factory) {
Ok(client) => {
let next_generation = observed_generation.saturating_add(1);
self.current.store(Arc::new(client));
// Request-driven rotation sends the triggering request through
// the new generation. Maintenance-driven rotation happens idle.
self.generation_requests
.store(if triggering_request { 1 } else { 0 }, Ordering::Relaxed);
self.generation_started_ms
.store(monotonic_millis(), Ordering::Relaxed);
self.generation.store(next_generation, Ordering::Release);
self.rotations.fetch_add(1, Ordering::Relaxed);
tracing::debug!(
backend = %self.factory.backend.id,
generation = next_generation,
reason,
"recycled upstream client pool"
);
}
Err(error) => {
// The same immutable settings built the current client. If a
// future dependency makes rebuilding fail, keep serving through
// the current generation and retry only after another interval.
self.generation_requests.store(0, Ordering::Relaxed);
self.generation_started_ms
.store(monotonic_millis(), Ordering::Relaxed);
tracing::warn!(
backend = %self.factory.backend.id,
error = %error,
"could not recycle upstream client pool; keeping current generation"
);
}
}
self.rotating.store(false, Ordering::Release);
}
}
pub fn client(backend: &Backend, policy: AddressPolicy, websocket: bool) -> Result<ProxyClient> {
client_with_keepalive(
backend,
policy,
websocket,
&crate::balance::Keepalive::default(),
)
}
pub fn client_with_keepalive(
backend: &Backend,
policy: AddressPolicy,
websocket: bool,
keepalive: &crate::balance::Keepalive,
) -> Result<ProxyClient> {
client_with_timeout(backend, policy, websocket, keepalive, CONNECT_TIMEOUT)
}
pub fn client_with_timeout(
backend: &Backend,
policy: AddressPolicy,
websocket: bool,
keepalive: &crate::balance::Keepalive,
timeout: Duration,
) -> Result<ProxyClient> {
let factory = ClientFactory {
backend: backend.clone(),
policy,
websocket,
keepalive: keepalive.clone(),
timeout,
};
let current = build_raw_client(&factory)?;
Ok(ProxyClient::new(current, factory))
}
fn build_raw_client(factory: &ClientFactory) -> Result<RawProxyClient> {
let backend = &factory.backend;
let policy = &factory.policy;
let websocket = factory.websocket;
let keepalive = &factory.keepalive;
let timeout = factory.timeout;
let is_https = url::Url::parse(&backend.url)?.scheme() == "https";
let protocol = if websocket {
UpstreamProtocol::Http1
} else if backend.protocol == UpstreamProtocol::Http2 && is_https {
UpstreamProtocol::Auto
} else {
backend.protocol
};
let provider = Arc::new(rustls::crypto::ring::default_provider());
let trust_roots = if backend.tls_verify {
roots(backend)?
} else {
public_roots()
};
let mut config = ClientConfig::builder_with_provider(provider.clone())
.with_safe_default_protocol_versions()?
.with_root_certificates(trust_roots)
.with_no_client_auth();
if !backend.tls_verify {
let supported_schemes = provider
.signature_verification_algorithms
.supported_schemes();
config.dangerous().set_certificate_verifier(Arc::new(
BackendCertificateVerificationDisabled { supported_schemes },
));
}
let connect_host = if backend.tls_server_name.is_some() {
Some(
url::Url::parse(&backend.url)?
.host_str()
.context("backend host missing")?
.trim_start_matches('[')
.trim_end_matches(']')
.to_owned(),
)
} else {
None
};
let mut http = hyper_util::client::legacy::connect::HttpConnector::new_with_resolver(
super::GuardedResolver {
policy: policy.clone(),
connect_host,
},
);
http.enforce_http(false);
http.set_connect_timeout(Some(timeout));
http.set_nodelay(true);
http.set_keepalive(
keepalive
.enabled
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
);
let builder = HttpsConnectorBuilder::new()
.with_tls_config(config)
.https_or_http();
let inner = match protocol {
UpstreamProtocol::Http1 => builder.enable_http1().wrap_connector(http),
UpstreamProtocol::Http2 => builder.enable_http2().wrap_connector(http),
UpstreamProtocol::Auto => builder.enable_http1().enable_http2().wrap_connector(http),
};
let mut builder = Client::builder(TokioExecutor::new());
builder
.http1_title_case_headers(true)
.http1_max_buf_size(keepalive.http1_max_buffer_bytes)
.pool_timer(TokioTimer::new())
.pool_idle_timeout(Duration::from_secs(keepalive.idle_timeout_seconds))
.pool_max_idle_per_host(if keepalive.enabled {
keepalive.max_idle_per_host
} else {
0
})
.retry_canceled_requests(false)
.http2_only(protocol == UpstreamProtocol::Http2)
.timer(TokioTimer::new())
.http2_keep_alive_interval(
keepalive
.enabled
.then_some(Duration::from_secs(keepalive.tcp_keepalive_seconds)),
)
.http2_keep_alive_timeout(Duration::from_secs(10))
.http2_keep_alive_while_idle(false);
Ok(builder.build(TimedConnector {
inner,
policy: policy.clone(),
timeout,
}))
}
#[cfg(test)]
include!("client_tests.rs");