[pve-devel] [PATCH installer v2 16/17] fix #5536: post-hook: add utility for sending notifications after auto-install

Christoph Heiss c.heiss at proxmox.com
Thu Jul 18 15:49:01 CEST 2024


This utility can be called with the low-level install config after a
successful installation to send a notification via a HTTP POST request,
if the user has configured an endpoint for that in the answer file.

Signed-off-by: Christoph Heiss <c.heiss at proxmox.com>
---
Changes v1 -> v2:
  * squash implementation and unit tests into one patch
  * simplify udev property retrieving by introducing proper helpers on
    `UdevInfo` itself
  * rename Answer::from_reader() -> Answer::try_from_reader to better
    reflect it returns a Result<>
  * improved error message in some places
  * added new fields; now includes ISO version, SecureBoot state, CPU
    and DMI info
  * product information was split into separate fields
  * boot mode information was split into separate fields
  * product version is now retrieved from the package using dpkg-query
    directly
  * kernel version was split into separate fields, retrieving version
    string from the image directly
  * all disks and NICs are now included, a field indicates whether they
    are boot disk or management interface, respectively
  * move with_chroot() invocation out of PostHookInfo::gather()

Signed-off-by: Christoph Heiss <c.heiss at proxmox.com>
---
 Cargo.toml                                    |   1 +
 Makefile                                      |   8 +-
 debian/install                                |   1 +
 proxmox-auto-installer/src/answer.rs          |  16 +-
 .../src/bin/proxmox-auto-installer.rs         |  13 +-
 proxmox-auto-installer/src/udevinfo.rs        |   8 +-
 .../src/fetch_plugins/http.rs                 |   2 +-
 proxmox-installer-common/src/http.rs          |   6 +-
 proxmox-installer-common/src/options.rs       |   5 +
 proxmox-installer-common/src/setup.rs         |   2 +-
 proxmox-installer-common/src/utils.rs         |   2 +
 proxmox-post-hook/Cargo.toml                  |  18 +
 proxmox-post-hook/src/main.rs                 | 784 ++++++++++++++++++
 13 files changed, 843 insertions(+), 23 deletions(-)
 create mode 100644 proxmox-post-hook/Cargo.toml
 create mode 100644 proxmox-post-hook/src/main.rs

diff --git a/Cargo.toml b/Cargo.toml
index 94a4dec..6d1e667 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,6 +7,7 @@ members = [
     "proxmox-fetch-answer",
     "proxmox-installer-common",
     "proxmox-tui-installer",
+    "proxmox-post-hook",
 ]
 
 [workspace.dependencies]
diff --git a/Makefile b/Makefile
index e96a0f2..9dc4c22 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,8 @@ USR_BIN := \
 	   proxmox-tui-installer\
 	   proxmox-fetch-answer\
 	   proxmox-auto-install-assistant \
-	   proxmox-auto-installer
+	   proxmox-auto-installer \
+	   proxmox-post-hook
 
 COMPILED_BINS := \
 	$(addprefix $(CARGO_COMPILEDIR)/,$(USR_BIN))
@@ -59,6 +60,7 @@ $(BUILDDIR):
 	  proxmox-chroot \
 	  proxmox-tui-installer/ \
 	  proxmox-installer-common/ \
+	  proxmox-post-hook \
 	  test/ \
 	  $(SHELL_SCRIPTS) \
 	  $@.tmp
@@ -132,7 +134,9 @@ cargo-build:
 		--package proxmox-auto-installer --bin proxmox-auto-installer \
 		--package proxmox-fetch-answer --bin proxmox-fetch-answer \
 		--package proxmox-auto-install-assistant --bin proxmox-auto-install-assistant \
-		--package proxmox-chroot --bin proxmox-chroot $(CARGO_BUILD_ARGS)
+		--package proxmox-chroot --bin proxmox-chroot \
+		--package proxmox-post-hook --bin proxmox-post-hook \
+		$(CARGO_BUILD_ARGS)
 
 %-banner.png: %-banner.svg
 	rsvg-convert -o $@ $<
diff --git a/debian/install b/debian/install
index bb91da7..b64c8ec 100644
--- a/debian/install
+++ b/debian/install
@@ -15,4 +15,5 @@ usr/bin/proxmox-chroot
 usr/bin/proxmox-fetch-answer
 usr/bin/proxmox-low-level-installer
 usr/bin/proxmox-tui-installer
+usr/bin/proxmox-post-hook
 var
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
index e27a321..2670735 100644
--- a/proxmox-auto-installer/src/answer.rs
+++ b/proxmox-auto-installer/src/answer.rs
@@ -1,10 +1,11 @@
+use anyhow::{format_err, Result};
 use clap::ValueEnum;
 use proxmox_installer_common::{
     options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel},
     utils::{CidrAddress, Fqdn},
 };
 use serde::{Deserialize, Serialize};
-use std::{collections::BTreeMap, net::IpAddr};
+use std::{collections::BTreeMap, io::BufRead, net::IpAddr};
 
 // BTreeMap is used to store filters as the order of the filters will be stable, compared to
 // storing them in a HashMap
@@ -20,6 +21,19 @@ pub struct Answer {
     pub posthook: Option<PostNotificationHookInfo>,
 }
 
+impl Answer {
+    pub fn try_from_reader(reader: impl BufRead) -> Result<Self> {
+        let mut buffer = String::new();
+        let lines = reader.lines();
+        for line in lines {
+            buffer.push_str(&line.unwrap());
+            buffer.push('\n');
+        }
+
+        toml::from_str(&buffer).map_err(|err| format_err!("Failed parsing answer file: {err}"))
+    }
+}
+
 #[derive(Clone, Deserialize, Debug)]
 #[serde(deny_unknown_fields)]
 pub struct Global {
diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
index bf6f8fb..6c78d5f 100644
--- a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
+++ b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
@@ -42,16 +42,7 @@ fn auto_installer_setup(in_test_mode: bool) -> Result<(Answer, UdevInfo)> {
             .map_err(|err| format_err!("Failed to retrieve udev info details: {err}"))?
     };
 
-    let mut buffer = String::new();
-    let lines = std::io::stdin().lock().lines();
-    for line in lines {
-        buffer.push_str(&line.unwrap());
-        buffer.push('\n');
-    }
-
-    let answer: Answer =
-        toml::from_str(&buffer).map_err(|err| format_err!("Failed parsing answer file: {err}"))?;
-
+    let answer = Answer::try_from_reader(std::io::stdin().lock())?;
     Ok((answer, udev_info))
 }
 
@@ -91,8 +82,6 @@ fn main() -> ExitCode {
         }
     }
 
-    // TODO: (optionally) do a HTTP post with basic system info, like host SSH public key(s) here
-
     ExitCode::SUCCESS
 }
 
diff --git a/proxmox-auto-installer/src/udevinfo.rs b/proxmox-auto-installer/src/udevinfo.rs
index a6b61b5..677f3f6 100644
--- a/proxmox-auto-installer/src/udevinfo.rs
+++ b/proxmox-auto-installer/src/udevinfo.rs
@@ -1,9 +1,11 @@
 use serde::Deserialize;
 use std::collections::BTreeMap;
 
+/// Uses a BTreeMap to have the keys sorted
+pub type UdevProperties = BTreeMap<String, String>;
+
 #[derive(Clone, Deserialize, Debug)]
 pub struct UdevInfo {
-    // use BTreeMap to have keys sorted
-    pub disks: BTreeMap<String, BTreeMap<String, String>>,
-    pub nics: BTreeMap<String, BTreeMap<String, String>>,
+    pub disks: BTreeMap<String, UdevProperties>,
+    pub nics: BTreeMap<String, UdevProperties>,
 }
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
index a6a8de0..4317430 100644
--- a/proxmox-fetch-answer/src/fetch_plugins/http.rs
+++ b/proxmox-fetch-answer/src/fetch_plugins/http.rs
@@ -68,7 +68,7 @@ impl FetchFromHTTP {
         let payload = SysInfo::as_json()?;
         info!("Sending POST request to '{answer_url}'.");
         let answer =
-            proxmox_installer_common::http::post(answer_url, fingerprint.as_deref(), payload)?;
+            proxmox_installer_common::http::post(&answer_url, fingerprint.as_deref(), payload)?;
         Ok(answer)
     }
 
diff --git a/proxmox-installer-common/src/http.rs b/proxmox-installer-common/src/http.rs
index 4a5d444..b754ed8 100644
--- a/proxmox-installer-common/src/http.rs
+++ b/proxmox-installer-common/src/http.rs
@@ -15,7 +15,7 @@ use ureq::{Agent, AgentBuilder};
 /// * `url` - URL to call
 /// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
 /// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
-pub fn post(url: String, fingerprint: Option<&str>, payload: String) -> Result<String> {
+pub fn post(url: &str, fingerprint: Option<&str>, payload: String) -> Result<String> {
     let answer;
 
     if let Some(fingerprint) = fingerprint {
@@ -27,7 +27,7 @@ pub fn post(url: String, fingerprint: Option<&str>, payload: String) -> Result<S
         let agent: Agent = AgentBuilder::new().tls_config(Arc::new(tls_config)).build();
 
         answer = agent
-            .post(&url)
+            .post(url)
             .set("Content-Type", "application/json; charset=utf-8")
             .send_string(&payload)?
             .into_string()?;
@@ -47,7 +47,7 @@ pub fn post(url: String, fingerprint: Option<&str>, payload: String) -> Result<S
             .tls_config(Arc::new(tls_config))
             .build();
         answer = agent
-            .post(&url)
+            .post(url)
             .set("Content-Type", "application/json; charset=utf-8")
             .timeout(std::time::Duration::from_secs(60))
             .send_string(&payload)?
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index 32c62a7..5c3471d 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -46,6 +46,11 @@ impl FsType {
     pub fn is_btrfs(&self) -> bool {
         matches!(self, FsType::Btrfs(_))
     }
+
+    /// Returns true if the filesystem is used on top of LVM, e.g. ext4 or XFS.
+    pub fn is_lvm(&self) -> bool {
+        matches!(self, FsType::Ext4 | FsType::Xfs)
+    }
 }
 
 impl fmt::Display for FsType {
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index 2ca9641..ca03e07 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -347,7 +347,7 @@ pub struct RuntimeInfo {
     pub secure_boot: Option<bool>,
 }
 
-#[derive(Copy, Clone, Eq, Deserialize, PartialEq)]
+#[derive(Copy, Clone, Eq, Deserialize, PartialEq, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum BootType {
     Bios,
diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs
index 57b1753..2579c80 100644
--- a/proxmox-installer-common/src/utils.rs
+++ b/proxmox-installer-common/src/utils.rs
@@ -114,6 +114,8 @@ impl<'de> Deserialize<'de> for CidrAddress {
     }
 }
 
+serde_plain::derive_serialize_from_display!(CidrAddress);
+
 fn mask_limit(addr: &IpAddr) -> usize {
     if addr.is_ipv4() {
         32
diff --git a/proxmox-post-hook/Cargo.toml b/proxmox-post-hook/Cargo.toml
new file mode 100644
index 0000000..3acea6c
--- /dev/null
+++ b/proxmox-post-hook/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "proxmox-post-hook"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Christoph Heiss <c.heiss at proxmox.com>",
+    "Proxmox Support Team <support at proxmox.com>",
+]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+anyhow.workspace = true
+proxmox-auto-installer.workspace = true
+proxmox-installer-common = { workspace = true, features = ["http"] }
+serde.workspace = true
+serde_json.workspace = true
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
new file mode 100644
index 0000000..d3e5b5c
--- /dev/null
+++ b/proxmox-post-hook/src/main.rs
@@ -0,0 +1,784 @@
+//! Post installation hook for the Proxmox installer, mainly for combination
+//! with the auto-installer.
+//!
+//! If a `[posthook]` section is specified in the given answer file, it will
+//! send a HTTP POST request to that URL, with an optional certificate fingerprint
+//! for usage with (self-signed) TLS certificates.
+//! In the body of the request, information about the newly installed system is sent.
+//!
+//! Relies on `proxmox-chroot` as an external dependency to (bind-)mount the
+//! previously installed system.
+
+use std::{
+    collections::HashSet,
+    ffi::CStr,
+    fs::{self, File},
+    io::BufReader,
+    os::unix::fs::FileExt,
+    path::PathBuf,
+    process::{Command, ExitCode},
+};
+
+use anyhow::{anyhow, bail, Context, Result};
+use proxmox_auto_installer::{
+    answer::{Answer, PostNotificationHookInfo},
+    udevinfo::{UdevInfo, UdevProperties},
+};
+use proxmox_installer_common::{
+    options::{Disk, FsType},
+    setup::{
+        load_installer_setup_files, BootType, InstallConfig, IsoInfo, ProxmoxProduct, RuntimeInfo,
+        SetupInfo,
+    },
+    sysinfo::SystemDMI,
+    utils::CidrAddress,
+};
+use serde::Serialize;
+
+/// Information about the system boot status.
+#[derive(Serialize)]
+struct BootInfo {
+    /// Whether the system is booted using UEFI or legacy BIOS.
+    mode: BootType,
+    /// Whether SecureBoot is enabled for the installation.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    secureboot: Option<bool>,
+}
+
+/// Holds all the public keys for the different algorithms available.
+#[derive(Serialize)]
+struct SshPublicHostKeys {
+    // ECDSA-based public host key
+    ecdsa: String,
+    // ED25519-based public host key
+    ed25519: String,
+    // RSA-based public host key
+    rsa: String,
+}
+
+/// A single disk configured as boot disk.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct DiskInfo {
+    /// Size in bytes
+    size: usize,
+    /// Set to true if the disk is used for booting.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    is_bootdisk: Option<bool>,
+    /// Properties about the device as given by udev.
+    udev_properties: UdevProperties,
+}
+
+/// Holds information about the management network interface.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct NetworkInterfaceInfo {
+    /// MAC address of the interface
+    mac: String,
+    /// (Designated) IP address of the interface
+    #[serde(skip_serializing_if = "Option::is_none")]
+    address: Option<CidrAddress>,
+    /// Set to true if the interface is the chosen management interface during
+    /// installation.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    is_management: Option<bool>,
+    /// Properties about the device as given by udev.
+    udev_properties: UdevProperties,
+}
+
+/// Information about the installed product itself.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct ProductInfo {
+    /// Full name of the product
+    fullname: String,
+    /// Product abbreviation
+    short: ProxmoxProduct,
+    /// Version of the installed product
+    version: String,
+}
+
+/// The current kernel version.
+/// Aligns with the format as used by the /nodes/<node>/status API of each product.
+#[derive(Serialize)]
+struct KernelVersionInformation {
+    /// The systemname/nodename
+    pub sysname: String,
+    /// The kernel release number
+    pub release: String,
+    /// The kernel version
+    pub version: String,
+    /// The machine architecture
+    pub machine: String,
+}
+
+/// Information about the CPU(s) installed in the system
+#[derive(Serialize)]
+struct CpuInfo {
+    /// Number of physical CPU cores.
+    cores: usize,
+    /// Number of logical CPU cores aka. threads.
+    cpus: usize,
+    /// CPU feature flag set as a space-delimited list.
+    flags: String,
+    /// Whether hardware-accelerated virtualization is supported.
+    hvm: bool,
+    /// Reported model of the CPU(s)
+    model: String,
+    /// Number of physical CPU sockets
+    sockets: usize,
+}
+
+/// All data sent as request payload with the post-hook POST request.
+#[derive(Serialize)]
+#[serde(rename_all = "kebab-case")]
+struct PostHookInfo {
+    /// major.minor version of Debian as installed, retrieved from /etc/debian_version
+    debian_version: String,
+    /// PVE/PMG/PBS version as reported by `pveversion`, `pmgversion` or
+    /// `proxmox-backup-manager version`, respectively.
+    product: ProductInfo,
+    /// Release information for the ISO used for the installation.
+    iso: IsoInfo,
+    /// Installed kernel version
+    kernel_version: KernelVersionInformation,
+    /// Describes the boot mode of the machine and the SecureBoot status.
+    boot_info: BootInfo,
+    /// Information about the installed CPU(s)
+    cpu_info: CpuInfo,
+    /// DMI information about the system
+    dmi: SystemDMI,
+    /// Filesystem used for boot disk(s)
+    filesystem: FsType,
+    /// Fully qualified domain name of the installed system
+    fqdn: String,
+    /// Unique systemd-id128 identifier of the installed system (128-bit, 16 bytes)
+    machine_id: String,
+    /// All disks detected on the system.
+    disks: Vec<DiskInfo>,
+    /// All network interfaces detected on the system.
+    network_interfaces: Vec<NetworkInterfaceInfo>,
+    /// Public parts of SSH host keys of the installed system
+    ssh_public_host_keys: SshPublicHostKeys,
+}
+
+/// Defines the size of a gibibyte in bytes.
+const SIZE_GIB: usize = 1024 * 1024 * 1024;
+
+impl PostHookInfo {
+    /// Gathers all needed information about the newly installed system for sending
+    /// it to a specified server.
+    ///
+    /// # Arguments
+    ///
+    /// * `target_path` - Path to where the chroot environment root is mounted
+    /// * `answer` - Answer file as provided by the user
+    fn gather(target_path: &str, answer: &Answer) -> Result<Self> {
+        println!("Gathering installed system data ..");
+
+        let config: InstallConfig =
+            serde_json::from_reader(BufReader::new(File::open("/tmp/low-level-config.json")?))?;
+
+        let (setup_info, _, run_env) =
+            load_installer_setup_files(proxmox_installer_common::RUNTIME_DIR)
+                .map_err(|err| anyhow!("Failed to load setup files: {err}"))?;
+
+        let udev: UdevInfo = {
+            let path =
+                PathBuf::from(proxmox_installer_common::RUNTIME_DIR).join("run-env-udev.json");
+            serde_json::from_reader(BufReader::new(File::open(path)?))?
+        };
+
+        // Opens a file, specified by an absolute path _inside_ the chroot
+        // from the target.
+        let open_file = |path: &str| {
+            File::open(format!("{}/{}", target_path, path))
+                .with_context(|| format!("failed to open '{path}'"))
+        };
+
+        // Reads a file, specified by an absolute path _inside_ the chroot
+        // from the target.
+        let read_file = |path: &str| {
+            fs::read_to_string(format!("{}/{}", target_path, path))
+                .map(|s| s.trim().to_owned())
+                .with_context(|| format!("failed to read '{path}'"))
+        };
+
+        // Runs a command inside the target chroot.
+        let run_cmd = |cmd: &[&str]| {
+            Command::new("chroot")
+                .arg(target_path)
+                .args(cmd)
+                .output()
+                .with_context(|| format!("failed to run '{cmd:?}'"))
+                .and_then(|r| Ok(String::from_utf8(r.stdout)?))
+        };
+
+        Ok(Self {
+            debian_version: read_file("/etc/debian_version")?,
+            product: Self::gather_product_info(&setup_info, &run_cmd)?,
+            iso: setup_info.iso_info.clone(),
+            kernel_version: Self::gather_kernel_version(&run_cmd, &open_file)?,
+            boot_info: BootInfo {
+                mode: run_env.boot_type,
+                secureboot: run_env.secure_boot,
+            },
+            cpu_info: Self::gather_cpu_info(&run_env)?,
+            dmi: SystemDMI::get()?,
+            filesystem: answer.disks.fs_type,
+            fqdn: answer.global.fqdn.to_string(),
+            machine_id: read_file("/etc/machine-id")?,
+            disks: Self::gather_disks(&config, &run_env, &udev)?,
+            network_interfaces: Self::gather_nic(&config, &run_env, &udev)?,
+            ssh_public_host_keys: SshPublicHostKeys {
+                ecdsa: read_file("/etc/ssh/ssh_host_ecdsa_key.pub")?,
+                ed25519: read_file("/etc/ssh/ssh_host_ed25519_key.pub")?,
+                rsa: read_file("/etc/ssh/ssh_host_rsa_key.pub")?,
+            },
+        })
+    }
+
+    /// Retrieves all needed information about the boot disks that were selected during
+    /// installation, most notable the udev properties.
+    ///
+    /// # Arguments
+    ///
+    /// * `config` - Low-level installation configuration
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    /// * `udev` - udev information for all system devices
+    fn gather_disks(
+        config: &InstallConfig,
+        run_env: &RuntimeInfo,
+        udev: &UdevInfo,
+    ) -> Result<Vec<DiskInfo>> {
+        let get_udev_properties = |disk: &Disk| {
+            udev.disks
+                .get(&disk.index)
+                .with_context(|| {
+                    format!("could not find udev information for disk '{}'", disk.path)
+                })
+                .cloned()
+        };
+
+        let disks = if config.filesys.is_lvm() {
+            // If the filesystem is LVM, there is only boot disk. The path (aka. /dev/..)
+            // can be found in `config.target_hd`.
+            run_env
+                .disks
+                .iter()
+                .flat_map(|disk| {
+                    let is_bootdisk = config
+                        .target_hd
+                        .as_ref()
+                        .and_then(|hd| (*hd == disk.path).then_some(true));
+
+                    anyhow::Ok(DiskInfo {
+                        size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+                        is_bootdisk,
+                        udev_properties: get_udev_properties(disk)?,
+                    })
+                })
+                .collect()
+        } else {
+            // If the filesystem is not LVM-based (thus Btrfs or ZFS), `config.disk_selection`
+            // contains a list of indices identifiying the boot disks, as given by udev.
+            let selected_disks_indices: Vec<&String> = config.disk_selection.values().collect();
+
+            run_env
+                .disks
+                .iter()
+                .flat_map(|disk| {
+                    let is_bootdisk = selected_disks_indices
+                        .contains(&&disk.index)
+                        .then_some(true);
+
+                    anyhow::Ok(DiskInfo {
+                        size: (config.hdsize * (SIZE_GIB as f64)) as usize,
+                        is_bootdisk,
+                        udev_properties: get_udev_properties(disk)?,
+                    })
+                })
+                .collect()
+        };
+
+        Ok(disks)
+    }
+
+    /// Retrieves all needed information about the management network interface that was selected
+    /// during installation, most notable the udev properties.
+    ///
+    /// # Arguments
+    ///
+    /// * `config` - Low-level installation configuration
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    /// * `udev` - udev information for all system devices
+    fn gather_nic(
+        config: &InstallConfig,
+        run_env: &RuntimeInfo,
+        udev: &UdevInfo,
+    ) -> Result<Vec<NetworkInterfaceInfo>> {
+        Ok(run_env
+            .network
+            .interfaces
+            .values()
+            .flat_map(|nic| {
+                let udev_properties = udev
+                    .nics
+                    .get(&nic.name)
+                    .with_context(|| {
+                        format!("could not find udev information for NIC '{}'", nic.name)
+                    })?
+                    .clone();
+
+                if config.mngmt_nic == nic.name {
+                    // Use the actual IP address from the low-level install config, as the runtime info
+                    // contains the original IP address from DHCP.
+                    anyhow::Ok(NetworkInterfaceInfo {
+                        mac: nic.mac.clone(),
+                        address: Some(config.cidr.clone()),
+                        is_management: Some(true),
+                        udev_properties,
+                    })
+                } else {
+                    anyhow::Ok(NetworkInterfaceInfo {
+                        mac: nic.mac.clone(),
+                        address: None,
+                        is_management: None,
+                        udev_properties,
+                    })
+                }
+            })
+            .collect())
+    }
+
+    /// Retrieves the version of the installed product from the chroot.
+    ///
+    /// # Arguments
+    ///
+    /// * `setup_info` - Filled-out struct with information about the product
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn gather_product_info(
+        setup_info: &SetupInfo,
+        run_cmd: &dyn Fn(&[&str]) -> Result<String>,
+    ) -> Result<ProductInfo> {
+        let package = match setup_info.config.product {
+            ProxmoxProduct::PVE => "pve-manager",
+            ProxmoxProduct::PMG => "pmg-api",
+            ProxmoxProduct::PBS => "proxmox-backup-server",
+        };
+
+        let version = run_cmd(&[
+            "dpkg-query",
+            "--showformat",
+            "${Version}",
+            "--show",
+            package,
+        ])
+        .with_context(|| format!("failed to retrieve version of {package}"))?;
+
+        Ok(ProductInfo {
+            fullname: setup_info.config.fullname.clone(),
+            short: setup_info.config.product,
+            version,
+        })
+    }
+
+    /// Extracts the version string from the *installed* kernel image.
+    ///
+    /// First, it determines the exact path to the kernel image (aka. `/boot/vmlinuz-<version>`)
+    /// by looking at the installed kernel package, then reads the string directly from the image
+    /// from the well-defined kernel header. See also [0] for details.
+    ///
+    /// [0] https://www.kernel.org/doc/html/latest/arch/x86/boot.html
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    /// * `open_file` - Callback to open a file inside the target chroot.
+    #[cfg(target_arch = "x86_64")]
+    fn gather_kernel_version(
+        run_cmd: &dyn Fn(&[&str]) -> Result<String>,
+        open_file: &dyn Fn(&str) -> Result<File>,
+    ) -> Result<KernelVersionInformation> {
+        let file = open_file(&Self::find_kernel_image_path(run_cmd)?)?;
+
+        // Read the 2-byte `kernel_version` field at offset 0x20e [0] from the file ..
+        // https://www.kernel.org/doc/html/latest/arch/x86/boot.html#the-real-mode-kernel-header
+        let mut buffer = [0u8; 2];
+        file.read_exact_at(&mut buffer, 0x20e)
+            .context("could not read kernel_version offset from image")?;
+
+        // .. which gives us the offset of the kernel version string inside the image, minus 0x200.
+        // https://www.kernel.org/doc/html/latest/arch/x86/boot.html#details-of-header-fields
+        let offset = u16::from_le_bytes(buffer) + 0x200;
+
+        // The string is usually somewhere around 80-100 bytes long, so 256 bytes is more than
+        // enough to cover all cases.
+        let mut buffer = [0u8; 256];
+        file.read_exact_at(&mut buffer, offset.into())
+            .context("could not read kernel version string from image")?;
+
+        // Now just consume the buffer until the NUL byte
+        let kernel_version = CStr::from_bytes_until_nul(&buffer)
+            .context("did not find a NUL-terminator in kernel version string")?
+            .to_str()
+            .context("could not convert kernel version string")?;
+
+        // The version string looks like:
+        //   6.8.4-2-pve (build at proxmox) #1 SMP PREEMPT_DYNAMIC PMX 6.8.4-2 (2024-04-10T17:36Z) x86_64 GNU/Linux
+        //
+        // Thus split it into three parts, as we are interested in the release version
+        // and everything starting at the build number
+        let parts: Vec<&str> = kernel_version.splitn(3, ' ').collect();
+
+        if parts.len() != 3 {
+            bail!("failed to split kernel version string");
+        }
+
+        Ok(KernelVersionInformation {
+            machine: std::env::consts::ARCH.to_owned(),
+            sysname: "Linux".to_owned(),
+            release: parts
+                .first()
+                .context("kernel release not found")?
+                .to_string(),
+            version: parts
+                .get(2)
+                .context("kernel version not found")?
+                .to_string(),
+        })
+    }
+
+    /// Retrieves the absolute path to the kernel image (aka. `/boot/vmlinuz-<version>`)
+    /// inside the chroot by looking at the file list installed by the kernel package.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn find_kernel_image_path(run_cmd: &dyn Fn(&[&str]) -> Result<String>) -> Result<String> {
+        let pkg_name = Self::find_kernel_package_name(run_cmd)?;
+
+        let all_files = run_cmd(&["dpkg-query", "--listfiles", &pkg_name])?;
+        for file in all_files.lines() {
+            if file.starts_with("/boot/vmlinuz-") {
+                return Ok(file.to_owned());
+            }
+        }
+
+        bail!("failed to find installed kernel image path")
+    }
+
+    /// Retrieves the full name of the kernel package installed inside the chroot.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_cmd` - Callback to run a command inside the target chroot.
+    fn find_kernel_package_name(run_cmd: &dyn Fn(&[&str]) -> Result<String>) -> Result<String> {
+        let dpkg_arch = run_cmd(&["dpkg", "--print-architecture"])?
+            .trim()
+            .to_owned();
+
+        let kernel_pkgs = run_cmd(&[
+            "dpkg-query",
+            "--showformat",
+            "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+            "--show",
+            "proxmox-kernel-[0-9]*",
+        ])?;
+
+        // The output to parse looks like this:
+        //   ii |all|proxmox-kernel-6.8
+        //   un ||proxmox-kernel-6.8.8-2-pve
+        //   ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+        for pkg in kernel_pkgs.lines() {
+            let parts = pkg.split('|').collect::<Vec<&str>>();
+
+            if let [status, arch, name] = parts[..] {
+                if status.trim() == "ii" && arch.trim() == dpkg_arch {
+                    return Ok(name.trim().to_owned());
+                }
+            }
+        }
+
+        bail!("failed to find installed kernel package")
+    }
+
+    /// Retrieves some basic information about the CPU in the running system,
+    /// reading them from /proc/cpuinfo.
+    ///
+    /// # Arguments
+    ///
+    /// * `run_env` - Runtime envirornment information gathered by the installer at the start
+    fn gather_cpu_info(run_env: &RuntimeInfo) -> Result<CpuInfo> {
+        let mut result = CpuInfo {
+            cores: 0,
+            cpus: 0,
+            flags: String::new(),
+            hvm: run_env.hvm_supported,
+            model: String::new(),
+            sockets: 0,
+        };
+        let mut sockets = HashSet::new();
+        let mut cores = HashSet::new();
+
+        // Does not matter if we read the file from inside the chroot or directly on the host.
+        let cpuinfo = fs::read_to_string("/proc/cpuinfo")?;
+        for line in cpuinfo.lines() {
+            match line.split_once(':') {
+                Some((key, _)) if key.trim() == "processor" => {
+                    result.cpus += 1;
+                }
+                Some((key, value)) if key.trim() == "core id" => {
+                    cores.insert(value);
+                }
+                Some((key, value)) if key.trim() == "physical id" => {
+                    sockets.insert(value);
+                }
+                Some((key, value)) if key.trim() == "flags" => {
+                    value.trim().clone_into(&mut result.flags);
+                }
+                Some((key, value)) if key.trim() == "model name" => {
+                    value.trim().clone_into(&mut result.model);
+                }
+                _ => {}
+            }
+        }
+
+        result.cores = cores.len();
+        result.sockets = sockets.len();
+
+        Ok(result)
+    }
+}
+
+/// Runs the specified callback with the mounted chroot, passing along the
+/// absolute path to where / is mounted.
+/// The callback is *not* run inside the chroot itself, that is left to the caller.
+///
+/// # Arguments
+///
+/// * `callback` - Callback to call with the absolute path where the chroot environment root is
+///                mounted.
+fn with_chroot<R, F: FnOnce(&str) -> Result<R>>(callback: F) -> Result<R> {
+    let ec = Command::new("proxmox-chroot")
+        .arg("prepare")
+        .status()
+        .context("failed to run proxmox-chroot")?;
+
+    if !ec.success() {
+        bail!("failed to create chroot for installed system");
+    }
+
+    // See also proxmox-chroot/src/main.rs w.r.t to the path, which is hard-coded there
+    let result = callback("/target");
+
+    let ec = Command::new("proxmox-chroot").arg("cleanup").status();
+    // We do not want to necessarily fail here, as the install environment is about
+    // to be teared down completely anyway.
+    if ec.is_err() || !ec.map(|ec| ec.success()).unwrap_or(false) {
+        eprintln!("failed to clean up chroot for installed system");
+    }
+
+    result
+}
+
+/// Reads the answer file from stdin, checks for a configured post-hook URL (+ optional certificate
+/// fingerprint for HTTPS). If configured, retrieves all relevant information about the installed
+/// system and sends them to the given endpoint.
+fn do_main() -> Result<()> {
+    let answer = Answer::try_from_reader(std::io::stdin().lock())?;
+
+    if let Some(PostNotificationHookInfo {
+        url,
+        cert_fingerprint,
+    }) = &answer.posthook
+    {
+        println!("Found posthook; sending POST request to '{url}'.");
+
+        let info = with_chroot(|target_path| PostHookInfo::gather(target_path, &answer))?;
+
+        proxmox_installer_common::http::post(
+            url,
+            cert_fingerprint.as_deref(),
+            serde_json::to_string(&info)?,
+        )?;
+    } else {
+        println!("No posthook found; skipping");
+    }
+
+    Ok(())
+}
+
+fn main() -> ExitCode {
+    match do_main() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(err) => {
+            eprintln!("\nError occurred during posthook:");
+            eprintln!("{err:#}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::PostHookInfo;
+
+    #[test]
+    fn finds_correct_kernel_package_name() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd).unwrap(),
+            "proxmox-kernel-6.8.8-2-pve-signed"
+        );
+    }
+
+    #[test]
+    fn find_kernel_package_name_fails_on_wrong_architecture() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("arm64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
+                .unwrap_err()
+                .to_string(),
+            "failed to find installed kernel package"
+        );
+    }
+
+    #[test]
+    fn find_kernel_package_name_fails_on_missing_package() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_package_name(&mocked_run_cmd)
+                .unwrap_err()
+                .to_string(),
+            "failed to find installed kernel package"
+        );
+    }
+
+    #[test]
+    fn finds_correct_absolute_kernel_image_path() {
+        let mocked_run_cmd = |cmd: &[&str]| {
+            if cmd[0] == "dpkg" {
+                assert_eq!(cmd, &["dpkg", "--print-architecture"]);
+                Ok("amd64\n".to_owned())
+            } else if cmd[0..=1] == ["dpkg-query", "--showformat"] {
+                assert_eq!(
+                    cmd,
+                    &[
+                        "dpkg-query",
+                        "--showformat",
+                        "${db:Status-Abbrev}|${Architecture}|${Package}\\n",
+                        "--show",
+                        "proxmox-kernel-[0-9]*",
+                    ]
+                );
+                Ok(r#"ii |all|proxmox-kernel-6.8
+un ||proxmox-kernel-6.8.8-2-pve
+ii |amd64|proxmox-kernel-6.8.8-2-pve-signed
+                "#
+                .to_owned())
+            } else {
+                assert_eq!(
+                    cmd,
+                    [
+                        "dpkg-query",
+                        "--listfiles",
+                        "proxmox-kernel-6.8.8-2-pve-signed"
+                    ]
+                );
+                Ok(r#"
+/.
+/boot
+/boot/System.map-6.8.8-2-pve
+/boot/config-6.8.8-2-pve
+/boot/vmlinuz-6.8.8-2-pve
+/lib
+/lib/modules
+/lib/modules/6.8.8-2-pve
+/lib/modules/6.8.8-2-pve/kernel
+/lib/modules/6.8.8-2-pve/kernel/arch
+/lib/modules/6.8.8-2-pve/kernel/arch/x86
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aegis128-aesni.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aesni-intel.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-aesni-avx2-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/aria-gfni-avx512-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/blowfish-x86_64.ko
+/lib/modules/6.8.8-2-pve/kernel/arch/x86/crypto/camellia-aesni-avx-x86_64.ko
+                "#
+                .to_owned())
+            }
+        };
+
+        assert_eq!(
+            PostHookInfo::find_kernel_image_path(&mocked_run_cmd).unwrap(),
+            "/boot/vmlinuz-6.8.8-2-pve"
+        );
+    }
+}
-- 
2.45.1





More information about the pve-devel mailing list