use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
use bytes::Bytes;
use hyper::body::Body;
use hyper::header::{CONTENT_ENCODING, CONTENT_RANGE, CONTENT_TYPE};
use hyper::{Response, StatusCode};
use plecto_control::otlp::SpanRecord;
use plecto_control::{
ChainOutcome, ConfigSnapshot, HashInput, HashKeySource, HttpRequest, HttpResponse,
InlineRequestOutcome, InlineResponseOutcome, OverCapMode, RateLimitDecision,
RequestBodyOutcome, RequestTrace, ResponseBodyConfig, ResponseBodyOutcome, ResponseOutcome,
RouteInfo, UninspectableMode,
};
use tokio::sync::OwnedSemaphorePermit;
use crate::body::{
INBOUND_BODY_READ_TIMEOUT, MAX_REQUEST_BODY_BUFFER, ResponseBufferOutcome,
UPSTREAM_BODY_READ_TIMEOUT, buffer_request_body, buffer_response_body, hold_budget,
hold_request_permit, prefixed,
};
use crate::error::ServerError;
use crate::forward::{ForwardBody, ForwardOutcome, ForwardRequest, forward_with_retry};
use crate::headers::{
copy_headers, copy_headers_direct, headers_to_vec, normalize_ingress_authority, set_forwarded,
to_http_request,
};
use crate::listener::ConnGuards;
use crate::metrics::InspectionSkip;
use crate::respond::{
discard_upstream_body, fault, http_response, stream_response, stream_response_direct, synth,
synth_retry_after, with_error_code,
};
use crate::{ReqBody, ResponseBody, ServerState, access_log};
#[derive(Default)]
struct Attribution {
route: Option<Arc<str>>,
inspection_skipped: Option<InspectionSkip>,
}
fn catch_chain_panic<T>(f: impl FnOnce() -> T) -> Result<T, ServerError> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| ServerError::ChainPanic)
}
pub(crate) async fn proxy_core(
state: Arc<ServerState>,
scheme: &'static str,
peer: SocketAddr,
parts: hyper::http::request::Parts,
body: ReqBody,
) -> Result<Response<ResponseBody>, ServerError> {
proxy_core_with_conn_guards(state, scheme, peer, parts, body, None).await
}
pub(crate) async fn proxy_core_with_conn_guards(
state: Arc<ServerState>,
scheme: &'static str,
peer: SocketAddr,
mut parts: hyper::http::request::Parts,
body: ReqBody,
conn_guards: Option<Arc<ConnGuards>>,
) -> Result<Response<ResponseBody>, ServerError> {
let admission = match state.request_limit.clone().try_acquire_owned() {
Ok(permit) => Arc::new(permit),
Err(_) => {
state.metrics.record_request(
plecto_control::UNMATCHED_ROUTE,
StatusCode::SERVICE_UNAVAILABLE.as_u16(),
Duration::ZERO,
);
return Ok(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::REQUEST_OVERLOADED,
b"server overloaded",
));
}
};
struct InFlight<'a>(&'a crate::metrics::ServerMetrics);
impl Drop for InFlight<'_> {
fn drop(&mut self) {
self.0.dec_in_flight();
}
}
let start = Instant::now();
state.metrics.inc_in_flight();
let in_flight = InFlight(&state.metrics);
let client = set_forwarded(
&mut parts.headers,
peer.ip(),
scheme,
state.trusted_proxy.as_ref(),
);
let authority_valid = normalize_ingress_authority(&mut parts, scheme).is_ok();
let access = state
.control
.access_log_enabled()
.then(|| access_log::Access {
method: parts.method.as_str().to_string(),
authority: crate::headers::request_authority(&parts),
path: parts.uri.path().to_string(),
});
let trace = parts
.headers
.get("traceparent")
.and_then(|v| v.to_str().ok())
.and_then(RequestTrace::from_traceparent)
.unwrap_or_else(RequestTrace::root);
let otlp_request = state.otlp.as_ref().filter(|_| trace.is_sampled()).map(|_| {
(
parts.method.as_str().to_string(),
parts.uri.path().to_string(),
SystemTime::now(),
)
});
let mut attribution = Attribution::default();
let result = if authority_valid {
proxy_core_inner(
state.clone(),
scheme,
client,
trace,
hyper::Request::from_parts(parts, body),
&mut attribution,
admission.clone(),
conn_guards,
)
.await
} else {
Ok(synth(
StatusCode::BAD_REQUEST,
&fault::BAD_AUTHORITY,
b"bad request authority",
))
};
drop(in_flight);
let status = match &result {
Ok(resp) => resp.status().as_u16(),
Err(_) => StatusCode::BAD_GATEWAY.as_u16(),
};
let elapsed = start.elapsed();
let route_name = attribution
.route
.as_deref()
.unwrap_or(plecto_control::UNMATCHED_ROUTE);
state.metrics.record_request(route_name, status, elapsed);
if let Some(access) = access {
let outcome = access_log::Outcome {
route: route_name,
status,
elapsed,
inspection_skipped: attribution.inspection_skipped.map(InspectionSkip::as_str),
};
access_log::record(scheme, client, &access, &outcome, &trace);
}
if let (Some(buffer), Some((method, path, started))) = (state.otlp.as_ref(), otlp_request) {
buffer.push(SpanRecord::request_span(
&trace, &method, &path, scheme, status, started, elapsed,
));
}
result.map(|resp| {
let (parts, body) = resp.into_parts();
Response::from_parts(parts, hold_request_permit(body, admission))
})
}
#[allow(clippy::too_many_arguments)]
async fn proxy_core_inner(
state: Arc<ServerState>,
scheme: &'static str,
client: IpAddr,
trace: RequestTrace,
request: hyper::Request<ReqBody>,
attribution: &mut Attribution,
admission: Arc<OwnedSemaphorePermit>,
conn_guards: Option<Arc<ConnGuards>>,
) -> Result<Response<ResponseBody>, ServerError> {
let (mut parts, body) = request.into_parts();
let mut http_req = to_http_request(&parts, scheme);
let bodyless = body.size_hint().exact() == Some(0);
match plecto_control::normalize_path(&http_req.path_with_query) {
Some(std::borrow::Cow::Owned(path)) => http_req.path_with_query = path,
Some(std::borrow::Cow::Borrowed(_)) => {}
None => {
return Ok(with_error_code(
synth(
StatusCode::BAD_REQUEST,
&fault::BAD_PATH,
b"bad request path",
),
&plecto_control::PATH_NORMALIZATION_REJECTED,
));
}
}
let snapshot = state.control.snapshot_with_trace(trace);
let Some(route) = snapshot.find_route(&http_req) else {
return Ok(synth(StatusCode::NOT_FOUND, &fault::NO_ROUTE, b"no route"));
};
attribution.route = Some(route.name.clone());
let idx = route.index;
let routed: Result<Routed, ServerError> = async {
if let RateLimitDecision::Limit { retry_after_ms } = route.check_rate_limit(client) {
state.metrics.inc_rate_limited(&route.name);
return Ok(Routed::Synthesised(with_error_code(
synth_retry_after(
StatusCode::TOO_MANY_REQUESTS,
&fault::RATE_LIMITED,
b"rate limit exceeded",
retry_after_ms.div_ceil(1000),
),
&plecto_control::QUOTA_EXCEEDED,
)));
}
let upgrade = upgrade_intent(&route, &mut parts, bodyless);
let chain = ChainRef {
snapshot: &snapshot,
idx,
has_filters: route.has_filters,
admission,
};
let mut forward = if route.has_filters {
let inline_res = catch_chain_panic(|| snapshot.dispatch_request_inline(idx, http_req))?;
let outcome = match inline_res {
InlineRequestOutcome::Complete(outcome) => outcome,
InlineRequestOutcome::Pending {
filter_index,
request,
} => {
let snap_req = snapshot.clone();
let admission = chain.admission.clone();
tokio::task::spawn_blocking(move || {
let _admission = admission;
snap_req.dispatch_request_from(idx, filter_index, request)
})
.await?
}
};
match outcome {
ChainOutcome::Respond(resp) => {
return Ok(Routed::Synthesised(http_response(resp)));
}
ChainOutcome::Forward(req) => req,
}
} else {
http_req
};
let Some(group) = route.pick_upstream() else {
return Ok(Routed::Synthesised(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::NO_HEALTHY_UPSTREAM,
b"no healthy upstream",
)));
};
let hash_key: Option<HashInput<'_>> = group.hash_key_source().and_then(|src| match src {
HashKeySource::Header(name) => parts
.headers
.get(name)
.map(|v| HashInput::Bytes(v.as_bytes())),
HashKeySource::SourceIp => Some(HashInput::Ip(client)),
});
let upstream_path = route.rewrite_path(&forward.path_with_query).into_owned();
let timeout = route.request_timeout(&group);
let overall = route.overall_timeout(&group);
let overall_deadline = (!overall.is_zero()).then(|| Instant::now() + overall);
let mut real_body = if bodyless {
ForwardBody::Bodyless
} else {
ForwardBody::OneShot(body)
};
match request_body_hook(
&state,
&chain,
route.body_hooks.request,
&mut real_body,
&mut forward.headers,
)
.await?
{
BodyHookOutcome::Proceed => {}
BodyHookOutcome::Respond(resp) => return Ok(Routed::Synthesised(resp)),
};
let permit = match group.try_acquire() {
Some(permit) => permit,
None => {
state.metrics.inc_circuit_open();
return Ok(Routed::Synthesised(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::CIRCUIT_OPEN,
b"upstream overloaded",
)));
}
};
let Some(pick) = group.pick(hash_key) else {
return Ok(Routed::Synthesised(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::NO_HEALTHY_UPSTREAM,
b"no healthy upstream",
)));
};
if route.body_hooks.response {
strip_inspection_hostile_headers(&mut forward.headers);
}
let forward_req = ForwardRequest {
method: forward.method.as_str(),
headers: if route.has_filters {
crate::forward::AttemptHeaders::Chain(&forward.headers)
} else {
crate::forward::AttemptHeaders::Direct(&parts.headers)
},
original_headers: &parts.headers,
authority: &forward.authority,
upstream_path: &upstream_path,
traceparent: &snapshot.traceparent(),
upgrade_token: upgrade.as_ref().map(|(t, _, _)| t.as_str()),
admission: chain.admission.clone(),
};
let client = state.clients.for_group(&group);
let (upstream_resp, upstream_pick) = match forward_with_retry(
&client,
&state.metrics,
&group,
pick,
hash_key,
forward_req,
real_body,
timeout,
overall_deadline,
group.max_retries(),
)
.await
{
ForwardOutcome::Response(resp, pick) => (resp, pick),
ForwardOutcome::OverallTimeout => {
return Ok(Routed::Synthesised(synth(
StatusCode::GATEWAY_TIMEOUT,
&fault::REQUEST_TIMEOUT,
b"request timeout",
)));
}
ForwardOutcome::PerTryTimeout => {
return Ok(Routed::Synthesised(synth(
StatusCode::GATEWAY_TIMEOUT,
&fault::UPSTREAM_TIMEOUT,
b"upstream timeout",
)));
}
ForwardOutcome::SendFailed(e) => return Err(e.into()),
ForwardOutcome::BuildFailed(e) => return Err(e.into()),
};
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
return upgrade_switch(
&state,
&chain,
forward,
upgrade,
upstream_resp,
permit,
upstream_pick,
conn_guards,
)
.await
.map(Routed::Synthesised);
}
respond_through_chain(
&state,
&chain,
&route,
forward,
upstream_resp,
&mut attribution.inspection_skipped,
)
.await
}
.await;
let routed = routed.unwrap_or_else(|e| {
tracing::warn!(error = %e, "fast-path error");
Routed::Synthesised(synth(
StatusCode::BAD_GATEWAY,
&fault::UPSTREAM,
b"upstream error",
))
});
Ok(match routed {
Routed::Synthesised(resp) => with_declared_headers(resp, &route),
Routed::Forwarded {
response,
body_budget,
} => {
let response =
crate::compression::apply(with_declared_headers(response, &route), &route, &parts);
match body_budget {
Some(permit) => {
let (parts, body) = response.into_parts();
Response::from_parts(parts, hold_budget(body, permit))
}
None => response,
}
}
})
}
enum Routed {
Synthesised(Response<ResponseBody>),
Forwarded {
response: Response<ResponseBody>,
body_budget: Option<OwnedSemaphorePermit>,
},
}
impl Routed {
fn forwarded(response: Response<ResponseBody>) -> Self {
Self::Forwarded {
response,
body_budget: None,
}
}
fn buffered(response: Response<ResponseBody>, permit: OwnedSemaphorePermit) -> Self {
Self::Forwarded {
response,
body_budget: Some(permit),
}
}
}
fn with_declared_headers(
mut resp: Response<ResponseBody>,
route: &RouteInfo,
) -> Response<ResponseBody> {
if let Some(declared) = route.response_headers.as_deref() {
declared.apply(resp.headers_mut());
}
resp
}
struct ChainRef<'a> {
snapshot: &'a ConfigSnapshot,
idx: usize,
has_filters: bool,
admission: Arc<OwnedSemaphorePermit>,
}
type UpgradeIntent = (String, hyper::upgrade::OnUpgrade, Option<Duration>);
fn upgrade_intent(
route: &RouteInfo,
parts: &mut hyper::http::request::Parts,
bodyless: bool,
) -> Option<UpgradeIntent> {
if !bodyless {
return None;
}
let cfg = route.upgrade.as_ref()?;
let header = crate::headers::upgrade_request_header(&parts.headers)?;
let token = cfg.allowed_token(header)?.to_string();
let on_upgrade = parts.extensions.remove::<hyper::upgrade::OnUpgrade>()?;
Some((token, on_upgrade, cfg.idle_timeout()))
}
enum BodyHookOutcome {
Proceed,
Respond(Response<ResponseBody>),
}
async fn request_body_hook(
state: &ServerState,
chain: &ChainRef<'_>,
reads_body: bool,
real_body: &mut ForwardBody,
headers: &mut Vec<plecto_control::Header>,
) -> Result<BodyHookOutcome, ServerError> {
if !reads_body {
return Ok(BodyHookOutcome::Proceed);
}
let Some(b) = real_body.take_oneshot() else {
return Ok(BodyHookOutcome::Proceed);
};
let permit = match state
.body_buffer_budget
.clone()
.acquire_many_owned(budget_permits(MAX_REQUEST_BODY_BUFFER))
.await
{
Ok(permit) => permit,
Err(_) => {
return Ok(BodyHookOutcome::Respond(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::BODY_BUFFER_UNAVAILABLE,
b"body buffer unavailable",
)));
}
};
let buffered = match tokio::time::timeout(
INBOUND_BODY_READ_TIMEOUT,
buffer_request_body(b, MAX_REQUEST_BODY_BUFFER),
)
.await
{
Ok(crate::body::BufferOutcome::Buffered(buf)) => buf,
Ok(crate::body::BufferOutcome::TooLarge) => {
return Ok(BodyHookOutcome::Respond(synth(
StatusCode::PAYLOAD_TOO_LARGE,
&fault::BODY_TOO_LARGE,
b"request body too large",
)));
}
Ok(crate::body::BufferOutcome::ReadError) => {
return Ok(BodyHookOutcome::Respond(synth(
StatusCode::BAD_REQUEST,
&fault::BODY_READ_ERROR,
b"request body read error",
)));
}
Err(_) => {
return Ok(BodyHookOutcome::Respond(synth(
StatusCode::REQUEST_TIMEOUT,
&fault::BODY_TIMEOUT,
b"request body read timeout",
)));
}
};
let snap_body = chain.snapshot.clone();
let idx = chain.idx;
let chain_headers = std::mem::take(headers);
let admission = chain.admission.clone();
let (outcome, permit) = tokio::task::spawn_blocking(move || {
let _admission = admission;
let outcome = snap_body.dispatch_request_body(idx, buffered, chain_headers);
(outcome, permit)
})
.await?;
match outcome {
RequestBodyOutcome::Respond(resp) => Ok(BodyHookOutcome::Respond(http_response(resp))),
RequestBodyOutcome::Forward {
body: edited,
headers: edited_headers,
} => {
*real_body = ForwardBody::Replayable(crate::body::bytes_with_permit(
bytes::Bytes::from(edited),
Arc::new(permit),
));
*headers = edited_headers;
Ok(BodyHookOutcome::Proceed)
}
}
}
const INSPECTION_HOSTILE_REQUEST_HEADERS: [&str; 3] = ["accept-encoding", "range", "if-range"];
fn strip_inspection_hostile_headers(headers: &mut Vec<plecto_control::Header>) {
headers.retain(|h| {
!INSPECTION_HOSTILE_REQUEST_HEADERS
.iter()
.any(|n| h.name.eq_ignore_ascii_case(n))
});
}
fn budget_permits(bytes: usize) -> u32 {
u32::try_from(bytes).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod admission_tests;
fn upstream_switched_to(headers: &hyper::HeaderMap, token: &str) -> bool {
headers
.get(hyper::header::UPGRADE)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.split(',').any(|t| t.trim().eq_ignore_ascii_case(token)))
}
#[allow(clippy::too_many_arguments)]
async fn upgrade_switch(
state: &Arc<ServerState>,
chain: &ChainRef<'_>,
forward: HttpRequest,
upgrade: Option<UpgradeIntent>,
mut upstream_resp: Response<ResponseBody>,
permit: impl Send + 'static,
pick: plecto_control::Pick,
conn_guards: Option<Arc<ConnGuards>>,
) -> Result<Response<ResponseBody>, ServerError> {
let Some((token, downstream_on, idle)) = upgrade else {
return Ok(synth(
StatusCode::BAD_GATEWAY,
&fault::BAD_UPGRADE,
b"unsolicited upgrade",
));
};
if !upstream_switched_to(upstream_resp.headers(), &token) {
return Ok(synth(
StatusCode::BAD_GATEWAY,
&fault::BAD_UPGRADE,
b"upgrade token mismatch",
));
}
let upstream_on = hyper::upgrade::on(&mut upstream_resp);
let mut builder = Response::builder().status(StatusCode::SWITCHING_PROTOCOLS);
if chain.has_filters {
let http_resp = HttpResponse {
status: StatusCode::SWITCHING_PROTOCOLS.as_u16(),
headers: headers_to_vec(upstream_resp.headers()),
body: Vec::new(),
};
let inline_res = catch_chain_panic(|| {
chain
.snapshot
.dispatch_response_inline(chain.idx, &forward, http_resp)
})?;
let outcome = match inline_res {
InlineResponseOutcome::Complete(outcome) => outcome,
InlineResponseOutcome::Pending {
filter_index,
response,
} => {
let snap_resp = chain.snapshot.clone();
let idx = chain.idx;
let admission = chain.admission.clone();
tokio::task::spawn_blocking(move || {
let _admission = admission;
snap_resp.dispatch_response_from(idx, filter_index, &forward, response)
})
.await?
}
};
let edited = match outcome {
ResponseOutcome::Respond(resp) => return Ok(http_response(resp)),
ResponseOutcome::Forward(edited) => edited,
};
if edited.status != StatusCode::SWITCHING_PROTOCOLS.as_u16() {
return Ok(http_response(edited));
}
copy_headers(builder.headers_mut(), &edited.headers);
} else {
copy_headers_direct(builder.headers_mut(), upstream_resp.headers());
}
let Ok(upgrade_value) = hyper::header::HeaderValue::from_str(&token) else {
return Ok(synth(
StatusCode::BAD_GATEWAY,
&fault::BAD_UPGRADE,
b"bad upgrade token",
));
};
if let Some(h) = builder.headers_mut() {
h.insert(hyper::header::UPGRADE, upgrade_value);
h.insert(
hyper::header::CONNECTION,
hyper::header::HeaderValue::from_static("upgrade"),
);
}
let resp101 = builder
.body(crate::body::full(Vec::new()))
.map_err(ServerError::from)?;
let drain = state.drain.clone();
let metrics = state.metrics.clone();
let tunnel_active = crate::metrics::TunnelActive::new(metrics.clone());
let admission = chain.admission.clone();
tokio::spawn(async move {
let _conn_guards = conn_guards;
let _permit = permit;
let _pick = pick;
let _active = tunnel_active;
let _admission = admission;
let (down, up) = crate::tunnel::run(downstream_on, upstream_on, idle, drain).await;
metrics.add_tunnel_bytes(down, up);
});
Ok(resp101)
}
async fn respond_through_chain(
state: &ServerState,
chain: &ChainRef<'_>,
route: &RouteInfo,
forward: HttpRequest,
upstream_resp: Response<ResponseBody>,
inspection_skipped: &mut Option<InspectionSkip>,
) -> Result<Routed, ServerError> {
let (uparts, ubody) = upstream_resp.into_parts();
if !chain.has_filters {
return Ok(Routed::forwarded(stream_response_direct(
uparts.status,
&uparts.headers,
ubody,
)));
}
let inspectability = route
.body_hooks
.response
.then(|| classify_response(uparts.status, &uparts.headers, &route.response_body));
let http_resp = HttpResponse {
status: uparts.status.as_u16(),
headers: headers_to_vec(&uparts.headers),
body: Vec::new(), };
let inline_res = catch_chain_panic(|| {
chain
.snapshot
.dispatch_response_inline(chain.idx, &forward, http_resp)
})?;
let (outcome, forward) = match inline_res {
InlineResponseOutcome::Complete(outcome) => (outcome, forward),
InlineResponseOutcome::Pending {
filter_index,
response,
} => {
let snap_resp = chain.snapshot.clone();
let idx = chain.idx;
let admission = chain.admission.clone();
tokio::task::spawn_blocking(move || {
let _admission = admission;
let outcome =
snap_resp.dispatch_response_from(idx, filter_index, &forward, response);
(outcome, forward)
})
.await?
}
};
let edited = match outcome {
ResponseOutcome::Forward(edited) => edited,
ResponseOutcome::Respond(resp) => {
discard_upstream_body(ubody, chain.admission.clone());
return Ok(Routed::Synthesised(http_response(resp)));
}
};
match inspectability {
None | Some(Inspectability::OutOfScope) => Ok(Routed::forwarded(stream_response(
edited.status,
&edited.headers,
&uparts.headers,
ubody,
))),
Some(Inspectability::Uninspectable(reason)) => {
state.metrics.inc_inspection_skipped(reason);
*inspection_skipped = Some(reason);
match route.response_body.uninspectable() {
UninspectableMode::Passthrough => Ok(Routed::forwarded(stream_response(
edited.status,
&edited.headers,
&uparts.headers,
ubody,
))),
UninspectableMode::Reject => {
discard_upstream_body(ubody, chain.admission.clone());
Ok(Routed::Synthesised(synth(
StatusCode::BAD_GATEWAY,
&fault::RESPONSE_BODY_UNINSPECTABLE,
b"response body could not be inspected",
)))
}
}
}
Some(Inspectability::Inspect) => {
inspect_response_body(
state,
chain,
route,
forward,
edited,
uparts.headers,
ubody,
inspection_skipped,
)
.await
}
}
}
enum Inspectability {
Inspect,
OutOfScope,
Uninspectable(InspectionSkip),
}
fn classify_response(
status: StatusCode,
headers: &hyper::HeaderMap,
cfg: &ResponseBodyConfig,
) -> Inspectability {
let essence = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.split(';').next().unwrap_or(s).trim())
.unwrap_or_default();
if is_streaming_media_type(essence) {
return Inspectability::Uninspectable(InspectionSkip::StreamingContentType);
}
if !cfg.content_type_eligible(essence) {
return Inspectability::OutOfScope;
}
if status == StatusCode::PARTIAL_CONTENT || headers.contains_key(CONTENT_RANGE) {
return Inspectability::Uninspectable(InspectionSkip::PartialContent);
}
if is_content_encoded(headers) {
return Inspectability::Uninspectable(InspectionSkip::ContentEncoding);
}
Inspectability::Inspect
}
fn is_streaming_media_type(essence: &str) -> bool {
essence.eq_ignore_ascii_case("text/event-stream")
}
fn is_content_encoded(headers: &hyper::HeaderMap) -> bool {
headers
.get_all(CONTENT_ENCODING)
.iter()
.any(|v| match v.to_str() {
Ok(s) => s.split(',').any(|token| {
let token = token.trim();
!token.is_empty() && !token.eq_ignore_ascii_case("identity")
}),
Err(_) => true,
})
}
#[allow(clippy::too_many_arguments)]
async fn inspect_response_body(
state: &ServerState,
chain: &ChainRef<'_>,
route: &RouteInfo,
forward: HttpRequest,
edited: HttpResponse,
upstream_headers: hyper::HeaderMap,
ubody: ResponseBody,
inspection_skipped: &mut Option<InspectionSkip>,
) -> Result<Routed, ServerError> {
let cfg = &route.response_body;
let cap = cfg.max_bytes();
let status = edited.status;
let permit = match state
.body_buffer_budget
.clone()
.acquire_many_owned(budget_permits(cap))
.await
{
Ok(permit) => permit,
Err(_) => {
discard_upstream_body(ubody, chain.admission.clone());
return Ok(Routed::Synthesised(synth(
StatusCode::SERVICE_UNAVAILABLE,
&fault::RESPONSE_BODY_BUFFER_UNAVAILABLE,
b"response body buffer unavailable",
)));
}
};
let buffered =
match tokio::time::timeout(UPSTREAM_BODY_READ_TIMEOUT, buffer_response_body(ubody, cap))
.await
{
Ok(outcome) => outcome,
Err(_) => {
return Ok(Routed::Synthesised(synth(
StatusCode::GATEWAY_TIMEOUT,
&fault::RESPONSE_BODY_TIMEOUT,
b"upstream response body timeout",
)));
}
};
let (body, rest) = match buffered {
ResponseBufferOutcome::Buffered(body) => (body, None),
ResponseBufferOutcome::ReadError => {
return Ok(Routed::Synthesised(synth(
StatusCode::BAD_GATEWAY,
&fault::RESPONSE_BODY_READ_ERROR,
b"upstream response body read error",
)));
}
ResponseBufferOutcome::OverCap { head, rest } => {
state
.metrics
.inc_inspection_skipped(InspectionSkip::OverCap);
*inspection_skipped = Some(InspectionSkip::OverCap);
match cfg.over_cap() {
OverCapMode::Reject => {
discard_upstream_body(rest, chain.admission.clone());
return Ok(Routed::Synthesised(synth(
StatusCode::BAD_GATEWAY,
&fault::RESPONSE_BODY_TOO_LARGE,
b"response body too large to inspect",
)));
}
OverCapMode::Passthrough => {
return Ok(Routed::buffered(
stream_response(
status,
&edited.headers,
&upstream_headers,
prefixed(Bytes::from(head), rest),
),
permit,
));
}
OverCapMode::ProcessPartial => (head, Some(rest)),
}
}
};
let snap = chain.snapshot.clone();
let idx = chain.idx;
let admission = chain.admission.clone();
let (outcome, permit) = tokio::task::spawn_blocking(move || {
let _admission = admission;
let outcome = snap.dispatch_response_body(idx, &forward, edited, body);
(outcome, permit)
})
.await?;
match outcome {
ResponseBodyOutcome::Respond(resp) => {
if let Some(rest) = rest {
discard_upstream_body(rest, chain.admission.clone());
}
Ok(Routed::Synthesised(http_response(resp)))
}
ResponseBodyOutcome::Forward {
body,
headers,
transformed,
} => match rest {
None => Ok(Routed::buffered(
http_response(HttpResponse {
status,
headers,
body,
}),
permit,
)),
Some(rest) if transformed => {
discard_upstream_body(rest, chain.admission.clone());
Ok(Routed::Synthesised(synth(
StatusCode::BAD_GATEWAY,
&fault::RESPONSE_BODY_PARTIAL_MODIFIED,
b"filter rewrote a partially inspected response body",
)))
}
Some(rest) => Ok(Routed::buffered(
stream_response(
status,
&headers,
&upstream_headers,
prefixed(Bytes::from(body), rest),
),
permit,
)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyper::header::HeaderValue;
use plecto_control::{Control, Host, Manifest, MemoryStore};
use plecto_host::test_support::TestSigner;
use tokio::sync::{Semaphore, watch};
fn capped_test_state(cap: usize) -> Arc<ServerState> {
let signer = TestSigner::new().unwrap();
let manifest = Manifest::from_toml("").unwrap();
let control = Arc::new(
Control::load(
Host::new(signer.trust_policy().unwrap()).unwrap(),
&manifest,
Box::new(MemoryStore::new()),
)
.unwrap(),
);
let (_, drain) = watch::channel(false);
let (_, ready) = watch::channel(true);
Arc::new(ServerState {
control,
clients: crate::upstream_client::UpstreamClients::new(),
alt_svc: None,
trusted_proxy: None,
conn_limit: Arc::new(Semaphore::new(crate::MAX_CONNECTIONS)),
per_ip_conn_limit: Arc::new(crate::conn_limit::PerIpConnLimit::new(
crate::MAX_CONNECTIONS_PER_IP,
)),
body_buffer_budget: Arc::new(Semaphore::new(1)),
request_limit: Arc::new(Semaphore::new(cap)),
metrics: Arc::new(crate::metrics::ServerMetrics::new()),
otlp: None,
drain,
ready,
})
}
fn no_route_parts() -> hyper::http::request::Parts {
hyper::Request::builder()
.uri("http://example.test/")
.header(hyper::header::HOST, "example.test")
.body(())
.unwrap()
.into_parts()
.0
}
#[tokio::test]
async fn admission_holds_a_real_proxy_response_until_its_body_is_dropped() {
let state = capped_test_state(1);
let first = proxy_core(
state.clone(),
"http",
"127.0.0.1:1".parse().unwrap(),
no_route_parts(),
crate::body::empty_req(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::NOT_FOUND);
let rejected = proxy_core(
state.clone(),
"http",
"127.0.0.1:1".parse().unwrap(),
no_route_parts(),
crate::body::empty_req(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
rejected
.headers()
.get("x-plecto-fault")
.map(|v| v.as_bytes()),
Some(b"request-overloaded".as_slice())
);
drop(first);
let readmitted = proxy_core(
state,
"http",
"127.0.0.1:1".parse().unwrap(),
no_route_parts(),
crate::body::empty_req(),
)
.await
.unwrap();
assert_eq!(readmitted.status(), StatusCode::NOT_FOUND);
}
fn cfg() -> ResponseBodyConfig {
ResponseBodyConfig::default()
}
fn headers(pairs: &[(&str, &str)]) -> hyper::HeaderMap {
let mut map = hyper::HeaderMap::new();
for (name, value) in pairs {
map.append(
hyper::header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
map
}
#[test]
fn the_allowlist_arms_inspection_on_the_essence_only() {
assert!(matches!(
classify_response(
StatusCode::OK,
&headers(&[("content-type", "Application/JSON; charset=utf-8")]),
&cfg()
),
Inspectability::Inspect
));
assert!(matches!(
classify_response(
StatusCode::OK,
&headers(&[("content-type", "image/png")]),
&cfg()
),
Inspectability::OutOfScope
));
assert!(
matches!(
classify_response(StatusCode::OK, &headers(&[]), &cfg()),
Inspectability::OutOfScope
),
"no declared type, no declared scope"
);
}
#[test]
fn every_uninspectable_class_is_named_rather_than_skipped() {
assert!(matches!(
classify_response(
StatusCode::OK,
&headers(&[("content-type", "text/event-stream")]),
&cfg()
),
Inspectability::Uninspectable(InspectionSkip::StreamingContentType)
));
assert!(matches!(
classify_response(
StatusCode::OK,
&headers(&[
("content-type", "application/json"),
("content-encoding", "gzip")
]),
&cfg()
),
Inspectability::Uninspectable(InspectionSkip::ContentEncoding)
));
assert!(matches!(
classify_response(
StatusCode::PARTIAL_CONTENT,
&headers(&[("content-type", "application/json")]),
&cfg()
),
Inspectability::Uninspectable(InspectionSkip::PartialContent)
));
assert!(
matches!(
classify_response(
StatusCode::OK,
&headers(&[
("content-type", "application/json"),
("content-range", "bytes 0-99/1000")
]),
&cfg()
),
Inspectability::Uninspectable(InspectionSkip::PartialContent)
),
"a fragment is a fragment whatever status carries it"
);
assert!(
matches!(
classify_response(
StatusCode::OK,
&headers(&[
("content-type", "application/json"),
("content-encoding", "identity")
]),
&cfg()
),
Inspectability::Inspect
),
"`identity` is not an encoding to see through"
);
}
#[test]
fn a_response_inspecting_route_asks_the_upstream_for_a_whole_identity_representation() {
let mut headers = vec![
plecto_control::Header {
name: "Accept-Encoding".to_string(),
value: b"gzip, br".to_vec(),
},
plecto_control::Header {
name: "range".to_string(),
value: b"bytes=0-99".to_vec(),
},
plecto_control::Header {
name: "If-Range".to_string(),
value: b"\"v1\"".to_vec(),
},
plecto_control::Header {
name: "x-keep".to_string(),
value: b"1".to_vec(),
},
];
strip_inspection_hostile_headers(&mut headers);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].name, "x-keep");
}
#[test]
fn catch_chain_panic_catches_panic_and_passes_value() {
let panic_res = catch_chain_panic(|| -> () {
panic!("inline chain panicked");
});
assert!(matches!(panic_res, Err(ServerError::ChainPanic)));
let pass_res = catch_chain_panic(|| 42);
assert!(matches!(pass_res, Ok(42)));
}
}