From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: <pbs-devel-bounces@lists.proxmox.com> Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id B74691FF17F for <inbox@lore.proxmox.com>; Mon, 19 May 2025 13:47:12 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 1552382AF; Mon, 19 May 2025 13:47:07 +0200 (CEST) From: Christian Ebner <c.ebner@proxmox.com> To: pbs-devel@lists.proxmox.com Date: Mon, 19 May 2025 13:46:06 +0200 Message-Id: <20250519114640.303640-6-c.ebner@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20250519114640.303640-1-c.ebner@proxmox.com> References: <20250519114640.303640-1-c.ebner@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.029 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] [RFC proxmox-backup 05/39] 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 <pbs-devel.lists.proxmox.com> List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pbs-devel>, <mailto:pbs-devel-request@lists.proxmox.com?subject=unsubscribe> List-Archive: <http://lists.proxmox.com/pipermail/pbs-devel/> List-Post: <mailto:pbs-devel@lists.proxmox.com> List-Help: <mailto:pbs-devel-request@lists.proxmox.com?subject=help> List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel>, <mailto:pbs-devel-request@lists.proxmox.com?subject=subscribe> Reply-To: Proxmox Backup Server development discussion <pbs-devel@lists.proxmox.com> Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" <pbs-devel-bounces@lists.proxmox.com> 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@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 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel