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 9566C1FF16F for ; Tue, 14 Oct 2025 15:23:28 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 308D24201; Tue, 14 Oct 2025 15:22:56 +0200 (CEST) From: Christoph Heiss To: pve-devel@lists.proxmox.com Date: Tue, 14 Oct 2025 15:21:52 +0200 Message-ID: <20251014132207.1171073-8-c.heiss@proxmox.com> X-Mailer: git-send-email 2.51.0 In-Reply-To: <20251014132207.1171073-1-c.heiss@proxmox.com> References: <20251014132207.1171073-1-c.heiss@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1760448096000 X-SPAM-LEVEL: Spam detection results: 0 AWL -2.462 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 KAM_SOMETLD_ARE_BAD_TLD 5 .bar, .beauty, .buzz, .cam, .casa, .cfd, .club, .date, .guru, .link, .live, .monster, .online, .press, .pw, .quest, .rest, .sbs, .shop, .stream, .top, .trade, .wiki, .work, .xyz TLD abuse 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 07/14] auto: add support for pinning network interface names 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" Introduce a new `[network.interface-name-pinning]` section in the answer file, which is just a (TOML) table mapping MAC addresses to interface names. Signed-off-by: Christoph Heiss --- proxmox-auto-installer/src/answer.rs | 63 ++++++++++++++----- proxmox-auto-installer/src/utils.rs | 40 ++++++++++-- proxmox-auto-installer/tests/parse-answer.rs | 2 + .../network_interface_pinning.json | 30 +++++++++ .../network_interface_pinning.toml | 22 +++++++ ...rface_pinning_overlong_interface_name.json | 3 + ...rface_pinning_overlong_interface_name.toml | 18 ++++++ 7 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs index 88f4c87..1e455ca 100644 --- a/proxmox-auto-installer/src/answer.rs +++ b/proxmox-auto-installer/src/answer.rs @@ -1,13 +1,17 @@ -use anyhow::{Result, format_err}; +use anyhow::{Result, bail, format_err}; use proxmox_installer_common::{ options::{ - BtrfsCompressOption, BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, - ZfsRaidLevel, + BtrfsCompressOption, BtrfsRaidLevel, FsType, NetworkInterfacePinningOptions, + ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel, }, utils::{CidrAddress, Fqdn}, }; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, io::BufRead, net::IpAddr}; +use std::{ + collections::{BTreeMap, HashMap}, + io::BufRead, + net::IpAddr, +}; // NOTE New answer file properties must use kebab-case, but should allow snake_case for backwards // compatibility. TODO Remove the snake_cased variants in a future major version (e.g. PVE 10). @@ -178,6 +182,18 @@ enum NetworkConfigMode { FromAnswer, } +/// Options controlling the behaviour of the network interface pinning (by +/// creating appropriate systemd.link files) during the installation. +#[derive(Clone, Debug, Default, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct NetworkInterfacePinningOptionsAnswer { + /// Whether interfaces should be pinned during the installation. + pub enabled: bool, + /// Maps MAC address to custom name + #[serde(default)] + pub mapping: HashMap, +} + #[derive(Clone, Deserialize, Debug)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] struct NetworkInAnswer { @@ -188,30 +204,47 @@ struct NetworkInAnswer { pub gateway: Option, #[serde(default)] pub filter: BTreeMap, + /// Controls network interface pinning behaviour during installation. + /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes. + #[serde(default)] + pub interface_name_pinning: Option, } #[derive(Clone, Deserialize, Debug)] #[serde(try_from = "NetworkInAnswer", deny_unknown_fields)] pub struct Network { pub network_settings: NetworkSettings, + /// Controls network interface pinning behaviour during installation. + pub interface_name_pinning: Option, } impl TryFrom for Network { - type Error = &'static str; + type Error = anyhow::Error; + + fn try_from(network: NetworkInAnswer) -> Result { + let interface_name_pinning = match network.interface_name_pinning { + Some(opts) if opts.enabled => { + let opts = NetworkInterfacePinningOptions { + mapping: opts.mapping, + }; + opts.verify()?; + Some(opts) + } + _ => None, + }; - fn try_from(network: NetworkInAnswer) -> Result { if network.source == NetworkConfigMode::FromAnswer { if network.cidr.is_none() { - return Err("Field 'cidr' must be set."); + bail!("Field 'cidr' must be set."); } if network.dns.is_none() { - return Err("Field 'dns' must be set."); + bail!("Field 'dns' must be set."); } if network.gateway.is_none() { - return Err("Field 'gateway' must be set."); + bail!("Field 'gateway' must be set."); } if network.filter.is_empty() { - return Err("Field 'filter' must be set."); + bail!("Field 'filter' must be set."); } Ok(Network { @@ -221,23 +254,25 @@ impl TryFrom for Network { gateway: network.gateway.unwrap(), filter: network.filter, }), + interface_name_pinning, }) } else { if network.cidr.is_some() { - return Err("Field 'cidr' not supported for 'from-dhcp' config."); + bail!("Field 'cidr' not supported for 'from-dhcp' config."); } if network.dns.is_some() { - return Err("Field 'dns' not supported for 'from-dhcp' config."); + bail!("Field 'dns' not supported for 'from-dhcp' config."); } if network.gateway.is_some() { - return Err("Field 'gateway' not supported for 'from-dhcp' config."); + bail!("Field 'gateway' not supported for 'from-dhcp' config."); } if !network.filter.is_empty() { - return Err("Field 'filter' not supported for 'from-dhcp' config."); + bail!("Field 'filter' not supported for 'from-dhcp' config."); } Ok(Network { network_settings: NetworkSettings::FromDhcp, + interface_name_pinning, }) } } diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs index eb666d1..14085a4 100644 --- a/proxmox-auto-installer/src/utils.rs +++ b/proxmox-auto-installer/src/utils.rs @@ -2,14 +2,14 @@ use anyhow::{Context, Result, bail}; use glob::Pattern; use log::info; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashSet}, process::Command, }; use crate::{ answer::{ self, Answer, DiskSelection, FirstBootHookSourceMode, FqdnConfig, FqdnExtendedConfig, - FqdnSourceMode, + FqdnSourceMode, Network, }, udevinfo::UdevInfo, }; @@ -35,7 +35,12 @@ fn get_network_settings( let mut network_options = match &answer.global.fqdn { // If the user set a static FQDN in the answer file, override it FqdnConfig::Simple(name) => { - let mut opts = NetworkOptions::defaults_from(setup_info, &runtime_info.network, None); + let mut opts = NetworkOptions::defaults_from( + setup_info, + &runtime_info.network, + None, + answer.network.interface_name_pinning.as_ref(), + ); opts.fqdn = name.to_owned(); opts } @@ -58,7 +63,12 @@ fn get_network_settings( bail!("no domain received from DHCP server and `global.fqdn.domain` is unset!"); } - NetworkOptions::defaults_from(setup_info, &runtime_info.network, domain.as_deref()) + NetworkOptions::defaults_from( + setup_info, + &runtime_info.network, + domain.as_deref(), + answer.network.interface_name_pinning.as_ref(), + ) } }; @@ -68,6 +78,12 @@ fn get_network_settings( network_options.gateway = settings.gateway; network_options.ifname = get_single_udev_index(&settings.filter, &udev_info.nics)?; } + + if let Some(opts) = &network_options.pinning_opts { + info!("Network interface name pinning is enabled"); + opts.verify()?; + } + info!("Network interface used is '{}'", &network_options.ifname); Ok(network_options) } @@ -430,6 +446,16 @@ pub fn verify_first_boot_settings(answer: &Answer) -> Result<()> { Ok(()) } +pub fn verify_network_settings(network: &Network) -> Result<()> { + info!("Verifying network settings"); + + if let Some(pin_opts) = &network.interface_name_pinning { + pin_opts.verify()?; + } + + Ok(()) +} + pub fn parse_answer( answer: &Answer, udev_info: &UdevInfo, @@ -451,6 +477,7 @@ pub fn parse_answer( verify_disks_settings(answer)?; verify_email_and_root_password_settings(answer)?; verify_first_boot_settings(answer)?; + verify_network_settings(&answer.network)?; let root_password = match ( &answer.global.root_password, @@ -485,7 +512,10 @@ pub fn parse_answer( root_ssh_keys: answer.global.root_ssh_keys.clone(), mngmt_nic: network_settings.ifname, - network_interface_pin_map: HashMap::new(), + network_interface_pin_map: network_settings + .pinning_opts + .map(|o| o.mapping) + .unwrap_or_default(), hostname: network_settings .fqdn diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs index 6754374..696fe1f 100644 --- a/proxmox-auto-installer/tests/parse-answer.rs +++ b/proxmox-auto-installer/tests/parse-answer.rs @@ -129,6 +129,7 @@ mod tests { full_fqdn_from_dhcp_with_default_domain, hashed_root_password, minimal, + network_interface_pinning, nic_matching, specific_nic, zfs, @@ -149,6 +150,7 @@ mod tests { fqdn_hostname_only, ipv4_and_subnet_mask_33, lvm_swapsize_greater_than_hdsize, + network_interface_pinning_overlong_interface_name, no_fqdn_from_dhcp, no_root_password_set, short_password, diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json new file mode 100644 index 0000000..76723c8 --- /dev/null +++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json @@ -0,0 +1,30 @@ +{ + "autoreboot": 1, + "cidr": "192.168.1.114/24", + "country": "at", + "dns": "192.168.1.254", + "domain": "testinstall", + "filesys": "ext4", + "gateway": "192.168.1.1", + "hdsize": 223.57088470458984, + "existing_storage_auto_rename": 1, + "hostname": "pveauto", + "keymap": "de", + "mailto": "mail@no.invalid", + "mngmt_nic": "mgmt", + "network_interface_pin_map": { + "1c:34:da:5c:5e:24": "nic2", + "1c:34:da:5c:5e:25": "nic3", + "24:8a:07:1e:05:bc": "lan0", + "24:8a:07:1e:05:bd": "lan1", + "5a:47:32:dd:c7:47": "nic8", + "a0:36:9f:0a:b3:82": "nic6", + "a0:36:9f:0a:b3:83": "nic7", + "b4:2e:99:ac:ad:b4": "mgmt", + "b4:2e:99:ac:ad:b5": "nic1" + }, + "root_password": { "plain": "12345678" }, + "target_hd": "/dev/sda", + "timezone": "Europe/Vienna", + "first_boot": { "enabled": 0 } +} diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml new file mode 100644 index 0000000..d9a2110 --- /dev/null +++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.toml @@ -0,0 +1,22 @@ +[global] +keyboard = "de" +country = "at" +fqdn = "pveauto.testinstall" +mailto = "mail@no.invalid" +timezone = "Europe/Vienna" +root-password = "12345678" + +[network] +source = "from-dhcp" + +[network.interface-name-pinning] +enabled = true + +[network.interface-name-pinning.mapping] +"24:8a:07:1e:05:bc" = "lan0" +"24:8a:07:1e:05:bd" = "lan1" +"b4:2e:99:ac:ad:b4" = "mgmt" + +[disk-setup] +filesystem = "ext4" +disk-list = ["sda"] diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json new file mode 100644 index 0000000..70e196c --- /dev/null +++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.json @@ -0,0 +1,3 @@ +{ + "parse-error": "error parsing answer.toml: interface name mapping 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters" +} diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml new file mode 100644 index 0000000..e82b47d --- /dev/null +++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_interface_pinning_overlong_interface_name.toml @@ -0,0 +1,18 @@ +[global] +keyboard = "de" +country = "at" +fqdn = "pveauto.fail.testinstall" +mailto = "mail@no.invalid" +timezone = "Europe/Vienna" +root-password = "12345678" + +[network] +source = "from-dhcp" +interface-name-pinning.enabled = true + +[network.interface-name-pinning.mapping] +"ab:cd:ef:12:34:56" = "waytoolonginterfacename" + +[disk-setup] +filesystem = "ext4" +disk-list = ["sda"] -- 2.51.0 _______________________________________________ pve-devel mailing list pve-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel