From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 4F3969D3AC for ; Wed, 25 Oct 2023 18:00:52 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8E18416AE7 for ; Wed, 25 Oct 2023 18:00:21 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Wed, 25 Oct 2023 18:00:16 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 9794C46261 for ; Wed, 25 Oct 2023 18:00:16 +0200 (CEST) From: Aaron Lauterer To: pve-devel@lists.proxmox.com Date: Wed, 25 Oct 2023 18:00:01 +0200 Message-Id: <20231025160011.3617524-3-a.lauterer@proxmox.com> X-Mailer: git-send-email 2.39.2 In-Reply-To: <20231025160011.3617524-1-a.lauterer@proxmox.com> References: <20231025160011.3617524-1-a.lauterer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.075 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 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 02/12] common: copy common code from tui-installer 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: , X-List-Received-Date: Wed, 25 Oct 2023 16:00:52 -0000 Copy code that is common to its own crate. Signed-off-by: Aaron Lauterer --- proxmox-installer-common/Cargo.toml | 2 + proxmox-installer-common/src/disk_checks.rs | 237 ++++++++++++ proxmox-installer-common/src/lib.rs | 4 + proxmox-installer-common/src/options.rs | 387 ++++++++++++++++++++ proxmox-installer-common/src/setup.rs | 330 +++++++++++++++++ proxmox-installer-common/src/utils.rs | 268 ++++++++++++++ 6 files changed, 1228 insertions(+) create mode 100644 proxmox-installer-common/src/disk_checks.rs create mode 100644 proxmox-installer-common/src/options.rs create mode 100644 proxmox-installer-common/src/setup.rs create mode 100644 proxmox-installer-common/src/utils.rs diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml index b8762e8..bde5457 100644 --- a/proxmox-installer-common/Cargo.toml +++ b/proxmox-installer-common/Cargo.toml @@ -8,3 +8,5 @@ exclude = [ "build", "debian" ] homepage = "https://www.proxmox.com" [dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/proxmox-installer-common/src/disk_checks.rs b/proxmox-installer-common/src/disk_checks.rs new file mode 100644 index 0000000..15b5928 --- /dev/null +++ b/proxmox-installer-common/src/disk_checks.rs @@ -0,0 +1,237 @@ +use std::collections::HashSet; + +use crate::options::{BtrfsRaidLevel, Disk, ZfsRaidLevel}; +use crate::setup::BootType; + +/// Checks a list of disks for duplicate entries, using their index as key. +/// +/// # Arguments +/// +/// * `disks` - A list of disks to check for duplicates. +fn check_for_duplicate_disks(disks: &[Disk]) -> Result<(), &Disk> { + let mut set = HashSet::new(); + + for disk in disks { + if !set.insert(&disk.index) { + return Err(disk); + } + } + + Ok(()) +} + +/// Simple wrapper which returns an descriptive error if the list of disks is too short. +/// +/// # Arguments +/// +/// * `disks` - A list of disks to check the lenght of. +/// * `min` - Minimum number of disks +fn check_raid_min_disks(disks: &[Disk], min: usize) -> Result<(), String> { + if disks.len() < min { + Err(format!("Need at least {min} disks")) + } else { + Ok(()) + } +} + +/// Checks all disks for legacy BIOS boot compatibility and reports an error as appropriate. 4Kn +/// disks are generally broken with legacy BIOS and cannot be booted from. +/// +/// # Arguments +/// +/// * `runinfo` - `RuntimeInfo` instance of currently running system +/// * `disks` - List of disks designated as bootdisk targets. +fn check_disks_4kn_legacy_boot(boot_type: BootType, disks: &[Disk]) -> Result<(), &str> { + let is_blocksize_4096 = |disk: &Disk| disk.block_size.map(|s| s == 4096).unwrap_or(false); + + if boot_type == BootType::Bios && disks.iter().any(is_blocksize_4096) { + return Err("Booting from 4Kn drive in legacy BIOS mode is not supported."); + } + + Ok(()) +} + +/// Checks whether a user-supplied ZFS RAID setup is valid or not, such as disk sizes andminimum +/// number of disks. +/// +/// # Arguments +/// +/// * `level` - The targeted ZFS RAID level by the user. +/// * `disks` - List of disks designated as RAID targets. +fn check_zfs_raid_config(level: ZfsRaidLevel, disks: &[Disk]) -> Result<(), String> { + // See also Proxmox/Install.pm:get_zfs_raid_setup() + + let check_mirror_size = |disk1: &Disk, disk2: &Disk| { + if (disk1.size - disk2.size).abs() > disk1.size / 10. { + Err(format!( + "Mirrored disks must have same size:\n\n * {disk1}\n * {disk2}" + )) + } else { + Ok(()) + } + }; + + match level { + ZfsRaidLevel::Raid0 => check_raid_min_disks(disks, 1)?, + ZfsRaidLevel::Raid1 => { + check_raid_min_disks(disks, 2)?; + for disk in disks { + check_mirror_size(&disks[0], disk)?; + } + } + ZfsRaidLevel::Raid10 => { + check_raid_min_disks(disks, 4)?; + // Pairs need to have the same size + for i in (0..disks.len()).step_by(2) { + check_mirror_size(&disks[i], &disks[i + 1])?; + } + } + // For RAID-Z: minimum disks number is level + 2 + ZfsRaidLevel::RaidZ => { + check_raid_min_disks(disks, 3)?; + for disk in disks { + check_mirror_size(&disks[0], disk)?; + } + } + ZfsRaidLevel::RaidZ2 => { + check_raid_min_disks(disks, 4)?; + for disk in disks { + check_mirror_size(&disks[0], disk)?; + } + } + ZfsRaidLevel::RaidZ3 => { + check_raid_min_disks(disks, 5)?; + for disk in disks { + check_mirror_size(&disks[0], disk)?; + } + } + } + + Ok(()) +} + +/// Checks whether a user-supplied Btrfs RAID setup is valid or not, such as minimum +/// number of disks. +/// +/// # Arguments +/// +/// * `level` - The targeted Btrfs RAID level by the user. +/// * `disks` - List of disks designated as RAID targets. +fn check_btrfs_raid_config(level: BtrfsRaidLevel, disks: &[Disk]) -> Result<(), String> { + // See also Proxmox/Install.pm:get_btrfs_raid_setup() + + match level { + BtrfsRaidLevel::Raid0 => check_raid_min_disks(disks, 1)?, + BtrfsRaidLevel::Raid1 => check_raid_min_disks(disks, 2)?, + BtrfsRaidLevel::Raid10 => check_raid_min_disks(disks, 4)?, + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_disk(index: usize) -> Disk { + Disk { + index: index.to_string(), + path: format!("/dev/dummy{index}"), + model: Some("Dummy disk".to_owned()), + size: 1024. * 1024. * 1024. * 8., + block_size: Some(512), + } + } + + fn dummy_disks(num: usize) -> Vec { + (0..num).map(dummy_disk).collect() + } + + #[test] + fn duplicate_disks() { + assert!(check_for_duplicate_disks(&dummy_disks(2)).is_ok()); + assert_eq!( + check_for_duplicate_disks(&[ + dummy_disk(0), + dummy_disk(1), + dummy_disk(2), + dummy_disk(2), + dummy_disk(3), + ]), + Err(&dummy_disk(2)), + ); + } + + #[test] + fn raid_min_disks() { + let disks = dummy_disks(10); + + assert!(check_raid_min_disks(&disks[..1], 2).is_err()); + assert!(check_raid_min_disks(&disks[..1], 1).is_ok()); + assert!(check_raid_min_disks(&disks, 1).is_ok()); + } + + #[test] + fn bios_boot_compat_4kn() { + for i in 0..10 { + let mut disks = dummy_disks(10); + disks[i].block_size = Some(4096); + + // Must fail if /any/ of the disks are 4Kn + assert!(check_disks_4kn_legacy_boot(BootType::Bios, &disks).is_err()); + // For UEFI, we allow it for every configuration + assert!(check_disks_4kn_legacy_boot(BootType::Efi, &disks).is_ok()); + } + } + + #[test] + fn btrfs_raid() { + let disks = dummy_disks(10); + + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &[]).is_err()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &disks[..1]).is_ok()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid0, &disks).is_ok()); + + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &[]).is_err()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks[..1]).is_err()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks[..2]).is_ok()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid1, &disks).is_ok()); + + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &[]).is_err()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks[..3]).is_err()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks[..4]).is_ok()); + assert!(check_btrfs_raid_config(BtrfsRaidLevel::Raid10, &disks).is_ok()); + } + + #[test] + fn zfs_raid() { + let disks = dummy_disks(10); + + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &disks[..1]).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid0, &disks).is_ok()); + + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &disks[..2]).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid1, &disks).is_ok()); + + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &dummy_disks(4)).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::Raid10, &disks).is_ok()); + + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks[..2]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks[..3]).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ, &disks).is_ok()); + + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks[..3]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks[..4]).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ2, &disks).is_ok()); + + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &[]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks[..4]).is_err()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks[..5]).is_ok()); + assert!(check_zfs_raid_config(ZfsRaidLevel::RaidZ3, &disks).is_ok()); + } +} diff --git a/proxmox-installer-common/src/lib.rs b/proxmox-installer-common/src/lib.rs index e69de29..f0093f5 100644 --- a/proxmox-installer-common/src/lib.rs +++ b/proxmox-installer-common/src/lib.rs @@ -0,0 +1,4 @@ +pub mod disk_checks; +pub mod options; +pub mod setup; +pub mod utils; diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs new file mode 100644 index 0000000..185be2e --- /dev/null +++ b/proxmox-installer-common/src/options.rs @@ -0,0 +1,387 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::{cmp, fmt}; + +use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo}; +use crate::utils::{CidrAddress, Fqdn}; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum BtrfsRaidLevel { + Raid0, + Raid1, + Raid10, +} + +impl fmt::Display for BtrfsRaidLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use BtrfsRaidLevel::*; + match self { + Raid0 => write!(f, "RAID0"), + Raid1 => write!(f, "RAID1"), + Raid10 => write!(f, "RAID10"), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ZfsRaidLevel { + Raid0, + Raid1, + Raid10, + RaidZ, + RaidZ2, + RaidZ3, +} + +impl fmt::Display for ZfsRaidLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use ZfsRaidLevel::*; + match self { + Raid0 => write!(f, "RAID0"), + Raid1 => write!(f, "RAID1"), + Raid10 => write!(f, "RAID10"), + RaidZ => write!(f, "RAIDZ-1"), + RaidZ2 => write!(f, "RAIDZ-2"), + RaidZ3 => write!(f, "RAIDZ-3"), + } + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum FsType { + Ext4, + Xfs, + Zfs(ZfsRaidLevel), + Btrfs(BtrfsRaidLevel), +} + +impl FsType { + pub fn is_btrfs(&self) -> bool { + matches!(self, FsType::Btrfs(_)) + } +} + +impl fmt::Display for FsType { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + use FsType::*; + match self { + Ext4 => write!(f, "ext4"), + Xfs => write!(f, "XFS"), + Zfs(level) => write!(f, "ZFS ({level})"), + Btrfs(level) => write!(f, "Btrfs ({level})"), + } + } +} + +#[derive(Clone, Debug)] +pub struct LvmBootdiskOptions { + pub total_size: f64, + pub swap_size: Option, + pub max_root_size: Option, + pub max_data_size: Option, + pub min_lvm_free: Option, +} + +impl LvmBootdiskOptions { + pub fn defaults_from(disk: &Disk) -> Self { + Self { + total_size: disk.size, + swap_size: None, + max_root_size: None, + max_data_size: None, + min_lvm_free: None, + } + } +} + +#[derive(Clone, Debug)] +pub struct BtrfsBootdiskOptions { + pub disk_size: f64, + pub selected_disks: Vec, +} + +impl BtrfsBootdiskOptions { + /// This panics if the provided slice is empty. + pub fn defaults_from(disks: &[Disk]) -> Self { + let disk = &disks[0]; + Self { + disk_size: disk.size, + selected_disks: (0..disks.len()).collect(), + } + } +} + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ZfsCompressOption { + #[default] + On, + Off, + Lzjb, + Lz4, + Zle, + Gzip, + Zstd, +} + +impl fmt::Display for ZfsCompressOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", format!("{self:?}").to_lowercase()) + } +} + +impl From<&ZfsCompressOption> for String { + fn from(value: &ZfsCompressOption) -> Self { + value.to_string() + } +} + +pub const ZFS_COMPRESS_OPTIONS: &[ZfsCompressOption] = { + use ZfsCompressOption::*; + &[On, Off, Lzjb, Lz4, Zle, Gzip, Zstd] +}; + +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub enum ZfsChecksumOption { + #[default] + On, + Off, + Fletcher2, + Fletcher4, + Sha256, +} + +impl fmt::Display for ZfsChecksumOption { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", format!("{self:?}").to_lowercase()) + } +} + +impl From<&ZfsChecksumOption> for String { + fn from(value: &ZfsChecksumOption) -> Self { + value.to_string() + } +} + +pub const ZFS_CHECKSUM_OPTIONS: &[ZfsChecksumOption] = { + use ZfsChecksumOption::*; + &[On, Off, Fletcher2, Fletcher4, Sha256] +}; + +#[derive(Clone, Debug)] +pub struct ZfsBootdiskOptions { + pub ashift: usize, + pub compress: ZfsCompressOption, + pub checksum: ZfsChecksumOption, + pub copies: usize, + pub disk_size: f64, + pub selected_disks: Vec, +} + +impl ZfsBootdiskOptions { + /// This panics if the provided slice is empty. + pub fn defaults_from(disks: &[Disk]) -> Self { + let disk = &disks[0]; + Self { + ashift: 12, + compress: ZfsCompressOption::default(), + checksum: ZfsChecksumOption::default(), + copies: 1, + disk_size: disk.size, + selected_disks: (0..disks.len()).collect(), + } + } +} + +#[derive(Clone, Debug)] +pub enum AdvancedBootdiskOptions { + Lvm(LvmBootdiskOptions), + Zfs(ZfsBootdiskOptions), + Btrfs(BtrfsBootdiskOptions), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Disk { + pub index: String, + pub path: String, + pub model: Option, + pub size: f64, + pub block_size: Option, +} + +impl fmt::Display for Disk { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // TODO: Format sizes properly with `proxmox-human-byte` once merged + // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.html + f.write_str(&self.path)?; + if let Some(model) = &self.model { + // FIXME: ellipsize too-long names? + write!(f, " ({model})")?; + } + write!(f, " ({:.2} GiB)", self.size) + } +} + +impl From<&Disk> for String { + fn from(value: &Disk) -> Self { + value.to_string() + } +} + +impl cmp::Eq for Disk {} + +impl cmp::PartialOrd for Disk { + fn partial_cmp(&self, other: &Self) -> Option { + self.index.partial_cmp(&other.index) + } +} + +impl cmp::Ord for Disk { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.index.cmp(&other.index) + } +} + +#[derive(Clone, Debug)] +pub struct BootdiskOptions { + pub disks: Vec, + pub fstype: FsType, + pub advanced: AdvancedBootdiskOptions, +} + +impl BootdiskOptions { + pub fn defaults_from(disk: &Disk) -> Self { + Self { + disks: vec![disk.clone()], + fstype: FsType::Ext4, + advanced: AdvancedBootdiskOptions::Lvm(LvmBootdiskOptions::defaults_from(disk)), + } + } +} + +#[derive(Clone, Debug)] +pub struct TimezoneOptions { + pub country: String, + pub timezone: String, + pub kb_layout: String, +} + +impl TimezoneOptions { + pub fn defaults_from(runtime: &RuntimeInfo, locales: &LocaleInfo) -> Self { + let country = runtime.country.clone().unwrap_or_else(|| "at".to_owned()); + + let timezone = locales + .cczones + .get(&country) + .and_then(|zones| zones.get(0)) + .cloned() + .unwrap_or_else(|| "UTC".to_owned()); + + let kb_layout = locales + .countries + .get(&country) + .and_then(|c| { + if c.kmap.is_empty() { + None + } else { + Some(c.kmap.clone()) + } + }) + .unwrap_or_else(|| "en-us".to_owned()); + + Self { + country, + timezone, + kb_layout, + } + } +} + +#[derive(Clone, Debug)] +pub struct PasswordOptions { + pub email: String, + pub root_password: String, +} + +impl Default for PasswordOptions { + fn default() -> Self { + Self { + email: "mail@example.invalid".to_string(), + root_password: String::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NetworkOptions { + pub ifname: String, + pub fqdn: Fqdn, + pub address: CidrAddress, + pub gateway: IpAddr, + pub dns_server: IpAddr, +} + +impl NetworkOptions { + const DEFAULT_DOMAIN: &str = "example.invalid"; + + pub fn defaults_from(setup: &SetupInfo, network: &NetworkInfo) -> Self { + let mut this = Self { + ifname: String::new(), + fqdn: Self::construct_fqdn(network, setup.config.product.default_hostname()), + // Safety: The provided mask will always be valid. + address: CidrAddress::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(), + gateway: Ipv4Addr::UNSPECIFIED.into(), + dns_server: Ipv4Addr::UNSPECIFIED.into(), + }; + + if let Some(ip) = network.dns.dns.first() { + this.dns_server = *ip; + } + + if let Some(routes) = &network.routes { + let mut filled = false; + if let Some(gw) = &routes.gateway4 { + if let Some(iface) = network.interfaces.get(&gw.dev) { + this.ifname = iface.name.clone(); + if let Some(addresses) = &iface.addresses { + if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv4()) { + this.gateway = gw.gateway; + this.address = addr.clone(); + filled = true; + } + } + } + } + if !filled { + if let Some(gw) = &routes.gateway6 { + if let Some(iface) = network.interfaces.get(&gw.dev) { + if let Some(addresses) = &iface.addresses { + if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv6()) { + this.ifname = iface.name.clone(); + this.gateway = gw.gateway; + this.address = addr.clone(); + } + } + } + } + } + } + + this + } + + fn construct_fqdn(network: &NetworkInfo, default_hostname: &str) -> Fqdn { + let hostname = network.hostname.as_deref().unwrap_or(default_hostname); + + let domain = network + .dns + .domain + .as_deref() + .unwrap_or(Self::DEFAULT_DOMAIN); + + Fqdn::from(&format!("{hostname}.{domain}")).unwrap_or_else(|_| { + // Safety: This will always result in a valid FQDN, as we control & know + // the values of default_hostname (one of "pve", "pmg" or "pbs") and + // constant-defined DEFAULT_DOMAIN. + Fqdn::from(&format!("{}.{}", default_hostname, Self::DEFAULT_DOMAIN)).unwrap() + }) + } +} diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs new file mode 100644 index 0000000..a4947f1 --- /dev/null +++ b/proxmox-installer-common/src/setup.rs @@ -0,0 +1,330 @@ +use std::{ + cmp, + collections::HashMap, + fmt, + fs::File, + io::BufReader, + net::IpAddr, + path::{Path, PathBuf}, +}; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +use crate::{ + options::{Disk, ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption}, + utils::CidrAddress, +}; + +#[allow(clippy::upper_case_acronyms)] +#[derive(Clone, Copy, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ProxmoxProduct { + PVE, + PBS, + PMG, +} + +impl ProxmoxProduct { + pub fn default_hostname(self) -> &'static str { + match self { + Self::PVE => "pve", + Self::PMG => "pmg", + Self::PBS => "pbs", + } + } +} + +#[derive(Clone, Deserialize)] +pub struct ProductConfig { + pub fullname: String, + pub product: ProxmoxProduct, + #[serde(deserialize_with = "deserialize_bool_from_int")] + pub enable_btrfs: bool, +} + +#[derive(Clone, Deserialize)] +pub struct IsoInfo { + pub release: String, + pub isorelease: String, +} + +/// Paths in the ISO environment containing installer data. +#[derive(Clone, Deserialize)] +pub struct IsoLocations { + pub iso: PathBuf, +} + +#[derive(Clone, Deserialize)] +pub struct SetupInfo { + #[serde(rename = "product-cfg")] + pub config: ProductConfig, + #[serde(rename = "iso-info")] + pub iso_info: IsoInfo, + pub locations: IsoLocations, +} + +#[derive(Clone, Deserialize)] +pub struct CountryInfo { + pub name: String, + #[serde(default)] + pub zone: String, + pub kmap: String, +} + +#[derive(Clone, Deserialize, Eq, PartialEq)] +pub struct KeyboardMapping { + pub name: String, + #[serde(rename = "kvm")] + pub id: String, + #[serde(rename = "x11")] + pub xkb_layout: String, + #[serde(rename = "x11var")] + pub xkb_variant: String, +} + +impl cmp::PartialOrd for KeyboardMapping { + fn partial_cmp(&self, other: &Self) -> Option { + self.name.partial_cmp(&other.name) + } +} + +impl cmp::Ord for KeyboardMapping { + fn cmp(&self, other: &Self) -> cmp::Ordering { + self.name.cmp(&other.name) + } +} + +#[derive(Clone, Deserialize)] +pub struct LocaleInfo { + #[serde(deserialize_with = "deserialize_cczones_map")] + pub cczones: HashMap>, + #[serde(rename = "country")] + pub countries: HashMap, + pub kmap: HashMap, +} + +#[derive(Serialize)] +struct InstallZfsOption { + ashift: usize, + #[serde(serialize_with = "serialize_as_display")] + compress: ZfsCompressOption, + #[serde(serialize_with = "serialize_as_display")] + checksum: ZfsChecksumOption, + copies: usize, +} + +impl From for InstallZfsOption { + fn from(opts: ZfsBootdiskOptions) -> Self { + InstallZfsOption { + ashift: opts.ashift, + compress: opts.compress, + checksum: opts.checksum, + copies: opts.copies, + } + } +} + +pub fn read_json Deserialize<'de>, P: AsRef>(path: P) -> Result { + let file = File::open(path).map_err(|err| err.to_string())?; + let reader = BufReader::new(file); + + serde_json::from_reader(reader).map_err(|err| format!("failed to parse JSON: {err}")) +} + +fn deserialize_bool_from_int<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let val: u32 = Deserialize::deserialize(deserializer)?; + Ok(val != 0) +} + +fn deserialize_cczones_map<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let map: HashMap> = Deserialize::deserialize(deserializer)?; + + let mut result = HashMap::new(); + for (cc, list) in map.into_iter() { + result.insert(cc, list.into_keys().collect()); + } + + Ok(result) +} + +fn deserialize_disks_map<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let disks = + , String)>>::deserialize(deserializer)?; + Ok(disks + .into_iter() + .map( + |(index, device, size_mb, model, logical_bsize, _syspath)| Disk { + index: index.to_string(), + // Linux always reports the size of block devices in sectors, where one sector is + // defined as being 2^9 = 512 bytes in size. + // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/blk_types.h?h=v6.4#n30 + size: (size_mb * 512.) / 1024. / 1024. / 1024., + block_size: logical_bsize, + path: device, + model: (!model.is_empty()).then_some(model), + }, + ) + .collect()) +} + +fn deserialize_cidr_list<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + struct CidrDescriptor { + address: String, + prefix: usize, + // family is implied anyway by parsing the address + } + + let list: Vec = Deserialize::deserialize(deserializer)?; + + let mut result = Vec::with_capacity(list.len()); + for desc in list { + let ip_addr = desc + .address + .parse::() + .map_err(|err| de::Error::custom(format!("{:?}", err)))?; + + result.push( + CidrAddress::new(ip_addr, desc.prefix) + .map_err(|err| de::Error::custom(format!("{:?}", err)))?, + ); + } + + Ok(Some(result)) +} + +fn serialize_as_display(value: &T, serializer: S) -> Result +where + S: Serializer, + T: fmt::Display, +{ + serializer.collect_str(value) +} + +#[derive(Clone, Deserialize)] +pub struct RuntimeInfo { + /// Whether is system was booted in (legacy) BIOS or UEFI mode. + pub boot_type: BootType, + + /// Detected country if available. + pub country: Option, + + /// Maps devices to their information. + #[serde(deserialize_with = "deserialize_disks_map")] + pub disks: Vec, + + /// Network addresses, gateways and DNS info. + pub network: NetworkInfo, + + /// Total memory of the system in MiB. + pub total_memory: usize, + + /// Whether the CPU supports hardware-accelerated virtualization + #[serde(deserialize_with = "deserialize_bool_from_int")] + pub hvm_supported: bool, +} + +#[derive(Copy, Clone, Eq, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum BootType { + Bios, + Efi, +} + +#[derive(Clone, Deserialize)] +pub struct NetworkInfo { + pub dns: Dns, + pub routes: Option, + + /// Maps devices to their configuration, if it has a usable configuration. + /// (Contains no entries for devices with only link-local addresses.) + #[serde(default)] + pub interfaces: HashMap, + + /// The hostname of this machine, if set by the DHCP server. + pub hostname: Option, +} + +#[derive(Clone, Deserialize)] +pub struct Dns { + pub domain: Option, + + /// List of stringified IP addresses. + #[serde(default)] + pub dns: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct Routes { + /// Ipv4 gateway. + pub gateway4: Option, + + /// Ipv6 gateway. + pub gateway6: Option, +} + +#[derive(Clone, Deserialize)] +pub struct Gateway { + /// Outgoing network device. + pub dev: String, + + /// Stringified gateway IP address. + pub gateway: IpAddr, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum InterfaceState { + Up, + Down, + #[serde(other)] + Unknown, +} + +impl InterfaceState { + // avoid display trait as this is not the string representation for a serializer + pub fn render(&self) -> String { + match self { + Self::Up => "\u{25CF}", + Self::Down | Self::Unknown => " ", + } + .into() + } +} + +#[derive(Clone, Deserialize)] +pub struct Interface { + pub name: String, + + pub index: usize, + + pub mac: String, + + pub state: InterfaceState, + + #[serde(default)] + #[serde(deserialize_with = "deserialize_cidr_list")] + pub addresses: Option>, +} + +impl Interface { + // avoid display trait as this is not the string representation for a serializer + pub fn render(&self) -> String { + format!("{} {}", self.state.render(), self.name) + } +} + diff --git a/proxmox-installer-common/src/utils.rs b/proxmox-installer-common/src/utils.rs new file mode 100644 index 0000000..89349ed --- /dev/null +++ b/proxmox-installer-common/src/utils.rs @@ -0,0 +1,268 @@ +use std::{ + fmt, + net::{AddrParseError, IpAddr}, + num::ParseIntError, + str::FromStr, +}; + +use serde::Deserialize; + +/// Possible errors that might occur when parsing CIDR addresses. +#[derive(Debug)] +pub enum CidrAddressParseError { + /// No delimiter for separating address and mask was found. + NoDelimiter, + /// The IP address part could not be parsed. + InvalidAddr(AddrParseError), + /// The mask could not be parsed. + InvalidMask(Option), +} + +/// An IP address (IPv4 or IPv6), including network mask. +/// +/// See the [`IpAddr`] type for more information how IP addresses are handled. +/// The mask is appropriately enforced to be `0 <= mask <= 32` for IPv4 or +/// `0 <= mask <= 128` for IPv6 addresses. +/// +/// # Examples +/// ``` +/// use std::net::{Ipv4Addr, Ipv6Addr}; +/// let ipv4 = CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwrap(); +/// let ipv6 = CidrAddress::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0xc0a8, 1), 32).unwrap(); +/// +/// assert_eq!(ipv4.to_string(), "192.168.0.1/24"); +/// assert_eq!(ipv6.to_string(), "2001:db8::c0a8:1/32"); +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct CidrAddress { + addr: IpAddr, + mask: usize, +} + +impl CidrAddress { + /// Constructs a new CIDR address. + /// + /// It fails if the mask is invalid for the given IP address. + pub fn new>(addr: T, mask: usize) -> Result { + let addr = addr.into(); + + if mask > mask_limit(&addr) { + Err(CidrAddressParseError::InvalidMask(None)) + } else { + Ok(Self { addr, mask }) + } + } + + /// Returns only the IP address part of the address. + pub fn addr(&self) -> IpAddr { + self.addr + } + + /// Returns `true` if this address is an IPv4 address, `false` otherwise. + pub fn is_ipv4(&self) -> bool { + self.addr.is_ipv4() + } + + /// Returns `true` if this address is an IPv6 address, `false` otherwise. + pub fn is_ipv6(&self) -> bool { + self.addr.is_ipv6() + } + + /// Returns only the mask part of the address. + pub fn mask(&self) -> usize { + self.mask + } +} + +impl FromStr for CidrAddress { + type Err = CidrAddressParseError; + + fn from_str(s: &str) -> Result { + let (addr, mask) = s + .split_once('/') + .ok_or(CidrAddressParseError::NoDelimiter)?; + + let addr = addr.parse().map_err(CidrAddressParseError::InvalidAddr)?; + + let mask = mask + .parse() + .map_err(|err| CidrAddressParseError::InvalidMask(Some(err)))?; + + if mask > mask_limit(&addr) { + Err(CidrAddressParseError::InvalidMask(None)) + } else { + Ok(Self { addr, mask }) + } + } +} + +impl fmt::Display for CidrAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", self.addr, self.mask) + } +} + +fn mask_limit(addr: &IpAddr) -> usize { + if addr.is_ipv4() { + 32 + } else { + 128 + } +} + +/// Possible errors that might occur when parsing FQDNs. +#[derive(Debug, Eq, PartialEq)] +pub enum FqdnParseError { + MissingHostname, + NumericHostname, + InvalidPart(String), +} + +impl fmt::Display for FqdnParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use FqdnParseError::*; + match self { + MissingHostname => write!(f, "missing hostname part"), + NumericHostname => write!(f, "hostname cannot be purely numeric"), + InvalidPart(part) => write!( + f, + "FQDN must only consist of alphanumeric characters and dashes. Invalid part: '{part}'", + ), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Fqdn { + parts: Vec, +} + +impl Fqdn { + pub fn from(fqdn: &str) -> Result { + let parts = fqdn + .split('.') + .map(ToOwned::to_owned) + .collect::>(); + + for part in &parts { + if !Self::validate_single(part) { + return Err(FqdnParseError::InvalidPart(part.clone())); + } + } + + if parts.len() < 2 { + Err(FqdnParseError::MissingHostname) + } else if parts[0].chars().all(|c| c.is_ascii_digit()) { + // Not allowed/supported on Debian systems. + Err(FqdnParseError::NumericHostname) + } else { + Ok(Self { parts }) + } + } + + pub fn host(&self) -> Option<&str> { + self.has_host().then_some(&self.parts[0]) + } + + pub fn domain(&self) -> String { + let parts = if self.has_host() { + &self.parts[1..] + } else { + &self.parts + }; + + parts.join(".") + } + + /// Checks whether the FQDN has a hostname associated with it, i.e. is has more than 1 part. + fn has_host(&self) -> bool { + self.parts.len() > 1 + } + + fn validate_single(s: &String) -> bool { + !s.is_empty() + // First character must be alphanumeric + && s.chars() + .next() + .map(|c| c.is_ascii_alphanumeric()) + .unwrap_or_default() + // .. last character as well, + && s.chars() + .last() + .map(|c| c.is_ascii_alphanumeric()) + .unwrap_or_default() + // and anything between must be alphanumeric or - + && s.chars() + .skip(1) + .take(s.len().saturating_sub(2)) + .all(|c| c.is_ascii_alphanumeric() || c == '-') + } +} + +impl FromStr for Fqdn { + type Err = FqdnParseError; + + fn from_str(value: &str) -> Result { + Self::from(value) + } +} + +impl fmt::Display for Fqdn { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.parts.join(".")) + } +} + +impl<'de> Deserialize<'de> for Fqdn { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s: String = Deserialize::deserialize(deserializer)?; + s.parse() + .map_err(|_| serde::de::Error::custom("invalid FQDN")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fqdn_construct() { + use FqdnParseError::*; + assert!(Fqdn::from("foo.example.com").is_ok()); + assert!(Fqdn::from("foo-bar.com").is_ok()); + assert!(Fqdn::from("a-b.com").is_ok()); + + assert_eq!(Fqdn::from("foo"), Err(MissingHostname)); + + assert_eq!(Fqdn::from("-foo.com"), Err(InvalidPart("-foo".to_owned()))); + assert_eq!(Fqdn::from("foo-.com"), Err(InvalidPart("foo-".to_owned()))); + assert_eq!(Fqdn::from("foo.com-"), Err(InvalidPart("com-".to_owned()))); + assert_eq!(Fqdn::from("-o-.com"), Err(InvalidPart("-o-".to_owned()))); + + assert_eq!(Fqdn::from("123.com"), Err(NumericHostname)); + assert!(Fqdn::from("foo123.com").is_ok()); + assert!(Fqdn::from("123foo.com").is_ok()); + } + + #[test] + fn fqdn_parts() { + let fqdn = Fqdn::from("pve.example.com").unwrap(); + assert_eq!(fqdn.host().unwrap(), "pve"); + assert_eq!(fqdn.domain(), "example.com"); + assert_eq!( + fqdn.parts, + &["pve".to_owned(), "example".to_owned(), "com".to_owned()] + ); + } + + #[test] + fn fqdn_display() { + assert_eq!( + Fqdn::from("foo.example.com").unwrap().to_string(), + "foo.example.com" + ); + } +} -- 2.39.2