From: Christoph Heiss <c.heiss@proxmox.com>
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 [thread overview]
Message-ID: <20260731143910.936881-9-c.heiss@proxmox.com> (raw)
In-Reply-To: <20260731143910.936881-1-c.heiss@proxmox.com>
The node certificate is created unconditionally created for all
products, as having the fingerprint is useful in general.
Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
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<File>,
+ run_cmd: &dyn Fn(&[&str]) -> Result<String>,
+ ) -> Result<Option<String>> {
+ 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<Path>) -> Result<bool> {
+ 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<R, F: FnOnce(&str) -> Result<R>>(target_path: &str, callback: F) -> Result<R> {
+ // 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<String> {
+ 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
next prev parent reply other threads:[~2026-07-31 14:40 UTC|newest]
Thread overview: 17+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-07-31 14:35 [PATCH proxmox/installer/datacenter-manager 00/16] auto-installer: add installed target systems as new remotes Christoph Heiss
2026-07-31 14:35 ` [PATCH proxmox 01/16] installer-types: drop unnecessary clippy attribute Christoph Heiss
2026-07-31 14:35 ` [PATCH proxmox 02/16] installer-types: post-hook: factor schema version into proper struct Christoph Heiss
2026-07-31 14:35 ` [PATCH proxmox 03/16] installer-types: post-hook: allow additional properties on api schema Christoph Heiss
2026-07-31 14:35 ` [PATCH proxmox 04/16] installer-types: post-hook: add api-token and cert-fingerprint options Christoph Heiss
2026-07-31 14:35 ` [PATCH proxmox 05/16] installer-types: systeminfo: add check for API token creation capability Christoph Heiss
2026-07-31 14:35 ` [PATCH installer 06/16] chroot: print full error if bind-mounting fails Christoph Heiss
2026-07-31 14:35 ` [PATCH installer 07/16] post-hook: re-use low-level config retrieval from proxmox-chroot Christoph Heiss
2026-07-31 14:35 ` Christoph Heiss [this message]
2026-07-31 14:35 ` [PATCH installer 09/16] post-hook: support creating API token if requested in answer file Christoph Heiss
2026-07-31 14:35 ` [PATCH installer 10/16] auto: enforce https for post hook when generating an API token Christoph Heiss
2026-07-31 14:35 ` [PATCH installer 11/16] assistant: validate-answer: also verify post-hook settings if set Christoph Heiss
2026-07-31 14:35 ` [PATCH datacenter-manager 12/16] ui: auto-installer: spell out Proxmox Datacenter Manager Christoph Heiss
2026-07-31 14:35 ` [PATCH datacenter-manager 13/16] config: auto-install: add optional `post-hook-add-as-remote` field Christoph Heiss
2026-07-31 14:35 ` [PATCH datacenter-manager 14/16] api: auto-installer: add option for adding new remotes to PDM Christoph Heiss
2026-07-31 14:35 ` [PATCH datacenter-manager 15/16] ui: auto-installer: wizard: add checkbox to add target as new remote Christoph Heiss
2026-07-31 14:35 ` [PATCH datacenter-manager 16/16] docs: auto-installer: document adding targets as remotes afterwards Christoph Heiss
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=20260731143910.936881-9-c.heiss@proxmox.com \
--to=c.heiss@proxmox.com \
--cc=pdm-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