From 5b51b60f9407bc4e088eb1dcb035a5d395178aab Mon Sep 17 00:00:00 2001 From: Chase Douglas Date: Wed, 9 Sep 2026 05:30:25 -0700 Subject: [PATCH] Route service clients through shared HTTP setup (#7639) * storage: route OpenDAL through HTTP client OpenDAL 0.58 requires applications to provide an HTTP transport. Its default installer creates a standalone client, bypassing Vaultwarden DNS, redirect, proxy, timeout, and request configuration. Build the client through the internal HTTP interface and inject it into OpenDAL's public reqwest transport. * http: honor block setting on redirects Clients can disable host blocking for administrator-configured private services. DNS resolution honors this setting, but the redirect policy still performs config-backed host checks. Capture the setting in the redirect policy and skip those checks when blocking is disabled. This also avoids re-entering CONFIG when a remote configuration request is redirected during startup. * http: make DNS setup bootstrap-safe Remote configuration can require an HTTP client while CONFIG is still initializing. Building the DNS resolver currently reads CONFIG.dns_prefer_ipv6(), so loading an S3-backed config can deadlock. Build one resolver without consulting CONFIG. Order addresses for each lookup using the merged setting when available, falling back to the environment and then IPv4-first during bootstrap. * aws: use internal HTTP client The AWS SDK connector builds a raw reqwest client, bypassing Vaultwarden TLS, DNS, redirect, proxy, timeout, and request setup. Construct it through the internal HTTP client interface and retain the standard ten-second request deadline. Permit private AWS metadata and service endpoints by disabling non-global IP blocking. Preserve timeout errors when adapting reqwest failures to the AWS SDK so the runtime receives the correct connector error category. * Added comment for the prefer IPv function Signed-off-by: BlackDex --------- Signed-off-by: BlackDex Co-authored-by: BlackDex --- Cargo.lock | 15 ++++++++ Cargo.toml | 2 ++ src/http_client.rs | 87 ++++++++++++++++++++++++++++++++++++++-------- src/storage.rs | 19 ++++++---- 4 files changed, 102 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c763d1..55a7233b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3520,6 +3520,20 @@ dependencies = [ "web-time", ] +[[package]] +name = "opendal-http-transport-reqwest" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "401999057db611e592f883fcf2cbd6754ff37af587deaadd07b8c1398b2b6b06" +dependencies = [ + "bytes", + "futures", + "http 1.5.0", + "http-body 1.1.0", + "opendal-core", + "reqwest", +] + [[package]] name = "opendal-service-fs" version = "0.59.1" @@ -5989,6 +6003,7 @@ dependencies = [ "num-derive", "num-traits", "opendal", + "opendal-http-transport-reqwest", "openidconnect", "openssl", "pastey 0.2.3", diff --git a/Cargo.toml b/Cargo.toml index cc6dff02..d3a3d5e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ vendored_openssl = ["openssl/vendored"] enable_mimalloc = ["dep:mimalloc"] s3 = [ "opendal/services-s3", + "dep:opendal-http-transport-reqwest", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", @@ -257,6 +258,7 @@ grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL opendal = { version = "0.59.1", default-features = false, features = ["services-fs"] } +opendal-http-transport-reqwest = { version = "0.59.1", default-features = false, features = ["rustls-no-provider"], optional = true } # For retrieving AWS credentials, including temporary SSO credentials aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ diff --git a/src/http_client.rs b/src/http_client.rs index 0831d990..5ef293fc 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -14,7 +14,10 @@ use reqwest::{ }; use url::Host; -use crate::{CONFIG, util::is_global}; +use crate::{ + CONFIG, + util::{get_env_bool, is_global}, +}; pub fn make_http_request(method: reqwest::Method, url: &str) -> Result { static INSTANCE: LazyLock = @@ -36,7 +39,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { let mut headers = header::HeaderMap::new(); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); - let redirect_policy = reqwest::redirect::Policy::custom(|attempt| { + let redirect_policy = reqwest::redirect::Policy::custom(move |attempt| { if attempt.previous().len() >= 5 { return attempt.error("Too many redirects"); } @@ -45,7 +48,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { return attempt.error("Invalid host"); }; - if let Err(e) = should_block_host(&host) { + if enforce_block && let Err(e) = should_block_host(&host) { return attempt.error(e); } @@ -59,6 +62,14 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { .timeout(Duration::from_secs(10)) } +fn dns_prefer_ipv6() -> bool { + // CONFIG may require DNS to initialize, so avoid forcing it during bootstrap. + match LazyLock::get(&CONFIG) { + Some(config) => config.dns_prefer_ipv6(), + None => get_env_bool("DNS_PREFER_IPV6").unwrap_or(false), + } +} + fn should_block_ip(ip: IpAddr) -> bool { if !CONFIG.http_request_block_non_global_ips() { return false; @@ -258,12 +269,8 @@ impl CustomDnsResolver { fn new() -> Arc { TokioResolver::builder(TokioRuntimeProvider::default()) .and_then(|mut builder| { - // Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first - // This might cause issues on IPv4 only systems or containers - // Unless someone enabled DNS_PREFER_IPV6, use Ipv4AndIpv6, which returns IPv4 first which was our previous default - if !CONFIG.dns_prefer_ipv6() { - builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6; - } + // Query both families; the preferred order is applied per lookup below. + builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6; builder.build() }) .inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}")) @@ -289,6 +296,17 @@ impl CustomDnsResolver { } } +fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) { + // `sort_by_key` orders `false` before `true`. + // When IPv6 is preferred, IPv6 addresses return `false` for `is_ipv4()` and sort first. + // When IPv4 is preferred, IPv4 addresses return `false` for `is_ipv6()` and sort first. + if prefer_ipv6 { + addresses.sort_by_key(SocketAddr::is_ipv4); + } else { + addresses.sort_by_key(SocketAddr::is_ipv6); + } +} + fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> { let Ok(host) = get_valid_host(name) else { return Err(CustomHttpClientError::Invalid { @@ -320,7 +338,9 @@ impl Resolve for CustomDns { let this = Arc::clone(&self.resolver); Box::pin(async move { let name = name.as_str(); - let results = this.resolve_domain(name, enforce_block).await?; + let mut results = this.resolve_domain(name, enforce_block).await?; + // Recheck after bootstrap so long-lived clients adopt the loaded config. + sort_addresses(&mut results, dns_prefer_ipv6()); if results.is_empty() { warn!("Unable to resolve {name} to any valid IP address"); } @@ -339,10 +359,29 @@ pub(crate) mod aws { }; use reqwest::Client; + use super::get_reqwest_client_builder; + // Adapter that wraps reqwest to be compatible with the AWS SDK #[derive(Debug)] pub(crate) struct AwsReqwestConnector { - pub(crate) client: Client, + client: Client, + } + + impl AwsReqwestConnector { + pub(crate) fn new() -> Self { + let client = get_reqwest_client_builder(false).build().expect("Failed to build AWS HTTP client"); + Self { + client, + } + } + } + + fn connector_error(error: reqwest::Error) -> ConnectorError { + if error.is_timeout() { + ConnectorError::timeout(Box::new(error)) + } else { + ConnectorError::io(Box::new(error)) + } } impl HttpConnector for AwsReqwestConnector { @@ -362,10 +401,10 @@ pub(crate) mod aws { req_builder = req_builder.body(body_bytes.to_vec()); } - let response = req_builder.send().await.map_err(|e| ConnectorError::io(Box::new(e)))?; + let response = req_builder.send().await.map_err(connector_error)?; let status = response.status().into(); - let bytes = response.bytes().await.map_err(|e| ConnectorError::io(Box::new(e)))?; + let bytes = response.bytes().await.map_err(connector_error)?; Ok(HttpResponse::new(status, bytes.into())) }; @@ -391,7 +430,7 @@ pub(crate) mod aws { mod tests { use super::*; use crate::util::is_global_hardcoded; - use std::net::Ipv4Addr; + use std::net::{Ipv4Addr, Ipv6Addr}; use url::Host; // === @@ -404,6 +443,26 @@ mod tests { } } + #[test] + fn dns_setup_does_not_initialize_config() { + assert!(LazyLock::get(&CONFIG).is_none()); + drop(CustomDns::instance(false)); + assert!(LazyLock::get(&CONFIG).is_none()); + } + + #[test] + fn dns_preference_orders_addresses() { + let ipv4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); + let ipv6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0); + let mut addresses = [ipv6, ipv4]; + + sort_addresses(&mut addresses, false); + assert_eq!(addresses, [ipv4, ipv6]); + + sort_addresses(&mut addresses, true); + assert_eq!(addresses, [ipv6, ipv4]); + } + #[test] fn dotted_decimal_loopback_normalizes() { let ip = parse_to_ip("127.0.0.1").unwrap(); diff --git a/src/storage.rs b/src/storage.rs index 689be302..32562a0d 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -77,10 +77,18 @@ pub(crate) fn operator_for_path(path: &str) -> Result = LazyLock::new(|| { + // Storage endpoints are administrator-configured and may be private. + crate::http_client::get_reqwest_client_builder(false).build().expect("Failed to build OpenDAL HTTP client") + }); + pub(super) fn is_uri(path: &str) -> bool { path.starts_with("s3://") } @@ -177,12 +185,7 @@ mod s3 { let chain = DEFAULT_CREDENTIAL_CHAIN .get_or_init(|| { - let reqwest_client = reqwest::Client::builder().build().unwrap(); - let connector = AwsReqwestConnector { - client: reqwest_client, - }; - - let conf = ProviderConfig::default().with_http_client(connector); + let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new()); DefaultCredentialsChain::builder().configure(conf).build() }) @@ -236,7 +239,9 @@ mod s3 { builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider)); } - Ok(opendal::Operator::new(builder)?) + let http_transport = opendal::HttpTransporter::new(ReqwestTransport::new(HTTP_CLIENT.clone())); + let context = opendal::OperationContext::new().with_http_transport(http_transport); + Ok(opendal::Operator::new(builder)?.with_context(context)) } fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {