[pbs-devel] [RFC v2 proxmox-backup 07/42] s3 client: add crate for AWS S3 compatible object store client

Christian Ebner c.ebner at proxmox.com
Thu May 29 16:31:32 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    |  16 +++++
 pbs-s3-client/src/client.rs | 131 ++++++++++++++++++++++++++++++++++++
 pbs-s3-client/src/lib.rs    |   2 +
 4 files changed, 152 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 6de6a6527..c2b0029ac 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..1999c3323
--- /dev/null
+++ b/pbs-s3-client/Cargo.toml
@@ -0,0 +1,16 @@
+[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
+
+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..e001cc7b0
--- /dev/null
+++ b/pbs-s3-client/src/client.rs
@@ -0,0 +1,131 @@
+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 proxmox_http::client::HttpsConnector;
+
+const S3_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+const S3_TCP_KEEPALIVE_TIME: u32 = 120;
+
+/// Configuration options for client
+pub struct S3ClientOptions {
+    pub host: String,
+    pub port: Option<u16>,
+    pub bucket: String,
+    pub secret_key: String,
+    pub access_key: String,
+    pub region: String,
+    pub fingerprint: Option<String>,
+}
+
+/// 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 = if let Some(port) = options.port {
+            format!("{}:{port}", options.host)
+        } else {
+            options.host.clone()
+        };
+        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