public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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





  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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal