From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 06B311FF191 for ; Mon, 16 Jun 2025 16:22:29 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1846CA3B7; Mon, 16 Jun 2025 16:22:52 +0200 (CEST) From: Christian Ebner To: pbs-devel@lists.proxmox.com Date: Mon, 16 Jun 2025 16:21:20 +0200 Message-Id: <20250616142156.413652-8-c.ebner@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20250616142156.413652-1-c.ebner@proxmox.com> References: <20250616142156.413652-1-c.ebner@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.034 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pbs-devel] [PATCH proxmox-backup v3 05/41] s3 client: add crate for AWS S3 compatible object store client X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Backup Server development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" 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 --- 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, + 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, +} + +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, + options: S3ClientOptions, + authority: Authority, +} + +impl S3Client { + pub fn new(options: S3ClientOptions) -> Result { + 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, + trust_openssl: Arc>, + ) -> Result, 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::>() + .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 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel