diff --git a/Cargo.lock b/Cargo.lock index b0a36edf..22cfbe51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "asyncband" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + [[package]] name = "atomic" version = "0.5.3" @@ -359,11 +369,11 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "aws-types", "bytes", @@ -399,7 +409,7 @@ dependencies = [ "aws-credential-types", "aws-sigv4", "aws-smithy-async", - "aws-smithy-http", + "aws-smithy-http 0.64.0", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -415,6 +425,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-sesv2" +version = "1.118.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d0642857f4fe76cd9a3d8c4f2b393546f7561f7725052dd9f268005fda92b7" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http 0.63.6", + "aws-smithy-json 0.62.7", + "aws-smithy-observability 0.2.6", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.105.0" @@ -425,12 +459,12 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "aws-types", "bytes", @@ -451,12 +485,12 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "aws-types", "bytes", @@ -477,13 +511,13 @@ dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-json", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-json 0.63.0", + "aws-smithy-observability 0.3.0", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "aws-smithy-xml", "aws-types", @@ -501,7 +535,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", - "aws-smithy-http", + "aws-smithy-http 0.64.0", "aws-smithy-runtime-api", "aws-smithy-types", "bytes", @@ -527,6 +561,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + [[package]] name = "aws-smithy-http" version = "0.64.0" @@ -548,6 +603,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema 0.1.0", + "aws-smithy-types", +] + [[package]] name = "aws-smithy-json" version = "0.63.0" @@ -555,10 +621,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", ] +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + [[package]] name = "aws-smithy-observability" version = "0.3.0" @@ -575,7 +650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "aws-smithy-xml", "urlencoding", @@ -588,10 +663,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", - "aws-smithy-http", - "aws-smithy-observability", + "aws-smithy-http 0.64.0", + "aws-smithy-observability 0.3.0", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "bytes", "fastrand", @@ -635,6 +710,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + [[package]] name = "aws-smithy-schema" version = "0.2.0" @@ -655,6 +741,7 @@ dependencies = [ "base64-simd", "bytes", "bytes-utils", + "futures-core", "http 0.2.12", "http 1.5.0", "http-body 0.4.6", @@ -667,6 +754,8 @@ dependencies = [ "ryu", "serde", "time", + "tokio", + "tokio-util", ] [[package]] @@ -676,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "xmlparser", ] @@ -690,7 +779,7 @@ dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", - "aws-smithy-schema", + "aws-smithy-schema 0.2.0", "aws-smithy-types", "rustc_version", "tracing", @@ -3100,15 +3189,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "mea" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31fc7d159de0085ab6dd7ff145a9819442cfd3d098f783263120503c3f3e58b0" -dependencies = [ - "slab", -] - [[package]] name = "memchr" version = "2.8.3" @@ -3437,11 +3517,12 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.58.1" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec75551ff4cf3e57da98979f6a937aaa9ddb3915bf68cc17d03df733be6646ed" +checksum = "48dbcef97d3eb7591db2c18d5cae95c836bcce07359b98d98dd6f4e861eb77b7" dependencies = [ "anyhow", + "asyncband", "base64 0.23.1", "bytes", "futures", @@ -3449,7 +3530,6 @@ dependencies = [ "jiff", "log", "md-5", - "mea", "percent-encoding", "quick-xml", "reqsign-core", @@ -3461,6 +3541,20 @@ dependencies = [ "web-time", ] +[[package]] +name = "opendal-http-transport-reqwest" +version = "0.58.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85663452ea32bbc17e8f79ab29788c846d116ec7de31451be9c787e462dcb36c" +dependencies = [ + "bytes", + "futures", + "http 1.5.0", + "http-body 1.1.0", + "opendal-core", + "reqwest", +] + [[package]] name = "opendal-service-fs" version = "0.58.1" @@ -5851,6 +5945,7 @@ dependencies = [ "argon2", "aws-config", "aws-credential-types", + "aws-sdk-sesv2", "aws-smithy-runtime-api", "bigdecimal", "bytes", @@ -5889,6 +5984,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 3e187ff3..97cb2dea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,10 @@ vendored_openssl = ["openssl/vendored"] # Enable MiMalloc memory allocator to replace the default malloc # This can improve performance for Alpine builds enable_mimalloc = ["dep:mimalloc"] +aws = ["s3", "ses"] s3 = [ "opendal/services-s3", + "dep:opendal-http-transport-reqwest", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-runtime-api", @@ -48,6 +50,7 @@ s3 = [ "dep:reqsign-aws-v4", "dep:reqsign-core", ] +ses = ["dep:aws-config", "dep:aws-sdk-sesv2", "dep:aws-smithy-runtime-api"] # OIDC specific features oidc-accept-rfc3339-timestamps = ["openidconnect/accept-rfc3339-timestamps"] @@ -258,6 +261,7 @@ grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL opendal = { version = "0.58.1", default-features = false, features = ["services-fs"] } +opendal-http-transport-reqwest = { version = "0.58.1", default-features = false, features = ["rustls-no-provider"], optional = true } # For retrieving AWS credentials, including temporary SSO credentials aws-config = { version = "1.10.1", optional = true, default-features = false, features = [ @@ -267,6 +271,7 @@ aws-config = { version = "1.10.1", optional = true, default-features = false, fe "sso", ] } aws-credential-types = { version = "1.3.0", optional = true } +aws-sdk-sesv2 = { version = "1.118.0", features = ["behavior-version-latest", "rt-tokio"], default-features = false, optional = true } aws-smithy-runtime-api = { version = "1.14.0", optional = true } http = { version = "1.5.0", optional = true } reqsign-aws-v4 = { version = "3.1.0", optional = true } diff --git a/build.rs b/build.rs index 32fcf845..90e9c571 100644 --- a/build.rs +++ b/build.rs @@ -15,6 +15,10 @@ fn main() { #[cfg(feature = "s3")] println!("cargo:rustc-cfg=s3"); + #[cfg(feature = "ses")] + println!("cargo:rustc-cfg=ses"); + #[cfg(feature = "aws")] + println!("cargo:rustc-cfg=aws"); // Use check-cfg to let cargo know which cfg's we define, // and avoid warnings when they are used in the code. @@ -22,6 +26,8 @@ fn main() { println!("cargo::rustc-check-cfg=cfg(mysql)"); println!("cargo::rustc-check-cfg=cfg(postgresql)"); println!("cargo::rustc-check-cfg=cfg(s3)"); + println!("cargo::rustc-check-cfg=cfg(ses)"); + println!("cargo::rustc-check-cfg=cfg(aws)"); // Rerun when these paths are changed. // Someone could have checked-out a tag or specific commit, but no other files changed. diff --git a/src/aws.rs b/src/aws.rs new file mode 100644 index 00000000..f6e787cd --- /dev/null +++ b/src/aws.rs @@ -0,0 +1,22 @@ +use aws_config::{AppName, BehaviorVersion}; +use tokio::sync::OnceCell; + +use crate::http_client::aws::AwsReqwestConnector; + +fn aws_reqwest_connector() -> AwsReqwestConnector { + AwsReqwestConnector::new() +} + +pub(crate) async fn aws_sdk_config() -> &'static aws_config::SdkConfig { + static AWS_CONFIG: OnceCell = OnceCell::const_new(); + + AWS_CONFIG + .get_or_init(async || { + aws_config::defaults(BehaviorVersion::latest()) + .app_name(AppName::new("vaultwarden").expect("Failed to build AWS app name")) + .http_client(aws_reqwest_connector()) + .load() + .await + }) + .await +} diff --git a/src/config.rs b/src/config.rs index 2502dd02..5a5baae3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -919,12 +919,14 @@ make_config! { smtp_accept_invalid_certs: bool, true, def, false; /// Accept Invalid Hostnames (Know the risks!) |> DANGEROUS: Allow invalid hostnames. This option introduces significant vulnerabilities to man-in-the-middle attacks! smtp_accept_invalid_hostnames: bool, true, def, false; + /// Use AWS SES |> Whether to send mail via AWS Simple Email Service (SES) + use_aws_ses: bool, true, def, false; }, /// Email 2FA Settings email_2fa: _enable_email_2fa { /// Enabled |> Disabling will prevent users from setting up new email 2FA and using existing email 2FA configured - _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail); + _enable_email_2fa: bool, true, auto, |c| c._enable_smtp && (c.smtp_host.is_some() || c.use_sendmail || c.use_aws_ses); /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. @@ -1169,6 +1171,9 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } } + } else if cfg.use_aws_ses { + #[cfg(not(ses))] + err!("`USE_AWS_SES` is set, but the `ses` feature is not enabled in this build"); } else { if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() { err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support without `USE_SENDMAIL`") @@ -1181,7 +1186,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - if (cfg.smtp_host.is_some() || cfg.use_sendmail) && !is_valid_email(&cfg.smtp_from) { + if (cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) && !is_valid_email(&cfg.smtp_from) { err!(format!("SMTP_FROM '{}' is not a valid email address", cfg.smtp_from)) } @@ -1190,7 +1195,7 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } } - if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail) { + if cfg._enable_email_2fa && !(cfg.smtp_host.is_some() || cfg.use_sendmail || cfg.use_aws_ses) { err!("To enable email 2FA, a mail transport must be configured") } @@ -1612,7 +1617,7 @@ impl Config { } pub fn mail_enabled(&self) -> bool { let inner = &self.inner.read().unwrap().config; - inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail) + inner._enable_smtp && (inner.smtp_host.is_some() || inner.use_sendmail || inner.use_aws_ses) } pub async fn get_duo_akey(&self) -> String { diff --git a/src/http_client.rs b/src/http_client.rs index 0831d990..933a5be0 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,14 @@ impl CustomDnsResolver { } } +fn sort_addresses(addresses: &mut [SocketAddr], prefer_ipv6: bool) { + 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 +335,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"); } @@ -329,7 +346,7 @@ impl Resolve for CustomDns { } } -#[cfg(s3)] +#[cfg(any(s3, ses))] pub(crate) mod aws { use aws_smithy_runtime_api::client::{ http::{HttpClient, HttpConnector, HttpConnectorFuture, HttpConnectorSettings, SharedHttpConnector}, @@ -339,10 +356,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 +398,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 +427,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 +440,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/mail.rs b/src/mail.rs index a7e5e5ae..21fc07ed 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -96,6 +96,44 @@ fn smtp_transport() -> AsyncSmtpTransport { smtp_client.build() } +#[cfg(ses)] +async fn send_with_aws_ses(email: Message) -> std::io::Result<()> { + use std::io::Error; + + use aws_sdk_sesv2::{ + Client, + types::{EmailContent, RawMessage}, + }; + use tokio::sync::OnceCell; + + static AWS_SESV2_CLIENT: OnceCell = OnceCell::const_new(); + + let client = AWS_SESV2_CLIENT + .get_or_init(async || { + let config = crate::aws::aws_sdk_config().await; + Client::new(config) + }) + .await; + + client + .send_email() + .content( + EmailContent::builder() + .raw( + RawMessage::builder() + .data(email.formatted().into()) + .build() + .map_err(|e| Error::other(format!("Failed to build AWS SESv2 RawMessage: {e:?}")))?, + ) + .build(), + ) + .send() + .await + .map_err(Error::other)?; + + Ok(()) +} + // This will sanitize the string values by stripping all the html tags to prevent XSS and HTML Injections fn sanitize_data(data: &mut serde_json::Value) { use regex::Regex; @@ -676,6 +714,15 @@ async fn send_with_selected_transport(email: Message) -> EmptyResult { err!(format!("Sendmail error: {e}")); } } + } else if CONFIG.use_aws_ses() { + #[cfg(ses)] + match send_with_aws_ses(email).await { + Ok(()) => Ok(()), + Err(e) => err!("Failed to send email", format!("Failed to send email using AWS SES: {e:?}")), + } + + #[cfg(not(ses))] + unreachable!("Failed to send email using AWS SES: `ses` feature is not enabled"); } else { match smtp_transport().send(email).await { Ok(_) => Ok(()), diff --git a/src/main.rs b/src/main.rs index 28645694..355fa28a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,6 +51,8 @@ use rocket::data::{Limits, ToByteUnit}; mod error; mod api; mod auth; +#[cfg(any(s3, ses))] +mod aws; mod config; mod crypto; #[macro_use] diff --git a/src/storage.rs b/src/storage.rs index 689be302..6bd7aaba 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://") } @@ -152,8 +160,6 @@ mod s3 { } pub(super) fn operator_for_path(path: &str) -> Result { - use crate::http_client::aws::AwsReqwestConnector; - use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig}; use opendal::Configurator; use reqsign_aws_v4::Credential; use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain}; @@ -171,24 +177,12 @@ mod s3 { async fn provide_credential(&self, _ctx: &Context) -> reqsign_core::Result> { use aws_credential_types::provider::ProvideCredentials as _; use reqsign_core::time::Timestamp; - use tokio::sync::OnceCell; - - static DEFAULT_CREDENTIAL_CHAIN: OnceCell = OnceCell::const_new(); - - 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); - - DefaultCredentialsChain::builder().configure(conf).build() - }) - .await; - - let creds = chain.provide_credentials().await.map_err(|e| { + let credentials_provider = + crate::aws::aws_sdk_config().await.credentials_provider().ok_or_else(|| { + reqsign_core::Error::unexpected("failed to load AWS credentials provider from AWS SDK config") + })?; + let creds = credentials_provider.provide_credentials().await.map_err(|e| { reqsign_core::Error::unexpected("failed to load AWS credentials via AWS SDK").with_source(e) })?; @@ -236,7 +230,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 {