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
next prev 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