mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
fix: avoid sending HEAD bodies over TLS HTTP/2 (#2648)
Signed-off-by: 唐小鸭 <tangtang1251@qq.com> Signed-off-by: houseme <housemecn@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: loverustfs <hello@rustfs.com>
This commit is contained in:
Generated
+1
@@ -3255,6 +3255,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"clap",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -73,3 +73,4 @@ rcgen.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
rustls.workspace = true
|
rustls.workspace = true
|
||||||
zip.workspace = true
|
zip.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use e2e_test::tls_gen::{Args, run};
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let out_dir = run(Args::parse())?;
|
||||||
|
println!("Generated RustFS TLS bundle in {}", out_dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -125,3 +125,5 @@ mod replication_extension_test;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod snowball_auto_extract_test;
|
mod snowball_auto_extract_test;
|
||||||
|
|
||||||
|
pub mod tls_gen;
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Regression test for TLS/HTTP2 `HEAD` responses on missing objects.
|
||||||
|
//!
|
||||||
|
//! Before the fix, RustFS returned `404` for a missing object but still wrote
|
||||||
|
//! the XML error payload on a `HEAD` request. Under HTTP/2 this emitted DATA
|
||||||
|
//! frames after the response headers, which clients surfaced as a protocol
|
||||||
|
//! error. This test keeps the request at the raw HTTPS layer so it can validate
|
||||||
|
//! the final wire-facing behavior rather than SDK-level error mapping.
|
||||||
|
|
||||||
|
#![cfg(test)]
|
||||||
|
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||||
|
use http::Version;
|
||||||
|
use http::header::HOST;
|
||||||
|
use rcgen::generate_simple_self_signed;
|
||||||
|
use reqwest::{Certificate, Client, Response, StatusCode};
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use tokio::fs;
|
||||||
|
use tokio::time::{Duration, sleep};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
const ACCESS_KEY: &str = "rustfsadmin";
|
||||||
|
const SECRET_KEY: &str = "rustfsadmin";
|
||||||
|
const BUCKET: &str = "test-head-tls-bodyless-bucket";
|
||||||
|
|
||||||
|
async fn generate_tls_bundle(tls_dir: &Path) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
|
||||||
|
fs::create_dir_all(tls_dir).await?;
|
||||||
|
let cert = generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])?;
|
||||||
|
let cert_pem = cert.cert.pem();
|
||||||
|
let key_pem = cert.signing_key.serialize_pem();
|
||||||
|
|
||||||
|
fs::write(tls_dir.join("rustfs_cert.pem"), cert_pem.as_bytes()).await?;
|
||||||
|
fs::write(tls_dir.join("rustfs_key.pem"), key_pem.as_bytes()).await?;
|
||||||
|
|
||||||
|
Ok(cert_pem.into_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_https_h2_client(ca_pem: &[u8]) -> Result<Client, Box<dyn Error + Send + Sync>> {
|
||||||
|
let _ca_cert = Certificate::from_pem(ca_pem)?;
|
||||||
|
Ok(Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.no_gzip()
|
||||||
|
.no_brotli()
|
||||||
|
.no_zstd()
|
||||||
|
.no_deflate()
|
||||||
|
.danger_accept_invalid_certs(true)
|
||||||
|
.build()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signed_empty_request(
|
||||||
|
client: &Client,
|
||||||
|
method: http::Method,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<Response, Box<dyn Error + Send + Sync>> {
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(method.as_str())
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||||
|
.body(Body::empty())?;
|
||||||
|
|
||||||
|
let signed = sign_v4(request, 0, ACCESS_KEY, SECRET_KEY, "", "us-east-1");
|
||||||
|
|
||||||
|
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
||||||
|
let mut builder = client.request(reqwest_method, url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
builder = builder.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(builder.send().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_bucket_exists(client: &Client, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let bucket_url = format!("{endpoint}/{BUCKET}/");
|
||||||
|
let response = signed_empty_request(client, http::Method::HEAD, &bucket_url).await?;
|
||||||
|
|
||||||
|
if response.status() == StatusCode::OK {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = signed_empty_request(client, http::Method::PUT, &bucket_url).await?;
|
||||||
|
match response.status() {
|
||||||
|
StatusCode::OK => Ok(()),
|
||||||
|
StatusCode::CONFLICT => Ok(()),
|
||||||
|
status => Err(format!("unexpected bucket setup status: {status}").into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_tls_server_ready(client: &Client, endpoint: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let ready_url = format!("{endpoint}/");
|
||||||
|
for _attempt in 0..60 {
|
||||||
|
match signed_empty_request(client, http::Method::GET, &ready_url).await {
|
||||||
|
Ok(response) if response.status().is_success() => return Ok(()),
|
||||||
|
Ok(_) | Err(_) => sleep(Duration::from_millis(500)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("RustFS TLS server failed to become ready within 30 seconds".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_tls_rustfs_server(env: &mut RustFSTestEnvironment, tls_dir: &Path) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
let binary_path = rustfs_binary_path();
|
||||||
|
let mut command = Command::new(&binary_path);
|
||||||
|
command
|
||||||
|
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug")
|
||||||
|
.env("RUSTFS_TLS_PATH", tls_dir)
|
||||||
|
.current_dir(&env.temp_dir);
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"RUSTFS_ADDRESS",
|
||||||
|
"RUSTFS_VOLUMES",
|
||||||
|
"RUSTFS_ACCESS_KEY",
|
||||||
|
"RUSTFS_SECRET_KEY",
|
||||||
|
"RUSTFS_TLS_PATH",
|
||||||
|
"RUSTFS_OBS_LOG_DIRECTORY",
|
||||||
|
] {
|
||||||
|
command.env_remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = command
|
||||||
|
.env("RUSTFS_TLS_PATH", tls_dir)
|
||||||
|
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||||
|
.args([
|
||||||
|
"--address",
|
||||||
|
&env.address,
|
||||||
|
"--access-key",
|
||||||
|
&env.access_key,
|
||||||
|
"--secret-key",
|
||||||
|
&env.secret_key,
|
||||||
|
&env.temp_dir,
|
||||||
|
])
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
env.process = Some(process);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_head_missing_object_over_tls_http2_is_bodyless() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
let tls_dir = std::path::PathBuf::from(&env.temp_dir).join("tls");
|
||||||
|
let ca_pem = generate_tls_bundle(&tls_dir).await?;
|
||||||
|
start_tls_rustfs_server(&mut env, &tls_dir).await?;
|
||||||
|
|
||||||
|
let endpoint = format!("https://{}", env.address);
|
||||||
|
let client = local_https_h2_client(&ca_pem)?;
|
||||||
|
wait_for_tls_server_ready(&client, &endpoint).await?;
|
||||||
|
ensure_bucket_exists(&client, &endpoint).await?;
|
||||||
|
|
||||||
|
let missing_key = "head-does-not-exist.txt";
|
||||||
|
let object_url = format!("{endpoint}/{BUCKET}/{missing_key}");
|
||||||
|
|
||||||
|
let get_response = signed_empty_request(&client, http::Method::GET, &object_url).await?;
|
||||||
|
assert_eq!(get_response.status(), StatusCode::NOT_FOUND);
|
||||||
|
let get_version = get_response.version();
|
||||||
|
let get_body = get_response.bytes().await?;
|
||||||
|
let get_body_text = String::from_utf8_lossy(&get_body);
|
||||||
|
assert!(
|
||||||
|
get_body_text.contains("<Code>NoSuchKey</Code>") || get_body_text.contains("<Code>NoSuchObject</Code>"),
|
||||||
|
"GET missing-object error body should expose NoSuchKey/NoSuchObject, got: {}",
|
||||||
|
get_body_text
|
||||||
|
);
|
||||||
|
info!("GET missing object over TLS used {:?} and returned {} bytes", get_version, get_body.len());
|
||||||
|
|
||||||
|
let head_response = signed_empty_request(&client, http::Method::HEAD, &object_url).await?;
|
||||||
|
assert_eq!(head_response.status(), StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(head_response.version(), Version::HTTP_2, "HEAD regression test must exercise HTTP/2");
|
||||||
|
let head_body = head_response.bytes().await?;
|
||||||
|
assert!(
|
||||||
|
head_body.is_empty(),
|
||||||
|
"HEAD missing-object response must not send body bytes over TLS/HTTP2, got {} bytes: {:?}",
|
||||||
|
head_body.len(),
|
||||||
|
head_body
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ mod get_deleted_object_test;
|
|||||||
mod grpc_lock_client;
|
mod grpc_lock_client;
|
||||||
mod grpc_lock_server;
|
mod grpc_lock_server;
|
||||||
mod head_deleted_object_versioning_test;
|
mod head_deleted_object_versioning_test;
|
||||||
|
mod head_tls_bodyless_test;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod lock;
|
mod lock;
|
||||||
mod node_interact_test;
|
mod node_interact_test;
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use clap::Parser;
|
||||||
|
use rcgen::{
|
||||||
|
BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose,
|
||||||
|
SanType,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use time::{Duration, OffsetDateTime};
|
||||||
|
|
||||||
|
pub const DEFAULT_OUT_DIR: &str = "target/tls";
|
||||||
|
pub const OUTPUT_FILES: [&str; 7] = [
|
||||||
|
"rustfs_cert.pem",
|
||||||
|
"rustfs_key.pem",
|
||||||
|
"ca.crt",
|
||||||
|
"public.crt",
|
||||||
|
"client_ca.crt",
|
||||||
|
"client_cert.pem",
|
||||||
|
"client_key.pem",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(name = "tls_gen", about = "Generate a full RustFS TLS bundle for local TLS and mTLS tests.")]
|
||||||
|
pub struct Args {
|
||||||
|
#[arg(long, default_value = DEFAULT_OUT_DIR)]
|
||||||
|
pub out_dir: PathBuf,
|
||||||
|
#[arg(long, default_value_t = 365)]
|
||||||
|
pub days: i64,
|
||||||
|
#[arg(long)]
|
||||||
|
pub force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(args: Args) -> Result<PathBuf> {
|
||||||
|
if args.days <= 0 {
|
||||||
|
bail!("--days must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
write_bundle(&args.out_dir, args.force, args.days)?;
|
||||||
|
Ok(args.out_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_writable(out_dir: &Path, force: bool) -> Result<()> {
|
||||||
|
if force {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing: Vec<_> = OUTPUT_FILES
|
||||||
|
.iter()
|
||||||
|
.map(|name| out_dir.join(name))
|
||||||
|
.filter(|path| path.exists())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if existing.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_list = existing
|
||||||
|
.iter()
|
||||||
|
.map(|path| path.file_name().and_then(|name| name.to_str()).unwrap_or("<unknown>"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
bail!(
|
||||||
|
"Refusing to overwrite existing files in {}: {}. Re-run with --force to replace them.",
|
||||||
|
out_dir.display(),
|
||||||
|
existing_list
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_bundle(out_dir: &Path, force: bool, days: i64) -> Result<()> {
|
||||||
|
fs::create_dir_all(out_dir).with_context(|| format!("failed to create output directory {}", out_dir.display()))?;
|
||||||
|
ensure_writable(out_dir, force)?;
|
||||||
|
|
||||||
|
let ca_key = generate_private_key()?;
|
||||||
|
let ca = build_ca_certificate(ca_key, days)?;
|
||||||
|
|
||||||
|
let server_key = generate_private_key()?;
|
||||||
|
let server_cert = build_leaf_certificate(
|
||||||
|
&server_key,
|
||||||
|
"localhost",
|
||||||
|
&[SanType::DnsName("localhost".try_into()?)],
|
||||||
|
&[
|
||||||
|
SanType::IpAddress(IpAddr::V4("127.0.0.1".parse()?)),
|
||||||
|
SanType::IpAddress(IpAddr::V6("::1".parse()?)),
|
||||||
|
],
|
||||||
|
ExtendedKeyUsagePurpose::ServerAuth,
|
||||||
|
&ca,
|
||||||
|
days,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let client_key = generate_private_key()?;
|
||||||
|
let client_cert = build_leaf_certificate(
|
||||||
|
&client_key,
|
||||||
|
"rustfs-test-client",
|
||||||
|
&[SanType::DnsName("rustfs-test-client".try_into()?)],
|
||||||
|
&[],
|
||||||
|
ExtendedKeyUsagePurpose::ClientAuth,
|
||||||
|
&ca,
|
||||||
|
days,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let ca_pem = ca.pem();
|
||||||
|
let bundle = [
|
||||||
|
("rustfs_cert.pem", server_cert.pem()),
|
||||||
|
("rustfs_key.pem", server_key.serialize_pem()),
|
||||||
|
("ca.crt", ca_pem.clone()),
|
||||||
|
("public.crt", ca_pem.clone()),
|
||||||
|
("client_ca.crt", ca_pem),
|
||||||
|
("client_cert.pem", client_cert.pem()),
|
||||||
|
("client_key.pem", client_key.serialize_pem()),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, content) in bundle {
|
||||||
|
fs::write(out_dir.join(name), content).with_context(|| format!("failed to write {}", out_dir.join(name).display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ca_certificate(signing_key: KeyPair, days: i64) -> Result<CertifiedIssuer<'static, KeyPair>> {
|
||||||
|
let mut params = base_params("RustFS Test CA", days)?;
|
||||||
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||||
|
|
||||||
|
CertifiedIssuer::self_signed(params, signing_key).context("failed to create CA certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_leaf_certificate(
|
||||||
|
signing_key: &KeyPair,
|
||||||
|
common_name: &str,
|
||||||
|
dns_names: &[SanType],
|
||||||
|
ip_addresses: &[SanType],
|
||||||
|
usage: ExtendedKeyUsagePurpose,
|
||||||
|
issuer: &CertifiedIssuer<'_, KeyPair>,
|
||||||
|
days: i64,
|
||||||
|
) -> Result<rcgen::Certificate> {
|
||||||
|
let mut params = base_params(common_name, days)?;
|
||||||
|
params.is_ca = IsCa::ExplicitNoCa;
|
||||||
|
params.key_usages = vec![KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment];
|
||||||
|
params.extended_key_usages = vec![usage];
|
||||||
|
params.use_authority_key_identifier_extension = true;
|
||||||
|
params.subject_alt_names.extend_from_slice(dns_names);
|
||||||
|
params.subject_alt_names.extend_from_slice(ip_addresses);
|
||||||
|
|
||||||
|
params
|
||||||
|
.signed_by(signing_key, issuer)
|
||||||
|
.with_context(|| format!("failed to create leaf certificate for {common_name}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_params(common_name: &str, days: i64) -> Result<CertificateParams> {
|
||||||
|
let mut params = CertificateParams::default();
|
||||||
|
let issued_at = OffsetDateTime::now_utc() - Duration::minutes(5);
|
||||||
|
params.not_before = issued_at;
|
||||||
|
params.not_after = issued_at + Duration::days(days);
|
||||||
|
params.distinguished_name.push(DnType::CountryName, "US");
|
||||||
|
params.distinguished_name.push(DnType::OrganizationName, "RustFS");
|
||||||
|
params.distinguished_name.push(DnType::CommonName, common_name);
|
||||||
|
Ok(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_private_key() -> Result<KeyPair> {
|
||||||
|
KeyPair::generate().context("failed to generate private key")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{Args, OUTPUT_FILES, ensure_writable, run};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn unique_temp_dir() -> PathBuf {
|
||||||
|
let suffix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system time must be after unix epoch")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("rustfs-tls-gen-{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TempDir {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempDir {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = unique_temp_dir();
|
||||||
|
fs::create_dir_all(&path).expect("temporary directory should be created");
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_writes_full_bundle() {
|
||||||
|
let temp_dir = TempDir::new();
|
||||||
|
|
||||||
|
let out_dir = run(Args {
|
||||||
|
out_dir: temp_dir.path().join("tls"),
|
||||||
|
days: 365,
|
||||||
|
force: false,
|
||||||
|
})
|
||||||
|
.expect("bundle generation should succeed");
|
||||||
|
|
||||||
|
for name in OUTPUT_FILES {
|
||||||
|
let content = fs::read(out_dir.join(name)).unwrap_or_else(|error| panic!("{name} should exist: {error}"));
|
||||||
|
assert!(!content.is_empty(), "{name} should not be empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_writable_rejects_existing_files_without_force() {
|
||||||
|
let temp_dir = TempDir::new();
|
||||||
|
let existing = temp_dir.path().join(OUTPUT_FILES[0]);
|
||||||
|
fs::write(&existing, "existing").expect("existing file should be created");
|
||||||
|
|
||||||
|
let error = ensure_writable(temp_dir.path(), false).expect_err("existing files must be rejected");
|
||||||
|
let message = format!("{error:#}");
|
||||||
|
|
||||||
|
assert!(message.contains("Refusing to overwrite existing files"));
|
||||||
|
assert!(message.contains(OUTPUT_FILES[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_rejects_non_positive_days() {
|
||||||
|
let temp_dir = TempDir::new();
|
||||||
|
let error = run(Args {
|
||||||
|
out_dir: temp_dir.path().join("tls"),
|
||||||
|
days: 0,
|
||||||
|
force: false,
|
||||||
|
})
|
||||||
|
.expect_err("non-positive days must fail");
|
||||||
|
|
||||||
|
assert_eq!(format!("{error:#}"), "--days must be a positive integer");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,8 @@ use crate::server::{
|
|||||||
compress::{CompressionConfig, PathAwareCompressionPredicate, PathCategoryInjectionLayer},
|
compress::{CompressionConfig, PathAwareCompressionPredicate, PathCategoryInjectionLayer},
|
||||||
hybrid::hybrid,
|
hybrid::hybrid,
|
||||||
layer::{
|
layer::{
|
||||||
AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, ObjectAttributesEtagFixLayer,
|
AdminChunkedContentLengthCompatLayer, BodylessStatusFixLayer, ConditionalCorsLayer, HeadRequestBodyFixLayer,
|
||||||
RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer,
|
ObjectAttributesEtagFixLayer, RedirectLayer, RequestContextLayer, S3ErrorMessageCompatLayer,
|
||||||
},
|
},
|
||||||
tls_material::{TlsAcceptorHolder, TlsHandshakeFailureKind, TlsMaterialSnapshot, spawn_reload_loop},
|
tls_material::{TlsAcceptorHolder, TlsHandshakeFailureKind, TlsMaterialSnapshot, spawn_reload_loop},
|
||||||
};
|
};
|
||||||
@@ -656,6 +656,7 @@ fn process_connection(
|
|||||||
// 16. ConditionalCorsLayer — S3 API CORS
|
// 16. ConditionalCorsLayer — S3 API CORS
|
||||||
// 17. RedirectLayer — console redirect (conditional)
|
// 17. RedirectLayer — console redirect (conditional)
|
||||||
// 18. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses
|
// 18. BodylessStatusFixLayer — clears body for 1xx/204/205/304 responses
|
||||||
|
// 19. HeadRequestBodyFixLayer — strips actual body bytes from HEAD responses
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
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:
|
||||||
@@ -807,6 +808,10 @@ fn process_connection(
|
|||||||
// other response-transforming layers see the already-bodyless
|
// other response-transforming layers see the already-bodyless
|
||||||
// response and so no layer (e.g. CORS) re-adds body headers afterward.
|
// response and so no layer (e.g. CORS) re-adds body headers afterward.
|
||||||
.layer(BodylessStatusFixLayer)
|
.layer(BodylessStatusFixLayer)
|
||||||
|
// HEAD responses must not send body bytes even when the inner S3 layer
|
||||||
|
// serializes an XML error payload. Keep this innermost so the final
|
||||||
|
// HTTP response written to hyper/h2 is bodyless.
|
||||||
|
.layer(HeadRequestBodyFixLayer)
|
||||||
.service(service);
|
.service(service);
|
||||||
|
|
||||||
let hybrid_service = TowerToHyperService::new(hybrid_service);
|
let hybrid_service = TowerToHyperService::new(hybrid_service);
|
||||||
|
|||||||
@@ -477,6 +477,74 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tower middleware that strips the actual response body for `HEAD` requests
|
||||||
|
/// while preserving metadata headers such as `Content-Length`.
|
||||||
|
///
|
||||||
|
/// The inner s3s layer may serialize S3 errors as XML bodies. That is valid for
|
||||||
|
/// regular requests, but for `HEAD` the HTTP layer must suppress the response
|
||||||
|
/// body entirely. If we forward the serialized error body over HTTP/2, clients
|
||||||
|
/// observe DATA frames on a `HEAD` response and fail the exchange with a
|
||||||
|
/// protocol error.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HeadRequestBodyFixLayer;
|
||||||
|
|
||||||
|
impl<S> Layer<S> for HeadRequestBodyFixLayer {
|
||||||
|
type Service = HeadRequestBodyFixService<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
HeadRequestBodyFixService { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HeadRequestBodyFixService<S> {
|
||||||
|
inner: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, ReqBody, RestBody, GrpcBody> Service<HttpRequest<ReqBody>> for HeadRequestBodyFixService<S>
|
||||||
|
where
|
||||||
|
S: Service<HttpRequest<ReqBody>, Response = Response<HybridBody<RestBody, GrpcBody>>> + Clone + Send + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
ReqBody: Send + 'static,
|
||||||
|
RestBody: Body<Data = Bytes> + 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 {
|
||||||
|
let is_head = req.method() == Method::HEAD;
|
||||||
|
let mut inner = self.inner.clone();
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
let response = inner.call(req).await?;
|
||||||
|
if !is_head {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (mut parts, body) = response.into_parts();
|
||||||
|
parts.headers.remove(http::header::TRANSFER_ENCODING);
|
||||||
|
|
||||||
|
let response = match body {
|
||||||
|
HybridBody::Rest { .. } => Response::from_parts(
|
||||||
|
parts,
|
||||||
|
HybridBody::Rest {
|
||||||
|
rest_body: RestBody::from(Bytes::new()),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
HybridBody::Grpc { grpc_body } => Response::from_parts(parts, HybridBody::Grpc { grpc_body }),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -1269,6 +1337,110 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod head_request_body_fix {
|
||||||
|
use super::*;
|
||||||
|
use crate::server::hybrid::HybridBody;
|
||||||
|
use http_body_util::Empty;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FixedResponse {
|
||||||
|
status: StatusCode,
|
||||||
|
body: Bytes,
|
||||||
|
content_type: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: Send + 'static> Service<Request<B>> for FixedResponse {
|
||||||
|
type Response = Response<HybridBody<Full<Bytes>, Empty<Bytes>>>;
|
||||||
|
type Error = Infallible;
|
||||||
|
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>> {
|
||||||
|
Poll::Ready(Ok(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, _req: Request<B>) -> Self::Future {
|
||||||
|
let this = self.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let body = this.body.clone();
|
||||||
|
let len = body.len();
|
||||||
|
let mut builder = Response::builder().status(this.status);
|
||||||
|
builder = builder.header(http::header::CONTENT_LENGTH, len.to_string());
|
||||||
|
builder = builder.header(http::header::TRANSFER_ENCODING, "chunked");
|
||||||
|
if let Some(ct) = this.content_type {
|
||||||
|
builder = builder.header(http::header::CONTENT_TYPE, ct);
|
||||||
|
}
|
||||||
|
Ok(builder
|
||||||
|
.body(HybridBody::Rest {
|
||||||
|
rest_body: Full::from(body),
|
||||||
|
})
|
||||||
|
.expect("build response"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_with_method(method: Method) -> Request<()> {
|
||||||
|
Request::builder()
|
||||||
|
.method(method)
|
||||||
|
.uri("/bucket/object")
|
||||||
|
.body(())
|
||||||
|
.expect("request")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_body<B: Body<Data = Bytes>>(body: B) -> Bytes
|
||||||
|
where
|
||||||
|
B::Error: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
BodyExt::collect(body).await.expect("collect body").to_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn strips_body_for_head_errors_but_preserves_metadata_headers() {
|
||||||
|
let payload = Bytes::from_static(b"<?xml version=\"1.0\"?><Error><Code>NoSuchKey</Code></Error>");
|
||||||
|
let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
body: payload.clone(),
|
||||||
|
content_type: Some("application/xml"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = svc.call(request_with_method(Method::HEAD)).await.expect("service call");
|
||||||
|
let (parts, body) = res.into_parts();
|
||||||
|
|
||||||
|
assert_eq!(parts.status, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
parts.headers.get(http::header::CONTENT_LENGTH).unwrap(),
|
||||||
|
payload.len().to_string().as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(parts.headers.get(http::header::CONTENT_TYPE).unwrap(), "application/xml");
|
||||||
|
assert!(parts.headers.get(http::header::TRANSFER_ENCODING).is_none());
|
||||||
|
|
||||||
|
let bytes = collect_body(body).await;
|
||||||
|
assert!(bytes.is_empty(), "HEAD response body must be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn preserves_body_for_get_errors() {
|
||||||
|
let payload = Bytes::from_static(b"<?xml version=\"1.0\"?><Error><Code>NoSuchKey</Code></Error>");
|
||||||
|
let mut svc = HeadRequestBodyFixLayer.layer(FixedResponse {
|
||||||
|
status: StatusCode::NOT_FOUND,
|
||||||
|
body: payload.clone(),
|
||||||
|
content_type: Some("application/xml"),
|
||||||
|
});
|
||||||
|
|
||||||
|
let res = svc.call(request_with_method(Method::GET)).await.expect("service call");
|
||||||
|
let (parts, body) = res.into_parts();
|
||||||
|
|
||||||
|
assert_eq!(parts.status, StatusCode::NOT_FOUND);
|
||||||
|
assert_eq!(
|
||||||
|
parts.headers.get(http::header::CONTENT_LENGTH).unwrap(),
|
||||||
|
payload.len().to_string().as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(parts.headers.get(http::header::TRANSFER_ENCODING).unwrap(), "chunked");
|
||||||
|
|
||||||
|
let bytes = collect_body(body).await;
|
||||||
|
assert_eq!(bytes, payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_apply_bucket_cors_result_replaces_existing_cors_headers() {
|
fn test_apply_bucket_cors_result_replaces_existing_cors_headers() {
|
||||||
let mut response_headers = HeaderMap::new();
|
let mut response_headers = HeaderMap::new();
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# TLS Bundle Generator
|
||||||
|
|
||||||
|
Generate a local TLS/mTLS certificate bundle for RustFS tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls
|
||||||
|
```
|
||||||
|
|
||||||
|
Overwrite an existing bundle with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the validity window with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p e2e_test --bin tls_gen -- --out-dir target/tls --days 30
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated files:
|
||||||
|
|
||||||
|
- `rustfs_cert.pem`
|
||||||
|
- `rustfs_key.pem`
|
||||||
|
- `ca.crt`
|
||||||
|
- `public.crt`
|
||||||
|
- `client_ca.crt`
|
||||||
|
- `client_cert.pem`
|
||||||
|
- `client_key.pem`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The command refuses to overwrite existing bundle files unless `--force` is set.
|
||||||
|
- `--days` must be a positive integer.
|
||||||
|
- The default output directory is `target/tls`.
|
||||||
Reference in New Issue
Block a user