mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-05-08 12:34:43 +02:00
Merge 73cf3eed1f into f21a3adae2
This commit is contained in:
commit
f89779a7d9
5 changed files with 553 additions and 12 deletions
|
|
@ -18,7 +18,27 @@
|
|||
## This can be a path to local folder or a path to an external location
|
||||
## depending on features enabled at build time. Possible external locations:
|
||||
##
|
||||
## - AWS S3 Bucket (via `s3` feature): s3://bucket-name/path/to/folder
|
||||
## - S3-compatible bucket (via `s3` feature): s3://bucket-name/path/to/folder
|
||||
##
|
||||
## Optional query parameters are supported for S3-compatible providers:
|
||||
## - endpoint (MinIO/R2/Ceph RGW): ?endpoint=https%3A%2F%2Fs3.example.internal
|
||||
## - enable_virtual_host_style (set false for path-style): ?enable_virtual_host_style=false
|
||||
## - default_storage_class: ?default_storage_class=STANDARD
|
||||
## Use an empty value to omit the storage-class header:
|
||||
## ?default_storage_class=
|
||||
## - region (provider/signing specific): ?region=us-east-1
|
||||
##
|
||||
## Examples:
|
||||
## - AWS S3 defaults: s3://bucket-name/path/to/folder
|
||||
## - MinIO path-style: s3://bucket-name/path/to/folder?endpoint=http%3A%2F%2Fminio%3A9000&enable_virtual_host_style=false&default_storage_class=STANDARD
|
||||
## - Cloudflare R2: s3://bucket-name/path/to/folder?endpoint=https%3A%2F%2F<accountid>.r2.cloudflarestorage.com®ion=auto&default_storage_class=
|
||||
##
|
||||
## Credentials in URI query params are supported as a last resort, but it is
|
||||
## strongly recommended to use environment credentials/IAM instead.
|
||||
##
|
||||
## Note: For S3 paths to work, the container/binary must be built with both
|
||||
## a DB backend and the `s3` feature (for example: `sqlite,s3`,
|
||||
## `postgresql,s3`, or `mysql,s3`).
|
||||
##
|
||||
## When using an external location, make sure to set TMP_FOLDER,
|
||||
## TEMPLATES_FOLDER, and DATABASE_URL to local paths and/or a remote database
|
||||
|
|
@ -451,6 +471,12 @@
|
|||
## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value.
|
||||
## Multiple values must be separated with a whitespace. And only HTTPS values are allowed.
|
||||
## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld"
|
||||
## For S3-compatible attachment downloads, include your object storage origin
|
||||
## (for example Cloudflare R2 endpoint):
|
||||
## "https://<accountid>.r2.cloudflarestorage.com"
|
||||
## Note: This only configures CSP on Vaultwarden. You also need a CORS policy
|
||||
## on the object storage bucket/provider that allows your Vaultwarden DOMAIN
|
||||
## origin for download requests.
|
||||
# ALLOWED_CONNECT_SRC=""
|
||||
|
||||
## Number of seconds, on average, between login requests from the same IP address before rate limiting kicks in.
|
||||
|
|
|
|||
69
.github/workflows/build.yml
vendored
69
.github/workflows/build.yml
vendored
|
|
@ -218,3 +218,72 @@ jobs:
|
|||
run: |
|
||||
echo "### :tada: Checks Passed!" >> "${GITHUB_STEP_SUMMARY}"
|
||||
echo "" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
s3-compatible-minio:
|
||||
name: S3-Compatible Integration (MinIO)
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: "Install dependencies Ubuntu"
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends curl openssl build-essential libmariadb-dev-compat libpq-dev libssl-dev pkg-config
|
||||
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "Install rust-toolchain version"
|
||||
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # master @ Dec 16, 2025, 6:11 PM GMT+1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: "Show environment"
|
||||
run: |
|
||||
rustc -vV
|
||||
cargo -vV
|
||||
|
||||
- name: Rust Caching
|
||||
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
|
||||
with:
|
||||
prefix-key: "v2025.09-rust"
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio:latest
|
||||
docker pull minio/mc:latest
|
||||
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=minioadmin \
|
||||
minio/minio:latest \
|
||||
server /data --console-address ":9001"
|
||||
|
||||
for i in {1..30}; do
|
||||
if curl -fsS "http://127.0.0.1:9000/minio/health/live" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! curl -fsS "http://127.0.0.1:9000/minio/health/live" >/dev/null; then
|
||||
docker ps -a
|
||||
docker logs minio || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run --rm --network host --entrypoint /bin/sh minio/mc:latest -c \
|
||||
"mc alias set local http://127.0.0.1:9000 minioadmin minioadmin && mc mb --ignore-existing local/vaultwarden-test"
|
||||
|
||||
- name: Run MinIO integration test
|
||||
env:
|
||||
VW_S3_MINIO_ENDPOINT: "http://127.0.0.1:9000"
|
||||
VW_S3_MINIO_BUCKET: "vaultwarden-test"
|
||||
VW_S3_MINIO_ROOT: "/vaultwarden-integration"
|
||||
VW_S3_MINIO_REGION: "auto"
|
||||
VW_S3_MINIO_ACCESS_KEY: "minioadmin"
|
||||
VW_S3_MINIO_SECRET_KEY: "minioadmin"
|
||||
run: |
|
||||
cargo test --profile ci --features sqlite,s3 test_s3_minio_integration_put_get_delete -- --ignored
|
||||
|
|
|
|||
63
README.md
63
README.md
|
|
@ -112,6 +112,69 @@ services:
|
|||
|
||||
<br>
|
||||
|
||||
### S3-Compatible Object Storage
|
||||
|
||||
When built with the `s3` feature, storage paths like `DATA_FOLDER`, `ATTACHMENTS_FOLDER`, `ICON_CACHE_FOLDER` and `SENDS_FOLDER` can use `s3://` URIs with query parameters:
|
||||
|
||||
```text
|
||||
s3://bucket/prefix?endpoint=https%3A%2F%2Fs3.example.internal&enable_virtual_host_style=false&default_storage_class=STANDARD
|
||||
```
|
||||
|
||||
- AWS S3 works with defaults (no extra parameters required).
|
||||
- MinIO/Ceph usually require `endpoint` and `enable_virtual_host_style=false`.
|
||||
- Cloudflare R2 usually requires `endpoint` and often `region=auto`.
|
||||
- To omit `x-amz-storage-class`, set `default_storage_class=` (empty value).
|
||||
- Container images must include both a DB backend feature and `s3` (for example `sqlite,s3`, `postgresql,s3`, or `mysql,s3`).
|
||||
|
||||
Kubernetes example:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: DATA_FOLDER
|
||||
value: "s3://vaultwarden-data/prod?endpoint=https%3A%2F%2Fs3.example.internal&enable_virtual_host_style=false&default_storage_class=STANDARD"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: vaultwarden-db
|
||||
key: url
|
||||
```
|
||||
|
||||
Use IAM/service account/environment credentials when possible. URI credentials are supported as a last resort.
|
||||
|
||||
### Browser Attachment Downloads (CSP + CORS)
|
||||
|
||||
For S3-compatible backends, attachment downloads from the Web Vault use presigned URLs. The browser downloads directly from the object storage endpoint.
|
||||
|
||||
Configure both sides:
|
||||
|
||||
- Vaultwarden CSP: allow the object-storage origin in `ALLOWED_CONNECT_SRC`.
|
||||
- Object storage CORS policy: allow your Vaultwarden origin (`DOMAIN`) for `GET`/`HEAD`.
|
||||
|
||||
R2 example:
|
||||
|
||||
```text
|
||||
ALLOWED_CONNECT_SRC="https://<accountid>.r2.cloudflarestorage.com"
|
||||
```
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://vault.example.com"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Troubleshooting:
|
||||
|
||||
- `violates the document's Content Security Policy`: set `ALLOWED_CONNECT_SRC` correctly.
|
||||
- `No 'Access-Control-Allow-Origin' header`: fix CORS policy on the bucket/provider.
|
||||
|
||||
<br>
|
||||
|
||||
## Get in touch
|
||||
|
||||
Have a question, suggestion or need help? Join our community on [Matrix](https://matrix.to/#/#vaultwarden:matrix.org), [GitHub Discussions](https://github.com/dani-garcia/vaultwarden/discussions) or [Discourse Forums](https://vaultwarden.discourse.group/).
|
||||
|
|
|
|||
105
docs/s3-compatible-object-storage.md
Normal file
105
docs/s3-compatible-object-storage.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# S3-Compatible Object Storage
|
||||
|
||||
This page documents Vaultwarden's S3-compatible storage support based on `s3://` URIs with query parameters (OpenDAL S3 config).
|
||||
|
||||
## Scope
|
||||
|
||||
Supported providers (via S3 API):
|
||||
|
||||
- AWS S3
|
||||
- MinIO
|
||||
- Cloudflare R2
|
||||
- Ceph RGW and similar S3-compatible services
|
||||
|
||||
The same URI format applies to:
|
||||
|
||||
- `DATA_FOLDER`
|
||||
- `ATTACHMENTS_FOLDER`
|
||||
- `ICON_CACHE_FOLDER`
|
||||
- `SENDS_FOLDER`
|
||||
|
||||
## URI Format
|
||||
|
||||
```text
|
||||
s3://bucket/prefix?endpoint=https%3A%2F%2Fs3.example.com&enable_virtual_host_style=false&default_storage_class=STANDARD®ion=us-east-1
|
||||
```
|
||||
|
||||
Supported query parameters:
|
||||
|
||||
- `endpoint`
|
||||
- `region`
|
||||
- `enable_virtual_host_style`
|
||||
- `default_storage_class`
|
||||
- `disable_virtual_host_style` (alias)
|
||||
|
||||
Notes:
|
||||
|
||||
- AWS S3 works with defaults.
|
||||
- For path-style providers, set `enable_virtual_host_style=false`.
|
||||
- To omit storage class header, set `default_storage_class=` (empty).
|
||||
- Unknown parameters are rejected.
|
||||
|
||||
## Build Requirement
|
||||
|
||||
Use images/binaries built with both:
|
||||
|
||||
1. a DB backend feature (`sqlite`, `postgresql`, or `mysql`)
|
||||
2. `s3`
|
||||
|
||||
Examples:
|
||||
|
||||
- `sqlite,s3`
|
||||
- `postgresql,s3`
|
||||
- `mysql,s3`
|
||||
|
||||
## Cloudflare R2 Example
|
||||
|
||||
```env
|
||||
ATTACHMENTS_FOLDER=s3://vaultwarden/attachments?endpoint=https://<accountid>.r2.cloudflarestorage.com®ion=auto&enable_virtual_host_style=false&default_storage_class=
|
||||
ICON_CACHE_FOLDER=s3://vaultwarden/icon_cache?endpoint=https://<accountid>.r2.cloudflarestorage.com®ion=auto&enable_virtual_host_style=false&default_storage_class=
|
||||
SENDS_FOLDER=s3://vaultwarden/sends?endpoint=https://<accountid>.r2.cloudflarestorage.com®ion=auto&enable_virtual_host_style=false&default_storage_class=
|
||||
```
|
||||
|
||||
## Browser Downloads: CSP + CORS
|
||||
|
||||
When attachments are stored in object storage, Web Vault downloads use presigned URLs and the browser fetches objects directly from the storage endpoint.
|
||||
|
||||
You must configure both sides:
|
||||
|
||||
1. Vaultwarden CSP (`ALLOWED_CONNECT_SRC`)
|
||||
2. Bucket/provider CORS policy
|
||||
|
||||
### 1) Vaultwarden CSP
|
||||
|
||||
```env
|
||||
ALLOWED_CONNECT_SRC=https://<accountid>.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
### 2) Bucket CORS Policy (example)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://vault.example.com"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag", "Content-Length", "Content-Type", "Content-Disposition"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `violates the document's Content Security Policy`
|
||||
- Configure/fix `ALLOWED_CONNECT_SRC`.
|
||||
- `No 'Access-Control-Allow-Origin' header`
|
||||
- Configure/fix CORS on the bucket/provider.
|
||||
- `S3 support is not enabled`
|
||||
- Image/binary was built without `s3` feature.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Prefer IAM/service account/environment credentials.
|
||||
- URI credentials are supported only as a last resort.
|
||||
- If credentials were exposed in logs/chats, rotate them immediately.
|
||||
300
src/config.rs
300
src/config.rs
|
|
@ -1391,16 +1391,175 @@ fn opendal_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
|
|||
Ok(operator)
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
fn is_s3_secret_param(param_name: &str) -> bool {
|
||||
matches!(param_name, "access_key_id" | "secret_access_key" | "session_token")
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
fn parse_s3_bool(value: &str) -> Option<bool> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"true" | "1" | "yes" => Some(true),
|
||||
"false" | "0" | "no" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
fn is_s3_implicit_bool_param(param_name: &str) -> bool {
|
||||
param_name.starts_with("enable_") || param_name.starts_with("disable_") || param_name.starts_with("allow_")
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
/// Set S3Config fields from query parameters using serde.
|
||||
fn set_s3_config_param(
|
||||
config: opendal::services::S3Config,
|
||||
param_name: &str,
|
||||
param_value: Option<&str>,
|
||||
) -> Result<opendal::services::S3Config, Error> {
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// Special handling for blocked parameters
|
||||
const BLOCKED_PARAMS: &[&str] = &["bucket", "root"];
|
||||
if BLOCKED_PARAMS.contains(¶m_name) {
|
||||
return Err(format!("S3 OpenDAL Parameter '{param_name}' cannot be overridden via query string").into());
|
||||
}
|
||||
|
||||
// Parse the parameter value
|
||||
let json_value = match param_value {
|
||||
None => {
|
||||
// For boolean fields that default to true when present without value
|
||||
// This includes fields starting with enable_, disable_, or allow_
|
||||
if is_s3_implicit_bool_param(param_name) {
|
||||
json!(true)
|
||||
} else {
|
||||
return Err(format!("S3 OpenDAL Parameter '{param_name}' requires a value").into());
|
||||
}
|
||||
}
|
||||
Some(value) => {
|
||||
// Try to parse as boolean first
|
||||
if let Some(bool_value) = parse_s3_bool(value) {
|
||||
json!(bool_value)
|
||||
} else if let Ok(num) = value.parse::<usize>() {
|
||||
// Try to parse as number (for fields like delete_max_size, batch_max_operations)
|
||||
json!(num)
|
||||
} else {
|
||||
// Default to string
|
||||
json!(value)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Convert current config to JSON
|
||||
let config_json =
|
||||
serde_json::to_value(config).map_err(|e| Error::from(format!("Failed to serialize S3Config to JSON: {e}")))?;
|
||||
|
||||
// Merge with the new field and deserialize
|
||||
if let Value::Object(mut config_obj) = config_json {
|
||||
if !config_obj.contains_key(param_name) {
|
||||
return Err(format!("Unknown S3 OpenDAL parameter '{param_name}'").into());
|
||||
}
|
||||
|
||||
// Insert the new field
|
||||
config_obj.insert(param_name.to_string(), json_value.clone());
|
||||
|
||||
// Try to deserialize with the new field
|
||||
let display_json_value = if is_s3_secret_param(param_name) {
|
||||
json!("***")
|
||||
} else {
|
||||
json_value
|
||||
};
|
||||
let new_config = serde_json::from_value::<opendal::services::S3Config>(Value::Object(config_obj))
|
||||
.map_err(|e| Error::from(format!("Failed to deserialize S3Config from JSON after updating parameter '{param_name}' to value {display_json_value}: {e}")))?;
|
||||
|
||||
Ok(new_config)
|
||||
} else {
|
||||
unreachable!("S3Config should always serialize to an object");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
fn parse_s3_config_for_path(path: &str) -> Result<opendal::services::S3Config, Error> {
|
||||
use opendal::services::S3Config;
|
||||
|
||||
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
|
||||
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
|
||||
|
||||
// Create S3Config and set base configuration based on best practices for
|
||||
// the official AWS S3 service.
|
||||
let mut config = S3Config::default();
|
||||
config.bucket = bucket.to_string();
|
||||
config.root = Some(url.path().to_string());
|
||||
|
||||
// Default to virtual host style enabled (AWS S3 has deprecated path style)
|
||||
//
|
||||
// Note: Some providers may not support virtual host style
|
||||
config.enable_virtual_host_style = true;
|
||||
|
||||
// Default to AWS S3's Intelligent Tiering storage class for optimal
|
||||
// cost/performance
|
||||
//
|
||||
// Note: Some providers may not support this storage class
|
||||
config.default_storage_class = Some("INTELLIGENT_TIERING".to_string());
|
||||
|
||||
// Process query parameters
|
||||
for (param_name, param_value) in url.query_pairs() {
|
||||
let param_name = param_name.as_ref();
|
||||
let mut param_value = if param_value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(param_value.as_ref())
|
||||
};
|
||||
|
||||
if param_name == "disable_virtual_host_style" {
|
||||
let value = param_value.unwrap_or("true");
|
||||
let bool_value = parse_s3_bool(value).ok_or_else(|| {
|
||||
format!("S3 OpenDAL Parameter 'disable_virtual_host_style' has invalid boolean value {value:?}")
|
||||
})?;
|
||||
|
||||
let enabled_value = if bool_value {
|
||||
"false"
|
||||
} else {
|
||||
"true"
|
||||
};
|
||||
config = set_s3_config_param(config, "enable_virtual_host_style", Some(enabled_value))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if param_name == "default_storage_class" && param_value.is_none() {
|
||||
param_value = Some("");
|
||||
}
|
||||
|
||||
// Use the generated setter function to handle parameters
|
||||
config = set_s3_config_param(config, param_name, param_value)?;
|
||||
}
|
||||
|
||||
if config.access_key_id.is_some() || config.secret_access_key.is_some() || config.session_token.is_some() {
|
||||
warn!(
|
||||
"S3 static credentials provided through path query parameters. This works, but using environment credentials or IAM is recommended."
|
||||
);
|
||||
}
|
||||
|
||||
if config.default_storage_class.as_deref() == Some("") {
|
||||
config.default_storage_class = None;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[cfg(s3)]
|
||||
fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error> {
|
||||
use crate::http_client::aws::AwsReqwestConnector;
|
||||
use aws_config::{default_provider::credentials::DefaultCredentialsChain, provider_config::ProviderConfig};
|
||||
use opendal::{services::S3Config, Configurator};
|
||||
|
||||
// This is a custom AWS credential loader that uses the official AWS Rust
|
||||
// SDK config crate to load credentials. This ensures maximum compatibility
|
||||
// with AWS credential configurations. For example, OpenDAL doesn't support
|
||||
// AWS SSO temporary credentials yet.
|
||||
struct OpenDALS3CredentialLoader {}
|
||||
struct OpenDALS3CredentialLoader {
|
||||
config: S3Config,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl reqsign::AwsCredentialLoad for OpenDALS3CredentialLoader {
|
||||
|
|
@ -1408,6 +1567,23 @@ fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error>
|
|||
use aws_credential_types::provider::ProvideCredentials as _;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
// If static credentials are provided, use them directly
|
||||
match (&self.config.access_key_id, &self.config.secret_access_key) {
|
||||
(Some(access_key_id), Some(secret_access_key)) => {
|
||||
return Ok(Some(reqsign::AwsCredential {
|
||||
access_key_id: access_key_id.clone(),
|
||||
secret_access_key: secret_access_key.clone(),
|
||||
session_token: self.config.session_token.clone(),
|
||||
expires_in: None,
|
||||
}));
|
||||
}
|
||||
(None, None) if self.config.session_token.is_none() => (),
|
||||
_ => anyhow::bail!(
|
||||
"s3 path must have access_key_id and secret_access_key both set, optionally with session_token set, or all three must be unset"
|
||||
),
|
||||
};
|
||||
|
||||
// Use the default credentials chain from the AWS SDK (especially useful for SSO)
|
||||
static DEFAULT_CREDENTIAL_CHAIN: OnceCell<DefaultCredentialsChain> = OnceCell::const_new();
|
||||
|
||||
let chain = DEFAULT_CREDENTIAL_CHAIN
|
||||
|
|
@ -1434,22 +1610,124 @@ fn opendal_s3_operator_for_path(path: &str) -> Result<opendal::Operator, Error>
|
|||
}
|
||||
}
|
||||
|
||||
const OPEN_DAL_S3_CREDENTIAL_LOADER: OpenDALS3CredentialLoader = OpenDALS3CredentialLoader {};
|
||||
let config = parse_s3_config_for_path(path)?;
|
||||
|
||||
let url = Url::parse(path).map_err(|e| format!("Invalid path S3 URL path {path:?}: {e}"))?;
|
||||
let credential_loader = OpenDALS3CredentialLoader {
|
||||
config: config.clone(),
|
||||
};
|
||||
|
||||
let bucket = url.host_str().ok_or_else(|| format!("Missing Bucket name in data folder S3 URL {path:?}"))?;
|
||||
|
||||
let builder = opendal::services::S3::default()
|
||||
.customized_credential_load(Box::new(OPEN_DAL_S3_CREDENTIAL_LOADER))
|
||||
.enable_virtual_host_style()
|
||||
.bucket(bucket)
|
||||
.root(url.path())
|
||||
.default_storage_class("INTELLIGENT_TIERING");
|
||||
// Convert config to builder and add custom credential loader
|
||||
let builder = config.into_builder().customized_credential_load(Box::new(credential_loader));
|
||||
|
||||
Ok(opendal::Operator::new(builder)?.finish())
|
||||
}
|
||||
|
||||
#[cfg(all(test, s3))]
|
||||
mod s3_tests {
|
||||
use super::{opendal_s3_operator_for_path, parse_s3_config_for_path};
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_defaults() {
|
||||
let config = parse_s3_config_for_path("s3://vaultwarden-data/path/to/root").expect("config should parse");
|
||||
|
||||
assert_eq!(config.bucket, "vaultwarden-data");
|
||||
assert_eq!(config.root.as_deref(), Some("/path/to/root"));
|
||||
assert!(config.enable_virtual_host_style);
|
||||
assert_eq!(config.default_storage_class.as_deref(), Some("INTELLIGENT_TIERING"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_custom_endpoint_and_path_style() {
|
||||
let config = parse_s3_config_for_path(
|
||||
"s3://vw/path?endpoint=http%3A%2F%2F127.0.0.1%3A9000&enable_virtual_host_style=false&default_storage_class=STANDARD®ion=us-east-1",
|
||||
)
|
||||
.expect("config should parse");
|
||||
|
||||
assert_eq!(config.endpoint.as_deref(), Some("http://127.0.0.1:9000"));
|
||||
assert!(!config.enable_virtual_host_style);
|
||||
assert_eq!(config.default_storage_class.as_deref(), Some("STANDARD"));
|
||||
assert_eq!(config.region.as_deref(), Some("us-east-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_disable_virtual_host_style_alias() {
|
||||
let config =
|
||||
parse_s3_config_for_path("s3://vw/path?disable_virtual_host_style=true").expect("config should parse");
|
||||
assert!(!config.enable_virtual_host_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_storage_class_can_be_omitted() {
|
||||
let config = parse_s3_config_for_path("s3://vw/path?default_storage_class=").expect("config should parse");
|
||||
assert_eq!(config.default_storage_class, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_implicit_boolean_flag() {
|
||||
let config = parse_s3_config_for_path("s3://vw/path?enable_virtual_host_style").expect("config should parse");
|
||||
assert!(config.enable_virtual_host_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_boolean_variants() {
|
||||
let config = parse_s3_config_for_path("s3://vw/path?enable_virtual_host_style=0").expect("config should parse");
|
||||
assert!(!config.enable_virtual_host_style);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_percent_encoded_prefix() {
|
||||
let config = parse_s3_config_for_path("s3://vw/path%20with%20spaces").expect("config should parse");
|
||||
assert_eq!(config.root.as_deref(), Some("/path%20with%20spaces"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_s3_config_rejects_unknown_parameter() {
|
||||
let error = parse_s3_config_for_path("s3://vw/path?region=auto&unknown_param=value")
|
||||
.expect_err("unknown params should fail");
|
||||
let error_message = format!("{error:?}");
|
||||
assert!(
|
||||
error_message.contains("Unknown S3 OpenDAL parameter") && error_message.contains("unknown_param"),
|
||||
"error message: {error_message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_s3_minio_integration_put_get_delete() {
|
||||
let endpoint = std::env::var("VW_S3_MINIO_ENDPOINT").unwrap_or_else(|_| "http://127.0.0.1:9000".to_string());
|
||||
let bucket = std::env::var("VW_S3_MINIO_BUCKET").unwrap_or_else(|_| "vaultwarden-test".to_string());
|
||||
let mut root = std::env::var("VW_S3_MINIO_ROOT").unwrap_or_else(|_| "/vaultwarden-s3-test".to_string());
|
||||
if !root.starts_with('/') {
|
||||
root = format!("/{root}");
|
||||
}
|
||||
let access_key = std::env::var("VW_S3_MINIO_ACCESS_KEY").unwrap_or_else(|_| "minioadmin".to_string());
|
||||
let secret_key = std::env::var("VW_S3_MINIO_SECRET_KEY").unwrap_or_else(|_| "minioadmin".to_string());
|
||||
let region = std::env::var("VW_S3_MINIO_REGION").unwrap_or_else(|_| "auto".to_string());
|
||||
|
||||
let mut query = url::form_urlencoded::Serializer::new(String::new());
|
||||
query.append_pair("endpoint", &endpoint);
|
||||
query.append_pair("region", ®ion);
|
||||
query.append_pair("enable_virtual_host_style", "false");
|
||||
query.append_pair("default_storage_class", "STANDARD");
|
||||
query.append_pair("access_key_id", &access_key);
|
||||
query.append_pair("secret_access_key", &secret_key);
|
||||
let s3_path = format!("s3://{bucket}{root}?{}", query.finish());
|
||||
|
||||
let rt =
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build().expect("tokio runtime should build");
|
||||
rt.block_on(async move {
|
||||
let operator = opendal_s3_operator_for_path(&s3_path).expect("operator should be created");
|
||||
let key = format!("integration/{}.txt", uuid::Uuid::new_v4());
|
||||
let payload = b"vaultwarden-opendal-s3-compatible";
|
||||
|
||||
operator.write(&key, payload.as_slice()).await.expect("object upload should succeed");
|
||||
let buffer = operator.read(&key).await.expect("object download should succeed");
|
||||
assert_eq!(buffer.to_vec(), payload.as_slice());
|
||||
operator.delete(&key).await.expect("object delete should succeed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PathType {
|
||||
Data,
|
||||
IconCache,
|
||||
|
|
|
|||
Loading…
Reference in a new issue