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 4B39F1FF29F for ; Thu, 18 Jul 2024 15:50:36 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BD2E9D985; Thu, 18 Jul 2024 15:50:14 +0200 (CEST) From: Christoph Heiss To: pve-devel@lists.proxmox.com Date: Thu, 18 Jul 2024 15:49:01 +0200 Message-ID: <20240718134905.1177775-17-c.heiss@proxmox.com> X-Mailer: git-send-email 2.45.1 In-Reply-To: <20240718134905.1177775-1-c.heiss@proxmox.com> References: <20240718134905.1177775-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.127 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 POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_1 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_3 0.1 random spam to be learned in bayes SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pve-devel] [PATCH installer v2 16/17] fix #5536: post-hook: add utility for sending notifications after auto-install X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox VE development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pve-devel-bounces@lists.proxmox.com Sender: "pve-devel" 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 --- 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 --- 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, } +impl Answer { + pub fn try_from_reader(reader: impl BufRead) -> Result { + 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; + #[derive(Clone, Deserialize, Debug)] pub struct UdevInfo { - // use BTreeMap to have keys sorted - pub disks: BTreeMap>, - pub nics: BTreeMap>, + pub disks: BTreeMap, + pub nics: BTreeMap, } 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 { +pub fn post(url: &str, fingerprint: Option<&str>, payload: String) -> Result { let answer; if let Some(fingerprint) = fingerprint { @@ -27,7 +27,7 @@ pub fn post(url: String, fingerprint: Option<&str>, payload: String) -> Result, payload: String) -> Result 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, } -#[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 ", + "Proxmox Support Team ", +] +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, +} + +/// 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, + /// 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, + /// Set to true if the interface is the chosen management interface during + /// installation. + #[serde(skip_serializing_if = "Option::is_none")] + is_management: Option, + /// 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//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, + /// All network interfaces detected on the system. + network_interfaces: Vec, + /// 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 { + 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> { + 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> { + 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, + ) -> Result { + 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-`) + /// 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, + open_file: &dyn Fn(&str) -> Result, + ) -> Result { + 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@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-`) + /// 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) -> Result { + 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) -> Result { + 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::>(); + + 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 { + 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 Result>(callback: F) -> Result { + 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 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel