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 EE9251FF13C for ; Thu, 11 Jun 2026 14:03:53 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7E0DD39D8; Thu, 11 Jun 2026 14:03:51 +0200 (CEST) From: Shannon Sterz To: pdm-devel@lists.proxmox.com Subject: [PATCH datacenter-manager 15/17] server: connection: rotate in staged fingerprints when encountering them Date: Thu, 11 Jun 2026 14:03:25 +0200 Message-ID: <20260611120327.257523-16-s.sterz@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260611120327.257523-1-s.sterz@proxmox.com> References: <20260611120327.257523-1-s.sterz@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1781179364902 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.108 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 Message-ID-Hash: 7NUPEM2WZHWC2WUC6EZQH2FJ7ABJHGZE X-Message-ID-Hash: 7NUPEM2WZHWC2WUC6EZQH2FJ7ABJHGZE X-MailFrom: s.sterz@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: if a staged fingerprint is encountered when connecting to a node, move the new fingerprint into the active position. the staged fingerprints will also be updated to remove the new fingerprint from the staged list. Signed-off-by: Shannon Sterz --- server/src/connection.rs | 104 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/server/src/connection.rs b/server/src/connection.rs index f8b277da..500ad6c6 100644 --- a/server/src/connection.rs +++ b/server/src/connection.rs @@ -53,18 +53,108 @@ impl ConnectInfo { } } } -/// + +/// Updates the current fingerprint of a node of a given remote. Will also remove the fingerprint +/// from the staged fingerprint list. +fn update_current_fp( + remote_id: &str, + hostname: &str, + current_fingerprint: &Option, + new_fingerprint: &Fingerprint, +) -> Result<(), Error> { + let _lock = pdm_config::remotes::lock_config()?; + let (mut config, _digest) = pdm_config::remotes::config()?; + + let Some(remote) = config.get_mut(remote_id) else { + log::debug!("Remote '{remote_id}' vanished while updating current fingerprint."); + return Ok(()); + }; + + for node in &mut remote.nodes { + if node.hostname == *hostname + && node.fingerprint.as_ref().and_then(|f| f.parse().ok()) == *current_fingerprint + { + node.fingerprint = Some(new_fingerprint.to_string()); + + if let Some(staged_fingerprints) = &mut node.staged_fingerprints { + staged_fingerprints.retain(|f| f != new_fingerprint); + } + } + } + + pdm_config::remotes::save_config(config) +} + /// Returns a [`proxmox_client::Client`] set up to connect to a specific node. fn prepare_connect_client_to_node( + remote_id: &str, node: &NodeUrl, default_port: u16, pve_compat: bool, ) -> Result { - let mut options = TlsOptions::default(); + let fingerprint = node + .fingerprint + .as_ref() + .map(|fp| fp.parse::()) + .transpose()?; - if let Some(fp) = &node.fingerprint { - options = TlsOptions::parse_fingerprint(fp)?; - } + let options = TlsOptions::Callback(Box::new({ + let remote_id = remote_id.to_owned(); + let staged_fingerprints = node.staged_fingerprints.clone(); + let node = node.hostname.clone(); + move |valid: bool, chain: &mut X509StoreContextRef| { + // If we have no fingerprint and no staged fingerprints, fall back to the system's + // trust store. + if fingerprint.is_none() && staged_fingerprints.is_none() { + return valid; + } + + let Some(cert) = chain.current_cert() else { + log::error!("Could not get current certificate when connecting to node '{node}'."); + return false; + }; + + let cert_fp = match cert.digest(MessageDigest::sha256()) { + // A valid SHA-256 digest is by definition a valid Fingerprint. So the `expect` + // below must always succeed. + Ok(fp) => Fingerprint::try_from(&*fp) + .expect("Could not get fingerprint from SHA256 digest."), + Err(e) => { + log::error!("Could not compute remote certificate digest: {e:#}"); + return false; + } + }; + + // If the stored fingerprint matches, the connection is valid. + if let Some(fingerprint) = &fingerprint { + if *fingerprint == cert_fp { + return true; + } + } + + // If we have staged fingerprints and one of them matches the certificate, promote it + // to the current fingerprint and mark the connection as valid. + if let Some(staged_fingerprints) = &staged_fingerprints { + if staged_fingerprints.contains(&cert_fp) { + // Update active fingerprint; handle this in a separate task, since this + // requires locking, reading and writing the remotes configuration. There is no + // need to handle this while establishing a connection. + let (remote_id, node, fingerprint) = + (remote_id.clone(), node.clone(), fingerprint.clone()); + tokio::spawn(async move { + if let Err(e) = update_current_fp(&remote_id, &node, &fingerprint, &cert_fp) + { + log::error!("Could not update current fingerprint: {e:#}"); + } + }); + return true; + } + } + + // Otherwise, the connection is invalid. + false + } + })); let host_port: Authority = node.hostname.parse()?; @@ -101,7 +191,8 @@ fn prepare_connect_client( let info = ConnectInfo::for_remote(remote); - let client = prepare_connect_client_to_node(node, info.default_port, info.pve_compat)?; + let client = + prepare_connect_client_to_node(&remote.id, node, info.default_port, info.pve_compat)?; Ok((client, info)) } @@ -137,6 +228,7 @@ fn prepare_connect_multi_client(remote: &Remote) -> Result<(MultiClient, Connect for node in &remote.nodes { clients.push(MultiClientEntry { client: Arc::new(prepare_connect_client_to_node( + &remote.id, node, info.default_port, info.pve_compat, -- 2.47.3