Browse Source

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 <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
Co-authored-by: BlackDex <black.dex@gmail.com>
pull/7419/merge
Chase Douglas 5 days ago
committed by GitHub
parent
commit
5b51b60f94
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 15
      Cargo.lock
  2. 2
      Cargo.toml
  3. 85
      src/http_client.rs
  4. 19
      src/storage.rs

15
Cargo.lock

@ -3520,6 +3520,20 @@ dependencies = [
"web-time", "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]] [[package]]
name = "opendal-service-fs" name = "opendal-service-fs"
version = "0.59.1" version = "0.59.1"
@ -5989,6 +6003,7 @@ dependencies = [
"num-derive", "num-derive",
"num-traits", "num-traits",
"opendal", "opendal",
"opendal-http-transport-reqwest",
"openidconnect", "openidconnect",
"openssl", "openssl",
"pastey 0.2.3", "pastey 0.2.3",

2
Cargo.toml

@ -40,6 +40,7 @@ vendored_openssl = ["openssl/vendored"]
enable_mimalloc = ["dep:mimalloc"] enable_mimalloc = ["dep:mimalloc"]
s3 = [ s3 = [
"opendal/services-s3", "opendal/services-s3",
"dep:opendal-http-transport-reqwest",
"dep:aws-config", "dep:aws-config",
"dep:aws-credential-types", "dep:aws-credential-types",
"dep:aws-smithy-runtime-api", "dep:aws-smithy-runtime-api",
@ -257,6 +258,7 @@ grass_compiler = { version = "0.13.4", default-features = false }
# File are accessed through Apache OpenDAL # File are accessed through Apache OpenDAL
opendal = { version = "0.59.1", default-features = false, features = ["services-fs"] } 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 # For retrieving AWS credentials, including temporary SSO credentials
aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ aws-config = { version = "1.12.0", optional = true, default-features = false, features = [

85
src/http_client.rs

@ -14,7 +14,10 @@ use reqwest::{
}; };
use url::Host; 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<reqwest::RequestBuilder, crate::Error> { pub fn make_http_request(method: reqwest::Method, url: &str) -> Result<reqwest::RequestBuilder, crate::Error> {
static INSTANCE: LazyLock<Client> = static INSTANCE: LazyLock<Client> =
@ -36,7 +39,7 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
let mut headers = header::HeaderMap::new(); let mut headers = header::HeaderMap::new();
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); 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 { if attempt.previous().len() >= 5 {
return attempt.error("Too many redirects"); 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"); 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); return attempt.error(e);
} }
@ -59,6 +62,14 @@ pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder {
.timeout(Duration::from_secs(10)) .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 { fn should_block_ip(ip: IpAddr) -> bool {
if !CONFIG.http_request_block_non_global_ips() { if !CONFIG.http_request_block_non_global_ips() {
return false; return false;
@ -258,12 +269,8 @@ impl CustomDnsResolver {
fn new() -> Arc<Self> { fn new() -> Arc<Self> {
TokioResolver::builder(TokioRuntimeProvider::default()) TokioResolver::builder(TokioRuntimeProvider::default())
.and_then(|mut builder| { .and_then(|mut builder| {
// Hickory's default since v0.26 is `Ipv6AndIpv4`, which sorts IPv6 first // Query both families; the preferred order is applied per lookup below.
// 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; builder.options_mut().ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv4AndIpv6;
}
builder.build() builder.build()
}) })
.inspect_err(|e| warn!("Error creating Hickory resolver, falling back to default: {e:?}")) .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> { fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> {
let Ok(host) = get_valid_host(name) else { let Ok(host) = get_valid_host(name) else {
return Err(CustomHttpClientError::Invalid { return Err(CustomHttpClientError::Invalid {
@ -320,7 +338,9 @@ impl Resolve for CustomDns {
let this = Arc::clone(&self.resolver); let this = Arc::clone(&self.resolver);
Box::pin(async move { Box::pin(async move {
let name = name.as_str(); 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() { if results.is_empty() {
warn!("Unable to resolve {name} to any valid IP address"); warn!("Unable to resolve {name} to any valid IP address");
} }
@ -339,10 +359,29 @@ pub(crate) mod aws {
}; };
use reqwest::Client; use reqwest::Client;
use super::get_reqwest_client_builder;
// Adapter that wraps reqwest to be compatible with the AWS SDK // Adapter that wraps reqwest to be compatible with the AWS SDK
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct AwsReqwestConnector { 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 { impl HttpConnector for AwsReqwestConnector {
@ -362,10 +401,10 @@ pub(crate) mod aws {
req_builder = req_builder.body(body_bytes.to_vec()); 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 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())) Ok(HttpResponse::new(status, bytes.into()))
}; };
@ -391,7 +430,7 @@ pub(crate) mod aws {
mod tests { mod tests {
use super::*; use super::*;
use crate::util::is_global_hardcoded; use crate::util::is_global_hardcoded;
use std::net::Ipv4Addr; use std::net::{Ipv4Addr, Ipv6Addr};
use url::Host; 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] #[test]
fn dotted_decimal_loopback_normalizes() { fn dotted_decimal_loopback_normalizes() {
let ip = parse_to_ip("127.0.0.1").unwrap(); let ip = parse_to_ip("127.0.0.1").unwrap();

19
src/storage.rs

@ -77,10 +77,18 @@ pub(crate) fn operator_for_path(path: &str) -> Result<opendal::Operator, crate::
#[cfg(s3)] #[cfg(s3)]
mod s3 { mod s3 {
use std::sync::LazyLock;
use opendal_http_transport_reqwest::ReqwestTransport;
use reqwest::Url; use reqwest::Url;
use crate::error::Error; use crate::error::Error;
static HTTP_CLIENT: LazyLock<reqwest::Client> = 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 { pub(super) fn is_uri(path: &str) -> bool {
path.starts_with("s3://") path.starts_with("s3://")
} }
@ -177,12 +185,7 @@ mod s3 {
let chain = DEFAULT_CREDENTIAL_CHAIN let chain = DEFAULT_CREDENTIAL_CHAIN
.get_or_init(|| { .get_or_init(|| {
let reqwest_client = reqwest::Client::builder().build().unwrap(); let conf = ProviderConfig::default().with_http_client(AwsReqwestConnector::new());
let connector = AwsReqwestConnector {
client: reqwest_client,
};
let conf = ProviderConfig::default().with_http_client(connector);
DefaultCredentialsChain::builder().configure(conf).build() DefaultCredentialsChain::builder().configure(conf).build()
}) })
@ -236,7 +239,9 @@ mod s3 {
builder.credential_provider_chain(ProvideCredentialChain::new().push(OpenDALS3CredentialProvider)); 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 { fn uri_has_option(uri: &opendal::OperatorUri, names: &[&str]) -> bool {

Loading…
Cancel
Save