all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Shannon Sterz <s.sterz@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH installer 16/21] installer-common: add option to verify TLS connections via callback
Date: Fri, 28 Aug 2026 15:30:25 +0200	[thread overview]
Message-ID: <20260828133030.351140-17-s.sterz@proxmox.com> (raw)
In-Reply-To: <20260828133030.351140-1-s.sterz@proxmox.com>

this allows more flexibility and can be useful when, for example,
implementing an interactive check whether a fingerprint is correct.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 .../src/bin/proxmox-auto-installer.rs         |   2 +-
 .../src/fetch_plugins/http.rs                 |   8 +-
 proxmox-installer-common/src/http.rs          | 274 +++++++++++++-----
 proxmox-post-hook/src/main.rs                 |   2 +-
 4 files changed, 216 insertions(+), 70 deletions(-)

diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
index 0ced7d4..54b7050 100644
--- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
+++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
@@ -37,7 +37,7 @@ fn setup_first_boot_executable(first_boot: &FirstBootHookInfo) -> Result<()> {
                 info!("Fetching first-boot hook from {url} ..");
                 Some(http::get_as_bytes(
                     url,
-                    first_boot.cert_fingerprint.as_deref(),
+                    first_boot.cert_fingerprint.as_deref().try_into()?,
                     FIRST_BOOT_EXEC_MAX_SIZE,
                 )?)
             } else {
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index 1251da6..ce5f648 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -103,8 +103,12 @@ impl FetchFromHTTP {
             );
         }
 
-        let http::Response { body, content_type } =
-            http::post(&answer_url, fingerprint.as_deref(), headers, payload)?;
+        let http::Response { body, content_type } = http::post(
+            &answer_url,
+            fingerprint.as_deref().try_into()?,
+            headers,
+            payload,
+        )?;
 
         if let Some(ct) = content_type
             && ct == http::ContentType::Json
diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs
index ca64a73..2fcc4f1 100644
--- a/proxmox-installer-common/src/http.rs
+++ b/proxmox-installer-common/src/http.rs
@@ -1,8 +1,10 @@
 use anyhow::{Result, bail};
 use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
-use rustls::{ClientConfig, ClientConnection, StreamOwned};
+use rustls::server::ParsedCertificate;
+use rustls::{ClientConfig, ClientConnection, RootCertStore, StreamOwned};
 use sha2::{Digest, Sha256};
 use std::fmt;
+use std::fmt::Debug;
 use std::io::{Read, Write};
 use std::str::FromStr;
 use std::sync::Arc;
@@ -18,45 +20,24 @@ use ureq::unversioned::transport::{
 // Re-export for conviencence when using post()
 pub use ureq::http::header;
 
-/// Builds an [`Agent`] with TLS suitable set up, depending whether a custom fingerprint was
-/// supplied or not. If a fingerprint was supplied, only matching certificates will be accepted.
-/// Otherwise, the system certificate store is loaded.
+/// Builds an [Agent] with a suitable TLS setup, depending on the verification option that was
+/// provided. Verification can either be done via a fingerprint, that needs to match the server
+/// certificate's fingerprint, a callback, that can implement custom verification logic, or can be
+/// delegated to the system's trust store.
 ///
 /// To gather the sha256 fingerprint you can use the following command:
+///
 /// ```no_compile
 /// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256  -noout -in /dev/stdin
 /// ```
 ///
 /// # Arguments
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
-fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
+/// * `verification_option` - Defines how the connection is verified.
+fn build_agent(verification_option: VerificationOption) -> Result<Agent> {
     const GLOBAL_TIMEOUT: Duration = Duration::from_secs(60);
 
-    if let Some(fingerprint) = fingerprint {
-        // If the user specified a custom TLS fingerprint, we must use a custom
-        // `rustls::ClientConfig`, which in turns means to use a custom
-        // `Connector`.
-        let crypto_provider = rustls::crypto::CryptoProvider::get_default()
-            .cloned()
-            .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
-
-        let tls_config = ClientConfig::builder_with_provider(crypto_provider)
-            .with_protocol_versions(rustls::ALL_VERSIONS)?
-            .dangerous()
-            .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?)
-            .with_no_client_auth();
-
-        let connector = UreqRustlsConnector::new(Arc::new(tls_config));
-
-        Ok(Agent::with_parts(
-            ureq::config::Config::builder()
-                .timeout_global(Some(GLOBAL_TIMEOUT))
-                .build(),
-            TcpConnector::default().chain(connector),
-            DefaultResolver::default(),
-        ))
-    } else {
-        Ok(Agent::config_builder()
+    let agent = match verification_option {
+        VerificationOption::Verify => Agent::config_builder()
             .timeout_global(Some(GLOBAL_TIMEOUT))
             .tls_config(
                 ureq::tls::TlsConfig::builder()
@@ -64,13 +45,38 @@ fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
                     .build(),
             )
             .build()
-            .into())
-    }
+            .into(),
+        opt => {
+            // If the user specified a custom TLS fingerprint or verification callback, we must use
+            // a custom `rustls::ClientConfig`, which in turn means to use a custom `Connector`.
+            let crypto_provider = rustls::crypto::CryptoProvider::get_default()
+                .cloned()
+                .unwrap_or_else(|| Arc::new(rustls::crypto::ring::default_provider()));
+
+            let tls_config = ClientConfig::builder_with_provider(crypto_provider)
+                .with_protocol_versions(rustls::ALL_VERSIONS)?
+                .dangerous()
+                .with_custom_certificate_verifier(VerifyCertHelper::new(opt)?)
+                .with_no_client_auth();
+
+            let connector = UreqRustlsConnector::new(Arc::new(tls_config));
+
+            Agent::with_parts(
+                ureq::config::Config::builder()
+                    .timeout_global(Some(GLOBAL_TIMEOUT))
+                    .build(),
+                TcpConnector::default().chain(connector),
+                DefaultResolver::default(),
+            )
+        }
+    };
+
+    Ok(agent)
 }
 
-/// Issues a GET request to the specified URL and fetches the response. Optionally a SHA256
-/// fingerprint can be used to check the certificate against it, instead of the regular certificate
-/// validation.
+/// Issues a GET request to the specified URL and fetches the response. TLS verification can either
+/// be done via a fingerprint, that needs to match the server certificate's fingerprint, a callback,
+/// that can implement custom verification logic, or can be delegated to the system's trust store.
 ///
 /// To gather the sha256 fingerprint you can use the following command:
 /// ```no_compile
@@ -79,12 +85,19 @@ fn build_agent(fingerprint: Option<&str>) -> Result<Agent> {
 ///
 /// # Arguments
 /// * `url` - URL to fetch
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `verification_option` - Defines how the connection is verified.
 /// * `max_size` - Maximum amount of bytes that will be read.
-pub fn get_as_bytes(url: &str, fingerprint: Option<&str>, max_size: usize) -> Result<Vec<u8>> {
+pub fn get_as_bytes(
+    url: &str,
+    verification_option: VerificationOption,
+    max_size: usize,
+) -> Result<Vec<u8>> {
     let mut result: Vec<u8> = Vec::new();
 
-    let (_, body) = build_agent(fingerprint)?.get(url).call()?.into_parts();
+    let (_, body) = build_agent(verification_option)?
+        .get(url)
+        .call()?
+        .into_parts();
 
     body.into_reader()
         .take(max_size as u64)
@@ -126,8 +139,10 @@ pub struct Response {
     pub content_type: Option<ContentType>,
 }
 
-/// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to
-/// check the cert against it, instead of the regular cert validation.
+/// Issues a POST request with the payload (JSON). TLS verification can either be done via a
+/// fingerprint, that needs to match the server certificate's fingerprint, a callback, that can
+/// implement custom verification logic, or can be delegated to the system's trust store.
+///
 /// To gather the sha256 fingerprint you can use the following command:
 /// ```no_compile
 /// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256  -noout -in /dev/stdin
@@ -137,7 +152,7 @@ pub struct Response {
 ///
 /// # Arguments
 /// * `url` - URL to call
-/// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+/// * `verification_option` - Defines how the connection is verified.
 /// * `headers` - Additional headers to add to the request.
 /// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
 ///
@@ -147,13 +162,13 @@ pub struct Response {
 /// contents and the `Content-Type` header, if present.
 pub fn post(
     url: &str,
-    fingerprint: Option<&str>,
+    verification_option: VerificationOption,
     headers: header::HeaderMap,
     payload: String,
 ) -> Result<Response> {
     // TODO: read_to_string limits the size to 10 MB, should be increase that?
 
-    let mut request = build_agent(fingerprint)?
+    let mut request = build_agent(verification_option)?
         .post(url)
         .header("Content-Type", "application/json; charset=utf-8")
         .config()
@@ -182,40 +197,148 @@ pub fn post(
     }
 }
 
-#[derive(Debug)]
-struct VerifyCertFingerprint {
-    cert_fingerprint: Vec<u8>,
+
+/// A callback used to validate a TLS connection via rustls. See
+/// [rustls::client::danger::ServerCertVerifier::verify_server_cert] for an explanation of the
+/// arguments. The last argument is `true` if the system's trust store contains a root certificate
+/// that validates the certificate and the server name matches the certificate.
+///
+/// If `true` is returned, no further checks are done and the connection is accepted. This can be
+/// dangerous.
+pub type RustlsCallback = dyn Fn(&CertificateDer, &[CertificateDer], &ServerName, &[u8], UnixTime, bool) -> bool
+    + Send
+    + Sync
+    + 'static;
+
+/// How TLS connections are verified.
+#[derive(Default)]
+pub enum VerificationOption {
+    /// Default TLS verification.
+    #[default]
+    Verify,
+
+    /// Expect a specific fingerprint, can be used for certificate pinning.
+    Fingerprint(Vec<u8>),
+
+    /// Use a custom callback to verify the connection, if it returns `true` the connection is
+    /// accepted. No further checks are carried out, this can be dangerous.
+    DangerousCallback(Box<RustlsCallback>),
 }
 
-impl VerifyCertFingerprint {
-    fn new<S: AsRef<str>>(cert_fingerprint: S) -> Result<std::sync::Arc<Self>> {
-        let cert_fingerprint = cert_fingerprint.as_ref();
-        let sanitized = cert_fingerprint.replace(':', "");
+impl TryFrom<&str> for VerificationOption {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
+        let sanitized = value.replace(':', "");
         let decoded = hex::decode(sanitized)?;
-        Ok(std::sync::Arc::new(Self {
-            cert_fingerprint: decoded,
-        }))
+        Ok(VerificationOption::Fingerprint(decoded))
     }
 }
 
-impl rustls::client::danger::ServerCertVerifier for VerifyCertFingerprint {
+impl TryFrom<Option<&str>> for VerificationOption {
+    type Error = anyhow::Error;
+
+    fn try_from(value: Option<&str>) -> std::result::Result<Self, Self::Error> {
+        match value {
+            Some(v) => v.try_into(),
+            None => Ok(VerificationOption::Verify),
+        }
+    }
+}
+
+impl Debug for VerificationOption {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            VerificationOption::Verify => write!(f, "Verify"),
+            VerificationOption::Fingerprint(v) => write!(f, "Fingerprint({:?})", v),
+            VerificationOption::DangerousCallback(_) => {
+                write!(f, "DangerousCallback(Box<RustlsCallback>)")
+            }
+        }
+    }
+}
+
+#[derive(Debug)]
+struct VerifyCertHelper {
+    option: VerificationOption,
+    store: RootCertStore,
+}
+
+impl VerifyCertHelper {
+    fn new(option: VerificationOption) -> Result<Arc<Self>> {
+        let res = rustls_native_certs::load_native_certs()?;
+        let mut store = RootCertStore::empty();
+
+        // with rustls_native_certs 0.7 [1], a `Result<Vec<CertificateDer>, Error>` is returned
+        // right away so the below switches to:
+        //
+        // ```
+        // let _ = store.add_parsable_certificates(res);
+        // ```
+        //
+        // rustls_native_certs 0.8 [2] again changes the API to return a `CertificateResult` so
+        // loading the certificates becomes
+        //
+        // ```
+        // let res = rustls_native_certs::load_native_certs();
+        // let mut store = RootCertStore::empty();
+        // let _ = store.add_parsable_certificates(res.certs);
+        // ```
+        //
+        // [1]: https://github.com/rustls/rustls-native-certs/blob/v/0.7.0/src/lib.rs#L57
+        // [2]: https://github.com/rustls/rustls-native-certs/blob/v/0.8.0/src/lib.rs#L120
+        let _ =
+            store.add_parsable_certificates(res.iter().map(|c| CertificateDer::from(c.as_ref())));
+
+        Ok(Arc::new(Self { option, store }))
+    }
+}
+
+impl rustls::client::danger::ServerCertVerifier for VerifyCertHelper {
     fn verify_server_cert(
         &self,
         end_entity: &CertificateDer,
-        _intermediates: &[CertificateDer],
-        _server_name: &ServerName,
-        _ocsp_response: &[u8],
-        _now: UnixTime,
+        intermediates: &[CertificateDer],
+        server_name: &ServerName,
+        ocsp_response: &[u8],
+        now: UnixTime,
     ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
-        let mut hasher = Sha256::new();
-        hasher.update(end_entity);
-        let result = hasher.finalize();
+        match &self.option {
+            VerificationOption::Fingerprint(fp) => {
+                let mut hasher = Sha256::new();
+                hasher.update(end_entity);
+                let result = hasher.finalize();
 
-        if result.as_slice() == self.cert_fingerprint {
-            Ok(rustls::client::danger::ServerCertVerified::assertion())
-        } else {
-            Err(rustls::Error::General("Fingerprint did not match!".into()))
+                if fp == result.as_slice() {
+                    return Ok(rustls::client::danger::ServerCertVerified::assertion());
+                } else {
+                    return Err(rustls::Error::General("Fingerprint did not match!".into()));
+                }
+            }
+            VerificationOption::DangerousCallback(cb) => {
+                let pre_ok =
+                    verify_server_cert(end_entity, &self.store, intermediates, now, server_name)
+                        .is_ok();
+
+                if cb(
+                    end_entity,
+                    intermediates,
+                    server_name,
+                    ocsp_response,
+                    now,
+                    pre_ok,
+                ) {
+                    return Ok(rustls::client::danger::ServerCertVerified::assertion());
+                }
+            }
+            // `VerificationOption::Verify` does not require this verifier, so if we encounter it
+            // here, something went wrong.
+            _ => {}
         }
+
+        Err(rustls::Error::General(
+            "Could not verify server certificate.".into(),
+        ))
     }
 
     fn verify_tls12_signature(
@@ -356,3 +479,22 @@ impl fmt::Debug for UreqRustlsTransport {
             .finish()
     }
 }
+
+fn verify_server_cert(
+    cert: &CertificateDer,
+    store: &RootCertStore,
+    intermediates: &[CertificateDer],
+    now: UnixTime,
+    server_name: &ServerName,
+) -> Result<(), rustls::Error> {
+    use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name};
+
+    let supported_algs = rustls::crypto::ring::default_provider()
+        .signature_verification_algorithms
+        .all;
+
+    let cert = ParsedCertificate::try_from(cert)?;
+
+    verify_server_cert_signed_by_trust_anchor(&cert, store, intermediates, now, supported_algs)?;
+    verify_server_name(&cert, server_name)
+}
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 5f760b7..704af71 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -944,7 +944,7 @@ fn do_main() -> Result<()> {
 
         http::post(
             url,
-            cert_fingerprint.as_deref(),
+            cert_fingerprint.as_deref().try_into()?,
             HeaderMap::new(),
             serde_json::to_string(&body)?,
         )?;
-- 
2.47.3





  parent reply	other threads:[~2026-08-28 13:33 UTC|newest]

Thread overview: 22+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-28 13:30 [RFC cluster/common/container/docs/installer/manager 00/21] add rudimentary host backup mechanism Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 01/21] pmxcfs: status: fix formatting of parameters in checked_mkdir() Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 02/21] pmxcfs: correctly log message when directory can't be created Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 03/21] pmxcfs: add live backup capability Shannon Sterz
2026-08-28 13:30 ` [PATCH cluster 04/21] pmxcfs: add ability to query backup progress Shannon Sterz
2026-08-28 13:30 ` [PATCH common 05/21] systemd: move parse_os_release() helper to PVE::Systemd Shannon Sterz
2026-08-28 13:30 ` [PATCH container 06/21] setup: use parse_os_release from PVE::Systemd Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 07/21] jobs/api: add basic host backup job logic Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 08/21] api: cluster: add endpoints for manage host backup jobs Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 09/21] api: node: add endpoints for listing backups for a node Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 10/21] api: host backup: include global, disk and network options for restore Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 11/21] api: host backup: add warnings in case zfs snapdir is disabled Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 12/21] ui: node: add panel to manage backups of a host Shannon Sterz
2026-08-28 13:30 ` [PATCH manager 13/21] ui: dc: add panel for managing host backup jobs Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 14/21] bump proxmox-installer-types to 0.2 Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 15/21] make tidy and clean up whitespace in unconfigured.sh Shannon Sterz
2026-08-28 13:30 ` Shannon Sterz [this message]
2026-08-28 13:30 ` [PATCH installer 17/21] low-level-installer: add support for restoring backups Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 18/21] installer-common/tui-installer: implement restore tui Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 19/21] unconfigured: add restore mode to unconfigured.sh Shannon Sterz
2026-08-28 13:30 ` [PATCH installer 20/21] tui-installer: unmount a potentially mounted backup on abort Shannon Sterz
2026-08-28 13:30 ` [PATCH docs 21/21] examples: add example hook script for host backup jobs Shannon Sterz

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260828133030.351140-17-s.sterz@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal