use super::*;
use boatramp_core::project::ProjectRef;
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 client = match pinned_client(&host, addr) {
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::new(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,
connect_timeout_ms: Option<u64>,
request_timeout_ms: Option<u64>,
tls_insecure: bool,
read_buffer_bytes: Option<usize>,
}
type PinnedConnector = hyper_rustls::HttpsConnector<
hyper_util::client::legacy::connect::HttpConnector<PinnedResolver>,
>;
type HyperClient = hyper_util::client::legacy::Client<PinnedConnector, Body>;
#[derive(Clone)]
struct UpstreamClient {
inner: HyperClient,
request_timeout: Option<Duration>,
}
#[derive(Debug)]
enum UpstreamError {
Timeout,
Failed,
}
impl UpstreamClient {
async fn send(
&self,
req: Request<Body>,
) -> Result<Response<hyper::body::Incoming>, UpstreamError> {
let fut = self.inner.request(req);
let result = match self.request_timeout {
Some(dur) => match tokio::time::timeout(dur, fut).await {
Ok(result) => result,
Err(_) => return Err(UpstreamError::Timeout),
},
None => fut.await,
};
result.map_err(|err| {
tracing::warn!(error = %err, "upstream request failed");
UpstreamError::Failed
})
}
}
#[derive(Clone)]
struct PinnedResolver(SocketAddr);
impl tower_service::Service<hyper_util::client::legacy::connect::dns::Name> for PinnedResolver {
type Response = std::iter::Once<SocketAddr>;
type Error = std::convert::Infallible;
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn call(&mut self, _name: hyper_util::client::legacy::connect::dns::Name) -> Self::Future {
std::future::ready(Ok(std::iter::once(self.0)))
}
}
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 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()
};
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()
}
}
static UPSTREAM_CLIENTS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<UpstreamClientKey, UpstreamClient>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
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>,
) -> Result<UpstreamClient, ()> {
let key = UpstreamClientKey {
host: host.to_string(),
addr,
connect_timeout_ms,
request_timeout_ms,
tls_insecure,
read_buffer_bytes,
};
if let Some(client) = UPSTREAM_CLIENTS.lock().unwrap().get(&key) {
return Ok(client.clone());
}
let tls = upstream_tls_config(tls_insecure)?;
let mut http =
hyper_util::client::legacy::connect::HttpConnector::new_with_resolver(PinnedResolver(addr));
http.enforce_http(false); http.set_nodelay(true);
if let Some(ms) = connect_timeout_ms {
http.set_connect_timeout(Some(Duration::from_millis(ms)));
}
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http1()
.wrap_connector(http);
let inner = hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.pool_timer(hyper_util::rt::TokioTimer::new())
.pool_idle_timeout(Duration::from_secs(20))
.http1_max_buf_size(read_buffer_bytes.unwrap_or(DEFAULT_UPSTREAM_READ_BUFFER))
.build(https);
let client = UpstreamClient {
inner,
request_timeout: request_timeout_ms.map(Duration::from_millis),
};
Ok(UPSTREAM_CLIENTS
.lock()
.unwrap()
.entry(key)
.or_insert(client)
.clone())
}
fn pinned_client(host: &str, addr: SocketAddr) -> Result<UpstreamClient, ()> {
cached_client(host, addr, None, None, false, None)
}
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, workload: &str) -> Vec<String> {
deploy
.list_replica_states(ProjectRef::DEFAULT, 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,
workload: &str,
) -> std::collections::BTreeMap<String, String> {
deploy
.list_replica_states(ProjectRef::DEFAULT, 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, workload: &str) -> bool {
deploy
.list_replica_states(ProjectRef::DEFAULT, workload)
.await
.unwrap_or_default()
.iter()
.any(|state| state.phase == boatramp_core::compute::ReplicaPhase::Zero)
}
pub(super) async fn await_warm(
deploy: &DeployStore,
workload: &str,
timeout: std::time::Duration,
) -> Vec<String> {
let deadline = std::time::Instant::now() + timeout;
loop {
let pool = compute_endpoints(deploy, 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 parsed = match reqwest::Url::parse(target) {
Ok(url) => url,
Err(_) => {
tracing::warn!(target = %target, "gateway upstream target unparsable");
return (StatusCode::BAD_GATEWAY, "bad gateway upstream\n").into_response();
}
};
match parsed.scheme() {
"http" | "https" => {}
_ => {
return (
StatusCode::BAD_GATEWAY,
"gateway upstream scheme not http(s)\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 pinned = 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 upstream 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) = pinned else {
return (
StatusCode::BAD_GATEWAY,
"gateway upstream did not resolve\n",
)
.into_response();
};
let mut target = 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 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),
) {
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::new(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 client_on_upgrade = hyper::upgrade::on(&mut request);
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: hyper::upgrade::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(client_io), Ok(upstream_io)) =
(client_on_upgrade.await, upstream_on_upgrade.await)
{
let mut client_io = hyper_util::rt::TokioIo::new(client_io);
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).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::new(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 raw-hyper 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);
}
}