use super::*;
use boatramp_core::project::ProjectRef;
use std::pin::Pin;
use std::task::{Context, Poll};
use axum::http;
use boatramp_http::h1::{chunked, encode_request_head, BodyReader, Conn};
use bytes::Bytes;
use futures::Stream;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::TcpStream;
pub(super) async fn proxy(
request: Request,
url: &str,
config: &DeployConfig,
client_ip: IpAddr,
) -> Response {
let (parsed, addr, host) = match check_proxy_target(url, config).await {
Ok(resolved) => resolved,
Err(reason) => {
tracing::warn!(%url, reason, "proxy target refused");
return (StatusCode::FORBIDDEN, "proxy target not allowed\n").into_response();
}
};
let https = parsed.scheme() == "https";
let client = match pinned_client(&host, addr, https) {
Ok(client) => client,
Err(_) => return (StatusCode::BAD_GATEWAY, "proxy client error\n").into_response(),
};
let (parts, body) = request.into_parts();
let scheme = parts
.headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http")
.to_string();
let uri: axum::http::Uri = match parsed.as_str().parse() {
Ok(uri) => uri,
Err(_) => return (StatusCode::BAD_GATEWAY, "proxy client error\n").into_response(),
};
let mut builder = Request::builder().method(parts.method).uri(uri);
let out_headers = builder
.headers_mut()
.expect("fresh request builder has no error");
for (name, value) in &parts.headers {
if name == header::HOST || is_hop_by_hop(name) {
continue;
}
out_headers.append(name.clone(), value.clone());
}
if let Ok(v) = HeaderValue::from_str(&client_ip.to_string()) {
out_headers.append(HeaderName::from_static("x-forwarded-for"), v);
}
if let Ok(v) = HeaderValue::from_str(&scheme) {
out_headers.append(HeaderName::from_static("x-forwarded-proto"), v);
}
if let Some(host_header) = parts.headers.get(header::HOST) {
out_headers.append(
HeaderName::from_static("x-forwarded-host"),
host_header.clone(),
);
}
let req = match builder.body(body) {
Ok(req) => req,
Err(_) => return (StatusCode::BAD_GATEWAY, "proxy client error\n").into_response(),
};
match client.send(req).await {
Ok(resp) => {
let status = resp.status();
let mut headers = HeaderMap::new();
for (name, value) in resp.headers() {
if is_hop_by_hop(name) || name == header::CONTENT_LENGTH {
continue;
}
headers.insert(name.clone(), value.clone());
}
(status, headers, Body::from_stream(resp.into_body())).into_response()
}
Err(UpstreamError::Timeout) => {
tracing::warn!(%url, "proxy request timed out");
(StatusCode::GATEWAY_TIMEOUT, "upstream timeout\n").into_response()
}
Err(UpstreamError::Failed) => (StatusCode::BAD_GATEWAY, "upstream error\n").into_response(),
}
}
fn is_hop_by_hop(name: &HeaderName) -> bool {
const HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
HOP.contains(&name.as_str())
}
async fn check_proxy_target(
url: &str,
config: &DeployConfig,
) -> Result<(reqwest::Url, SocketAddr, String), &'static str> {
let parsed = reqwest::Url::parse(url).map_err(|_| "unparsable url")?;
match parsed.scheme() {
"http" | "https" => {}
_ => return Err("scheme not http(s)"),
}
let host = parsed.host_str().ok_or("missing host")?.to_string();
if !config.proxy_host_allowed(&host) {
return Err("host not in proxy_allow");
}
let port = parsed.port_or_known_default().unwrap_or(80);
let mut pinned = None;
for addr in tokio::net::lookup_host((host.as_str(), port))
.await
.map_err(|_| "dns resolution failed")?
{
if !boatramp_core::access::is_global_ip(addr.ip()) {
return Err("resolves to a non-public address");
}
pinned.get_or_insert(addr);
}
let addr = pinned.ok_or("no addresses resolved")?;
Ok((parsed, addr, host))
}
const DEFAULT_UPSTREAM_READ_BUFFER: usize = 32 * 1024;
#[derive(Clone, PartialEq, Eq, Hash)]
struct UpstreamClientKey {
host: String,
addr: SocketAddr,
https: bool,
connect_timeout_ms: Option<u64>,
request_timeout_ms: Option<u64>,
tls_insecure: bool,
read_buffer_bytes: Option<usize>,
}
enum Upstream {
Plain(TcpStream),
Tls(Box<tokio_rustls::client::TlsStream<TcpStream>>),
}
impl AsyncRead for Upstream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Plain(s) => Pin::new(s).poll_read(cx, buf),
Self::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
}
}
}
impl AsyncWrite for Upstream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<std::io::Result<usize>> {
match self.get_mut() {
Self::Plain(s) => Pin::new(s).poll_write(cx, buf),
Self::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
}
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[std::io::IoSlice<'_>],
) -> Poll<std::io::Result<usize>> {
match self.get_mut() {
Self::Plain(s) => Pin::new(s).poll_write_vectored(cx, bufs),
Self::Tls(s) => Pin::new(s.as_mut()).poll_write_vectored(cx, bufs),
}
}
fn is_write_vectored(&self) -> bool {
match self {
Self::Plain(s) => s.is_write_vectored(),
Self::Tls(s) => s.is_write_vectored(),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Plain(s) => Pin::new(s).poll_flush(cx),
Self::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
match self.get_mut() {
Self::Plain(s) => Pin::new(s).poll_shutdown(cx),
Self::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
}
}
}
#[derive(Debug)]
enum UpstreamError {
Timeout,
Failed,
}
struct IdleConn {
conn: Conn<Upstream>,
idle_since: std::time::Instant,
}
const POOL_IDLE_TIMEOUT: Duration = Duration::from_secs(20);
const MAX_IDLE_PER_KEY: usize = 64;
static POOL: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<UpstreamClientKey, Vec<IdleConn>>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
fn pool_checkout(key: &UpstreamClientKey) -> Option<Conn<Upstream>> {
let mut pool = POOL.lock().unwrap();
let list = pool.get_mut(key)?;
while let Some(idle) = list.pop() {
if idle.idle_since.elapsed() < POOL_IDLE_TIMEOUT {
return Some(idle.conn);
}
}
None
}
fn pool_return(key: &UpstreamClientKey, conn: Conn<Upstream>) {
if conn.buffered() != 0 {
return;
}
let mut pool = POOL.lock().unwrap();
let list = pool.entry(key.clone()).or_default();
if list.len() < MAX_IDLE_PER_KEY {
list.push(IdleConn {
conn,
idle_since: std::time::Instant::now(),
});
}
}
static TLS_CLIENT_CONFIGS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<bool, Arc<rustls::ClientConfig>>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
fn tls_client_config(tls_insecure: bool) -> Result<Arc<rustls::ClientConfig>, ()> {
if let Some(cfg) = TLS_CLIENT_CONFIGS.lock().unwrap().get(&tls_insecure) {
return Ok(cfg.clone());
}
let cfg = Arc::new(upstream_tls_config(tls_insecure)?);
Ok(TLS_CLIENT_CONFIGS
.lock()
.unwrap()
.entry(tls_insecure)
.or_insert(cfg)
.clone())
}
enum BodyPlan {
None,
Fixed,
Chunked(Bytes),
}
type BodyData = Pin<Box<dyn Stream<Item = Result<Bytes, axum::Error>> + Send>>;
#[derive(Clone)]
struct UpstreamClient {
key: UpstreamClientKey,
}
impl UpstreamClient {
async fn send(&self, req: Request<Body>) -> Result<Response<ClientBody>, UpstreamError> {
let (mut parts, body) = req.into_parts();
let target = parts
.uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| "/".to_string());
let declared_len = parts
.headers
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok());
parts.headers.remove(header::CONTENT_LENGTH);
parts.headers.remove(header::TRANSFER_ENCODING);
if !parts.headers.contains_key(header::HOST) {
if let Some(auth) = parts.uri.authority() {
if let Ok(v) = HeaderValue::from_str(auth.as_str()) {
parts.headers.insert(header::HOST, v);
}
}
}
let mut data: BodyData = Box::pin(body.into_data_stream());
let plan = match declared_len {
Some(0) => BodyPlan::None,
Some(n) => {
parts
.headers
.insert(header::CONTENT_LENGTH, HeaderValue::from(n));
BodyPlan::Fixed
}
None => match data.next().await {
None => BodyPlan::None,
Some(Ok(first)) => {
parts.headers.insert(
header::TRANSFER_ENCODING,
HeaderValue::from_static("chunked"),
);
BodyPlan::Chunked(first)
}
Some(Err(_)) => return Err(UpstreamError::Failed),
},
};
let head_bytes = encode_request_head(&parts.method, &target, &parts.headers);
let method = parts.method.clone();
let retryable = matches!(plan, BodyPlan::None) && is_idempotent(&method);
if let Some(conn) = pool_checkout(&self.key) {
match self.attempt(conn, &head_bytes, plan, data, &method).await {
Ok(resp) => return Ok(resp),
Err(UpstreamError::Timeout) => return Err(UpstreamError::Timeout),
Err(UpstreamError::Failed) if retryable => {
let conn = self.dial().await.map_err(|()| UpstreamError::Failed)?;
return self
.attempt(
conn,
&head_bytes,
BodyPlan::None,
empty_body_data(),
&method,
)
.await;
}
Err(e) => return Err(e),
}
}
let conn = self.dial().await.map_err(|()| UpstreamError::Failed)?;
self.attempt(conn, &head_bytes, plan, data, &method).await
}
async fn attempt(
&self,
mut conn: Conn<Upstream>,
head_bytes: &[u8],
plan: BodyPlan,
mut data: BodyData,
method: &Method,
) -> Result<Response<ClientBody>, UpstreamError> {
let exchange = async {
conn.write_all(head_bytes).await?;
match plan {
BodyPlan::None => {}
BodyPlan::Fixed => {
while let Some(item) = data.next().await {
let chunk =
item.map_err(|_| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?;
conn.write_all(&chunk).await?;
}
}
BodyPlan::Chunked(first) => {
if !first.is_empty() {
conn.write_all(&chunked::encode(&first)).await?;
}
while let Some(item) = data.next().await {
let chunk =
item.map_err(|_| std::io::Error::from(std::io::ErrorKind::BrokenPipe))?;
if !chunk.is_empty() {
conn.write_all(&chunked::encode(&chunk)).await?;
}
}
conn.write_all(&chunked::encode_last(&HeaderMap::new()))
.await?;
}
}
conn.flush().await?;
conn.read_response_head().await
};
let request_timeout = self.key.request_timeout_ms.map(Duration::from_millis);
let head = match request_timeout {
Some(dur) => match tokio::time::timeout(dur, exchange).await {
Ok(result) => result,
Err(_) => return Err(UpstreamError::Timeout),
},
None => exchange.await,
}
.map_err(|err| {
tracing::warn!(error = %err, "upstream request failed");
UpstreamError::Failed
})?;
let reader = BodyReader::r#for(method, &head);
let keep_alive = reader.keep_alive_possible() && response_keep_alive(&head);
let status = head.status;
let headers = head.headers;
let body = ClientBody {
conn: Some(conn),
reader,
key: self.key.clone(),
keep_alive,
};
let mut resp = http::Response::new(body);
*resp.status_mut() = status;
*resp.headers_mut() = headers;
Ok(resp)
}
async fn dial(&self) -> Result<Conn<Upstream>, ()> {
let connect = TcpStream::connect(self.key.addr);
let tcp = match self.key.connect_timeout_ms {
Some(ms) => tokio::time::timeout(Duration::from_millis(ms), connect)
.await
.map_err(|_| ())?
.map_err(|_| ())?,
None => connect.await.map_err(|_| ())?,
};
let _ = tcp.set_nodelay(true);
let read_chunk = self
.key
.read_buffer_bytes
.unwrap_or(DEFAULT_UPSTREAM_READ_BUFFER);
let transport = if self.key.https {
let cfg = tls_client_config(self.key.tls_insecure)?;
let connector = tokio_rustls::TlsConnector::from(cfg);
let server_name =
rustls::pki_types::ServerName::try_from(self.key.host.clone()).map_err(|_| ())?;
let tls = connector.connect(server_name, tcp).await.map_err(|_| ())?;
Upstream::Tls(Box::new(tls))
} else {
Upstream::Plain(tcp)
};
Ok(Conn::with_read_chunk(transport, read_chunk))
}
}
fn is_idempotent(method: &Method) -> bool {
matches!(
*method,
Method::GET | Method::HEAD | Method::PUT | Method::DELETE | Method::OPTIONS | Method::TRACE
)
}
fn response_keep_alive(head: &boatramp_http::h1::ResponseHead) -> bool {
let conn = head
.headers
.get(header::CONNECTION)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
if conn.split(',').any(|t| t.trim() == "close") {
return false;
}
if head.version == http::Version::HTTP_10 {
return conn.split(',').any(|t| t.trim() == "keep-alive");
}
true
}
fn empty_body_data() -> BodyData {
Box::pin(futures::stream::empty())
}
struct ClientBody {
conn: Option<Conn<Upstream>>,
reader: BodyReader,
key: UpstreamClientKey,
keep_alive: bool,
}
impl Stream for ClientBody {
type Item = Result<Bytes, std::io::Error>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
let Some(conn) = this.conn.as_mut() else {
return Poll::Ready(None);
};
match conn.poll_read_body_chunk(cx, &mut this.reader) {
Poll::Ready(Ok(Some(chunk))) => Poll::Ready(Some(Ok(chunk))),
Poll::Ready(Ok(None)) => {
if let Some(conn) = this.conn.take() {
if this.keep_alive {
pool_return(&this.key, conn);
}
}
Poll::Ready(None)
}
Poll::Ready(Err(e)) => {
this.conn = None; Poll::Ready(Some(Err(e)))
}
Poll::Pending => Poll::Pending,
}
}
}
fn upstream_tls_config(tls_insecure: bool) -> Result<rustls::ClientConfig, ()> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let builder = rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|_| ())?;
let mut config = if tls_insecure {
builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoCertVerify))
.with_no_client_auth()
} else {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
builder.with_root_certificates(roots).with_no_client_auth()
};
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(config)
}
#[derive(Debug)]
struct NoCertVerify;
impl rustls::client::danger::ServerCertVerifier for NoCertVerify {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
fn cached_client(
host: &str,
addr: SocketAddr,
connect_timeout_ms: Option<u64>,
request_timeout_ms: Option<u64>,
tls_insecure: bool,
read_buffer_bytes: Option<usize>,
https: bool,
) -> Result<UpstreamClient, ()> {
Ok(UpstreamClient {
key: UpstreamClientKey {
host: host.to_string(),
addr,
https,
connect_timeout_ms,
request_timeout_ms,
tls_insecure,
read_buffer_bytes,
},
})
}
fn pinned_client(host: &str, addr: SocketAddr, https: bool) -> Result<UpstreamClient, ()> {
cached_client(host, addr, None, None, false, None, https)
}
#[derive(Clone)]
pub(crate) struct ResolvedTarget {
pub(crate) parsed: reqwest::Url,
pub(crate) host: String,
pub(crate) addr: SocketAddr,
resolved_at: std::time::Instant,
}
const RESOLVE_TTL: Duration = Duration::from_secs(15);
static RESOLVED_TARGETS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, ResolvedTarget>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
pub(crate) async fn resolve_target(
target: &str,
posture: &boatramp_core::security::SecurityPosture,
) -> Result<ResolvedTarget, Response> {
if let Some(hit) = RESOLVED_TARGETS.lock().unwrap().get(target) {
if hit.resolved_at.elapsed() < RESOLVE_TTL {
return Ok(hit.clone());
}
}
let parsed = reqwest::Url::parse(target).map_err(|_| {
tracing::warn!(target = %target, "gateway upstream target unparsable");
(StatusCode::BAD_GATEWAY, "bad gateway upstream\n").into_response()
})?;
match parsed.scheme() {
"http" | "https" => {}
_ => {
return Err((
StatusCode::BAD_GATEWAY,
"gateway upstream scheme not http(s)\n",
)
.into_response())
}
}
let Some(host) = parsed.host_str().map(str::to_string) else {
return Err((StatusCode::BAD_GATEWAY, "gateway upstream missing host\n").into_response());
};
let port = parsed.port_or_known_default().unwrap_or(80);
let mut chosen = None;
for addr in tokio::net::lookup_host((host.as_str(), port))
.await
.into_iter()
.flatten()
{
if !gateway_addr_allowed(addr.ip(), posture) {
tracing::warn!(
%host, ip = %addr.ip(),
"gateway upstream refused: address not permitted by security posture"
);
return Err((StatusCode::FORBIDDEN, "gateway upstream not allowed\n").into_response());
}
chosen.get_or_insert(addr);
}
let Some(addr) = chosen else {
return Err((
StatusCode::BAD_GATEWAY,
"gateway upstream did not resolve\n",
)
.into_response());
};
let resolved = ResolvedTarget {
parsed,
host,
addr,
resolved_at: std::time::Instant::now(),
};
{
let mut cache = RESOLVED_TARGETS.lock().unwrap();
if cache.len() >= 1024 {
cache.clear();
}
cache.insert(target.to_string(), resolved.clone());
}
Ok(resolved)
}
pub(super) const CLOUD_METADATA_IPV4: std::net::Ipv4Addr =
std::net::Ipv4Addr::new(169, 254, 169, 254);
fn request_posture(request: &Request) -> boatramp_core::security::SecurityPosture {
request
.extensions()
.get::<boatramp_core::security::SecurityPosture>()
.copied()
.unwrap_or_default()
}
pub(super) fn gateway_addr_allowed(
ip: IpAddr,
posture: &boatramp_core::security::SecurityPosture,
) -> bool {
if ip == IpAddr::V4(CLOUD_METADATA_IPV4) {
return false;
}
posture.allow_site_private_upstreams || boatramp_core::access::is_global_ip(ip)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn dispatch_gateway(
request: Request,
site: &str,
upstream_name: &str,
upstream: &boatramp_core::gateway::Upstream,
request_path: &str,
client_ip: IpAddr,
compute_backends: Option<Vec<String>>,
compute_regions: Option<std::collections::BTreeMap<String, String>>,
) -> Response {
let posture = request_posture(&request);
let state = gateway::upstream_state(site, upstream_name);
state.arm_active_probe(upstream);
let now = std::time::Instant::now();
let merged_upstream = compute_regions.filter(|r| !r.is_empty()).map(|regions| {
let mut u = upstream.clone();
u.regions.extend(regions);
u
});
let upstream = merged_upstream.as_ref().unwrap_or(upstream);
let backends =
compute_backends.unwrap_or_else(|| state.backends(upstream, &gateway::SystemResolver, now));
if backends.is_empty() {
return (
StatusCode::BAD_GATEWAY,
"gateway upstream has no backends\n",
)
.into_response();
}
let client_region = upstream
.client_region_header
.as_deref()
.and_then(|name| request.headers().get(name))
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let candidates = state.candidates(&backends, upstream, now, client_region.as_deref());
if !gateway_retryable(&request) || candidates.len() == 1 {
let target = &candidates[0];
let response =
proxy_upstream(request, upstream, target, request_path, client_ip, posture).await;
state.record(
target,
!response.status().is_server_error(),
upstream.passive_health,
now,
);
return response;
}
let method = request.method().clone();
let uri = request.uri().clone();
let headers = request.headers().clone();
let mut last: Option<Response> = None;
for target in &candidates {
let mut attempt = axum::http::Request::new(Body::empty());
*attempt.method_mut() = method.clone();
*attempt.uri_mut() = uri.clone();
*attempt.headers_mut() = headers.clone();
let response =
proxy_upstream(attempt, upstream, target, request_path, client_ip, posture).await;
let ok = !response.status().is_server_error();
state.record(target, ok, upstream.passive_health, now);
if ok {
return response;
}
last = Some(response);
}
last.unwrap_or_else(|| {
(
StatusCode::BAD_GATEWAY,
"gateway: all upstream backends failed\n",
)
.into_response()
})
}
pub(super) async fn compute_endpoints(
deploy: &DeployStore,
project: &str,
workload: &str,
) -> Vec<String> {
deploy
.list_replica_states(ProjectRef::new(project), workload)
.await
.unwrap_or_default()
.into_iter()
.filter(|state| state.healthy)
.map(|state| state.endpoint.url())
.collect()
}
pub(super) async fn compute_endpoint_regions(
deploy: &DeployStore,
project: &str,
workload: &str,
) -> std::collections::BTreeMap<String, String> {
deploy
.list_replica_states(ProjectRef::new(project), workload)
.await
.unwrap_or_default()
.into_iter()
.filter(|state| state.healthy)
.filter_map(|state| state.region.map(|region| (state.endpoint.url(), region)))
.collect()
}
pub(super) const COMPUTE_WAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub(super) async fn has_parked_replica(
deploy: &DeployStore,
project: &str,
workload: &str,
) -> bool {
deploy
.list_replica_states(ProjectRef::new(project), workload)
.await
.unwrap_or_default()
.iter()
.any(|state| state.phase == boatramp_core::compute::ReplicaPhase::Zero)
}
pub(super) async fn await_warm(
deploy: &DeployStore,
project: &str,
workload: &str,
timeout: std::time::Duration,
) -> Vec<String> {
let deadline = std::time::Instant::now() + timeout;
loop {
let pool = compute_endpoints(deploy, project, workload).await;
if !pool.is_empty() || std::time::Instant::now() >= deadline {
return pool;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_compute_reconcile(
deploy: DeployStore,
backends: boatramp_core::compute::BackendRegistry,
nodes: Vec<boatramp_core::compute::Node>,
policy: boatramp_core::compute::BackendPolicy,
is_leader: CronLeaderGate,
tick: std::time::Duration,
idle_timeout: std::time::Duration,
resolver: Option<std::sync::Arc<dyn boatramp_core::compute::ComputeBindingResolver>>,
managed_db: Option<std::sync::Arc<dyn boatramp_core::compute::ManagedDbEnvResolver>>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let activity = gateway::GatewayActivitySource::new(idle_timeout);
let mut interval = tokio::time::interval(tick);
loop {
tokio::select! {
_ = interval.tick() => {}
_ = gateway::await_reconcile_wake() => {}
}
if !is_leader() {
continue;
}
match boatramp_core::compute::reconcile_once(
&deploy,
&backends,
&nodes,
&policy,
&activity,
resolver.as_deref(),
managed_db.as_deref(),
)
.await
{
Ok(report) if !report.errors.is_empty() => tracing::warn!(
launched = report.launched,
stopped = report.stopped,
errors = ?report.errors,
"compute reconcile: partial",
),
Ok(report) if report.launched + report.stopped > 0 => tracing::info!(
launched = report.launched,
stopped = report.stopped,
"compute reconcile",
),
Ok(_) => {}
Err(err) => tracing::warn!(%err, "compute reconcile tick failed"),
}
}
})
}
fn gateway_retryable(request: &Request) -> bool {
matches!(*request.method(), Method::GET | Method::HEAD)
&& request
.headers()
.get(header::CONTENT_LENGTH)
.is_none_or(|v| v.as_bytes() == b"0")
&& !request.headers().contains_key(header::TRANSFER_ENCODING)
}
async fn proxy_upstream(
request: Request,
upstream: &boatramp_core::gateway::Upstream,
target: &str,
request_path: &str,
client_ip: IpAddr,
posture: boatramp_core::security::SecurityPosture,
) -> Response {
if is_upgrade_request(request.headers()) {
return proxy_upgrade(request, upstream, target, request_path, client_ip, posture).await;
}
if let Some(socket_path) = target.strip_prefix("unix:") {
if !posture.allow_site_unix_upstreams {
tracing::warn!(
%target,
"gateway upstream refused: unix-socket upstreams disabled by security posture"
);
return (StatusCode::FORBIDDEN, "gateway upstream not allowed\n").into_response();
}
#[cfg(unix)]
{
return proxy_upstream_unix(request, upstream, socket_path, request_path, client_ip)
.await;
}
#[cfg(not(unix))]
{
let _ = socket_path;
return (
StatusCode::NOT_IMPLEMENTED,
"unix-socket upstreams are only supported on unix\n",
)
.into_response();
}
}
let resolved = match resolve_target(target, &posture).await {
Ok(resolved) => resolved,
Err(resp) => return resp,
};
if !gateway_addr_allowed(resolved.addr.ip(), &posture) {
tracing::warn!(
host = %resolved.host, ip = %resolved.addr.ip(),
"gateway upstream refused: address not permitted by security posture"
);
return (StatusCode::FORBIDDEN, "gateway upstream not allowed\n").into_response();
}
let host = resolved.host.as_str();
let addr = resolved.addr;
let mut target = resolved.parsed.clone();
let base = target.path().trim_end_matches('/').to_string();
let forwarded = upstream.forward_path(request_path);
target.set_path(&format!("{base}{forwarded}"));
let (mut parts, body) = request.into_parts();
target.set_query(parts.uri.query());
if upstream.tls_insecure {
tracing::warn!(%host, "gateway upstream TLS verification disabled (tls_insecure)");
}
let https = resolved.parsed.scheme() == "https";
let client = match cached_client(
host,
addr,
upstream.connect_timeout_ms,
upstream.request_timeout_ms,
upstream.tls_insecure,
upstream.read_buffer_bytes.map(|n| n as usize),
https,
) {
Ok(client) => client,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway client error\n").into_response(),
};
let scheme = parts
.headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http")
.to_string();
let requested_host = parts.headers.get(header::HOST).cloned();
let uri: axum::http::Uri = match target.as_str().parse() {
Ok(uri) => uri,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway client error\n").into_response(),
};
let mut builder = Request::builder().method(parts.method.clone()).uri(uri);
let out_headers = builder
.headers_mut()
.expect("fresh request builder has no error");
for (name, value) in &parts.headers {
if name == header::HOST
|| is_hop_by_hop(name)
|| upstream
.header_up
.remove
.iter()
.any(|h| name.as_str().eq_ignore_ascii_case(h))
{
continue;
}
out_headers.append(name.clone(), value.clone());
}
if let Ok(v) = HeaderValue::from_str(&client_ip.to_string()) {
out_headers.append(HeaderName::from_static("x-forwarded-for"), v);
}
if let Ok(v) = HeaderValue::from_str(&scheme) {
out_headers.append(HeaderName::from_static("x-forwarded-proto"), v);
}
if let Some(h) = &requested_host {
out_headers.append(HeaderName::from_static("x-forwarded-host"), h.clone());
}
if let Some(hh) = &upstream.host_header {
if let Ok(v) = HeaderValue::from_str(hh) {
out_headers.insert(header::HOST, v);
}
}
for (name, value) in &upstream.header_up.set {
if let (Ok(n), Ok(v)) = (
HeaderName::try_from(name.as_str()),
HeaderValue::from_str(value),
) {
out_headers.append(n, v);
}
}
parts.headers.clear(); let req = match builder.body(body) {
Ok(req) => req,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway client error\n").into_response(),
};
match client.send(req).await {
Ok(resp) => {
let status = resp.status();
let mut headers = HeaderMap::new();
for (name, value) in resp.headers() {
if is_hop_by_hop(name)
|| name == header::CONTENT_LENGTH
|| upstream
.header_down
.remove
.iter()
.any(|h| name.as_str().eq_ignore_ascii_case(h))
{
continue;
}
headers.insert(name.clone(), value.clone());
}
for (name, value) in &upstream.header_down.set {
set_header_str(&mut headers, name, value);
}
(status, headers, Body::from_stream(resp.into_body())).into_response()
}
Err(UpstreamError::Timeout) => {
tracing::warn!(%host, "gateway upstream request timed out");
(StatusCode::GATEWAY_TIMEOUT, "upstream timeout\n").into_response()
}
Err(UpstreamError::Failed) => (StatusCode::BAD_GATEWAY, "upstream error\n").into_response(),
}
}
fn set_header_str(headers: &mut HeaderMap, name: &str, value: &str) {
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
headers.insert(name, value);
}
}
#[cfg(unix)]
async fn proxy_upstream_unix(
request: Request,
upstream: &boatramp_core::gateway::Upstream,
socket_path: &str,
request_path: &str,
client_ip: IpAddr,
) -> Response {
let stream = match tokio::net::UnixStream::connect(socket_path).await {
Ok(stream) => stream,
Err(err) => {
tracing::warn!(socket = socket_path, %err, "gateway unix upstream unreachable");
return (
StatusCode::BAD_GATEWAY,
"gateway unix upstream unreachable\n",
)
.into_response();
}
};
let io = hyper_util::rt::TokioIo::new(stream);
let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await {
Ok(pair) => pair,
Err(_) => {
return (StatusCode::BAD_GATEWAY, "gateway unix handshake failed\n").into_response()
}
};
tokio::spawn(async move {
let _ = conn.await;
});
let (parts, body) = request.into_parts();
let scheme = parts
.headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("http")
.to_string();
let forwarded = upstream.forward_path(request_path);
let uri = match parts.uri.query() {
Some(q) => format!("{forwarded}?{q}"),
None => forwarded.into_owned(),
};
let host = upstream
.host_header
.clone()
.unwrap_or_else(|| "localhost".to_string());
let mut builder = hyper::Request::builder()
.method(parts.method.clone())
.uri(uri);
for (name, value) in &parts.headers {
if name == header::HOST
|| is_hop_by_hop(name)
|| upstream
.header_up
.remove
.iter()
.any(|h| name.as_str().eq_ignore_ascii_case(h))
{
continue;
}
builder = builder.header(name, value);
}
builder = builder
.header(header::HOST, &host)
.header("x-forwarded-for", client_ip.to_string())
.header("x-forwarded-proto", scheme);
for (name, value) in &upstream.header_up.set {
builder = builder.header(name, value);
}
let upstream_req = match builder.body(body) {
Ok(req) => req,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway unix request error\n").into_response(),
};
match sender.send_request(upstream_req).await {
Ok(resp) => {
let status =
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let mut headers = HeaderMap::new();
for (name, value) in resp.headers() {
if is_hop_by_hop(name)
|| name == header::CONTENT_LENGTH
|| upstream
.header_down
.remove
.iter()
.any(|h| name.as_str().eq_ignore_ascii_case(h))
{
continue;
}
headers.insert(name.clone(), value.clone());
}
for (name, value) in &upstream.header_down.set {
set_header_str(&mut headers, name, value);
}
(status, headers, Body::new(resp.into_body())).into_response()
}
Err(err) => {
tracing::warn!(socket = socket_path, %err, "gateway unix upstream request failed");
(StatusCode::BAD_GATEWAY, "upstream error\n").into_response()
}
}
}
pub(super) fn is_upgrade_request(headers: &HeaderMap) -> bool {
let connection_upgrade = headers
.get(header::CONNECTION)
.and_then(|v| v.to_str().ok())
.is_some_and(|c| {
c.split(',')
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"))
});
connection_upgrade && headers.contains_key(header::UPGRADE)
}
fn upgrade_transport(scheme: &str) -> Option<bool> {
match scheme {
"http" | "ws" => Some(false),
"https" | "wss" => Some(true),
_ => None,
}
}
async fn proxy_upgrade(
mut request: Request,
upstream: &boatramp_core::gateway::Upstream,
target: &str,
request_path: &str,
client_ip: IpAddr,
posture: boatramp_core::security::SecurityPosture,
) -> Response {
let Some(client_on_upgrade) = boatramp_http::on_upgrade(&mut request) else {
return (
StatusCode::BAD_GATEWAY,
"gateway upgrade: no client upgrade handle\n",
)
.into_response();
};
let method = request.method().clone();
let req_headers = request.headers().clone();
let query = request.uri().query().map(str::to_string);
let forwarded = upstream.forward_path(request_path);
let uri = match &query {
Some(q) => format!("{forwarded}?{q}"),
None => forwarded.into_owned(),
};
if let Some(socket_path) = target.strip_prefix("unix:") {
if !posture.allow_site_unix_upstreams {
tracing::warn!(
%target,
"gateway upgrade refused: unix-socket upstreams disabled by security posture"
);
return (StatusCode::FORBIDDEN, "gateway upstream not allowed\n").into_response();
}
#[cfg(unix)]
{
let stream = match tokio::net::UnixStream::connect(socket_path).await {
Ok(s) => s,
Err(_) => {
return (
StatusCode::BAD_GATEWAY,
"gateway unix upstream unreachable\n",
)
.into_response()
}
};
let host = upstream
.host_header
.clone()
.unwrap_or_else(|| "localhost".to_string());
return upgrade_over(
hyper_util::rt::TokioIo::new(stream),
method,
uri,
req_headers,
host,
upstream,
client_ip,
client_on_upgrade,
)
.await;
}
#[cfg(not(unix))]
{
let _ = socket_path;
return (
StatusCode::NOT_IMPLEMENTED,
"unix upstreams are unix-only\n",
)
.into_response();
}
}
let parsed = match reqwest::Url::parse(target) {
Ok(u) => u,
Err(_) => return (StatusCode::BAD_GATEWAY, "bad gateway upstream\n").into_response(),
};
let tls = match upgrade_transport(parsed.scheme()) {
Some(tls) => tls,
None => {
return (
StatusCode::NOT_IMPLEMENTED,
"gateway upgrade supports http/ws, https/wss, or unix upstreams\n",
)
.into_response()
}
};
let Some(host) = parsed.host_str().map(str::to_string) else {
return (StatusCode::BAD_GATEWAY, "gateway upstream missing host\n").into_response();
};
let port = parsed.port_or_known_default().unwrap_or(80);
let addr = match tokio::net::lookup_host((host.as_str(), port)).await {
Ok(addrs) => {
let mut chosen = None;
for addr in addrs {
if !gateway_addr_allowed(addr.ip(), &posture) {
tracing::warn!(
%host, ip = %addr.ip(),
"gateway upgrade refused: address not permitted by security posture"
);
return (StatusCode::FORBIDDEN, "gateway upstream not allowed\n")
.into_response();
}
chosen.get_or_insert(addr);
}
chosen
}
Err(_) => None,
};
let Some(addr) = addr else {
return (
StatusCode::BAD_GATEWAY,
"gateway upstream did not resolve\n",
)
.into_response();
};
let stream = match tokio::net::TcpStream::connect(addr).await {
Ok(s) => s,
Err(_) => {
return (StatusCode::BAD_GATEWAY, "gateway upstream unreachable\n").into_response()
}
};
let host_hdr = upstream.host_header.clone().unwrap_or_else(|| host.clone());
if tls {
let server_name = match rustls::pki_types::ServerName::try_from(host) {
Ok(name) => name,
Err(_) => {
return (
StatusCode::BAD_GATEWAY,
"gateway upstream host invalid for TLS\n",
)
.into_response()
}
};
let tls_stream = match tls_connector().connect(server_name, stream).await {
Ok(s) => s,
Err(_) => {
return (StatusCode::BAD_GATEWAY, "gateway TLS handshake failed\n").into_response()
}
};
return upgrade_over(
hyper_util::rt::TokioIo::new(tls_stream),
method,
uri,
req_headers,
host_hdr,
upstream,
client_ip,
client_on_upgrade,
)
.await;
}
upgrade_over(
hyper_util::rt::TokioIo::new(stream),
method,
uri,
req_headers,
host_hdr,
upstream,
client_ip,
client_on_upgrade,
)
.await
}
fn tls_connector() -> tokio_rustls::TlsConnector {
use std::sync::OnceLock;
static CONFIG: OnceLock<std::sync::Arc<rustls::ClientConfig>> = OnceLock::new();
let config = CONFIG.get_or_init(|| {
let mut roots = rustls::RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
std::sync::Arc::new(
rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.expect("ring provider supports the default TLS versions")
.with_root_certificates(roots)
.with_no_client_auth(),
)
});
tokio_rustls::TlsConnector::from(config.clone())
}
#[allow(clippy::too_many_arguments)]
async fn upgrade_over<I>(
io: I,
method: Method,
uri: String,
req_headers: HeaderMap,
host: String,
upstream: &boatramp_core::gateway::Upstream,
client_ip: IpAddr,
client_on_upgrade: boatramp_http::OnUpgrade,
) -> Response
where
I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
{
let (mut sender, conn) = match hyper::client::conn::http1::handshake(io).await {
Ok(pair) => pair,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway handshake failed\n").into_response(),
};
tokio::spawn(async move {
let _ = conn.with_upgrades().await;
});
let mut builder = hyper::Request::builder().method(method).uri(uri);
for (name, value) in &req_headers {
if name == header::HOST
|| upstream
.header_up
.remove
.iter()
.any(|h| name.as_str().eq_ignore_ascii_case(h))
{
continue;
}
builder = builder.header(name, value);
}
builder = builder
.header(header::HOST, &host)
.header("x-forwarded-for", client_ip.to_string())
.header("x-forwarded-proto", "http");
for (name, value) in &upstream.header_up.set {
builder = builder.header(name, value);
}
let upstream_req = match builder.body(Body::empty()) {
Ok(req) => req,
Err(_) => return (StatusCode::BAD_GATEWAY, "gateway request error\n").into_response(),
};
let mut upstream_resp = match sender.send_request(upstream_req).await {
Ok(resp) => resp,
Err(_) => return (StatusCode::BAD_GATEWAY, "upstream error\n").into_response(),
};
if upstream_resp.status() == hyper::StatusCode::SWITCHING_PROTOCOLS {
let upstream_on_upgrade = hyper::upgrade::on(&mut upstream_resp);
tokio::spawn(async move {
if let (Ok(mut client_io), Ok(upstream_io)) =
(client_on_upgrade.await, upstream_on_upgrade.await)
{
let mut upstream_io = hyper_util::rt::TokioIo::new(upstream_io);
let _ = tokio::io::copy_bidirectional(&mut client_io, &mut upstream_io).await;
}
});
let mut headers = HeaderMap::new();
for (name, value) in upstream_resp.headers() {
headers.insert(name.clone(), value.clone());
}
return (StatusCode::SWITCHING_PROTOCOLS, headers, Body::empty()).into_response();
}
let status =
StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let mut headers = HeaderMap::new();
for (name, value) in upstream_resp.headers() {
if name == header::CONTENT_LENGTH {
continue;
}
headers.insert(name.clone(), value.clone());
}
(status, headers, Body::new(upstream_resp.into_body())).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn upstream_client_small_request_is_prompt() {
use axum::serve::ListenerExt;
use std::time::Instant;
let app = axum::Router::new().route(
"/",
axum::routing::get(|| async { axum::body::Bytes::from(vec![7u8; 1024]) }),
);
let raw = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = raw.local_addr().unwrap();
let listener = raw.tap_io(|s| {
let _ = s.set_nodelay(true);
});
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let client = cached_client("127.0.0.1", addr, None, None, false, None, false).unwrap();
let uri = format!("http://127.0.0.1:{}/", addr.port());
let mut worst = 0f64;
for i in 0..30 {
let req = Request::builder()
.method(Method::GET)
.uri(&uri)
.body(Body::empty())
.unwrap();
let start = Instant::now();
let resp = match client.send(req).await {
Ok(resp) => resp,
Err(err) => panic!("send failed at iter {i}: {err:?}"),
};
let body = axum::body::to_bytes(Body::from_stream(resp.into_body()), 1 << 20)
.await
.unwrap();
assert_eq!(body.len(), 1024);
let ms = start.elapsed().as_secs_f64() * 1000.0;
eprintln!("iter {i}: {ms:.2}ms");
if i > 1 {
worst = worst.max(ms); }
}
assert!(
worst < 15.0,
"warm upstream request latency {worst:.1}ms — Nagle/flush stall on the native upstream client"
);
}
#[test]
fn upgrade_transport_maps_scheme_to_tls() {
assert_eq!(upgrade_transport("http"), Some(false));
assert_eq!(upgrade_transport("ws"), Some(false));
assert_eq!(upgrade_transport("https"), Some(true));
assert_eq!(upgrade_transport("wss"), Some(true));
assert_eq!(upgrade_transport("ftp"), None);
assert_eq!(upgrade_transport("unix"), None);
assert_eq!(upgrade_transport(""), None);
}
async fn spawn_upstream(app: axum::Router) -> SocketAddr {
use axum::serve::ListenerExt;
let raw = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = raw.local_addr().unwrap();
let listener = raw.tap_io(|s| {
let _ = s.set_nodelay(true);
});
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
addr
}
fn plain_key(addr: SocketAddr) -> UpstreamClientKey {
UpstreamClientKey {
host: "127.0.0.1".to_string(),
addr,
https: false,
connect_timeout_ms: None,
request_timeout_ms: None,
tls_insecure: false,
read_buffer_bytes: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_client_large_get_byte_identical_and_pools_connection() {
let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
let served = big.clone();
let app = axum::Router::new().route(
"/big",
axum::routing::get(move || {
let b = served.clone();
async move { axum::body::Bytes::from(b) }
}),
);
let addr = spawn_upstream(app).await;
let client = cached_client("127.0.0.1", addr, None, None, false, None, false).unwrap();
let uri = format!("http://127.0.0.1:{}/big", addr.port());
for round in 0..2 {
let req = Request::builder()
.method(Method::GET)
.uri(&uri)
.body(Body::empty())
.unwrap();
let resp = client.send(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(Body::from_stream(resp.into_body()), 1 << 20)
.await
.unwrap();
assert_eq!(
body.as_ref(),
big.as_slice(),
"body mismatch on round {round}"
);
let idle = POOL
.lock()
.unwrap()
.get(&plain_key(addr))
.map_or(0, Vec::len);
assert_eq!(
idle, 1,
"expected exactly one pooled connection after round {round}"
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_client_forwards_fixed_length_request_body() {
let app = axum::Router::new().route(
"/echo",
axum::routing::post(|body: axum::body::Bytes| async move { body }),
);
let addr = spawn_upstream(app).await;
let client = cached_client("127.0.0.1", addr, None, None, false, None, false).unwrap();
let uri = format!("http://127.0.0.1:{}/echo", addr.port());
let payload = vec![b'p'; 4096];
let req = Request::builder()
.method(Method::POST)
.uri(&uri)
.header(header::CONTENT_LENGTH, payload.len())
.body(Body::from(payload.clone()))
.unwrap();
let resp = client.send(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let echoed = axum::body::to_bytes(Body::from_stream(resp.into_body()), 1 << 20)
.await
.unwrap();
assert_eq!(echoed.as_ref(), payload.as_slice());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn native_client_forwards_chunked_request_body() {
let app = axum::Router::new().route(
"/echo",
axum::routing::post(|body: axum::body::Bytes| async move { body }),
);
let addr = spawn_upstream(app).await;
let client = cached_client("127.0.0.1", addr, None, None, false, None, false).unwrap();
let uri = format!("http://127.0.0.1:{}/echo", addr.port());
let chunks: Vec<Result<Bytes, std::io::Error>> = vec![
Ok(Bytes::from_static(b"the quick ")),
Ok(Bytes::from_static(b"brown fox ")),
Ok(Bytes::from_static(b"jumps")),
];
let req = Request::builder()
.method(Method::POST)
.uri(&uri)
.body(Body::from_stream(futures::stream::iter(chunks)))
.unwrap();
let resp = client.send(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let echoed = axum::body::to_bytes(Body::from_stream(resp.into_body()), 1 << 20)
.await
.unwrap();
assert_eq!(echoed.as_ref(), b"the quick brown fox jumps".as_slice());
}
}