From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id A2F7B1FF0ED for ; Fri, 31 Jul 2026 16:40:06 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 7229A2155F; Fri, 31 Jul 2026 16:40:06 +0200 (CEST) From: Christoph Heiss To: pdm-devel@lists.proxmox.com Subject: [PATCH installer 08/16] post-hook: generate and retrieve node certificate fingerprint Date: Fri, 31 Jul 2026 16:35:31 +0200 Message-ID: <20260731143910.936881-9-c.heiss@proxmox.com> X-Mailer: git-send-email 2.54.0 In-Reply-To: <20260731143910.936881-1-c.heiss@proxmox.com> References: <20260731143910.936881-1-c.heiss@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1785508791940 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.008 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust 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: 6HW4VTGCCUWE7SVLESQKQDDJEMBE57A5 X-Message-ID-Hash: 6HW4VTGCCUWE7SVLESQKQDDJEMBE57A5 X-MailFrom: c.heiss@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: The node certificate is created unconditionally created for all products, as having the fingerprint is useful in general. Signed-off-by: Christoph Heiss --- Depends on the `proxmox-installer-types` changes and an accompanying dependency bump. Cargo.toml | 2 + proxmox-installer-common/Cargo.toml | 4 +- proxmox-post-hook/Cargo.toml | 2 + proxmox-post-hook/src/main.rs | 172 ++++++++++++++++++++++++++-- 4 files changed, 169 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index af0655d..7ba4a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,9 +21,11 @@ anyhow = "1.0" log = "0.4.20" pico-args = "0.5" regex = "1.7" +rustls = "0.23" serde = "1.0" serde_json = "1.0" serde_plain = "1.0" +sha2 = "0.10" toml = "0.8" proxmox-auto-installer.path = "./proxmox-auto-installer" proxmox-installer-common.path = "./proxmox-installer-common" diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml index 7682680..ec4cdb8 100644 --- a/proxmox-installer-common/Cargo.toml +++ b/proxmox-installer-common/Cargo.toml @@ -19,9 +19,9 @@ proxmox-installer-types.workspace = true # `http` feature hex = { version = "0.4", optional = true } native-tls = { version = "0.2", optional = true } -rustls = { version = "0.23", optional = true } +rustls = { workspace = true, optional = true } rustls-native-certs = { version = "0.6", optional = true } -sha2 = { version = "0.10", optional = true } +sha2 = { workspace = true, optional = true } ureq = { version = "3", features = [ "platform-verifier" ], optional = true } # `cli` feature diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml index 748b922..b6d9ce5 100644 --- a/proxmox-post-hook/Cargo.toml +++ b/proxmox-post-hook/Cargo.toml @@ -15,6 +15,8 @@ anyhow.workspace = true proxmox-installer-common = { workspace = true, features = ["http"] } proxmox-network-types.workspace = true proxmox-installer-types.workspace = true +rustls.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +sha2.workspace = true toml.workspace = true diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs index d0fb297..760ab53 100644 --- a/proxmox-post-hook/src/main.rs +++ b/proxmox-post-hook/src/main.rs @@ -17,21 +17,26 @@ use std::{ }; use proxmox_installer_common::http::{self, header::HeaderMap}; -use proxmox_installer_types::answer::{AutoInstallerConfig, PostNotificationHookInfo}; +use proxmox_installer_types::answer::{ + AutoInstallerConfig, PostNotificationHookInfo, SchemaVersion, +}; /// Current version of the schema sent by this implementation. -const POST_HOOK_SCHEMA_VERSION: &str = "1.2"; +const POST_HOOK_SCHEMA_VERSION: SchemaVersion = SchemaVersion(1, 3); mod detail { use anyhow::{Context, Result, anyhow, bail}; + use rustls::pki_types::{CertificateDer, pem::PemObject}; + use sha2::{Digest, Sha256}; use std::{ collections::{HashMap, HashSet}, ffi::CStr, fs::{self, File}, - io::BufReader, - os::unix::fs::FileExt, - path::PathBuf, - process::Command, + io::{BufReader, ErrorKind}, + os::unix::fs::{FileExt, MetadataExt}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::Duration, }; use proxmox_installer_common::{ @@ -40,10 +45,12 @@ mod detail { }; use proxmox_installer_types::{ ProxmoxProduct, SystemDMI, UdevInfo, - answer::{AutoInstallerConfig, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode}, + answer::{ + AutoInstallerConfig, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode, SchemaVersion, + }, post_hook::{ BootInfo, CpuInfo, DiskInfo, KernelVersionInformation, NetworkInterfaceInfo, - PostHookInfo, PostHookInfoSchema, ProductInfo, SshPublicHostKeys, + PostHookInfo, PostHookInfoSchema, ProductInfo, SchemaVersion, SshPublicHostKeys, }, }; @@ -93,8 +100,15 @@ mod detail { .arg(target_path) .args(cmd) .output() + .map_err(|err| anyhow!(err)) + .and_then(|r| { + if r.status.success() { + Ok(String::from_utf8(r.stdout)?) + } else { + Err(anyhow!("{}", String::from_utf8(r.stderr)?)) + } + }) .with_context(|| format!("failed to run '{cmd:?}'")) - .and_then(|r| Ok(String::from_utf8(r.stdout)?)) }; let fqdn = match &answer.global.fqdn { @@ -110,6 +124,30 @@ mod detail { .to_string(), }; + let schema_1_3_supported = answer + .post_installation_webhook + .as_ref() + .and_then(|p| p.max_schema_version) + .map(|v| v >= SchemaVersion(1, 3)) + .unwrap_or_default(); + + let cert_fingerprint = if schema_1_3_supported { + match gather_node_cert_fingerprint( + target_path, + setup_info.config.product, + &open_file, + &run_cmd, + ) { + Ok(fp) => fp, + Err(err) => { + eprintln!("could not retrieve node certificate: {err:#}"); + None + } + } + } else { + None + }; + Ok(PostHookInfo { schema: PostHookInfoSchema { version: super::POST_HOOK_SCHEMA_VERSION.to_owned(), @@ -181,6 +219,8 @@ mod detail { }), }, reboot_mode: answer.global.reboot_mode, + cert_fingerprint, + api_token: None, }) } @@ -568,6 +608,119 @@ mod detail { result } + /// First creates the initial node certificate, then computes the fingerprint from the freshly + /// initialized proxy certificate. + fn gather_node_cert_fingerprint( + target_path: &str, + product: ProxmoxProduct, + open_file: &dyn Fn(&str) -> Result, + run_cmd: &dyn Fn(&[&str]) -> Result, + ) -> Result> { + println!("Generating node certificates .."); + + match product { + ProxmoxProduct::Pve => with_pmxcfs(target_path, |_| { + run_cmd(&["pvecm", "updatecerts"]) + .context("failed to generate node certificates")?; + retrieve_cert_fingerprint(open_file("/etc/pve/local/pve-ssl.pem")?).map(Some) + }), + ProxmoxProduct::Pbs => { + run_cmd(&["proxmox-backup-manager", "cert", "update"]) + .context("failed to generate node certificates")?; + retrieve_cert_fingerprint(open_file("/etc/proxmox-backup/proxy.pem")?).map(Some) + } + ProxmoxProduct::Pmg => { + run_cmd(&["pmgconfig", "apicert"]) + .context("failed to generate node certificates")?; + retrieve_cert_fingerprint(open_file("/etc/pmg/pmg-api.pem")?).map(Some) + } + _ => Ok(None), + } + } + + fn is_path_a_mountpoint(path: impl AsRef) -> Result { + let path = path.as_ref(); + + let parent = match path.parent() { + Some(p) => p, + None => return Ok(true), // / is always a mount + }; + + let dev = match fs::metadata(path).map(|md| md.dev()) { + Ok(dev) => dev, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err).with_context(|| format!("stat {}", path.display())), + }; + + let parent_dev = match fs::metadata(parent).map(|md| md.dev()) { + Ok(dev) => dev, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err).with_context(|| format!("stat {}", parent.display())), + }; + + // If the devices differ, it's a filesystem boundary + Ok(dev != parent_dev) + } + + fn with_pmxcfs Result>(target_path: &str, callback: F) -> Result { + // Don't consider failing to start pmxcfs fatal, as detail::gather() will handle unavailable + // files and commands gracefully. + + let mut pmxcfs = Command::new("chroot") + .args([target_path, "/usr/bin/pmxcfs", "--local", "--foreground"]) + .stdin(Stdio::piped()) + .spawn() + .context("starting pmxcfs")?; + + // Wait until the cluster filesystem is mounted under /etc/pve + // Typically takes at least 1-2 seconds, so give it some time beforehand to avoid superfluous + // polling + std::thread::sleep(Duration::from_secs(1)); + + let pmxcfs_path = Path::new(target_path).join("etc/pve"); + let result = loop { + match is_path_a_mountpoint(&pmxcfs_path) { + Ok(true) => break callback(target_path), + Ok(false) => { + std::thread::sleep(Duration::from_millis(500)); + } + Err(err) => break Err(err), + } + }; + + // TODO: Switch to std::os::unix::process::ChildExt::send_signal() once that becomes stable + let kill_cmd = Command::new("/usr/bin/kill") + .arg("-TERM") + .arg(pmxcfs.id().to_string()) + .status(); + + // gracefully shut down pmxcfs + if let Err(err) = kill_cmd { + eprintln!("failed to gracefully terminate pmxcfs ({err}), killing .."); + let _ = pmxcfs.kill(); + } + + if let Err(err) = pmxcfs.wait() { + eprintln!("failed to wait for pmxcfs to terminate: {err}"); + } + + result + } + + fn retrieve_cert_fingerprint(file: File) -> Result { + let reader = CertificateDer::from_pem_reader(file)?; + + let mut hasher = Sha256::new(); + hasher.update(reader); + + Ok(hasher + .finalize() + .iter() + .fold(String::new(), |acc, b| format!("{acc}:{b:02X}")) + .trim_start_matches(':') + .to_owned()) + } + #[cfg(test)] mod tests { use super::{ @@ -919,6 +1072,7 @@ fn do_main() -> Result<()> { url, cert_fingerprint, auth_token, + .. }) = &answer.post_installation_webhook { println!("Found post-installation-webhook; sending POST request to '{url}'."); -- 2.54.0