public inbox for pve-devel@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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal