mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-01 01:38:18 +00:00
fix(server): actionable 501 for virtual-hosted-style S3 requests (#3442)
This commit is contained in:
committed by
GitHub
parent
f8e300bf3b
commit
e4c1ee9941
@@ -13,7 +13,10 @@ RUSTFS_ADDRESS=0.0.0.0:9000
|
|||||||
RUSTFS_CONSOLE_ENABLE=true
|
RUSTFS_CONSOLE_ENABLE=true
|
||||||
# RustFS console listen address and port
|
# RustFS console listen address and port
|
||||||
RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
|
||||||
# Optional service domain configuration for virtual-hosted-style requests (comma-separated).
|
# Optional service domain(s) for virtual-hosted-style requests (comma-separated).
|
||||||
|
# Required for clients that default to virtual-hosted-style addressing (AWS SDK,
|
||||||
|
# Terraform/Pulumi). Without it, only path-style addressing works (set the client's
|
||||||
|
# s3_use_path_style = true / force_path_style=true). Example:
|
||||||
# RUSTFS_SERVER_DOMAINS=s3.example.com
|
# RUSTFS_SERVER_DOMAINS=s3.example.com
|
||||||
# Optional RustFS license content
|
# Optional RustFS license content
|
||||||
# RUSTFS_LICENSE=REPLACE_WITH_LICENSE_CONTENT
|
# RUSTFS_LICENSE=REPLACE_WITH_LICENSE_CONTENT
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ pub struct ServerOpts {
|
|||||||
)]
|
)]
|
||||||
pub address: String,
|
pub address: String,
|
||||||
|
|
||||||
/// Domain name used for virtual-hosted-style requests.
|
/// Domain name(s) for virtual-hosted-style S3 requests (comma-separated).
|
||||||
|
///
|
||||||
|
/// Required for clients that default to virtual-hosted-style addressing
|
||||||
|
/// (AWS SDK, Terraform/Pulumi, etc.), e.g. `RUSTFS_SERVER_DOMAINS=s3.example.com`
|
||||||
|
/// so that `bucket.s3.example.com` is routed to bucket `bucket`. When unset, only
|
||||||
|
/// path-style addressing is supported (configure clients with
|
||||||
|
/// `s3_use_path_style = true` / `force_path_style=true`).
|
||||||
#[arg(
|
#[arg(
|
||||||
long,
|
long,
|
||||||
env = "RUSTFS_SERVER_DOMAINS",
|
env = "RUSTFS_SERVER_DOMAINS",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use crate::server::{
|
|||||||
layer::{
|
layer::{
|
||||||
BodylessStatusFixLayer, ConditionalCorsLayer, EmptyBodyContentLengthCompatLayer, HeadRequestBodyFixLayer,
|
BodylessStatusFixLayer, ConditionalCorsLayer, EmptyBodyContentLengthCompatLayer, HeadRequestBodyFixLayer,
|
||||||
ObjectAttributesEtagFixLayer, PublicHealthEndpointLayer, RedirectLayer, RequestContextLayer, RequestLoggingLayer,
|
ObjectAttributesEtagFixLayer, PublicHealthEndpointLayer, RedirectLayer, RequestContextLayer, RequestLoggingLayer,
|
||||||
S3ErrorMessageCompatLayer, redact_sensitive_uri_query,
|
S3ErrorMessageCompatLayer, VirtualHostStyleHintLayer, redact_sensitive_uri_query,
|
||||||
},
|
},
|
||||||
tls_material::{
|
tls_material::{
|
||||||
TlsAcceptorHolder, TlsHandshakeFailureKind, build_acceptor_from_loaded, load_tls_material, spawn_reload_loop,
|
TlsAcceptorHolder, TlsHandshakeFailureKind, build_acceptor_from_loaded, load_tls_material, spawn_reload_loop,
|
||||||
@@ -506,6 +506,7 @@ pub async fn start_http_server(config: &config::Config, readiness: Arc<GlobalRea
|
|||||||
}
|
}
|
||||||
|
|
||||||
let is_console = config.console_enable;
|
let is_console = config.console_enable;
|
||||||
|
let server_domains_configured = !config.server_domains.is_empty();
|
||||||
let task_handle = tokio::spawn(async move {
|
let task_handle = tokio::spawn(async move {
|
||||||
// Note: CORS layer is removed from global middleware stack
|
// Note: CORS layer is removed from global middleware stack
|
||||||
// - S3 API CORS is handled by bucket-level CORS configuration in apply_cors_headers()
|
// - S3 API CORS is handled by bucket-level CORS configuration in apply_cors_headers()
|
||||||
@@ -690,6 +691,7 @@ pub async fn start_http_server(config: &config::Config, readiness: Arc<GlobalRea
|
|||||||
s3_service: s3_service.clone(),
|
s3_service: s3_service.clone(),
|
||||||
compression_config: compression_config.clone(),
|
compression_config: compression_config.clone(),
|
||||||
is_console,
|
is_console,
|
||||||
|
server_domains_configured,
|
||||||
readiness: readiness.clone(),
|
readiness: readiness.clone(),
|
||||||
keystone_auth: auth_keystone::get_keystone_auth(),
|
keystone_auth: auth_keystone::get_keystone_auth(),
|
||||||
trusted_proxy_layer: rustfs_trusted_proxies::is_enabled().then(|| rustfs_trusted_proxies::layer().clone()),
|
trusted_proxy_layer: rustfs_trusted_proxies::is_enabled().then(|| rustfs_trusted_proxies::layer().clone()),
|
||||||
@@ -742,6 +744,8 @@ struct ConnectionContext {
|
|||||||
s3_service: S3Service,
|
s3_service: S3Service,
|
||||||
compression_config: HttpCompressionConfig,
|
compression_config: HttpCompressionConfig,
|
||||||
is_console: bool,
|
is_console: bool,
|
||||||
|
/// Whether `RUSTFS_SERVER_DOMAINS` is configured (i.e. s3s virtual-hosted-style routing is active).
|
||||||
|
server_domains_configured: bool,
|
||||||
readiness: Arc<GlobalReadiness>,
|
readiness: Arc<GlobalReadiness>,
|
||||||
/// Pre-computed Keystone auth provider (avoids per-connection OnceLock read).
|
/// Pre-computed Keystone auth provider (avoids per-connection OnceLock read).
|
||||||
keystone_auth: Option<Arc<rustfs_keystone::KeystoneAuthProvider>>,
|
keystone_auth: Option<Arc<rustfs_keystone::KeystoneAuthProvider>>,
|
||||||
@@ -843,6 +847,7 @@ fn process_connection(
|
|||||||
s3_service,
|
s3_service,
|
||||||
compression_config,
|
compression_config,
|
||||||
is_console,
|
is_console,
|
||||||
|
server_domains_configured,
|
||||||
readiness,
|
readiness,
|
||||||
keystone_auth,
|
keystone_auth,
|
||||||
trusted_proxy_layer,
|
trusted_proxy_layer,
|
||||||
@@ -900,6 +905,7 @@ fn process_connection(
|
|||||||
// 19. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses
|
// 19. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses
|
||||||
// 20. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses
|
// 20. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses
|
||||||
// 21. PublicHealthEndpointLayer — handles public health before s3s host parsing
|
// 21. PublicHealthEndpointLayer — handles public health before s3s host parsing
|
||||||
|
// 22. VirtualHostStyleHintLayer — actionable error for unroutable virtual-hosted-style (conditional)
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
let hybrid_service = ServiceBuilder::new()
|
let hybrid_service = ServiceBuilder::new()
|
||||||
// NOTE: Both extension types are intentionally inserted to maintain compatibility:
|
// NOTE: Both extension types are intentionally inserted to maintain compatibility:
|
||||||
@@ -1106,6 +1112,11 @@ fn process_connection(
|
|||||||
// buckets before custom routes. Handle them here so SERVER_DOMAINS
|
// buckets before custom routes. Handle them here so SERVER_DOMAINS
|
||||||
// cannot turn /health into an S3 bucket request.
|
// cannot turn /health into an S3 bucket request.
|
||||||
.layer(PublicHealthEndpointLayer)
|
.layer(PublicHealthEndpointLayer)
|
||||||
|
// Virtual-hosted-style S3 requests (the AWS SDK / Terraform default) cannot be
|
||||||
|
// routed when no server domain is configured: s3s parses them path-style and
|
||||||
|
// returns an opaque 501. When RUSTFS_SERVER_DOMAINS is unset, return an actionable
|
||||||
|
// error pointing at the fix. Inert (not installed) once domains are configured.
|
||||||
|
.option_layer((!server_domains_configured && !is_console).then_some(VirtualHostStyleHintLayer))
|
||||||
.service(service);
|
.service(service);
|
||||||
|
|
||||||
let hybrid_service = TowerToHyperService::new(hybrid_service);
|
let hybrid_service = TowerToHyperService::new(hybrid_service);
|
||||||
|
|||||||
+313
-1
@@ -26,7 +26,7 @@ use crate::server::{
|
|||||||
use crate::storage::apply_cors_headers;
|
use crate::storage::apply_cors_headers;
|
||||||
use crate::storage::request_context::{RequestContext, extract_request_id_from_headers, extract_trace_context_ids_from_headers};
|
use crate::storage::request_context::{RequestContext, extract_request_id_from_headers, extract_trace_context_ids_from_headers};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http::{HeaderMap, HeaderValue, Method, Request as HttpRequest, Response, StatusCode};
|
use http::{HeaderMap, HeaderValue, Method, Request as HttpRequest, Response, StatusCode, Uri};
|
||||||
use http_body::Body;
|
use http_body::Body;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use hyper::body::Incoming;
|
use hyper::body::Incoming;
|
||||||
@@ -955,6 +955,164 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured-log event emitted when a virtual-hosted-style request cannot be
|
||||||
|
/// routed because no server domain is configured.
|
||||||
|
const VIRTUAL_HOST_STYLE_UNROUTABLE_EVENT: &str = "virtual_host_style_unroutable";
|
||||||
|
|
||||||
|
/// Detects S3 requests that can only be virtual-hosted-style yet cannot be routed
|
||||||
|
/// because no server domain is configured (`RUSTFS_SERVER_DOMAINS` unset).
|
||||||
|
///
|
||||||
|
/// Without a configured domain, s3s parses every request as path-style, so a
|
||||||
|
/// virtual-hosted-style `PUT /` (the AWS SDK / Terraform default for `CreateBucket`)
|
||||||
|
/// arrives with an empty bucket and fails with an opaque `501 NotImplemented`.
|
||||||
|
///
|
||||||
|
/// Only the service root (`/`) with a method that has no path-style equivalent
|
||||||
|
/// (`PUT`/`DELETE`) and a DNS-style host (not an IP/socket address, and dotted like
|
||||||
|
/// `bucket.example.com`) is matched. The host is read from the `Host` header
|
||||||
|
/// (HTTP/1.1) or the request URI authority (HTTP/2 `:authority`). Such requests
|
||||||
|
/// already fail today, so returning a clearer error never changes the outcome of a
|
||||||
|
/// routable request. Returns the request host (without port) when matched.
|
||||||
|
fn unroutable_virtual_host_target(method: &Method, uri: &Uri, headers: &HeaderMap) -> Option<String> {
|
||||||
|
if *method != Method::PUT && *method != Method::DELETE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if uri.path() != "/" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Host` is set for HTTP/1.1; HTTP/2 carries the host in the URI authority.
|
||||||
|
let raw_authority = match headers.get(http::header::HOST).and_then(|value| value.to_str().ok()) {
|
||||||
|
Some(value) => value,
|
||||||
|
None => uri.authority().map(|authority| authority.as_str())?,
|
||||||
|
};
|
||||||
|
if raw_authority.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// IP / socket-address hosts always use path-style addressing in s3s.
|
||||||
|
if rustfs_utils::is_socket_addr(raw_authority) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Parse as an Authority to split host/port robustly (e.g. bracketed IPv6) rather
|
||||||
|
// than slicing on `:`.
|
||||||
|
let authority = raw_authority.parse::<http::uri::Authority>().ok()?;
|
||||||
|
let host = authority.host();
|
||||||
|
// Require a dotted DNS name (the shape of a virtual-hosted bucket subdomain).
|
||||||
|
if !host.contains('.') || host.starts_with('.') || host.ends_with('.') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(host.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xml_escape(value: &str) -> String {
|
||||||
|
let mut escaped = String::with_capacity(value.len());
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'&' => escaped.push_str("&"),
|
||||||
|
'<' => escaped.push_str("<"),
|
||||||
|
'>' => escaped.push_str(">"),
|
||||||
|
'"' => escaped.push_str("""),
|
||||||
|
'\'' => escaped.push_str("'"),
|
||||||
|
_ => escaped.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
escaped
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_virtual_host_hint_response<RestBody, GrpcBody>(
|
||||||
|
version: http::Version,
|
||||||
|
host: &str,
|
||||||
|
resource: &str,
|
||||||
|
) -> Response<HybridBody<RestBody, GrpcBody>>
|
||||||
|
where
|
||||||
|
RestBody: From<Bytes>,
|
||||||
|
{
|
||||||
|
let message = format!(
|
||||||
|
"Virtual-hosted-style request for host '{host}' could not be routed because no server domain is \
|
||||||
|
configured. Set RUSTFS_SERVER_DOMAINS to the S3 endpoint domain your buckets are addressed under \
|
||||||
|
(for example RUSTFS_SERVER_DOMAINS=s3.example.com, so that bucket.s3.example.com routes to bucket \
|
||||||
|
'bucket'), or configure your S3 client to use path-style addressing (AWS SDK: force_path_style=true; \
|
||||||
|
Terraform aws provider: s3_use_path_style = true)."
|
||||||
|
);
|
||||||
|
let body = format!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
||||||
|
<Error><Code>NotImplemented</Code><Message>{message}</Message><Resource>{resource}</Resource></Error>",
|
||||||
|
message = xml_escape(&message),
|
||||||
|
resource = xml_escape(resource),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut builder = Response::builder()
|
||||||
|
.status(StatusCode::NOT_IMPLEMENTED)
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/xml");
|
||||||
|
// This short-circuit path does not drain the request body. For HTTP/1.x, signal
|
||||||
|
// connection close so an undrained body cannot disrupt keep-alive reuse. `Connection`
|
||||||
|
// is a forbidden header in HTTP/2+, so it is only set for HTTP/1.x.
|
||||||
|
if !matches!(version, http::Version::HTTP_2 | http::Version::HTTP_3) {
|
||||||
|
builder = builder.header(http::header::CONNECTION, "close");
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.body(HybridBody::Rest {
|
||||||
|
rest_body: RestBody::from(Bytes::from(body)),
|
||||||
|
})
|
||||||
|
.expect("failed to build virtual-host hint response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an actionable error for virtual-hosted-style S3 requests that cannot be
|
||||||
|
/// routed because `RUSTFS_SERVER_DOMAINS` is not configured. See
|
||||||
|
/// [`unroutable_virtual_host_target`]. The layer is only installed when no server
|
||||||
|
/// domain is configured, so it is a pure pass-through for every routable request.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VirtualHostStyleHintLayer;
|
||||||
|
|
||||||
|
impl<S> Layer<S> for VirtualHostStyleHintLayer {
|
||||||
|
type Service = VirtualHostStyleHintService<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
VirtualHostStyleHintService { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct VirtualHostStyleHintService<S> {
|
||||||
|
inner: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, ReqBody, RestBody, GrpcBody> Service<HttpRequest<ReqBody>> for VirtualHostStyleHintService<S>
|
||||||
|
where
|
||||||
|
S: Service<HttpRequest<ReqBody>, Response = Response<HybridBody<RestBody, GrpcBody>>> + Clone + Send + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
ReqBody: Send + 'static,
|
||||||
|
RestBody: From<Bytes> + Send + 'static,
|
||||||
|
GrpcBody: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = Response<HybridBody<RestBody, GrpcBody>>;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, req: HttpRequest<ReqBody>) -> Self::Future {
|
||||||
|
if let Some(host) = unroutable_virtual_host_target(req.method(), req.uri(), req.headers()) {
|
||||||
|
let resource = req.uri().path().to_owned();
|
||||||
|
let version = req.version();
|
||||||
|
debug!(
|
||||||
|
event = VIRTUAL_HOST_STYLE_UNROUTABLE_EVENT,
|
||||||
|
component = LOG_COMPONENT_SERVER,
|
||||||
|
subsystem = LOG_SUBSYSTEM_HTTP,
|
||||||
|
host = %host,
|
||||||
|
method = %req.method(),
|
||||||
|
"Rejected virtual-hosted-style request: no server domain configured (set RUSTFS_SERVER_DOMAINS or use path-style)"
|
||||||
|
);
|
||||||
|
return Box::pin(async move { Ok(build_virtual_host_hint_response(version, &host, &resource)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = self.inner.clone();
|
||||||
|
Box::pin(async move { inner.call(req).await })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_bodyless_status(status: StatusCode) -> bool {
|
fn is_bodyless_status(status: StatusCode) -> bool {
|
||||||
status.is_informational()
|
status.is_informational()
|
||||||
|| status == StatusCode::NO_CONTENT
|
|| status == StatusCode::NO_CONTENT
|
||||||
@@ -1454,6 +1612,160 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unroutable_virtual_host_target_matches_service_root_writes() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(http::header::HOST, HeaderValue::from_static("my-bucket.s3.example.com"));
|
||||||
|
assert_eq!(
|
||||||
|
unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &headers),
|
||||||
|
Some("my-bucket.s3.example.com".to_owned())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unroutable_virtual_host_target(&Method::DELETE, &Uri::from_static("/"), &headers),
|
||||||
|
Some("my-bucket.s3.example.com".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unroutable_virtual_host_target_strips_port() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(http::header::HOST, HeaderValue::from_static("bucket.localhost:9000"));
|
||||||
|
assert_eq!(
|
||||||
|
unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &headers),
|
||||||
|
Some("bucket.localhost".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unroutable_virtual_host_target_uses_uri_authority_when_host_header_absent() {
|
||||||
|
// HTTP/2 clients carry the host in the URI authority (`:authority`) rather than a Host header.
|
||||||
|
let uri = Uri::from_static("https://my-bucket.s3.example.com/");
|
||||||
|
assert_eq!(
|
||||||
|
unroutable_virtual_host_target(&Method::PUT, &uri, &HeaderMap::new()),
|
||||||
|
Some("my-bucket.s3.example.com".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unroutable_virtual_host_target_ignores_non_matching_requests() {
|
||||||
|
let mut vhost = HeaderMap::new();
|
||||||
|
vhost.insert(http::header::HOST, HeaderValue::from_static("bucket.s3.example.com"));
|
||||||
|
// Path-style request carries the bucket in the path.
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/bucket"), &vhost), None);
|
||||||
|
// GET / is ListBuckets, a valid path-style service call.
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::GET, &Uri::from_static("/"), &vhost), None);
|
||||||
|
|
||||||
|
// IP / socket-address hosts always use path-style addressing.
|
||||||
|
let mut ip = HeaderMap::new();
|
||||||
|
ip.insert(http::header::HOST, HeaderValue::from_static("127.0.0.1:9000"));
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &ip), None);
|
||||||
|
|
||||||
|
// Bracketed IPv6 authority (with port) is a socket address, not a bucket subdomain.
|
||||||
|
let mut ipv6 = HeaderMap::new();
|
||||||
|
ipv6.insert(http::header::HOST, HeaderValue::from_static("[::1]:9000"));
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &ipv6), None);
|
||||||
|
|
||||||
|
// Bare single-label hosts are not virtual-hosted bucket subdomains.
|
||||||
|
let mut bare = HeaderMap::new();
|
||||||
|
bare.insert(http::header::HOST, HeaderValue::from_static("localhost:9000"));
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &bare), None);
|
||||||
|
|
||||||
|
// Trailing-dot FQDN is not matched (avoids suggesting a domain with a trailing dot).
|
||||||
|
let mut fqdn = HeaderMap::new();
|
||||||
|
fqdn.insert(http::header::HOST, HeaderValue::from_static("bucket.example.com."));
|
||||||
|
assert_eq!(unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &fqdn), None);
|
||||||
|
|
||||||
|
// Missing Host header and no URI authority.
|
||||||
|
assert_eq!(
|
||||||
|
unroutable_virtual_host_target(&Method::PUT, &Uri::from_static("/"), &HeaderMap::new()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_virtual_host_hint_response_is_actionable() {
|
||||||
|
let response: Response<HybridBody<Full<Bytes>, Full<Bytes>>> =
|
||||||
|
build_virtual_host_hint_response(http::Version::HTTP_11, "my-bucket.s3.example.com", "/");
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("application/xml")
|
||||||
|
);
|
||||||
|
// HTTP/1.x error responses close the connection (the request body is not drained).
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::CONNECTION)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("close")
|
||||||
|
);
|
||||||
|
|
||||||
|
// `Connection` is forbidden in HTTP/2, so it must not be set there.
|
||||||
|
let h2_response: Response<HybridBody<Full<Bytes>, Full<Bytes>>> =
|
||||||
|
build_virtual_host_hint_response(http::Version::HTTP_2, "my-bucket.s3.example.com", "/");
|
||||||
|
assert!(h2_response.headers().get(http::header::CONNECTION).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn virtual_host_style_hint_layer_short_circuits_unroutable_put() {
|
||||||
|
let inner = CountingHybridService::default();
|
||||||
|
let calls = inner.calls();
|
||||||
|
let mut service = VirtualHostStyleHintLayer.layer(inner);
|
||||||
|
|
||||||
|
let response = service
|
||||||
|
.call(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::PUT)
|
||||||
|
.uri("/")
|
||||||
|
.header(http::header::HOST, "my-bucket.s3.example.com")
|
||||||
|
.body(Full::<Bytes>::from(Bytes::new()))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("hint response");
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(http::header::CONNECTION)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("close")
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = BodyExt::collect(response.into_body()).await.expect("body").to_bytes();
|
||||||
|
let body = String::from_utf8(body.to_vec()).expect("utf8 body");
|
||||||
|
assert!(body.contains("RUSTFS_SERVER_DOMAINS=s3.example.com"));
|
||||||
|
assert!(body.contains("s3_use_path_style"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn virtual_host_style_hint_layer_passes_through_path_style() {
|
||||||
|
let inner = CountingHybridService::default();
|
||||||
|
let calls = inner.calls();
|
||||||
|
let mut service = VirtualHostStyleHintLayer.layer(inner);
|
||||||
|
|
||||||
|
let response = service
|
||||||
|
.call(
|
||||||
|
Request::builder()
|
||||||
|
.method(Method::PUT)
|
||||||
|
.uri("/my-bucket")
|
||||||
|
.header(http::header::HOST, "s3.example.com")
|
||||||
|
.body(Full::<Bytes>::from(Bytes::new()))
|
||||||
|
.expect("request"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("inner response");
|
||||||
|
|
||||||
|
// Path-style request reaches the inner service unchanged.
|
||||||
|
assert_eq!(response.status(), StatusCode::IM_A_TEAPOT);
|
||||||
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn public_health_endpoint_layer_handles_ready_head_before_inner_service() {
|
async fn public_health_endpoint_layer_handles_ready_head_before_inner_service() {
|
||||||
|
|||||||
Reference in New Issue
Block a user