From e388a3ff5380f67f201c587dd2b8d7037978e904 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Fri, 28 Aug 2026 08:34:51 +0800 Subject: [PATCH] fix(startup): never panic when the system CA bundle is absent (#6769) --- .../ecstore/src/bucket/bucket_target_sys.rs | 36 ++++++++++++- crates/policy/src/policy/opa.rs | 36 +++++++------ .../trusted-proxies/src/cloud/metadata/aws.rs | 2 +- .../src/cloud/metadata/azure.rs | 2 +- .../trusted-proxies/src/cloud/metadata/gcp.rs | 2 +- .../trusted-proxies/src/cloud/metadata/mod.rs | 37 ++++++++++++++ rustfs/src/update.rs | 50 +++++++++++++------ 7 files changed, 132 insertions(+), 33 deletions(-) diff --git a/crates/ecstore/src/bucket/bucket_target_sys.rs b/crates/ecstore/src/bucket/bucket_target_sys.rs index 4c4e66a79..136b67f32 100644 --- a/crates/ecstore/src/bucket/bucket_target_sys.rs +++ b/crates/ecstore/src/bucket/bucket_target_sys.rs @@ -352,6 +352,28 @@ pub struct BucketTargetSys { heartbeat_started: OnceLock<()>, } +/// Build the bucket-target health-check HTTP client without panicking when +/// the host has no system CA bundle (issue #6734). +/// +/// `BucketTargetSys::get()` initializes lazily on the startup path (bucket +/// metadata install calls it on the main thread), and `reqwest::Client::new()` +/// panics when the TLS backend cannot load any system trust root — the state +/// of a minimal container image. Fall back to a client with an explicit empty +/// trust store: HTTP health checks keep working, and HTTPS targets fail closed +/// at the TLS handshake with a clear certificate error instead of aborting +/// the whole process at startup. +fn build_health_check_client() -> HttpClient { + HttpClient::builder().build().unwrap_or_else(|error| { + warn!( + "bucket target health-check HTTP client could not load system TLS roots ({error}); continuing with an empty trust store — HTTPS target health checks will fail until a CA bundle is installed" + ); + HttpClient::builder() + .tls_certs_only(std::iter::empty::()) + .build() + .expect("HTTP client construction must succeed with an explicit empty trust store") + }) +} + impl BucketTargetSys { pub fn get() -> &'static Self { GLOBAL_BUCKET_TARGET_SYS.get_or_init(Self::new) @@ -364,7 +386,7 @@ impl BucketTargetSys { targets_map: Arc::new(RwLock::new(HashMap::new())), h_mutex: Arc::new(RwLock::new(HashMap::new())), target_h_mutex: Arc::new(RwLock::new(HashMap::new())), - hc_client: Arc::new(HttpClient::new()), + hc_client: Arc::new(build_health_check_client()), a_mutex: Arc::new(Mutex::new(HashMap::new())), arn_errs_map: Arc::new(RwLock::new(HashMap::new())), target_update_mutexes: Arc::new(Mutex::new(HashMap::new())), @@ -2490,6 +2512,18 @@ mod tests { use super::*; use rcgen::generate_simple_self_signed; + // The startup panic fix for hosts without a CA bundle (issue #6734) rests + // on two properties: the health-check client constructor never panics, and + // its degraded fallback — an explicit empty trust store — always builds. + #[test] + fn health_check_client_construction_never_panics() { + let _ = build_health_check_client(); + HttpClient::builder() + .tls_certs_only(std::iter::empty::()) + .build() + .expect("empty-trust-store client build must succeed without touching system roots"); + } + #[derive(Clone, Debug)] struct RecordingHttpConnector { request_uris: Arc>>, diff --git a/crates/policy/src/policy/opa.rs b/crates/policy/src/policy/opa.rs index 15d32bbe2..fe0a19d40 100644 --- a/crates/policy/src/policy/opa.rs +++ b/crates/policy/src/policy/opa.rs @@ -173,20 +173,28 @@ pub async fn lookup_config() -> Result { impl AuthZPlugin { pub fn new(config: Args) -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .connect_timeout(Duration::from_secs(1)) - .pool_max_idle_per_host(10) - .pool_idle_timeout(Some(Duration::from_secs(60))) - .tcp_keepalive(Some(Duration::from_secs(30))) - .tcp_nodelay(true) - .http2_keep_alive_interval(Some(Duration::from_secs(30))) - .http2_keep_alive_timeout(Duration::from_secs(15)) - .build() - .unwrap_or_else(|err| { - error!("failed to build OPA HTTP client, falling back to default reqwest client: {err}"); - reqwest::Client::new() - }); + let builder = || { + reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .connect_timeout(Duration::from_secs(1)) + .pool_max_idle_per_host(10) + .pool_idle_timeout(Some(Duration::from_secs(60))) + .tcp_keepalive(Some(Duration::from_secs(30))) + .tcp_nodelay(true) + .http2_keep_alive_interval(Some(Duration::from_secs(30))) + .http2_keep_alive_timeout(Duration::from_secs(15)) + }; + // Never fall back to `reqwest::Client::new()`: it panics for the same + // reason the first build failed (e.g. no system CA bundle, issue + // #6734). Retry with an explicit empty trust store instead — an HTTP + // OPA endpoint keeps working, an HTTPS one fails closed per request. + let client = builder().build().unwrap_or_else(|err| { + error!("failed to build OPA HTTP client ({err}); continuing with an empty trust store"); + builder() + .tls_certs_only(std::iter::empty::()) + .build() + .expect("HTTP client construction must succeed with an explicit empty trust store") + }); Self { client, args: config } } diff --git a/crates/trusted-proxies/src/cloud/metadata/aws.rs b/crates/trusted-proxies/src/cloud/metadata/aws.rs index c971cfa4d..127cd7f30 100644 --- a/crates/trusted-proxies/src/cloud/metadata/aws.rs +++ b/crates/trusted-proxies/src/cloud/metadata/aws.rs @@ -43,7 +43,7 @@ impl AwsMetadataFetcher { /// /// Returns a new instance of `AwsMetadataFetcher`. pub fn new(timeout: Duration) -> Self { - let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + let client = super::metadata_http_client(timeout); Self { client, diff --git a/crates/trusted-proxies/src/cloud/metadata/azure.rs b/crates/trusted-proxies/src/cloud/metadata/azure.rs index 2095249d9..c9bf5f0b7 100644 --- a/crates/trusted-proxies/src/cloud/metadata/azure.rs +++ b/crates/trusted-proxies/src/cloud/metadata/azure.rs @@ -34,7 +34,7 @@ pub struct AzureMetadataFetcher { impl AzureMetadataFetcher { /// Creates a new `AzureMetadataFetcher`. pub fn new(timeout: Duration) -> Self { - let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + let client = super::metadata_http_client(timeout); Self { client, diff --git a/crates/trusted-proxies/src/cloud/metadata/gcp.rs b/crates/trusted-proxies/src/cloud/metadata/gcp.rs index 6872a293c..708eea725 100644 --- a/crates/trusted-proxies/src/cloud/metadata/gcp.rs +++ b/crates/trusted-proxies/src/cloud/metadata/gcp.rs @@ -34,7 +34,7 @@ pub struct GcpMetadataFetcher { impl GcpMetadataFetcher { /// Creates a new `GcpMetadataFetcher`. pub fn new(timeout: Duration) -> Self { - let client = Client::builder().timeout(timeout).build().unwrap_or_else(|_| Client::new()); + let client = super::metadata_http_client(timeout); Self { client, diff --git a/crates/trusted-proxies/src/cloud/metadata/mod.rs b/crates/trusted-proxies/src/cloud/metadata/mod.rs index 31a4fafdf..d70eeb7b0 100644 --- a/crates/trusted-proxies/src/cloud/metadata/mod.rs +++ b/crates/trusted-proxies/src/cloud/metadata/mod.rs @@ -24,3 +24,40 @@ mod gcp; pub use aws::*; pub use azure::*; pub use gcp::*; + +/// Build the metadata HTTP client without panicking when the host has no +/// system CA bundle (issue #6734). +/// +/// `reqwest::Client::new()` panics when the TLS backend cannot load any +/// system trust root, which is exactly the state of a minimal container +/// image. Cloud metadata endpoints are plain HTTP link-local addresses, so a +/// client with an explicit empty trust store is fully functional here; TLS +/// requests through it fail closed at the handshake. +pub(crate) fn metadata_http_client(timeout: std::time::Duration) -> reqwest::Client { + reqwest::Client::builder().timeout(timeout).build().unwrap_or_else(|error| { + tracing::warn!( + "cloud metadata HTTP client could not load system TLS roots ({error}); continuing with an empty trust store" + ); + reqwest::Client::builder() + .timeout(timeout) + .tls_certs_only(std::iter::empty::()) + .build() + .expect("HTTP client construction must succeed with an explicit empty trust store") + }) +} + +#[cfg(test)] +mod tests { + // The startup panic fix for hosts without a CA bundle (issue #6734) rests + // on the constructor never panicking and its degraded fallback — an + // explicit empty trust store — always building. + #[test] + fn metadata_http_client_construction_never_panics() { + let _ = super::metadata_http_client(std::time::Duration::from_secs(1)); + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(1)) + .tls_certs_only(std::iter::empty::()) + .build() + .expect("empty-trust-store client build must succeed without touching system roots"); + } +} diff --git a/rustfs/src/update.rs b/rustfs/src/update.rs index 3b7a267cb..910fc01d2 100644 --- a/rustfs/src/update.rs +++ b/rustfs/src/update.rs @@ -16,7 +16,7 @@ use crate::version; use serde::{Deserialize, Serialize}; use std::time::Duration; use thiserror::Error; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; /// Update check related errors #[derive(Error, Debug)] @@ -73,17 +73,31 @@ impl Default for VersionChecker { } } +/// Build the update-check HTTP client without panicking when the host has no +/// system CA bundle (issue #6734): `reqwest::Client::new()` panics for the +/// same reason a builder `build()` fails, so falling back to it turned a +/// degraded environment into a process abort. With an explicit empty trust +/// store the HTTPS version check fails closed per request instead. +fn version_check_client(timeout: Duration) -> reqwest::Client { + let builder = || { + reqwest::Client::builder() + .timeout(timeout) + .user_agent(format!("RustFS/{}", get_current_version())) + }; + builder().build().unwrap_or_else(|error| { + warn!("update-check HTTP client could not load system TLS roots ({error}); continuing with an empty trust store"); + builder() + .tls_certs_only(std::iter::empty::()) + .build() + .expect("HTTP client construction must succeed with an explicit empty trust store") + }) +} + impl VersionChecker { /// Create a new version checker pub fn new() -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .user_agent(format!("RustFS/{}", get_current_version())) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, + client: version_check_client(Duration::from_secs(10)), version_url: "https://version.rustfs.com/latest.json".to_string(), timeout: Duration::from_secs(10), } @@ -91,14 +105,8 @@ impl VersionChecker { /// Create version checker with custom configuration pub fn with_config(url: String, timeout: Duration) -> Self { - let client = reqwest::Client::builder() - .timeout(timeout) - .user_agent(format!("RustFS/{}", get_current_version())) - .build() - .unwrap_or_else(|_| reqwest::Client::new()); - Self { - client, + client: version_check_client(timeout), version_url: url, timeout, } @@ -182,6 +190,18 @@ pub async fn check_updates_with_url(url: String) -> Result()) + .build() + .expect("empty-trust-store client build must succeed without touching system roots"); + } + #[tokio::test] async fn test_get_current_version() { let version = get_current_version();