[pbs-devel] [PATCH proxmox-backup v3 05/41] s3 client: add crate for AWS S3 compatible object store client
Christian Ebner
c.ebner at proxmox.com
Mon Jun 16 16:21:20 CEST 2025
Adds the client to connect to an AWS S3 compatible object store API.
Force the use of an TLS encrypted connection as the communication
with the object store will contain sensitive information.
For self-signed certificates, check the fingerprint against the one
configured. This follows along the lines of the PBS client, used to
connect to the PBS server API.
The `S3Client` stores the client state and has to be configured upon
instantiation by providing `S3ClientOptions`.
Signed-off-by: Christian Ebner <c.ebner at proxmox.com>
---
Cargo.toml | 3 +
pbs-s3-client/Cargo.toml | 17 ++++
pbs-s3-client/src/client.rs | 170 ++++++++++++++++++++++++++++++++++++
pbs-s3-client/src/lib.rs | 2 +
4 files changed, 192 insertions(+)
create mode 100644 pbs-s3-client/Cargo.toml
create mode 100644 pbs-s3-client/src/client.rs
create mode 100644 pbs-s3-client/src/lib.rs
diff --git a/Cargo.toml b/Cargo.toml
index d38321e33..87742f571 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ members = [
"pbs-fuse-loop",
"pbs-key-config",
"pbs-pxar-fuse",
+ "pbs-s3-client",
"pbs-tape",
"pbs-tools",
@@ -105,6 +106,7 @@ pbs-datastore = { path = "pbs-datastore" }
pbs-fuse-loop = { path = "pbs-fuse-loop" }
pbs-key-config = { path = "pbs-key-config" }
pbs-pxar-fuse = { path = "pbs-pxar-fuse" }
+pbs-s3-client = { path = "pbs-s3-client" }
pbs-tape = { path = "pbs-tape" }
pbs-tools = { path = "pbs-tools" }
@@ -245,6 +247,7 @@ pbs-client.workspace = true
pbs-config.workspace = true
pbs-datastore.workspace = true
pbs-key-config.workspace = true
+pbs-s3-client.workspace = true
pbs-tape.workspace = true
pbs-tools.workspace = true
proxmox-rrd.workspace = true
diff --git a/pbs-s3-client/Cargo.toml b/pbs-s3-client/Cargo.toml
new file mode 100644
index 000000000..9e3961efa
--- /dev/null
+++ b/pbs-s3-client/Cargo.toml
@@ -0,0 +1,17 @@
+[package]
+name = "pbs-s3-client"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+description = "low level client for AWS S3 compatible object stores"
+rust-version.workspace = true
+
+[dependencies]
+anyhow.workspace = true
+hex = { workspace = true, features = [ "serde" ] }
+hyper.workspace = true
+openssl.workspace = true
+tracing.workspace = true
+
+pbs-api-types.workspace = true
+proxmox-http.workspace = true
diff --git a/pbs-s3-client/src/client.rs b/pbs-s3-client/src/client.rs
new file mode 100644
index 000000000..b886843a3
--- /dev/null
+++ b/pbs-s3-client/src/client.rs
@@ -0,0 +1,170 @@
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+use anyhow::{bail, format_err, Context, Error};
+use hyper::client::{Client, HttpConnector};
+use hyper::http::uri::Authority;
+use hyper::Body;
+use openssl::hash::MessageDigest;
+use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
+use openssl::x509::X509StoreContextRef;
+use tracing::error;
+
+use pbs_api_types::{S3ClientConfig, S3ClientSecretsConfig};
+use proxmox_http::client::HttpsConnector;
+
+const S3_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+const S3_TCP_KEEPALIVE_TIME: u32 = 120;
+
+/// S3 object key path prefix without the context prefix as defined by the client options.
+///
+/// The client option's context prefix will be pre-pended by the various client methods before
+/// sending api requests.
+pub enum S3PathPrefix {
+ /// Path prefix relative to client's context prefix
+ Some(String),
+ /// No prefix
+ None,
+}
+
+/// Configuration options for client
+pub struct S3ClientOptions {
+ pub endpoint: String,
+ pub port: Option<u16>,
+ pub bucket: String,
+ pub store_prefix: String,
+ pub path_style: bool,
+ pub secret_key: String,
+ pub access_key: String,
+ pub region: String,
+ pub fingerprint: Option<String>,
+}
+
+impl S3ClientOptions {
+ pub fn from_config(
+ config: S3ClientConfig,
+ secrets: S3ClientSecretsConfig,
+ bucket: String,
+ store_prefix: String,
+ ) -> Self {
+ Self {
+ endpoint: config.endpoint,
+ port: config.port,
+ bucket,
+ store_prefix,
+ path_style: config.path_style.unwrap_or_default(),
+ region: config.region.unwrap_or("us-west-1".to_string()),
+ fingerprint: config.fingerprint,
+ access_key: config.access_key,
+ secret_key: secrets.secret_key,
+ }
+ }
+}
+
+/// S3 client for object stores compatible with the AWS S3 API
+pub struct S3Client {
+ client: Client<HttpsConnector>,
+ options: S3ClientOptions,
+ authority: Authority,
+}
+
+impl S3Client {
+ pub fn new(options: S3ClientOptions) -> Result<Self, Error> {
+ let expected_fingerprint = options.fingerprint.clone();
+ let verified_fingerprint = Arc::new(Mutex::new(None));
+ let trust_openssl_valid = Arc::new(Mutex::new(true));
+ let mut ssl_connector_builder = SslConnector::builder(SslMethod::tls())?;
+ ssl_connector_builder.set_verify_callback(
+ SslVerifyMode::PEER,
+ move |openssl_valid, context| match Self::verify_certificate_fingerprint(
+ openssl_valid,
+ context,
+ expected_fingerprint.clone(),
+ trust_openssl_valid.clone(),
+ ) {
+ Ok(None) => true,
+ Ok(Some(fingerprint)) => {
+ *verified_fingerprint.lock().unwrap() = Some(fingerprint);
+ true
+ }
+ Err(err) => {
+ error!("certificate validation failed {err:#}");
+ false
+ }
+ },
+ );
+
+ let mut http_connector = HttpConnector::new();
+ // want communication to object store backend api to always use https
+ http_connector.enforce_http(false);
+ http_connector.set_connect_timeout(Some(S3_HTTP_CONNECT_TIMEOUT));
+ let https_connector = HttpsConnector::with_connector(
+ http_connector,
+ ssl_connector_builder.build(),
+ S3_TCP_KEEPALIVE_TIME,
+ );
+ let client = Client::builder().build::<_, Body>(https_connector);
+
+ let authority_template = if let Some(port) = options.port {
+ format!("{}:{port}", options.endpoint)
+ } else {
+ options.endpoint.clone()
+ };
+ let authority = authority_template
+ .replace("{{bucket}}", &options.bucket)
+ .replace("{{region}}", &options.region);
+ let authority = Authority::try_from(authority)?;
+
+ Ok(Self {
+ client,
+ options,
+ authority,
+ })
+ }
+
+ fn verify_certificate_fingerprint(
+ openssl_valid: bool,
+ context: &mut X509StoreContextRef,
+ expected_fingerprint: Option<String>,
+ trust_openssl: Arc<Mutex<bool>>,
+ ) -> Result<Option<String>, Error> {
+ let mut trust_openssl_valid = trust_openssl.lock().unwrap();
+
+ // only rely on openssl prevalidation if was not forced earlier
+ if openssl_valid && *trust_openssl_valid {
+ return Ok(None);
+ }
+
+ let certificate = match context.current_cert() {
+ Some(certificate) => certificate,
+ None => bail!("context lacks current certificate."),
+ };
+
+ if context.error_depth() > 0 {
+ *trust_openssl_valid = false;
+ return Ok(None);
+ }
+
+ let certificate_digest = certificate
+ .digest(MessageDigest::sha256())
+ .context("failed to calculate certificate digest")?;
+ let certificate_fingerprint = hex::encode(certificate_digest);
+ let certificate_fingerprint = certificate_fingerprint
+ .as_bytes()
+ .chunks(2)
+ .map(|v| std::str::from_utf8(v).unwrap())
+ .collect::<Vec<&str>>()
+ .join(":");
+
+ if let Some(expected_fingerprint) = expected_fingerprint {
+ let expected_fingerprint = expected_fingerprint.to_lowercase();
+ if expected_fingerprint == certificate_fingerprint {
+ return Ok(Some(certificate_fingerprint));
+ }
+ }
+
+ Err(format_err!(
+ "unexpected certificate fingerprint {certificate_fingerprint}"
+ ))
+ }
+}
diff --git a/pbs-s3-client/src/lib.rs b/pbs-s3-client/src/lib.rs
new file mode 100644
index 000000000..533ceab8e
--- /dev/null
+++ b/pbs-s3-client/src/lib.rs
@@ -0,0 +1,2 @@
+mod client;
+pub use client::{S3Client, S3ClientOptions};
--
2.39.5
More information about the pbs-devel
mailing list