public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers
@ 2026-09-08 10:16 Christoph Heiss
  2026-09-08 10:16 ` [PATCH proxmox 01/15] installer-types: use `MacAddress` type where applicable Christoph Heiss
                   ` (14 more replies)
  0 siblings, 15 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Partially fixes #2164 [0] by adding the possibility to set up a bond as
management interface during the installation, for all of GUI, TUI and
auto-installer.

For the GUI and TUI, the current name pinning dialog is converted into a
full-fledged advanced network options dialog, in the same vein as with
the bootdisk options.

The auto-installer gains a new section under [network]:

  [network.bond]
  members = ["ab:cd:ef:12:34:56", "ab:cd:ef:12:34:57"]
  mode = "active-backup"
  hash-policy = "layer2+3"
  primary-interface = "ab:cd:ef:12:34:57"

The respective PDM integration will be sent separately.

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=2164

Questions
=========

1. Since we now discourage the use of the 'balance-alb' and 'balance-tlb'
   modes in combination with a Linux bridge (as of [1]), should these not
   even be available for selection here?

2. For the Rust part, it reuses the already existing bond types from the
   'proxmox-network-api' crate. Given that they are a lot universal
   rather than just API-specific, would it make sense to move them into
   'proxmox-network-types' instead?

   @Stefan might have some comment on this as well, as we discussed that
   off-list recently as well.

[1] https://lore.proxmox.com/pve-devel/20260828095721.263382-1-m.sandoval@proxmox.com/

Depends on
==========

- https://lore.proxmox.com/pve-devel/20260817125907.878237-1-c.heiss@proxmox.com/

Diffstat
========

proxmox:

Christoph Heiss (2):
  installer-types: use `MacAddress` type where applicable
  installer-types: answer: add options for configuring management bond

 Cargo.toml                               |   1 +
 proxmox-installer-types/Cargo.toml       |   1 +
 proxmox-installer-types/debian/control   |   2 +
 proxmox-installer-types/src/answer.rs    | 190 ++++++++++++++++++++++-
 proxmox-installer-types/src/post_hook.rs |   4 +-
 5 files changed, 194 insertions(+), 4 deletions(-)

pve-installer:

Christoph Heiss (13):
  install: run make tidy
  tree-wide: use `MacAddress` type instead of string for MAC addresses
  install: factor out /etc/network/interfaces setup into own subroutine
  install: do not rely on interface name map to be fully populated
  install: config: add option for setting up bond on management
    interface
  install: network: set up management bond if requested
  gui: network: factor out name pinning into advanced options dialog
  gui: network: add bond setup options to advanced options dialog
  auto: pass trough management network bond options to low-level
    installer
  tui: drop long obsolete comment about logo formatting
  tui: views: add widget for displaying a group of checkboxes
  tui: network: factor out name pinning into advanced options dialog
  tui: network: add bond setup options to advanced network dialog

 Cargo.toml                                    |   2 +
 Proxmox/Install.pm                            | 168 +++--
 Proxmox/Install/Config.pm                     |  44 ++
 Proxmox/Sys/Net.pm                            |  30 +
 debian/control                                |   1 +
 proxinstall                                   | 419 +++++++++---
 proxmox-auto-installer/Cargo.toml             |   1 +
 proxmox-auto-installer/src/utils.rs           |  38 +-
 proxmox-auto-installer/tests/parse-answer.rs  |   9 +-
 .../tests/resources/parse_answer/btrfs.json   |   5 +
 .../btrfs_raid_level_uppercase.json           |   5 +
 .../resources/parse_answer/disk_match.json    |   5 +
 .../parse_answer/disk_match_all.json          |   5 +
 .../parse_answer/disk_match_any.json          |   5 +
 .../resources/parse_answer/first_boot.json    |   5 +
 .../parse_answer/fqdn_from_dhcp.json          |   5 +
 ...n_from_dhcp_empty_dhcp_domain_setting.json |   5 +
 ...cp_no_dhcp_domain_with_default_domain.json |   5 +
 ...ll_fqdn_from_dhcp_with_default_domain.json |   5 +
 .../parse_answer/hashed_root_password.json    |   5 +
 .../tests/resources/parse_answer/minimal.json |   5 +
 .../resources/parse_answer/network_bond.json  |  25 +
 .../resources/parse_answer/network_bond.toml  |  20 +
 .../network_interface_pinning.json            |   5 +
 ...ace_pinning_mixed_case_mac_addresses.json} |   5 +
 ...ace_pinning_mixed_case_mac_addresses.toml} |   4 +-
 .../resources/parse_answer/nic_matching.json  |   5 +
 .../resources/parse_answer/no_network.json    |   5 +
 .../resources/parse_answer/specific_nic.json  |   5 +
 .../tests/resources/parse_answer/zfs.json     |   5 +
 .../zfs_raid_level_uppercase.json             |   5 +
 .../network_bond_invalid_hash_policy.json     |   3 +
 .../network_bond_invalid_hash_policy.toml     |  20 +
 .../network_bond_invalid_mode.json            |   3 +
 .../network_bond_invalid_mode.toml            |  18 +
 .../network_bond_primary_not_member.json      |   3 +
 .../network_bond_primary_not_member.toml      |  19 +
 .../network_bond_single_interface.json        |   3 +
 .../network_bond_single_interface.toml        |  18 +
 ...rface_pinning_overlong_interface_name.json |   2 +-
 proxmox-installer-common/Cargo.toml           |   1 +
 proxmox-installer-common/src/options.rs       | 104 +--
 proxmox-installer-common/src/setup.rs         |  38 +-
 proxmox-post-hook/src/main.rs                 |   2 +-
 proxmox-tui-installer/Cargo.toml              |   1 +
 proxmox-tui-installer/src/main.rs             |   8 +-
 proxmox-tui-installer/src/options.rs          |  45 +-
 proxmox-tui-installer/src/setup.rs            |   5 +
 proxmox-tui-installer/src/views/mod.rs        | 106 ++-
 proxmox-tui-installer/src/views/network.rs    | 623 +++++++++++++-----
 50 files changed, 1526 insertions(+), 352 deletions(-)
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_bond.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_bond.toml
 rename proxmox-auto-installer/tests/resources/parse_answer/{network_interface_pinning_uppercase_mac_address.json => network_interface_pinning_mixed_case_mac_addresses.json} (88%)
 rename proxmox-auto-installer/tests/resources/parse_answer/{network_interface_pinning_uppercase_mac_address.toml => network_interface_pinning_mixed_case_mac_addresses.toml} (85%)
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.toml





^ permalink raw reply	[flat|nested] 16+ messages in thread

* [PATCH proxmox 01/15] installer-types: use `MacAddress` type where applicable
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH proxmox 02/15] installer-types: answer: add options for configuring management bond Christoph Heiss
                   ` (13 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

.. instead of plain strings, making the definitions more strongly typed.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-installer-types/src/answer.rs    | 2 +-
 proxmox-installer-types/src/post_hook.rs | 4 ++--
 2 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs
index e2e9b8e4..e608b4cb 100644
--- a/proxmox-installer-types/src/answer.rs
+++ b/proxmox-installer-types/src/answer.rs
@@ -435,7 +435,7 @@ pub struct NetworkInterfacePinningOptionsAnswer {
     pub enabled: bool,
     /// Maps MAC address to custom name
     #[serde(default, skip_serializing_if = "HashMap::is_empty")]
-    pub mapping: HashMap<String, String>,
+    pub mapping: HashMap<MacAddress, String>,
 }
 
 #[cfg_attr(feature = "api-types", api(
diff --git a/proxmox-installer-types/src/post_hook.rs b/proxmox-installer-types/src/post_hook.rs
index 77cbef48..2cbc0f52 100644
--- a/proxmox-installer-types/src/post_hook.rs
+++ b/proxmox-installer-types/src/post_hook.rs
@@ -2,7 +2,7 @@
 
 use serde::{Deserialize, Serialize};
 
-use proxmox_network_types::ip_address::Cidr;
+use proxmox_network_types::{MacAddress, ip_address::Cidr};
 #[cfg(feature = "api-types")]
 use proxmox_schema::api;
 
@@ -90,7 +90,7 @@ pub struct NetworkInterfaceInfo {
     /// Name of the interface
     pub name: String,
     /// MAC address of the interface
-    pub mac: String,
+    pub mac: MacAddress,
     /// (Designated) IP address of the interface
     #[serde(skip_serializing_if = "Option::is_none")]
     pub address: Option<Cidr>,
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH proxmox 02/15] installer-types: answer: add options for configuring management bond
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
  2026-09-08 10:16 ` [PATCH proxmox 01/15] installer-types: use `MacAddress` type where applicable Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 03/15] install: run make tidy Christoph Heiss
                   ` (12 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Adds a new subsection `bond` in [network]. This allows for configuring a
bond for the management network interface during the installation.

E.g.

  [network.bond]
  members = ["ab:cd:ef:12:34:56", "ab:cd:ef:12:34:57"]
  mode = "active-backup"
  hash-policy = "layer2+3"
  primary-interface = "ab:cd:ef:12:34:57"

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Cargo.toml                             |   1 +
 proxmox-installer-types/Cargo.toml     |   1 +
 proxmox-installer-types/debian/control |   2 +
 proxmox-installer-types/src/answer.rs  | 188 ++++++++++++++++++++++++-
 4 files changed, 191 insertions(+), 1 deletion(-)

diff --git a/Cargo.toml b/Cargo.toml
index 16e91c94..13f62856 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -178,6 +178,7 @@ proxmox-lang = { version = "1.5", path = "proxmox-lang" }
 proxmox-log = { version = "1.0.0", path = "proxmox-log" }
 proxmox-login = { version = "1.0.0", path = "proxmox-login" }
 proxmox-network-types = { version = "1.0.2", path = "proxmox-network-types" }
+proxmox-network-api = { version = "1.0.5", path = "proxmox-network-api" }
 proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" }
 proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" }
 proxmox-procfs = { version = "0.1.0", path = "proxmox-procfs" }
diff --git a/proxmox-installer-types/Cargo.toml b/proxmox-installer-types/Cargo.toml
index 45f436e9..b208b0b7 100644
--- a/proxmox-installer-types/Cargo.toml
+++ b/proxmox-installer-types/Cargo.toml
@@ -17,6 +17,7 @@ serde = { workspace = true, features = ["derive"] }
 serde_plain.workspace = true
 regex = { workspace = true }
 proxmox-network-types.workspace = true
+proxmox-network-api.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro"] }
 proxmox-section-config = { workspace = true, optional = true }
 proxmox-node-status.workspace = true
diff --git a/proxmox-installer-types/debian/control b/proxmox-installer-types/debian/control
index e6f2ad6a..9dfe0017 100644
--- a/proxmox-installer-types/debian/control
+++ b/proxmox-installer-types/debian/control
@@ -7,6 +7,7 @@ Build-Depends-Arch: cargo:native <!nocheck>,
  rustc:native (>= 1.85) <!nocheck>,
  libstd-rust-dev <!nocheck>,
  librust-anyhow-1+default-dev <!nocheck>,
+ librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~) <!nocheck>,
  librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~) <!nocheck>,
  librust-proxmox-node-status-1+default-dev <!nocheck>,
  librust-proxmox-schema-5+api-macro-dev (>= 5.3.0-~~) <!nocheck>,
@@ -28,6 +29,7 @@ Multi-Arch: same
 Depends:
  ${misc:Depends},
  librust-anyhow-1+default-dev,
+ librust-proxmox-network-api-1+default-dev (>= 1.0.5-~~),
  librust-proxmox-network-types-1+default-dev (>= 1.0.2-~~),
  librust-proxmox-node-status-1+default-dev,
  librust-proxmox-schema-5+api-macro-dev (>= 5.3.0-~~),
diff --git a/proxmox-installer-types/src/answer.rs b/proxmox-installer-types/src/answer.rs
index e608b4cb..93dc32a4 100644
--- a/proxmox-installer-types/src/answer.rs
+++ b/proxmox-installer-types/src/answer.rs
@@ -14,7 +14,8 @@ use std::{
     str::FromStr,
 };
 
-use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
+use proxmox_network_api::{BondXmitHashPolicy, LinuxBondMode};
+use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
 
 #[cfg(feature = "api-types")]
 use proxmox_schema::{
@@ -438,6 +439,106 @@ pub struct NetworkInterfacePinningOptionsAnswer {
     pub mapping: HashMap<MacAddress, String>,
 }
 
+#[cfg_attr(feature = "api-types", api(
+    properties: {
+        members: {
+            description: "List of interface MAC addresses to add as bond members.",
+            type: Array,
+            items: {
+                type: MacAddress,
+            },
+        },
+    },
+))]
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+/// Options for creating a bond for the management interface with the given
+/// physical interfaces and mode.
+pub struct NetworkBondOptions {
+    /// List of interface MAC addresses to add as bond members.
+    pub members: Vec<MacAddress>,
+    /// Mode for the bond to set.
+    pub mode: LinuxBondMode,
+    /// Bond hash policy. Only used with '802.3ad' and 'balance-xor' mode.
+    #[serde(default = "NetworkBondOptions::default_hash_policy")]
+    pub hash_policy: BondXmitHashPolicy,
+    /// Primary bond interface. Only used with 'active-backup' mode.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub primary_interface: Option<MacAddress>,
+}
+
+impl NetworkBondOptions {
+    /// List of all available bond modes.
+    pub const MODES: &[LinuxBondMode] = {
+        use LinuxBondMode::*;
+        &[
+            ActiveBackup,
+            BalanceAlb,
+            BalanceRr,
+            BalanceTlb,
+            BalanceXor,
+            Broadcast,
+            Ieee802_3ad,
+        ]
+    };
+
+    /// List of all available hash policies.
+    pub const HASH_POLICIES: &[BondXmitHashPolicy] = {
+        use BondXmitHashPolicy::*;
+        &[Layer2, Layer2_3, Layer3_4]
+    };
+
+    /// Returns the default hash policy for new bonds.
+    const fn default_hash_policy() -> BondXmitHashPolicy {
+        BondXmitHashPolicy::Layer2
+    }
+
+    /// Returns a bond configuration instance that disables any bonding setup in the low-level
+    /// installer.
+    pub const fn disabled() -> Self {
+        Self {
+            // an empty interface list is interpreted as disabled by the low-level installer
+            members: Vec::new(),
+            mode: LinuxBondMode::ActiveBackup,
+            hash_policy: BondXmitHashPolicy::Layer2,
+            primary_interface: None,
+        }
+    }
+
+    /// Does some basic checks on the options.
+    ///
+    /// This includes checks for:
+    /// - There are at least to members
+    /// - Whether the primary interface is applicable to the mode
+    /// - Whether the primary interface (if given) is a member
+    pub fn verify(&self) -> Result<()> {
+        if self.members.len() < 2 {
+            bail!(
+                "bond setup requires at least 2 interfaces, got {}",
+                self.members.len()
+            );
+        }
+
+        if let Some(primary_if) = &self.primary_interface {
+            if self.mode != LinuxBondMode::ActiveBackup {
+                bail!("primary bond interface can only be set in active-backup mode");
+            }
+
+            if !self.members.contains(primary_if) {
+                bail!(
+                    "primary bond interface {primary_if} must be a member of the bond (members: {})",
+                    self.members
+                        .iter()
+                        .fold(String::new(), |acc, mac| format!("{acc}, {mac}"))
+                        .trim_start_matches(", ")
+                );
+            }
+        }
+
+        Ok(())
+    }
+}
+
 #[cfg_attr(feature = "api-types", api(
     properties: {
         filter: {
@@ -464,6 +565,9 @@ pub struct NetworkConfigFromAnswer {
     /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub interface_name_pinning: Option<NetworkInterfacePinningOptionsAnswer>,
+    /// If set, sets up a Linux bond for the management interface.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub bond: Option<NetworkBondOptions>,
 }
 
 #[cfg_attr(feature = "api-types", api)]
@@ -475,6 +579,9 @@ pub struct NetworkConfigFromDhcp {
     /// Off by default. Allowed for both `from-dhcp` and `from-answer` modes.
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub interface_name_pinning: Option<NetworkInterfacePinningOptionsAnswer>,
+    /// If set, sets up a Linux bond for the management interface.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub bond: Option<NetworkBondOptions>,
 }
 
 #[cfg_attr(feature = "api-types", api(
@@ -503,6 +610,14 @@ impl NetworkConfig {
             Self::FromAnswer(answer) => answer.interface_name_pinning.as_ref(),
         }
     }
+
+    /// Returns the management network Linux bond options, if any.
+    pub const fn bond(&self) -> Option<&NetworkBondOptions> {
+        match self {
+            Self::FromDhcp(dhcp) => dhcp.bond.as_ref(),
+            Self::FromAnswer(answer) => answer.bond.as_ref(),
+        }
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -1475,4 +1590,75 @@ mod tests {
         };
         assert!(setup.filesystem_details().is_ok());
     }
+
+    #[test]
+    fn network_bond_requires_at_least_two_members() {
+        let mut bond = NetworkBondOptions::disabled();
+        let res = bond.verify();
+        assert!(res.is_err());
+        assert_eq!(
+            res.unwrap_err().to_string(),
+            "bond setup requires at least 2 interfaces, got 0"
+        );
+
+        bond.members = vec!["12:34:56:ab:cd:df".parse().unwrap()];
+
+        let res = bond.verify();
+        assert!(res.is_err());
+        assert_eq!(
+            res.unwrap_err().to_string(),
+            "bond setup requires at least 2 interfaces, got 1"
+        );
+
+        bond.members = vec![
+            "12:34:56:ab:cd:df".parse().unwrap(),
+            "ab:cd:df:12:34:56".parse().unwrap(),
+        ];
+        assert!(bond.verify().is_ok());
+    }
+
+    #[test]
+    fn network_bond_primary_if_only_with_active_backup() {
+        let mut bond = NetworkBondOptions::disabled();
+        bond.members = vec![
+            "12:34:56:ab:cd:df".parse().unwrap(),
+            "ab:cd:df:12:34:56".parse().unwrap(),
+        ];
+        bond.primary_interface = Some("12:34:56:ab:cd:df".parse().unwrap());
+
+        for m in NetworkBondOptions::MODES {
+            bond.mode = *m;
+            match m {
+                LinuxBondMode::ActiveBackup => assert!(bond.verify().is_ok()),
+                _ => {
+                    let res = bond.verify();
+                    assert!(res.is_err());
+                    assert_eq!(
+                        res.unwrap_err().to_string(),
+                        "primary bond interface can only be set in active-backup mode"
+                    );
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn network_bond_primary_if_must_be_member() {
+        let mut bond = NetworkBondOptions::disabled();
+        bond.members = vec![
+            "12:34:56:ab:cd:df".parse().unwrap(),
+            "ab:cd:df:12:34:56".parse().unwrap(),
+        ];
+        bond.primary_interface = Some("12:34:56:ab:cd:df".parse().unwrap());
+        assert!(bond.verify().is_ok());
+
+        bond.primary_interface = Some("ab:12:cd:34:ef:56".parse().unwrap());
+
+        let res = bond.verify();
+        assert!(res.is_err());
+        assert_eq!(
+            res.unwrap_err().to_string(),
+            "primary bond interface AB:12:CD:34:EF:56 must be a member of the bond (members: 12:34:56:AB:CD:DF, AB:CD:DF:12:34:56)"
+        );
+    }
 }
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 03/15] install: run make tidy
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
  2026-09-08 10:16 ` [PATCH proxmox 01/15] installer-types: use `MacAddress` type where applicable Christoph Heiss
  2026-09-08 10:16 ` [PATCH proxmox 02/15] installer-types: answer: add options for configuring management bond Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 04/15] tree-wide: use `MacAddress` type instead of string for MAC addresses Christoph Heiss
                   ` (11 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Proxmox/Install.pm | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 6298cff..7bb8e29 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -673,7 +673,8 @@ sub prepare_grub_efi_boot_esp {
             # without a shim package installed (no signed shim exists for arm64 yet) grub-install
             # deploys only the plain grub image, so use that as the removable-media default loader
             $shim_src = $run_env->{arch} eq 'arm64' ? 'grubaa64.efi' : 'grubx64.efi';
-            warn "no shim binary found, using '$shim_src' as removable-media default boot loader\n";
+            warn
+                "no shim binary found, using '$shim_src' as removable-media default boot loader\n";
         }
         syscmd(
             "mv $targetdir/boot/efi/EFI/BOOT/$shim_src $targetdir/boot/efi/EFI/BOOT/$boot_dst")
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 04/15] tree-wide: use `MacAddress` type instead of string for MAC addresses
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (2 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 03/15] install: run make tidy Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 05/15] install: factor out /etc/network/interfaces setup into own subroutine Christoph Heiss
                   ` (10 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Makes all Rust code dealing with MAC addresses a lot more typesafe. The
`MacAddress` type is also cheaper to construct than `String`, since the
former is just 6 bytes and avoids heap allocations.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-auto-installer/tests/parse-answer.rs  |  2 +-
 ...ace_pinning_mixed_case_mac_addresses.json} |  0
 ...ace_pinning_mixed_case_mac_addresses.toml} |  4 +-
 ...rface_pinning_overlong_interface_name.json |  2 +-
 proxmox-installer-common/src/options.rs       | 66 +++++++++----------
 proxmox-installer-common/src/setup.rs         | 28 ++++++--
 proxmox-post-hook/src/main.rs                 |  2 +-
 proxmox-tui-installer/src/views/network.rs    | 26 +++-----
 8 files changed, 68 insertions(+), 62 deletions(-)
 rename proxmox-auto-installer/tests/resources/parse_answer/{network_interface_pinning_uppercase_mac_address.json => network_interface_pinning_mixed_case_mac_addresses.json} (100%)
 rename proxmox-auto-installer/tests/resources/parse_answer/{network_interface_pinning_uppercase_mac_address.toml => network_interface_pinning_mixed_case_mac_addresses.toml} (85%)

diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs
index 8b0bae7..ae5b87d 100644
--- a/proxmox-auto-installer/tests/parse-answer.rs
+++ b/proxmox-auto-installer/tests/parse-answer.rs
@@ -128,7 +128,7 @@ mod tests {
             hashed_root_password,
             minimal,
             network_interface_pinning,
-            network_interface_pinning_uppercase_mac_address,
+            network_interface_pinning_mixed_case_mac_addresses,
             nic_matching,
             no_network,
             specific_nic,
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_uppercase_mac_address.json b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json
similarity index 100%
rename from proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_uppercase_mac_address.json
rename to proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_uppercase_mac_address.toml b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.toml
similarity index 85%
rename from proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_uppercase_mac_address.toml
rename to proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.toml
index 6681fe9..deb1fd3 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_uppercase_mac_address.toml
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.toml
@@ -14,8 +14,8 @@ 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"
+"24:8a:07:1e:05:bd" = "lan1"
+"B4:2e:99:ac:AD:b4" = "mgmt"
 
 [disk-setup]
 filesystem = "ext4"
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
index f3c9169..952b34a 100644
--- 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
@@ -1,3 +1,3 @@
 {
-  "error": "interface name 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters"
+  "error": "interface name 'waytoolonginterfacename' for 'AB:CD:EF:12:34:56' cannot be longer than 15 characters"
 }
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index c7f0baf..fbbfe77 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -19,7 +19,7 @@ use proxmox_installer_types::{
         ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
     },
 };
-use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
+use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
 
 pub trait RaidLevel {
     /// Returns the minimum number of disks needed for this RAID level.
@@ -315,7 +315,7 @@ impl TimezoneOptions {
 pub struct NetworkInterfacePinningOptions {
     /// Maps MAC address to custom name
     #[serde(default)]
-    pub mapping: HashMap<String, String>,
+    pub mapping: HashMap<MacAddress, String>,
 }
 
 impl NetworkInterfacePinningOptions {
@@ -341,7 +341,7 @@ impl NetworkInterfacePinningOptions {
                 .unwrap()
         });
 
-        let mut reverse_mapping = HashMap::<String, String>::new();
+        let mut reverse_mapping = HashMap::<String, MacAddress>::new();
         for (mac, name) in self.mapping.iter() {
             if name.len() < MIN_IFNAME_LEN {
                 bail!(
@@ -368,7 +368,7 @@ impl NetworkInterfacePinningOptions {
                 bail!("duplicate interface name mapping '{name}' for: {mac}, {duplicate_mac}");
             }
 
-            reverse_mapping.insert(name.clone(), mac.clone());
+            reverse_mapping.insert(name.clone(), *mac);
         }
 
         Ok(())
@@ -379,13 +379,7 @@ impl From<&NetworkInterfacePinningOptionsAnswer> for NetworkInterfacePinningOpti
     fn from(answer: &NetworkInterfacePinningOptionsAnswer) -> Self {
         if answer.enabled {
             Self {
-                // convert all MAC addresses to lowercase before further usage,
-                // to enable easy comparison
-                mapping: answer
-                    .mapping
-                    .iter()
-                    .map(|(k, v)| (k.to_lowercase(), v.clone()))
-                    .collect(),
+                mapping: answer.mapping.clone(),
             }
         } else {
             Self::default()
@@ -480,7 +474,7 @@ impl NetworkOptions {
             // required by the low-level installer
             for iface in network.interfaces.values() {
                 if let Some(pinned) = iface.to_pinned(opts) {
-                    opts.mapping.entry(iface.mac.clone()).or_insert(pinned.name);
+                    opts.mapping.entry(iface.mac).or_insert(pinned.name);
                 }
             }
         }
@@ -609,7 +603,7 @@ mod tests {
                 pinned_id: Some("0".to_owned()),
                 state: InterfaceState::Up,
                 driver: "dummy".to_owned(),
-                mac: "01:23:45:67:89:ab".to_owned(),
+                mac: "01:23:45:67:89:ab".parse().unwrap(),
                 addresses: vec![Cidr::new(Ipv4Addr::new(192, 168, 0, 2), 24).unwrap()],
             },
         );
@@ -699,7 +693,7 @@ mod tests {
                 pinned_id: Some("0".to_owned()),
                 state: InterfaceState::Up,
                 driver: "dummy".to_owned(),
-                mac: "01:23:45:67:89:ab".to_owned(),
+                mac: "01:23:45:67:89:ab".parse().unwrap(),
                 addresses: vec![
                     Cidr::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 2), 64).unwrap(),
                 ],
@@ -798,7 +792,7 @@ mod tests {
                 pinned_id: Some("0".to_owned()),
                 state: InterfaceState::Up,
                 driver: "dummy".to_owned(),
-                mac: "01:23:45:67:89:ab".to_owned(),
+                mac: "01:23:45:67:89:ab".parse().unwrap(),
                 addresses: vec![],
             },
         );
@@ -836,13 +830,13 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), String::new());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), String::new());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name for 'ab:cd:ef:12:34:56' must be at least 2 characters long"
+            "interface name for 'AB:CD:EF:12:34:56' must be at least 2 characters long"
         )
     }
 
@@ -851,13 +845,13 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "a".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "a".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name for 'ab:cd:ef:12:34:56' must be at least 2 characters long"
+            "interface name for 'AB:CD:EF:12:34:56' must be at least 2 characters long"
         )
     }
 
@@ -865,7 +859,7 @@ mod tests {
     fn network_interface_pinning_options_fail_on_overlong_name() {
         let mut options = NetworkInterfacePinningOptions::default();
         options.mapping.insert(
-            "ab:cd:ef:12:34:56".to_owned(),
+            "ab:cd:ef:12:34:56".parse().unwrap(),
             "waytoolonginterfacename".to_owned(),
         );
 
@@ -873,7 +867,7 @@ mod tests {
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name 'waytoolonginterfacename' for 'ab:cd:ef:12:34:56' cannot be longer than 15 characters"
+            "interface name 'waytoolonginterfacename' for 'AB:CD:EF:12:34:56' cannot be longer than 15 characters"
         )
     }
 
@@ -882,10 +876,10 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "nic0".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "nic0".to_owned());
         options
             .mapping
-            .insert("12:34:56:ab:cd:ef".to_owned(), "nic0".to_owned());
+            .insert("12:34:56:ab:cd:ef".parse().unwrap(), "nic0".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
@@ -894,8 +888,8 @@ mod tests {
         // [HashMap] does not guarantee iteration order, so just check for the substrings
         // we expect to find
         assert!(err.contains("duplicate interface name mapping 'nic0' for: "));
-        assert!(err.contains("12:34:56:ab:cd:ef"));
-        assert!(err.contains("ab:cd:ef:12:34:56"));
+        assert!(err.contains("12:34:56:AB:CD:EF"));
+        assert!(err.contains("AB:CD:EF:12:34:56"));
     }
 
     #[test]
@@ -903,13 +897,13 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "nic-".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "nic-".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name 'nic-' for 'ab:cd:ef:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
+            "interface name 'nic-' for 'AB:CD:EF:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
         )
     }
 
@@ -918,24 +912,24 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "0nic".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "0nic".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name '0nic' for 'ab:cd:ef:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
+            "interface name '0nic' for 'AB:CD:EF:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
         );
 
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "_a".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "_a".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name '_a' for 'ab:cd:ef:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
+            "interface name '_a' for 'AB:CD:EF:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
         );
     }
 
@@ -944,21 +938,21 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "Nic0".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "Nic0".to_owned());
 
         let res = options.verify();
         assert!(res.is_ok());
 
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "nIc0".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "nIc0".to_owned());
 
         let res = options.verify();
         assert!(res.is_ok());
 
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "nic0".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "nic0".to_owned());
 
         let res = options.verify();
         assert!(res.is_ok());
@@ -969,13 +963,13 @@ mod tests {
         let mut options = NetworkInterfacePinningOptions::default();
         options
             .mapping
-            .insert("ab:cd:ef:12:34:56".to_owned(), "12345".to_owned());
+            .insert("ab:cd:ef:12:34:56".parse().unwrap(), "12345".to_owned());
 
         let res = options.verify();
         assert!(res.is_err());
         assert_eq!(
             res.unwrap_err().to_string(),
-            "interface name '12345' for 'ab:cd:ef:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
+            "interface name '12345' for 'AB:CD:EF:12:34:56' is invalid: name must start with a letter and contain only ascii characters, digits and underscores"
         )
     }
 }
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index b8af4e3..14882bc 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -1,4 +1,4 @@
-use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
+use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeMap};
 use std::{
     cmp,
     collections::{BTreeMap, HashMap},
@@ -18,7 +18,7 @@ use proxmox_installer_types::{
     BootType, IsoInfo, ProductConfig,
     answer::{BtrfsCompressOption, FilesystemType, ZfsChecksumOption, ZfsCompressOption},
 };
-use proxmox_network_types::Cidr;
+use proxmox_network_types::{Cidr, MacAddress};
 
 /// Paths in the ISO environment containing installer data.
 #[derive(Clone, Deserialize)]
@@ -395,7 +395,7 @@ pub struct Interface {
     /// interface name cannot/should not be pinned due to being e.g. a non-physical link.
     pub pinned_id: Option<String>,
 
-    pub mac: String,
+    pub mac: MacAddress,
 
     pub state: InterfaceState,
 
@@ -535,9 +535,10 @@ pub struct InstallConfig {
     #[serde(
         default,
         skip_serializing_if = "HashMap::is_empty",
-        deserialize_with = "deserialize_optional_map"
+        deserialize_with = "deserialize_optional_map",
+        serialize_with = "serialize_low_level_mac_address_map"
     )]
-    pub network_interface_pin_map: HashMap<String, String>,
+    pub network_interface_pin_map: HashMap<MacAddress, String>,
 
     pub hostname: String,
     pub domain: String,
@@ -583,3 +584,20 @@ where
     let map: Option<HashMap<K, V>> = Deserialize::deserialize(deserializer)?;
     Ok(map.unwrap_or_default())
 }
+
+/// Serializes a [HashMap] using [MacAddress] as keys, normalizing MAC addresses into their
+/// lower-case format as the low-level installer expects it.
+fn serialize_low_level_mac_address_map<S, V>(
+    map: &HashMap<MacAddress, V>,
+    serializer: S,
+) -> Result<S::Ok, S::Error>
+where
+    S: Serializer,
+    V: Serialize,
+{
+    let mut serializer = serializer.serialize_map(Some(map.len()))?;
+    for (mac, v) in map {
+        serializer.serialize_entry(&mac.to_string().to_lowercase(), &v)?;
+    }
+    serializer.end()
+}
diff --git a/proxmox-post-hook/src/main.rs b/proxmox-post-hook/src/main.rs
index 749fd0e..e50a773 100644
--- a/proxmox-post-hook/src/main.rs
+++ b/proxmox-post-hook/src/main.rs
@@ -286,7 +286,7 @@ mod detail {
 
                 anyhow::Ok(NetworkInterfaceInfo {
                     name: ifname.clone(),
-                    mac: nic.mac.clone(),
+                    mac: nic.mac,
                     // Use the actual IP address from the low-level install config, as the runtime info
                     // contains the original IP address from DHCP.
                     address: is_management.then_some(config.cidr),
diff --git a/proxmox-tui-installer/src/views/network.rs b/proxmox-tui-installer/src/views/network.rs
index 12cef19..6401174 100644
--- a/proxmox-tui-installer/src/views/network.rs
+++ b/proxmox-tui-installer/src/views/network.rs
@@ -17,12 +17,12 @@ use proxmox_installer_common::{
     options::{NetworkInterfacePinningOptions, NetworkOptions},
     setup::{Interface, NetworkInfo},
 };
-use proxmox_network_types::{fqdn::Fqdn, ip_address::Cidr};
+use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
 
 use super::{CidrAddressEditView, FormView};
 
 struct NetworkViewOptions {
-    selected_mac: String,
+    selected_mac: MacAddress,
     pinning_enabled: bool,
     // For UI purposes, we want to always save the mapping, to save the state
     // between toggling the checkbox
@@ -55,14 +55,8 @@ impl NetworkOptionsView {
         let selected_mac = network_info
             .interfaces
             .get(&options.ifname)
-            .map(|iface| iface.mac.clone())
-            .unwrap_or_else(|| {
-                ifaces
-                    .first()
-                    .expect("at least one network interface")
-                    .mac
-                    .clone()
-            });
+            .map(|iface| iface.mac)
+            .unwrap_or_else(|| ifaces.first().expect("at least one network interface").mac);
 
         let options_ref = Arc::new(Mutex::new(NetworkViewOptions {
             selected_mac,
@@ -309,7 +303,7 @@ impl NetworkOptionsView {
             .on_submit({
                 let options_ref = options_ref.clone();
                 move |_, iface| {
-                    options_ref.lock().expect("unpoisoned lock").selected_mac = iface.mac.clone();
+                    options_ref.lock().expect("unpoisoned lock").selected_mac = iface.mac;
                 }
             });
 
@@ -330,7 +324,7 @@ impl ViewWrapper for NetworkOptionsView {
 }
 
 struct InterfacePinningOptionsView {
-    view: ScrollView<NamedView<FormView<String>>>,
+    view: ScrollView<NamedView<FormView<MacAddress>>>,
 }
 
 impl InterfacePinningOptionsView {
@@ -344,7 +338,7 @@ impl InterfacePinningOptionsView {
         // The low-level installer will skip them anyway.
         let interfaces = interfaces.iter().filter(|iface| iface.pinned_id.is_some());
 
-        let mut form = FormView::<String>::new();
+        let mut form = FormView::<MacAddress>::new();
 
         for iface in interfaces {
             let label = format!(
@@ -366,7 +360,7 @@ impl InterfacePinningOptionsView {
                         .fixed_width(MAX_IFNAME_LEN),
                 );
 
-            form.add_child_with_data(&label, view, iface.mac.clone());
+            form.add_child_with_data(&label, view, iface.mac);
 
             if !iface.addresses.is_empty() {
                 for chunk in iface.addresses.chunks(2) {
@@ -403,7 +397,7 @@ impl InterfacePinningOptionsView {
                 .map(|v| v.get_inner().get_content())
                 .ok_or_else(|| format!("failed to retrieve pinning ID for interface {}", mac))?;
 
-            mapping.insert(mac.clone(), (*name).clone());
+            mapping.insert(*mac, (*name).clone());
         }
 
         let opts = NetworkInterfacePinningOptions { mapping };
@@ -414,5 +408,5 @@ impl InterfacePinningOptionsView {
 }
 
 impl ViewWrapper for InterfacePinningOptionsView {
-    cursive::wrap_impl!(self.view: ScrollView<NamedView<FormView<String>>>);
+    cursive::wrap_impl!(self.view: ScrollView<NamedView<FormView<MacAddress>>>);
 }
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 05/15] install: factor out /etc/network/interfaces setup into own subroutine
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (3 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 04/15] tree-wide: use `MacAddress` type instead of string for MAC addresses Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 06/15] install: do not rely on interface name map to be fully populated Christoph Heiss
                   ` (9 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Makes it more readable and a bit easier to add more logic in the future.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Proxmox/Install.pm | 124 +++++++++++++++++++++++++--------------------
 1 file changed, 68 insertions(+), 56 deletions(-)

diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 7bb8e29..45aa53b 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -798,6 +798,71 @@ my sub setup_proxmox_first_boot_service {
     }
 }
 
+my sub setup_ifupdown2_config {
+    my ($targetdir) = @_;
+
+    my $run_env = Proxmox::Install::RunEnv::get();
+    my $iso_env = Proxmox::Install::ISOEnv::get();
+
+    my $ip_version = Proxmox::Install::Config::get_ip_version();
+    my $ntype = $ip_version == 4 ? 'inet' : 'inet6';
+
+    my $interfaces = $run_env->{network}->{interfaces};
+    my $ethdev = Proxmox::Install::Config::get_mngmt_nic();
+    my $cidr = Proxmox::Install::Config::get_cidr();
+    my $gateway = Proxmox::Install::Config::get_gateway();
+
+    my $netif_pin_map = Proxmox::Install::Config::get_network_interface_pin_map();
+
+    my $ifaces = "auto lo\niface lo inet loopback\n\n";
+
+    # iproute2 omits the gateway and protocol keys for some routes; route devices are original
+    # kernel interface names, while $ethdev can be the pinned one
+    my $gateways6 = $run_env->{network}->{routes}->{gateways6} // [];
+    my $is_gateway6_from_ra = grep {
+        my $dev = $_->{dev} // '';
+        if (defined($netif_pin_map) && defined($interfaces->{$dev})) {
+            $dev = $netif_pin_map->{ $interfaces->{$dev}->{mac} } // $dev;
+        }
+        $dev eq $ethdev
+            && ($_->{gateway} // '') eq $gateway
+            && ($_->{protocol} // '') eq 'ra';
+    } $gateways6->@*;
+
+    if ($iso_env->{cfg}->{bridged_network}) {
+        $ifaces .= "iface $ethdev $ntype manual\n";
+
+        $ifaces .=
+            "\nauto vmbr0\niface vmbr0 $ntype static\n"
+            . "\taddress $cidr\n"
+            . "\tgateway $gateway\n"
+            . "\tbridge-ports $ethdev\n"
+            . "\tbridge-stp off\n"
+            . "\tbridge-fd 0\n";
+    } else {
+        $ifaces .= "auto $ethdev\n" . #
+            "iface $ethdev $ntype static\n" . #
+            "\taddress $cidr\n";
+
+        if (!$is_gateway6_from_ra) {
+            $ifaces .= "\tgateway $gateway\n";
+        }
+    }
+
+    foreach my $iface (sort keys $interfaces->%*) {
+        my $if = $interfaces->{$iface};
+        my $name = defined($netif_pin_map) ? $netif_pin_map->{ $if->{mac} } : $if->{name};
+
+        next if $name eq $ethdev;
+
+        $ifaces .= "\niface $name $ntype manual\n";
+    }
+
+    $ifaces .= "\n\nsource /etc/network/interfaces.d/*\n";
+
+    file_write_all("$targetdir/etc/network/interfaces", $ifaces);
+}
+
 sub extract_data {
     my $iso_env = Proxmox::Install::ISOEnv::get();
     my $run_env = Proxmox::Install::RunEnv::get();
@@ -1140,19 +1205,14 @@ sub extract_data {
 
         # configure interfaces
 
-        my $ifaces = "auto lo\niface lo inet loopback\n\n";
-
-        my $ip_version = Proxmox::Install::Config::get_ip_version();
-        my $ntype = $ip_version == 4 ? 'inet' : 'inet6';
-
-        my $ethdev = Proxmox::Install::Config::get_mngmt_nic();
-        my $cidr = Proxmox::Install::Config::get_cidr();
-        my $gateway = Proxmox::Install::Config::get_gateway();
+        setup_ifupdown2_config($targetdir);
 
         # configure pinned names for all network interfaces, if enabled
 
         my $netif_pin_map = Proxmox::Install::Config::get_network_interface_pin_map();
         if (defined($netif_pin_map)) {
+            my $ethdev = Proxmox::Install::Config::get_mngmt_nic();
+
             mkdir "$targetdir/usr/local/lib/systemd", 0755;
             mkdir "$targetdir/usr/local/lib/systemd/network", 0755;
 
@@ -1189,54 +1249,6 @@ sub extract_data {
             }
         }
 
-        my $interfaces = $run_env->{network}->{interfaces};
-
-        # iproute2 omits the gateway and protocol keys for some routes; route devices are original
-        # kernel interface names, while $ethdev can be the pinned one
-        my $gateways6 = $run_env->{network}->{routes}->{gateways6} // [];
-        my $is_gateway6_from_ra = grep {
-            my $dev = $_->{dev} // '';
-            if (defined($netif_pin_map) && defined($interfaces->{$dev})) {
-                $dev = $netif_pin_map->{ $interfaces->{$dev}->{mac} } // $dev;
-            }
-            $dev eq $ethdev
-                && ($_->{gateway} // '') eq $gateway
-                && ($_->{protocol} // '') eq 'ra';
-        } $gateways6->@*;
-
-        if ($iso_env->{cfg}->{bridged_network}) {
-            $ifaces .= "iface $ethdev $ntype manual\n";
-
-            $ifaces .=
-                "\nauto vmbr0\niface vmbr0 $ntype static\n"
-                . "\taddress $cidr\n"
-                . "\tgateway $gateway\n"
-                . "\tbridge-ports $ethdev\n"
-                . "\tbridge-stp off\n"
-                . "\tbridge-fd 0\n";
-        } else {
-            $ifaces .= "auto $ethdev\n" . #
-                "iface $ethdev $ntype static\n" . #
-                "\taddress $cidr\n";
-
-            if (!$is_gateway6_from_ra) {
-                $ifaces .= "\tgateway $gateway\n";
-            }
-        }
-
-        foreach my $iface (sort keys $interfaces->%*) {
-            my $if = $interfaces->{$iface};
-            my $name = defined($netif_pin_map) ? $netif_pin_map->{ $if->{mac} } : $if->{name};
-
-            next if $name eq $ethdev;
-
-            $ifaces .= "\niface $name $ntype manual\n";
-        }
-
-        $ifaces .= "\n\nsource /etc/network/interfaces.d/*\n";
-
-        file_write_all("$targetdir/etc/network/interfaces", $ifaces);
-
         # configure dns
 
         my $dnsserver = Proxmox::Install::Config::get_dns();
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 06/15] install: do not rely on interface name map to be fully populated
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (4 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 05/15] install: factor out /etc/network/interfaces setup into own subroutine Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 07/15] install: config: add option for setting up bond on management interface Christoph Heiss
                   ` (8 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

While this hasn't been a problem yet AFAIK, better be safe than sorry.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Proxmox/Install.pm | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 45aa53b..04264a9 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -851,7 +851,11 @@ my sub setup_ifupdown2_config {
 
     foreach my $iface (sort keys $interfaces->%*) {
         my $if = $interfaces->{$iface};
-        my $name = defined($netif_pin_map) ? $netif_pin_map->{ $if->{mac} } : $if->{name};
+        my $name =
+            defined($netif_pin_map)
+            && defined($netif_pin_map->{ $if->{mac} })
+            ? $netif_pin_map->{ $if->{mac} }
+            : $if->{name};
 
         next if $name eq $ethdev;
 
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 07/15] install: config: add option for setting up bond on management interface
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (5 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 06/15] install: do not rely on interface name map to be fully populated Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 08/15] install: network: set up management bond if requested Christoph Heiss
                   ` (7 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Prerequisite for the rest of the installer to support setting up a bond
as management interface during installations.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Proxmox/Install/Config.pm | 44 +++++++++++++++++++++++++++++++++++++++
 Proxmox/Sys/Net.pm        | 30 ++++++++++++++++++++++++++
 2 files changed, 74 insertions(+)

diff --git a/Proxmox/Install/Config.pm b/Proxmox/Install/Config.pm
index dba3692..946e642 100644
--- a/Proxmox/Install/Config.pm
+++ b/Proxmox/Install/Config.pm
@@ -103,6 +103,21 @@ my sub init_cfg {
         # network related
         mngmt_nic => undef,
 
+        # Options for creating a bond for the management interface with the given physical
+        # interfaces and mode.
+        # Enabled if `interfaces` is not empty.
+        mngmt_bond => {
+            # MAC addresses of member interfaces. If empty, bonding is disabled.
+            members => [],
+            # must be one of: 'balance-rr', 'active-backup', 'balance-xor', 'broadcast', '802.3ad', 'balance-tlb', 'balance-alb'
+            mode => 'active-backup', # recommended by our docs
+            # hash policy, only used with '802.3ad' and 'balance-xor' mode
+            # must be one of 'layer2', 'layer2+3', 'layer3+4'
+            hash_policy => 'layer2',
+            # MAC address of primary bond interface, only used with 'active-backup' mode, optional
+            primary_interface => undef,
+        },
+
         # maps mac address -> custom name
         # if set to a hash, enables interface name pinning for all interfaces
         network_interface_pin_map => undef,
@@ -257,6 +272,35 @@ sub get_subscription_key { return get('subscription_key'); }
 sub set_mngmt_nic { set_key('mngmt_nic', $_[0]); }
 sub get_mngmt_nic { return get('mngmt_nic'); }
 
+sub set_mngmt_bond_opt {
+    my ($k, $v) = @_;
+    my $opts = get('mngmt_bond');
+
+    croak "unknown management bond option key '$k'\n" if !exists($opts->{$k});
+    croak "unknown bond mode '$v'\n"
+        if $k eq 'mode' && !Proxmox::Sys::Net::is_valid_bond_mode($v);
+    croak "unknown bond hash policy '$v'\n"
+        if $k eq 'hash_policy' && !Proxmox::Sys::Net::is_valid_bond_hash_policy($v);
+
+    # allow setting no interfaces (disabling bonding), or 2+ for enabling it
+    croak "at least 2 member interfaces are required for bond\n"
+        if $k eq 'members' && scalar($v->@*) == 1;
+    croak "primary interface '$v' is not a member of the bond\n"
+        if $k eq 'primary_interface' && !grep { $_ eq $v } $opts->{members}->@*;
+
+    $opts->{$k} = $v;
+}
+
+sub get_mngmt_bond_opt {
+    my ($k) = @_;
+    my $opts = get('mngmt_bond');
+    return defined($k) ? $opts->{$k} : $opts;
+}
+
+sub get_mngmt_bond_enabled { # virtual config
+    return scalar(get_mngmt_bond_opt('members')->@*) >= 2;
+}
+
 sub set_network_interface_pin_map { set_key('network_interface_pin_map', $_[0]); }
 sub get_network_interface_pin_map { return get('network_interface_pin_map'); }
 
diff --git a/Proxmox/Sys/Net.pm b/Proxmox/Sys/Net.pm
index c43311d..04bb782 100644
--- a/Proxmox/Sys/Net.pm
+++ b/Proxmox/Sys/Net.pm
@@ -15,6 +15,8 @@ our @EXPORT_OK = qw(
     parse_ip_mask
     parse_fqdn
     validate_link_pin_map
+    is_valid_bond_mode
+    is_valid_bond_hash_policy
     MIN_IFNAME_LEN
     MAX_IFNAME_LEN
     DEFAULT_PIN_PREFIX
@@ -450,4 +452,32 @@ sub validate_link_pin_map : prototype($) {
     }
 }
 
+sub is_valid_bond_mode {
+    my ($mode) = @_;
+
+    my $valid = {
+        'active-backup' => 1,
+        'balance-alb' => 1,
+        'balance-rr' => 1,
+        'balance-tlb' => 1,
+        'balance-xor' => 1,
+        'broadcast' => 1,
+        '802.3ad' => 1,
+    };
+
+    return exists($valid->{$mode});
+}
+
+sub is_valid_bond_hash_policy {
+    my ($policy) = @_;
+
+    my $valid = {
+        layer2 => 1,
+        'layer2+3' => 1,
+        'layer3+4' => 1,
+    };
+
+    return exists($valid->{$policy});
+}
+
 1;
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 08/15] install: network: set up management bond if requested
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (6 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 07/15] install: config: add option for setting up bond on management interface Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 09/15] gui: network: factor out name pinning into advanced options dialog Christoph Heiss
                   ` (6 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Adds the necessary configuration to /etc/network/interfaces when bonding
is enabled in the installation config.

A bond named 'bond0' will be created, with the default bridge (as
before) 'vmbr0' on top of that.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Proxmox/Install.pm | 43 ++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 40 insertions(+), 3 deletions(-)

diff --git a/Proxmox/Install.pm b/Proxmox/Install.pm
index 04264a9..9c2b244 100644
--- a/Proxmox/Install.pm
+++ b/Proxmox/Install.pm
@@ -829,8 +829,39 @@ my sub setup_ifupdown2_config {
             && ($_->{protocol} // '') eq 'ra';
     } $gateways6->@*;
 
+    my $bond_opts = Proxmox::Install::Config::get_mngmt_bond_opt();
+    my $bond_enabled = Proxmox::Install::Config::get_mngmt_bond_enabled();
+    if ($bond_enabled) {
+        # When setting up bridged networking, the bond is attached to the main bridge as port.
+        # Otherwise, the bond represents the main management interface, i.e. with the address
+        # configured directly on it.
+
+        $ethdev = 'bond0';
+        my $addr_method = $iso_env->{cfg}->{bridged_network} ? 'manual' : 'static';
+
+        # translate all bond members MACs to their final interface name, either pinned or not
+        my @members;
+        for my $mac ($bond_opts->{members}->@*) {
+            $mac = lc($mac); # normalize into lowercase
+
+            if (defined($netif_pin_map) && defined($netif_pin_map->{$mac})) {
+                push @members, $netif_pin_map->{$mac};
+            } else {
+                my ($if) = grep { $_->{mac} eq $mac } values %$interfaces;
+                push @members, $if->{name};
+            }
+        }
+
+        $ifaces .=
+            "auto $ethdev\niface $ethdev inet $addr_method\n"
+            . "\tbond-ports "
+            . join(' ', @members) . "\n"
+            . "\tbond-mode $bond_opts->{mode}\n"
+            . "\tbond-miimon 100\n";
+    }
+
     if ($iso_env->{cfg}->{bridged_network}) {
-        $ifaces .= "iface $ethdev $ntype manual\n";
+        $ifaces .= "\niface $ethdev $ntype manual\n" if !$bond_enabled;
 
         $ifaces .=
             "\nauto vmbr0\niface vmbr0 $ntype static\n"
@@ -839,11 +870,17 @@ my sub setup_ifupdown2_config {
             . "\tbridge-ports $ethdev\n"
             . "\tbridge-stp off\n"
             . "\tbridge-fd 0\n";
-    } else {
-        $ifaces .= "auto $ethdev\n" . #
+    } elsif (!$bond_enabled) {
+        $ifaces .= "\nauto $ethdev\n" . #
             "iface $ethdev $ntype static\n" . #
             "\taddress $cidr\n";
 
+        if (!$is_gateway6_from_ra) {
+            $ifaces .= "\tgateway $gateway\n";
+        }
+    } else {
+        $ifaces .= "\taddress $cidr\n";
+
         if (!$is_gateway6_from_ra) {
             $ifaces .= "\tgateway $gateway\n";
         }
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 09/15] gui: network: factor out name pinning into advanced options dialog
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (7 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 08/15] install: network: set up management bond if requested Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 10/15] gui: network: add bond setup options to " Christoph Heiss
                   ` (5 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

This dialog can now be easily extended with additional frames for other,
advanced network-related options.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxinstall | 183 +++++++++++++++++++++++++++++++---------------------
 1 file changed, 110 insertions(+), 73 deletions(-)

diff --git a/proxinstall b/proxinstall
index 51efe79..e3a4b20 100755
--- a/proxinstall
+++ b/proxinstall
@@ -349,34 +349,55 @@ my $create_basic_grid = sub {
     return $grid;
 };
 
-my sub create_network_interface_pin_view {
-    my ($done_cb) = @_;
+# takes an array ref of rows with [$label_text, $widget, $suffix_label] array refs as columns
+# $suffix_label is optional
+my $create_label_widget_grid = sub {
+    my ($labeled_widgets) = @_;
 
-    my $dialog = Gtk3::Dialog->new();
-    $dialog->set_title('Interface Name Pinning Options');
-    $dialog->add_button('_OK', 'ok');
+    my $grid = &$create_basic_grid();
 
-    my $content = $dialog->get_content_area();
+    for (my $row = 0; $row < scalar($labeled_widgets->@*); $row++) {
+        my ($label_text, $widget, $suffix_label) = $labeled_widgets->[$row]->@*;
 
-    my $hbox = Gtk3::Box->new('horizontal', 0);
-    $content->pack_start($hbox, 1, 1, 5);
+        my $label = Gtk3::Label->new($label_text);
+        $label->set_visible(1);
+        $label->set_xalign(1.0);
+        $grid->attach($label, 0, $row, 1, 1);
+
+        $widget->set_visible(1);
+        $grid->attach($widget, 1, $row, 1, 1);
+
+        if ($suffix_label) {
+            my $suffix_label = Gtk3::Label->new($suffix_label);
+            $suffix_label->set_visible(1);
+            $suffix_label->set_xalign(1.0);
+            $grid->attach($suffix_label, 2, $row, 1, 1);
+        }
+    }
+
+    return $grid;
+};
+
+my sub create_network_interface_pin_frame {
+    my ($on_enable_changed) = @_;
+
+    my $frame = Gtk3::Frame->new('Interface Name Pinning');
+
+    my $vbox = Gtk3::Box->new('vertical', 5);
+    $frame->add($vbox);
+    $vbox->set_margin_start(10);
+    $vbox->set_margin_end(10);
+    $vbox->set_margin_top(5);
+    $vbox->set_margin_bottom(5);
+
+    my $enable_checkbox = Gtk3::CheckButton->new('Pin network interface names');
+    $enable_checkbox->set_active($gtk_state->{network_pinning_enabled});
+    $vbox->pack_start($enable_checkbox, 1, 0, 5);
 
     my $grid = Gtk3::Grid->new();
     $grid->set_column_spacing(10);
     $grid->set_row_spacing(10);
-
-    # make the list scrollable, in case there are lots of interfaces
-    my $scrolled_window = Gtk3::ScrolledWindow->new();
-    $scrolled_window->set_hexpand(1);
-    $scrolled_window->set_propagate_natural_height(1);
-
-    $scrolled_window->add($grid);
-    $scrolled_window->set_policy('never', 'automatic');
-    $scrolled_window->set_min_content_height(200);
-    $scrolled_window->set_margin_start(10);
-    $scrolled_window->set_margin_end(10);
-
-    $hbox->pack_start($scrolled_window, 1, 0, 5);
+    $vbox->pack_start($grid, 1, 1, 5);
 
     my $interfaces = Proxmox::Install::RunEnv::get()->{network}->{interfaces};
     my $mapping = Proxmox::Install::Config::get_network_interface_pin_map();
@@ -412,24 +433,83 @@ my sub create_network_interface_pin_view {
         $inputs->{ $iface->{mac} } = $input;
     }
 
-    $hbox->show_all();
+    my $set_sensitive = sub {
+        my ($active) = @_;
 
+        $grid->foreach(sub {
+            $_[0]->set_sensitive($active);
+        });
+    };
+
+    $set_sensitive->($gtk_state->{network_pinning_enabled});
+    $enable_checkbox->signal_connect(
+        toggled => sub {
+            my $active = !!$enable_checkbox->get_active();
+
+            $gtk_state->{network_pinning_enabled} = $active;
+            $set_sensitive->($active);
+
+            $on_enable_changed->($active);
+        },
+    );
+
+    my $on_done = sub {
+        if (!$enable_checkbox->get_active()) {
+            Proxmox::Install::Config::set_network_interface_pin_map();
+            return;
+        }
+
+        my %new_mapping = map { $_ => $inputs->{$_}->get_text() } keys %$inputs;
+
+        validate_link_pin_map(\%new_mapping);
+        Proxmox::Install::Config::set_network_interface_pin_map(\%new_mapping);
+    };
+
+    return ($frame, $on_done);
+}
+
+my sub create_network_advanced_options_view {
+    my ($done_cb) = @_;
+
+    my $dialog = Gtk3::Dialog->new();
+    $dialog->set_title('Management Network Options');
+    $dialog->add_button('_OK', 'ok');
+
+    my $content = $dialog->get_content_area();
+
+    my $scrolled_window = Gtk3::ScrolledWindow->new();
+    $scrolled_window->set_hexpand(1);
+    $scrolled_window->set_vexpand(1);
+    $scrolled_window->set_propagate_natural_height(1);
+    $scrolled_window->set_policy('never', 'automatic');
+    $scrolled_window->set_min_content_height(200);
+
+    my $hbox = Gtk3::Box->new('vertical', 0);
+
+    my ($pin_frame, $pin_on_done) = create_network_interface_pin_frame(sub {});
+
+    $hbox->pack_start($pin_frame, 0, 0, 10);
+
+    $scrolled_window->add($hbox);
+    $content->pack_start($scrolled_window, 1, 1, 10);
+
+    my $inputs = {};
     $dialog->signal_connect(
         response => sub {
-            my %new_mapping = map { $_ => $inputs->{$_}->get_text() } keys %$inputs;
-
-            eval { validate_link_pin_map(\%new_mapping); };
+            eval {
+                $pin_on_done->();
+            };
             if ($@) {
                 Proxmox::UI::message($@, $dialog);
                 return;
             }
 
-            Proxmox::Install::Config::set_network_interface_pin_map(\%new_mapping);
             $dialog->destroy();
             $done_cb->();
         },
     );
 
+    $dialog->show_all();
     $dialog->run();
 }
 
@@ -522,21 +602,10 @@ sub create_ipconf_view {
         $device_cb->set_active($active);
     };
 
-    my $name_pin_opts_button = Gtk3::Button->new('Options');
-    $name_pin_opts_button->set_sensitive($gtk_state->{network_pinning_enabled});
-    $name_pin_opts_button->signal_connect(
+    my $advanced_opts_button = Gtk3::Button->new('Advanced Options');
+    $advanced_opts_button->signal_connect(
         clicked => sub {
-            create_network_interface_pin_view($refresh_device_cb);
-        },
-    );
-
-    my $name_pin_checkbox = Gtk3::CheckButton->new('Pin network interface names');
-    $name_pin_checkbox->set_active($gtk_state->{network_pinning_enabled});
-    $name_pin_checkbox->signal_connect(
-        toggled => sub {
-            $name_pin_opts_button->set_sensitive(!!$name_pin_checkbox->get_active());
-            $gtk_state->{network_pinning_enabled} = !!$name_pin_checkbox->get_active();
-            $refresh_device_cb->();
+            create_network_advanced_options_view($refresh_device_cb);
         },
     );
 
@@ -661,8 +730,7 @@ sub create_ipconf_view {
     $grid->attach($dns_label, 0, 4, 1, 1);
     $grid->attach($ipconf_entry_dns, 1, 4, 2, 1);
 
-    $grid->attach($name_pin_checkbox, 1, 5, 1, 1);
-    $grid->attach($name_pin_opts_button, 2, 5, 1, 1);
+    $grid->attach($advanced_opts_button, 1, 5, 2, 1);
 
     $gtk_state->{inbox}->show_all;
     set_next(
@@ -730,8 +798,6 @@ sub create_ipconf_view {
             }
             Proxmox::Install::Config::set_dns($dns_ip);
 
-            $gtk_state->{network_pinning_enabled} = !!$name_pin_checkbox->get_active();
-
             #print STDERR "TEST $ipaddress/$netmask $gateway_ip $dns_ip\n";
 
             $step_number++;
@@ -1188,35 +1254,6 @@ my $target_hd_label;
 
 my $hdoption_first_setup = 1;
 
-# takes an array ref of rows with [$label_text, $widget, $suffix_label] array refs as columns
-# $suffix_label is optional
-my $create_label_widget_grid = sub {
-    my ($labeled_widgets) = @_;
-
-    my $grid = &$create_basic_grid();
-
-    for (my $row = 0; $row < scalar($labeled_widgets->@*); $row++) {
-        my ($label_text, $widget, $suffix_label) = $labeled_widgets->[$row]->@*;
-
-        my $label = Gtk3::Label->new($label_text);
-        $label->set_visible(1);
-        $label->set_xalign(1.0);
-        $grid->attach($label, 0, $row, 1, 1);
-
-        $widget->set_visible(1);
-        $grid->attach($widget, 1, $row, 1, 1);
-
-        if ($suffix_label) {
-            my $suffix_label = Gtk3::Label->new($suffix_label);
-            $suffix_label->set_visible(1);
-            $suffix_label->set_xalign(1.0);
-            $grid->attach($suffix_label, 2, $row, 1, 1);
-        }
-    }
-
-    return $grid;
-};
-
 # only relevant for raid with its multipl diskX to diskY mappings.
 my $get_selected_hdsize = sub {
     my $hdsize = shift;
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 10/15] gui: network: add bond setup options to advanced options dialog
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (8 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 09/15] gui: network: factor out name pinning into advanced options dialog Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 11/15] auto: pass trough management network bond options to low-level installer Christoph Heiss
                   ` (4 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Partially fixes #2164 [0].

Adds a second frame to the advanced network options dialog, allowing to
enable bonding and setting all the required options for it; such as
members, mode, hash policy and primary bond member, as required.

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=2164

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxinstall | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 232 insertions(+), 6 deletions(-)

diff --git a/proxinstall b/proxinstall
index e3a4b20..d90d0ba 100755
--- a/proxinstall
+++ b/proxinstall
@@ -85,7 +85,9 @@ my @steps = (
 );
 
 # GUI global variables
-my $gtk_state = {};
+my $gtk_state = {
+    network_bonding_enabled => 0,
+};
 
 my $target_hds; # only for the summary view
 
@@ -468,6 +470,179 @@ my sub create_network_interface_pin_frame {
     return ($frame, $on_done);
 }
 
+my sub create_network_bond_frame {
+    my $frame = Gtk3::Frame->new('Management Bond');
+
+    my $vbox = Gtk3::Box->new('vertical', 5);
+    $frame->add($vbox);
+    $vbox->set_margin_start(10);
+    $vbox->set_margin_end(10);
+    $vbox->set_margin_top(5);
+    $vbox->set_margin_bottom(5);
+
+    my $enable_checkbox = Gtk3::CheckButton->new('Enable bonding for management interface');
+    $enable_checkbox->set_active($gtk_state->{network_bonding_enabled});
+    $vbox->pack_start($enable_checkbox, 1, 0, 5);
+
+    my $bond_opts = Proxmox::Install::Config::get_mngmt_bond_opt();
+    my $interfaces = Proxmox::Install::RunEnv::get()->{network}->{interfaces};
+    my $name_mapping = Proxmox::Install::Config::get_network_interface_pin_map();
+
+    my $mode_combo = Gtk3::ComboBoxText->new();
+    my @modes = qw(active-backup balance-alb balance-rr balance-tlb balance-xor broadcast 802.3ad);
+    for my $m (@modes) {
+        $mode_combo->append_text($m);
+    }
+    for my $i (0 .. $#modes) {
+        if ($modes[$i] eq $bond_opts->{mode}) {
+            $mode_combo->set_active($i);
+            last;
+        }
+    }
+
+    my $hash_policy_combo = Gtk3::ComboBoxText->new();
+    my @hash_policies = qw(layer2 layer2+3 layer3+4);
+    for my $p (@hash_policies) {
+        $hash_policy_combo->append_text($p);
+    }
+    for my $i (0 .. $#hash_policies) {
+        if ($hash_policies[$i] eq $bond_opts->{hash_policy}) {
+            $hash_policy_combo->set_active($i);
+            last;
+        }
+    }
+
+    my $primary_if_input = Gtk3::Entry->new();
+    $primary_if_input->set_tooltip_text('Name of the primary interface to set on the bond.');
+
+    my $primary_if = $bond_opts->{primary_interface};
+    if (defined($primary_if)) {
+        $primary_if_input->set_text($name_mapping->{$primary_if} // $interfaces->{$primary_if});
+    }
+
+    my $on_mode_changed = sub {
+        my $mode = $mode_combo->get_active_text();
+        $hash_policy_combo->set_sensitive($mode eq '802.3ad' || $mode eq 'balance-xor');
+        $primary_if_input->set_sensitive($mode eq 'active-backup');
+    };
+    $mode_combo->signal_connect(changed => $on_mode_changed);
+
+    my $opts_grid = $create_label_widget_grid->([
+        ['Bond Mode', $mode_combo],
+        ['Hash Policy', $hash_policy_combo],
+        ['Primary Interface', $primary_if_input],
+    ]);
+
+    $vbox->pack_start($opts_grid, 0, 0, 5);
+
+    my $if_label = Gtk3::Label->new('Bond Interfaces:');
+    $if_label->set_xalign(0.0);
+    $if_label->set_margin_top(5);
+    $vbox->pack_start($if_label, 0, 0, 0);
+
+    my $checkboxes = {};
+    for my $iface (values %$interfaces) {
+        next if !defined($iface->{pinned_id});
+
+        my $checkbox = Gtk3::CheckButton->new('');
+
+        my $is_member = grep { $_ eq $iface->{mac} } $bond_opts->{members}->@*;
+        $checkbox->set_active($is_member);
+
+        $vbox->pack_start($checkbox, 0, 0, 5);
+        $checkboxes->{ $iface->{mac} } = $checkbox;
+    }
+
+    my $set_sensitive = sub {
+        my ($active) = @_;
+
+        $opts_grid->foreach(sub {
+            $_[0]->set_sensitive($active);
+        });
+
+        if ($active) {
+            # if activating all inputs, observe the rules applied by the mode change handler
+            $on_mode_changed->(Proxmox::Install::Config::get_mngmt_bond_opt('mode'));
+        }
+
+        $if_label->set_sensitive($active);
+        for my $cb (values %$checkboxes) {
+            $cb->set_sensitive($active);
+        }
+    };
+    $set_sensitive->($gtk_state->{network_bonding_enabled});
+
+    $enable_checkbox->signal_connect(
+        toggled => sub {
+            my $active = !!$enable_checkbox->get_active();
+
+            $gtk_state->{network_bonding_enabled} = $active;
+            $set_sensitive->($active);
+        },
+    );
+
+    my $refresh_if_names = sub {
+        my ($pinning_active) = @_;
+        my $mapping = Proxmox::Install::Config::get_network_interface_pin_map();
+
+        for my $iface (values %$interfaces) {
+            next if !defined($checkboxes->{ $iface->{mac} });
+
+            my $name =
+                $pinning_active
+                ? $name_mapping->{ $iface->{mac} }
+                : $iface->{name};
+
+            my $label = "$name ($iface->{mac}, $iface->{driver}, $iface->{state})";
+            $checkboxes->{ $iface->{mac} }->set_label($label);
+        }
+    };
+    $refresh_if_names->($gtk_state->{network_pinning_enabled});
+
+    my $on_done = sub {
+        if (!$enable_checkbox->get_active()) {
+            Proxmox::Install::Config::set_mngmt_bond_opt('members', []);
+            return;
+        }
+
+        my @members =
+            grep { $checkboxes->{$_}->get_active() } keys %$checkboxes;
+
+        if (scalar(@members) < 2) {
+            die "at least 2 member interfaces are required for setting up bond\n";
+        }
+
+        Proxmox::Install::Config::set_mngmt_nic('bond0');
+        Proxmox::Install::Config::set_mngmt_bond_opt('members', \@members);
+        Proxmox::Install::Config::set_mngmt_bond_opt('mode', $mode_combo->get_active_text());
+        Proxmox::Install::Config::set_mngmt_bond_opt(
+            'hash_policy',
+            $hash_policy_combo->get_active_text(),
+        );
+
+        my $primary_if = $primary_if_input->get_text();
+        if (length($primary_if) > 0) {
+            # resolve the name to its corresponding MAC address
+            if ($gtk_state->{network_pinning_enabled}) {
+                my $mapping = Proxmox::Install::Config::get_network_interface_pin_map();
+                while (my ($mac, $name) = each %$mapping) {
+                    if ($primary_if eq $name) {
+                        $primary_if = $mac;
+                        last;
+                    }
+                }
+            } else {
+                my $interfaces = Proxmox::Install::RunEnv::get()->{network}->{interfaces};
+                $primary_if = $interfaces->{$primary_if};
+            }
+
+            Proxmox::Install::Config::set_mngmt_bond_opt('primary_interface', $primary_if);
+        }
+    };
+
+    return ($frame, $refresh_if_names, $on_done);
+}
+
 my sub create_network_advanced_options_view {
     my ($done_cb) = @_;
 
@@ -486,9 +661,11 @@ my sub create_network_advanced_options_view {
 
     my $hbox = Gtk3::Box->new('vertical', 0);
 
-    my ($pin_frame, $pin_on_done) = create_network_interface_pin_frame(sub {});
+    my ($bond_frame, $bond_refresh, $bond_on_done) = create_network_bond_frame();
+    my ($pin_frame, $pin_on_done) = create_network_interface_pin_frame($bond_refresh);
 
     $hbox->pack_start($pin_frame, 0, 0, 10);
+    $hbox->pack_start($bond_frame, 0, 0, 10);
 
     $scrolled_window->add($hbox);
     $content->pack_start($scrolled_window, 1, 1, 10);
@@ -498,6 +675,7 @@ my sub create_network_advanced_options_view {
         response => sub {
             eval {
                 $pin_on_done->();
+                $bond_on_done->();
             };
             if ($@) {
                 Proxmox::UI::message($@, $dialog);
@@ -573,7 +751,31 @@ sub create_ipconf_view {
     $device_cb->pack_start($cell, 0);
     $device_cb->add_attribute($cell, 'text', 1);
 
+    my $device_bond_label = Gtk3::Label->new('bond0');
+    $device_bond_label->set_justify('center');
+    $device_bond_label->set_margin_top(5);
+    $device_bond_label->set_margin_bottom(5);
+
     my $refresh_device_cb = sub {
+        my $bond_active = Proxmox::Install::Config::get_mngmt_bond_enabled();
+        if ($bond_active) {
+            my $bond_opts = Proxmox::Install::Config::get_mngmt_bond_opt();
+
+            $device_bond_label->set_text(
+                "bond0 (mode: $bond_opts->{mode}, "
+                    . scalar($bond_opts->{members}->@*)
+                    . " members)",
+            );
+
+            $device_cb->set_visible(0);
+            $device_cb->set_no_show_all(1);
+
+            return;
+        }
+
+        $device_cb->set_visible(1);
+        $device_cb->set_no_show_all(0);
+
         # clear all entries and re-add them with their new names
         my $active = $device_cb->get_active();
         $device_model->clear();
@@ -711,6 +913,7 @@ sub create_ipconf_view {
 
     $grid->attach($label, 0, 0, 1, 1);
     $grid->attach($device_cb, 1, 0, 2, 1);
+    $grid->attach($device_bond_label, 1, 0, 2, 1);
 
     my $fqdn = Proxmox::Install::Config::get_fqdn();
     my $hostname = $run_env->{network}->{hostname} || $iso_env->{product};
@@ -834,11 +1037,34 @@ sub create_ack_view {
     my $country = Proxmox::Install::Config::get_country();
 
     my $mngmt_nic = Proxmox::Install::Config::get_mngmt_nic();
-    my $iface = Proxmox::Install::RunEnv::get('network')->{interfaces}->{$mngmt_nic};
-
+    my $all_ifaces = Proxmox::Install::RunEnv::get('network')->{interfaces};
+    my $iface = $all_ifaces->{$mngmt_nic};
     my $nic_mapping = Proxmox::Install::Config::get_network_interface_pin_map();
-    my $interface =
-        $gtk_state->{network_pinning_enabled} ? $nic_mapping->{ $iface->{mac} } : $iface->{name};
+
+    my $bond_opts = Proxmox::Install::Config::get_mngmt_bond_opt();
+
+    my $interface;
+    if (Proxmox::Install::Config::get_mngmt_bond_enabled()) {
+        my @member_names;
+
+        # translate all bond members MACs to their final interface name, either pinned or not
+        for my $mac ($bond_opts->{members}->@*) {
+            if ($gtk_state->{network_pinning_enabled}) {
+                push @member_names, $nic_mapping->{$mac};
+            } else {
+                my ($if) = grep { $_->{mac} eq $mac } values %$all_ifaces;
+                push @member_names, $if->{name};
+            }
+        }
+
+        my $members = join(' | ', @member_names);
+        $interface = "bond0 (mode: $bond_opts->{mode}, interfaces: $members)";
+    } else {
+        $interface =
+            $gtk_state->{network_pinning_enabled}
+            ? $nic_mapping->{ $iface->{mac} }
+            : $iface->{name};
+    }
 
     my %config_values = (
         __target_hd__ => join(' | ', $target_hds->@*),
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 11/15] auto: pass trough management network bond options to low-level installer
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (9 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 10/15] gui: network: add bond setup options to " Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 12/15] tui: drop long obsolete comment about logo formatting Christoph Heiss
                   ` (3 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Partially fixes #2164 [0].

The auto-installer gains a new section under [network] in its answer
file for setting up a management bond:

  [network.bond]
  members = ["ab:cd:ef:12:34:56", "ab:cd:ef:12:34:57"]
  mode = "active-backup"
  hash-policy = "layer2+3"
  primary-interface = "ab:cd:ef:12:34:57"

This then just passes through the given bond options in the answer file
to the low-level installation configuration, verifying them beforehand
and printing a status message that bonding is enabled.

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=2164

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 Cargo.toml                                    |  2 +
 debian/control                                |  1 +
 proxmox-auto-installer/Cargo.toml             |  1 +
 proxmox-auto-installer/src/utils.rs           | 38 ++++++++++++++++++-
 proxmox-auto-installer/tests/parse-answer.rs  |  7 +++-
 .../tests/resources/parse_answer/btrfs.json   |  5 +++
 .../btrfs_raid_level_uppercase.json           |  5 +++
 .../resources/parse_answer/disk_match.json    |  5 +++
 .../parse_answer/disk_match_all.json          |  5 +++
 .../parse_answer/disk_match_any.json          |  5 +++
 .../resources/parse_answer/first_boot.json    |  5 +++
 .../parse_answer/fqdn_from_dhcp.json          |  5 +++
 ...n_from_dhcp_empty_dhcp_domain_setting.json |  5 +++
 ...cp_no_dhcp_domain_with_default_domain.json |  5 +++
 ...ll_fqdn_from_dhcp_with_default_domain.json |  5 +++
 .../parse_answer/hashed_root_password.json    |  5 +++
 .../tests/resources/parse_answer/minimal.json |  5 +++
 .../resources/parse_answer/network_bond.json  | 25 ++++++++++++
 .../resources/parse_answer/network_bond.toml  | 20 ++++++++++
 .../network_interface_pinning.json            |  5 +++
 ...face_pinning_mixed_case_mac_addresses.json |  5 +++
 .../resources/parse_answer/nic_matching.json  |  5 +++
 .../resources/parse_answer/no_network.json    |  5 +++
 .../resources/parse_answer/specific_nic.json  |  5 +++
 .../tests/resources/parse_answer/zfs.json     |  5 +++
 .../zfs_raid_level_uppercase.json             |  5 +++
 .../network_bond_invalid_hash_policy.json     |  3 ++
 .../network_bond_invalid_hash_policy.toml     | 20 ++++++++++
 .../network_bond_invalid_mode.json            |  3 ++
 .../network_bond_invalid_mode.toml            | 18 +++++++++
 .../network_bond_primary_not_member.json      |  3 ++
 .../network_bond_primary_not_member.toml      | 19 ++++++++++
 .../network_bond_single_interface.json        |  3 ++
 .../network_bond_single_interface.toml        | 18 +++++++++
 proxmox-installer-common/Cargo.toml           |  1 +
 proxmox-installer-common/src/options.rs       | 38 +++++++++++++------
 proxmox-installer-common/src/setup.rs         | 10 ++++-
 37 files changed, 310 insertions(+), 15 deletions(-)
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_bond.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer/network_bond.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.toml
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.json
 create mode 100644 proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.toml

diff --git a/Cargo.toml b/Cargo.toml
index 335fb58..3849ed3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,7 @@ serde_plain = "1.0"
 toml = "0.8"
 proxmox-auto-installer.path = "./proxmox-auto-installer"
 proxmox-installer-common.path = "./proxmox-installer-common"
+proxmox-network-api = "1.0"
 proxmox-network-types = "1.1"
 proxmox-installer-types = { version = "0.2", features = ["legacy"] }
 
@@ -34,4 +35,5 @@ proxmox-installer-types = { version = "0.2", features = ["legacy"] }
 # NOTE: You must run `cargo update` after changing this for it to take effect!
 [patch.crates-io]
 # proxmox-network-types.path = "../proxmox/proxmox-network-types"
+# proxmox-installer-api.path = "../proxmox/proxmox-installer-api"
 # proxmox-installer-types.path = "../proxmox/proxmox-installer-types"
diff --git a/debian/control b/debian/control
index a1d648f..75bbefd 100644
--- a/debian/control
+++ b/debian/control
@@ -20,6 +20,7 @@ Build-Depends: cargo:native,
                librust-pico-args-0.5-dev,
                librust-pretty-assertions-1.4-dev,
                librust-proxmox-installer-types-0.2+legacy-dev (>= 0.1.1-~~),
+               librust-proxmox-network-api-1-dev,
                librust-proxmox-network-types-1-dev (>= 1.1-~~),
                librust-proxmox-sys+crypt-dev,
                librust-regex-1+default-dev (>= 1.7~~),
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
index 5ef2f4f..b7ca85d 100644
--- a/proxmox-auto-installer/Cargo.toml
+++ b/proxmox-auto-installer/Cargo.toml
@@ -15,6 +15,7 @@ anyhow.workspace = true
 log.workspace = true
 proxmox-installer-common = { workspace = true, features = ["http"] }
 proxmox-network-types.workspace = true
+proxmox-network-api.workspace = true
 proxmox-installer-types.workspace = true
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
index 7205ae3..aa8a0fd 100644
--- a/proxmox-auto-installer/src/utils.rs
+++ b/proxmox-auto-installer/src/utils.rs
@@ -11,7 +11,7 @@ use proxmox_installer_types::{
     answer::{
         AutoInstallerConfig, DiskSelection, Filesystem, FilesystemOptions, FilesystemType,
         FilterMatch, FirstBootHookSourceMode, FqdnConfig, FqdnFromDhcpConfig, FqdnSourceMode,
-        NetworkConfig,
+        NetworkBondOptions, NetworkConfig,
     },
 };
 
@@ -48,6 +48,7 @@ fn get_network_settings(
                 &runtime_info.network,
                 None,
                 pinning_opts.as_ref(),
+                answer.network.bond(),
             );
             opts.fqdn = name.to_owned();
             opts
@@ -76,6 +77,7 @@ fn get_network_settings(
                 &runtime_info.network,
                 domain.as_deref(),
                 pinning_opts.as_ref(),
+                answer.network.bond(),
             )
         }
     };
@@ -92,6 +94,33 @@ fn get_network_settings(
         opts.verify()?;
     }
 
+    if let Some(opts) = &network_options.bond_opts {
+        let members = opts.members.iter().fold(String::new(), |acc, mac| {
+            // Resolve either to the pinned name, if enabled, otherwise to the kernel-assigned one.
+            let name = network_options
+                .pinning_opts
+                .as_ref()
+                .and_then(|o| o.mapping.get(mac).cloned())
+                .or_else(|| {
+                    runtime_info
+                        .network
+                        .interfaces
+                        .iter()
+                        .find_map(|(_, iface)| (iface.mac == *mac).then_some(iface.name.to_owned()))
+                })
+                .unwrap_or("<unknown>".to_owned());
+
+            if acc.is_empty() {
+                name
+            } else {
+                format!("{acc}, {name}")
+            }
+        });
+
+        info!("Network interface bonding enabled on: {members}");
+        opts.verify()?;
+    }
+
     info!("Network interface used is '{}'", &network_options.ifname);
     Ok(network_options)
 }
@@ -493,6 +522,10 @@ pub fn verify_network_settings(
         }
     }
 
+    if let Some(opts) = network.bond() {
+        opts.verify()?;
+    }
+
     Ok(())
 }
 
@@ -552,6 +585,9 @@ pub fn parse_answer(
         subscription_key: answer.global.subscription_key.clone(),
 
         mngmt_nic: network_settings.ifname,
+        mngmt_bond: network_settings
+            .bond_opts
+            .unwrap_or_else(NetworkBondOptions::disabled),
         network_interface_pin_map: network_settings
             .pinning_opts
             .map(|o| o.mapping)
diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs
index ae5b87d..0320bba 100644
--- a/proxmox-auto-installer/tests/parse-answer.rs
+++ b/proxmox-auto-installer/tests/parse-answer.rs
@@ -127,6 +127,7 @@ mod tests {
             full_fqdn_from_dhcp_with_default_domain,
             hashed_root_password,
             minimal,
+            network_bond,
             network_interface_pinning,
             network_interface_pinning_mixed_case_mac_addresses,
             nic_matching,
@@ -150,11 +151,15 @@ mod tests {
             fqdn_hostname_only,
             ipv4_and_subnet_mask_33,
             lvm_swapsize_greater_than_hdsize,
+            network_bond_invalid_hash_policy,
+            network_bond_invalid_mode,
+            network_bond_primary_not_member,
+            network_bond_single_interface,
             network_interface_pinning_overlong_interface_name,
             no_fqdn_from_dhcp,
             no_root_password_set,
             short_password,
-            zfs_raid_single_disk
+            zfs_raid_single_disk,
         );
     }
 }
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/btrfs.json b/proxmox-auto-installer/tests/resources/parse_answer/btrfs.json
index 0c1f032..ce27d41 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/btrfs.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/btrfs.json
@@ -16,6 +16,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "btrfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/btrfs_raid_level_uppercase.json b/proxmox-auto-installer/tests/resources/parse_answer/btrfs_raid_level_uppercase.json
index cb6711c..cc0bbe9 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/btrfs_raid_level_uppercase.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/btrfs_raid_level_uppercase.json
@@ -16,6 +16,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "btrfs_opts": { "compress": "off" },
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match.json
index d5ffddd..0c13325 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/disk_match.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/disk_match.json
@@ -18,6 +18,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "zfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json
index 78a5e0c..4f2150d 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json
@@ -15,6 +15,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "zfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json
index 2e65fce..5adb9c6 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json
@@ -22,6 +22,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "zfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/first_boot.json b/proxmox-auto-installer/tests/resources/parse_answer/first_boot.json
index fafde51..e3012a8 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/first_boot.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/first_boot.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp.json b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp.json
index 5ec6656..3424487 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_empty_dhcp_domain_setting.json b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_empty_dhcp_domain_setting.json
index 5ec6656..3424487 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_empty_dhcp_domain_setting.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_empty_dhcp_domain_setting.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_no_dhcp_domain_with_default_domain.json b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_no_dhcp_domain_with_default_domain.json
index 7ed46a2..78e32a7 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_no_dhcp_domain_with_default_domain.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/fqdn_from_dhcp_no_dhcp_domain_with_default_domain.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/full_fqdn_from_dhcp_with_default_domain.json b/proxmox-auto-installer/tests/resources/parse_answer/full_fqdn_from_dhcp_with_default_domain.json
index 7ed46a2..78e32a7 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/full_fqdn_from_dhcp_with_default_domain.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/full_fqdn_from_dhcp_with_default_domain.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/hashed_root_password.json b/proxmox-auto-installer/tests/resources/parse_answer/hashed_root_password.json
index 4e049bd..e3bf44f 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/hashed_root_password.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/hashed_root_password.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": {
     "hashed": "$y$j9T$VgMv8lsz/TEvzesCZU3xD.$SK.h4QW51Jr/EmjuaTz5Bt4kYiX2Iezz6omzoqVEwj9"
   },
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/minimal.json b/proxmox-auto-installer/tests/resources/parse_answer/minimal.json
index 0339dbc..1262832 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/minimal.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/minimal.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_bond.json b/proxmox-auto-installer/tests/resources/parse_answer/network_bond.json
new file mode 100644
index 0000000..cf0aa98
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_bond.json
@@ -0,0 +1,25 @@
+{
+  "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": "bond0",
+  "mngmt_bond": {
+      "members": ["B4:2E:99:AC:AD:B4", "B4:2E:99:AC:AD:B5"],
+      "mode": "active-backup",
+      "hash-policy": "layer3+4",
+      "primary-interface": "B4:2E:99:AC:AD:B5"
+  },
+  "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_bond.toml b/proxmox-auto-installer/tests/resources/parse_answer/network_bond.toml
new file mode 100644
index 0000000..dc6b7b0
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_bond.toml
@@ -0,0 +1,20 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.bond]
+members = ["b4:2e:99:ac:ad:b4", "b4:2e:99:ac:ad:b5"]
+mode = "active-backup"
+hash-policy = "layer3+4"
+primary-interface = "b4:2e:99:ac:ad:b5"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
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
index 76723c8..42bc5a3 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "mgmt",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "network_interface_pin_map": {
     "1c:34:da:5c:5e:24": "nic2",
     "1c:34:da:5c:5e:25": "nic3",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json
index 76723c8..42bc5a3 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/network_interface_pinning_mixed_case_mac_addresses.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "mgmt",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "network_interface_pin_map": {
     "1c:34:da:5c:5e:24": "nic2",
     "1c:34:da:5c:5e:25": "nic3",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json b/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json
index 5d707c4..defcfa5 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "enp65s0f0",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/no_network.json b/proxmox-auto-installer/tests/resources/parse_answer/no_network.json
index de5f263..1bc3567 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/no_network.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/no_network.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json b/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json
index 49240b4..bd4a459 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json
@@ -12,6 +12,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "enp129s0f1np1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "target_hd": "/dev/sda",
   "timezone": "Europe/Vienna",
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/zfs.json b/proxmox-auto-installer/tests/resources/parse_answer/zfs.json
index 622f6d6..149588f 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/zfs.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/zfs.json
@@ -16,6 +16,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "zfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/zfs_raid_level_uppercase.json b/proxmox-auto-installer/tests/resources/parse_answer/zfs_raid_level_uppercase.json
index 46b8344..99ac240 100644
--- a/proxmox-auto-installer/tests/resources/parse_answer/zfs_raid_level_uppercase.json
+++ b/proxmox-auto-installer/tests/resources/parse_answer/zfs_raid_level_uppercase.json
@@ -17,6 +17,11 @@
   "keymap": "de",
   "mailto": "mail@no.invalid",
   "mngmt_nic": "eno1",
+  "mngmt_bond": {
+      "members": [],
+      "mode": "active-backup",
+      "hash-policy": "layer2"
+  },
   "root_password": { "plain": "12345678" },
   "timezone": "Europe/Vienna",
   "zfs_opts": {
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.json
new file mode 100644
index 0000000..b76c9d6
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.json
@@ -0,0 +1,3 @@
+{
+  "parse-error": "error parsing answer.toml: unknown variant `vlan+srcmac`, expected one of `layer2`, `layer2+3`, `layer3+4`"
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.toml
new file mode 100644
index 0000000..af2789b
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_hash_policy.toml
@@ -0,0 +1,20 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.bond]
+members = ["b4:2e:99:ac:ad:b4", "b4:2e:99:ac:ad:b5"]
+mode = "active-backup"
+hash-policy = "vlan+srcmac"
+primary-interface = "b4:2e:99:ac:ad:b5"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.json
new file mode 100644
index 0000000..95461dc
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.json
@@ -0,0 +1,3 @@
+{
+  "parse-error": "error parsing answer.toml: unknown variant `non-existent-mode`, expected one of `balance-rr`, `active-backup`, `balance-xor`, `broadcast`, `802.3ad`, `balance-tlb`, `balance-alb`"
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.toml
new file mode 100644
index 0000000..79695da
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_invalid_mode.toml
@@ -0,0 +1,18 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.bond]
+members = ["b4:2e:99:ac:ad:b4", "b4:2e:99:ac:ad:b5"]
+mode = "non-existent-mode"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.json
new file mode 100644
index 0000000..7e64e92
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.json
@@ -0,0 +1,3 @@
+{
+  "error": "primary bond interface B4:2E:99:AC:AD:B6 must be a member of the bond (members: B4:2E:99:AC:AD:B4, B4:2E:99:AC:AD:B5)"
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.toml
new file mode 100644
index 0000000..fab400a
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_primary_not_member.toml
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.bond]
+members = ["b4:2e:99:ac:ad:b4", "b4:2e:99:ac:ad:b5"]
+mode = "active-backup"
+primary-interface = "b4:2e:99:ac:ad:b6"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.json b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.json
new file mode 100644
index 0000000..730fad6
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.json
@@ -0,0 +1,3 @@
+{
+  "error": "bond setup requires at least 2 interfaces, got 1"
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.toml b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.toml
new file mode 100644
index 0000000..b035cef
--- /dev/null
+++ b/proxmox-auto-installer/tests/resources/parse_answer_fail/network_bond_single_interface.toml
@@ -0,0 +1,18 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root-password = "12345678"
+
+[network]
+source = "from-dhcp"
+
+[network.bond]
+members = ["b4:2e:99:ac:ad:b4"]
+mode = "balance-rr"
+
+[disk-setup]
+filesystem = "ext4"
+disk-list = ["sda"]
diff --git a/proxmox-installer-common/Cargo.toml b/proxmox-installer-common/Cargo.toml
index 7682680..a283094 100644
--- a/proxmox-installer-common/Cargo.toml
+++ b/proxmox-installer-common/Cargo.toml
@@ -13,6 +13,7 @@ regex.workspace = true
 serde = { workspace = true, features = [ "derive" ] }
 serde_json.workspace = true
 serde_plain.workspace = true
+proxmox-network-api.workspace = true
 proxmox-network-types.workspace = true
 proxmox-installer-types.workspace = true
 
diff --git a/proxmox-installer-common/src/options.rs b/proxmox-installer-common/src/options.rs
index fbbfe77..1add63b 100644
--- a/proxmox-installer-common/src/options.rs
+++ b/proxmox-installer-common/src/options.rs
@@ -15,8 +15,8 @@ use crate::setup::{LocaleInfo, NetworkInfo, RuntimeInfo, SetupInfo};
 use proxmox_installer_types::{
     EMAIL_DEFAULT_PLACEHOLDER,
     answer::{
-        BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, NetworkInterfacePinningOptionsAnswer,
-        ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
+        BtrfsCompressOption, BtrfsRaidLevel, FilesystemType, NetworkBondOptions,
+        NetworkInterfacePinningOptionsAnswer, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
     },
 };
 use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
@@ -395,6 +395,7 @@ pub struct NetworkOptions {
     pub gateway: IpAddr,
     pub dns_server: IpAddr,
     pub pinning_opts: Option<NetworkInterfacePinningOptions>,
+    pub bond_opts: Option<NetworkBondOptions>,
 }
 
 impl NetworkOptions {
@@ -405,6 +406,7 @@ impl NetworkOptions {
         network: &NetworkInfo,
         default_domain: Option<&str>,
         pinning_opts: Option<&NetworkInterfacePinningOptions>,
+        bond_opts: Option<&NetworkBondOptions>,
     ) -> Self {
         // Sets up sensible defaults as much as possible, such that even in the
         // worse case nothing breaks down *completely*.
@@ -417,6 +419,7 @@ impl NetworkOptions {
             gateway: Ipv4Addr::new(192, 168, 100, 1).into(),
             dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
             pinning_opts: pinning_opts.cloned(),
+            bond_opts: bond_opts.cloned(),
         };
 
         let iface = if let Some(gw) = &network.routes.gateway4
@@ -479,6 +482,10 @@ impl NetworkOptions {
             }
         }
 
+        if this.bond_opts.is_some() {
+            this.ifname = "bond0".to_owned();
+        }
+
         this
     }
 
@@ -632,7 +639,7 @@ mod tests {
         let (setup, mut info) = mock_setup_network();
 
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.bar.com").unwrap(),
@@ -640,12 +647,13 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
 
         info.hostname = None;
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("pve.bar.com").unwrap(),
@@ -653,12 +661,13 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
 
         info.dns.domain = None;
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("pve.example.invalid").unwrap(),
@@ -666,12 +675,13 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
 
         info.hostname = Some("foo".to_owned());
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.example.invalid").unwrap(),
@@ -679,6 +689,7 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
     }
@@ -726,7 +737,7 @@ mod tests {
         let (setup, info) = mock_setup_network_v6_only();
 
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.bar.com").unwrap(),
@@ -734,6 +745,7 @@ mod tests {
                 gateway: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
                 dns_server: IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x53)),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
     }
@@ -743,7 +755,7 @@ mod tests {
         let (setup, mut info) = mock_setup_network();
 
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.bar.com").unwrap(),
@@ -751,12 +763,13 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
 
         info.dns.domain = None;
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, Some("custom.local"), None),
+            NetworkOptions::defaults_from(&setup, &info, Some("custom.local"), None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.custom.local").unwrap(),
@@ -764,12 +777,13 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
 
         info.dns.domain = Some("some.domain.local".to_owned());
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, Some("custom.local"), None),
+            NetworkOptions::defaults_from(&setup, &info, Some("custom.local"), None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("foo.custom.local").unwrap(),
@@ -777,6 +791,7 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
     }
@@ -813,7 +828,7 @@ mod tests {
         let setup = SetupInfo::mocked();
 
         pretty_assertions::assert_eq!(
-            NetworkOptions::defaults_from(&setup, &info, None, None),
+            NetworkOptions::defaults_from(&setup, &info, None, None, None),
             NetworkOptions {
                 ifname: "eth0".to_owned(),
                 fqdn: Fqdn::from("pve.example.invalid").unwrap(),
@@ -821,6 +836,7 @@ mod tests {
                 gateway: IpAddr::V4(Ipv4Addr::new(192, 168, 100, 1)),
                 dns_server: Ipv4Addr::new(192, 168, 100, 1).into(),
                 pinning_opts: None,
+                bond_opts: None,
             }
         );
     }
diff --git a/proxmox-installer-common/src/setup.rs b/proxmox-installer-common/src/setup.rs
index 14882bc..139d276 100644
--- a/proxmox-installer-common/src/setup.rs
+++ b/proxmox-installer-common/src/setup.rs
@@ -16,7 +16,10 @@ use crate::options::{
 };
 use proxmox_installer_types::{
     BootType, IsoInfo, ProductConfig,
-    answer::{BtrfsCompressOption, FilesystemType, ZfsChecksumOption, ZfsCompressOption},
+    answer::{
+        BtrfsCompressOption, FilesystemType, NetworkBondOptions, ZfsChecksumOption,
+        ZfsCompressOption,
+    },
 };
 use proxmox_network_types::{Cidr, MacAddress};
 
@@ -326,7 +329,8 @@ pub struct NetworkInfo {
     pub dns: Dns,
     pub routes: Routes,
 
-    /// Maps devices to their configuration, if it has a usable configuration.
+    /// Maps devices to their configuration, if it has a usable configuration, keyed by their
+    /// kernel-assigned interface name.
     /// (Contains no entries for devices with only link-local addresses.)
     #[serde(default)]
     pub interfaces: BTreeMap<String, Interface>,
@@ -530,6 +534,8 @@ pub struct InstallConfig {
     pub subscription_key: Option<String>,
 
     pub mngmt_nic: String,
+    pub mngmt_bond: NetworkBondOptions,
+
     // Maps MAC addresses -> custom name. If set, enables pinning for all
     // interfaces present.
     #[serde(
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 12/15] tui: drop long obsolete comment about logo formatting
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (10 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 11/15] auto: pass trough management network bond options to low-level installer Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 13/15] tui: views: add widget for displaying a group of checkboxes Christoph Heiss
                   ` (2 subsequent siblings)
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

This was made obsolete by the changes in

  7b0596e ("tui: drop long obsolete comment about logo formatting")

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-tui-installer/src/main.rs | 1 -
 1 file changed, 1 deletion(-)

diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs
index e9f47a9..61a06f7 100644
--- a/proxmox-tui-installer/src/main.rs
+++ b/proxmox-tui-installer/src/main.rs
@@ -36,7 +36,6 @@ use views::{
     TableViewItem, TimezoneOptionsView,
 };
 
-// TextView::center() seems to garble the first two lines, so fix it manually here.
 const PROXMOX_LOGO: &str = r"
  ____
 |  _ \ _ __ _____  ___ __ ___   _____  __
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 13/15] tui: views: add widget for displaying a group of checkboxes
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (11 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 12/15] tui: drop long obsolete comment about logo formatting Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 14/15] tui: network: factor out name pinning into advanced options dialog Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 15/15] tui: network: add bond setup options to advanced network dialog Christoph Heiss
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Adds a simple view which displays multiple checkboxes, each optionally
associated with some user-defined data.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-tui-installer/src/views/mod.rs | 106 ++++++++++++++++++++++++-
 1 file changed, 105 insertions(+), 1 deletion(-)

diff --git a/proxmox-tui-installer/src/views/mod.rs b/proxmox-tui-installer/src/views/mod.rs
index a343e60..b4896e2 100644
--- a/proxmox-tui-installer/src/views/mod.rs
+++ b/proxmox-tui-installer/src/views/mod.rs
@@ -5,7 +5,9 @@ use cursive::{
     event::{Event, EventResult},
     theme::BaseColor,
     view::{Resizable, ViewWrapper},
-    views::{EditView, LinearLayout, NamedView, ResizedView, SelectView, TextView},
+    views::{
+        Checkbox, DummyView, EditView, LinearLayout, NamedView, ResizedView, SelectView, TextView,
+    },
 };
 
 mod bootdisk;
@@ -626,3 +628,105 @@ impl CidrAddressEditView {
 impl ViewWrapper for CidrAddressEditView {
     cursive::wrap_impl!(self.view: LinearLayout);
 }
+
+/// List of checkboxes, each with an associated label and optional user-defined
+/// data for identifying its meaning.
+pub struct CheckboxGroup<UDT = ()> {
+    view: LinearLayout,
+    user_data: HashMap<usize, UDT>,
+}
+
+impl<UDT> CheckboxGroup<UDT> {
+    pub fn new() -> Self {
+        Self {
+            view: LinearLayout::vertical(),
+            user_data: HashMap::new(),
+        }
+    }
+
+    pub fn add(&mut self, label: &str, checked: bool, data: UDT) {
+        self.user_data.insert(self.view.len(), data);
+        self.view.add_child(
+            LinearLayout::horizontal()
+                .child(Checkbox::new().with_checked(checked))
+                .child(DummyView)
+                .child(TextView::new(label)),
+        );
+    }
+
+    pub fn entries(&self) -> impl Iterator<Item = (bool, &UDT)> {
+        CheckboxGroupIterator {
+            group: self,
+            index: 0,
+        }
+    }
+
+    pub fn set_enabled(&mut self, enabled: bool) {
+        for i in 0..self.view.len() {
+            if let Some(checkbox) = self
+                .view
+                .get_child_mut(i)
+                .and_then(|v| v.downcast_mut::<LinearLayout>())
+                .and_then(|v| v.get_child_mut(0))
+                .and_then(|v| v.downcast_mut::<Checkbox>())
+            {
+                checkbox.set_enabled(enabled);
+            }
+        }
+    }
+
+    pub fn update_labels(&mut self, callback: &dyn Fn(bool, &mut TextView, &mut UDT)) {
+        for i in 0..self.view.len() {
+            if let Some(inner) = self
+                .view
+                .get_child_mut(i)
+                .and_then(|v| v.downcast_mut::<LinearLayout>())
+            {
+                let checked = inner
+                    .get_child(0)
+                    .and_then(|v| v.downcast_ref::<Checkbox>())
+                    .map(|v| v.is_checked())
+                    .unwrap_or(false);
+
+                let label = inner
+                    .get_child_mut(2)
+                    .and_then(|v| v.downcast_mut::<TextView>());
+
+                let data = self.user_data.get_mut(&i);
+
+                if let (Some(label), Some(data)) = (label, data) {
+                    callback(checked, label, data);
+                }
+            }
+        }
+    }
+}
+
+impl<UDT: Send + Sync + 'static> ViewWrapper for CheckboxGroup<UDT> {
+    cursive::wrap_impl!(self.view: LinearLayout);
+}
+
+pub struct CheckboxGroupIterator<'a, UDT> {
+    group: &'a CheckboxGroup<UDT>,
+    index: usize,
+}
+
+impl<'a, UDT> Iterator for CheckboxGroupIterator<'a, UDT> {
+    type Item = (bool, &'a UDT);
+
+    fn next(&mut self) -> Option<Self::Item> {
+        let checked = self
+            .group
+            .view
+            .get_child(self.index)?
+            .downcast_ref::<LinearLayout>()?
+            .get_child(0)?
+            .downcast_ref::<Checkbox>()?
+            .is_checked();
+
+        let data = self.group.user_data.get(&self.index)?;
+
+        self.index += 1;
+        Some((checked, data))
+    }
+}
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 14/15] tui: network: factor out name pinning into advanced options dialog
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (12 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 13/15] tui: views: add widget for displaying a group of checkboxes Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  2026-09-08 10:16 ` [PATCH installer 15/15] tui: network: add bond setup options to advanced network dialog Christoph Heiss
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

This dialog can now be easily extended with additional panels for other,
advanced network-related options.

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-tui-installer/src/views/network.rs | 272 +++++++++++----------
 1 file changed, 138 insertions(+), 134 deletions(-)

diff --git a/proxmox-tui-installer/src/views/network.rs b/proxmox-tui-installer/src/views/network.rs
index 6401174..f040881 100644
--- a/proxmox-tui-installer/src/views/network.rs
+++ b/proxmox-tui-installer/src/views/network.rs
@@ -1,8 +1,9 @@
+use anyhow::{Result, anyhow, bail};
 use cursive::{
     Cursive, View,
     view::{Nameable, Resizable, ViewWrapper},
     views::{
-        Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, NamedView, ResizedView,
+        Button, Checkbox, Dialog, DummyView, EditView, LinearLayout, NamedView, Panel, ResizedView,
         ScrollView, SelectView, TextView,
     },
 };
@@ -22,10 +23,10 @@ use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
 use super::{CidrAddressEditView, FormView};
 
 struct NetworkViewOptions {
-    selected_mac: MacAddress,
     pinning_enabled: bool,
-    // For UI purposes, we want to always save the mapping, to save the state
-    // between toggling the checkbox
+    interfaces: Vec<Interface>,
+    // For UI purposes, we want to always save the state for the below options, even if not enabled
+    // by the user - to preserve the state between toggling the checkbox
     pinning_options: NetworkInterfacePinningOptions,
 }
 
@@ -40,32 +41,29 @@ pub struct NetworkOptionsView {
 }
 
 impl NetworkOptionsView {
-    const PINNING_OPTIONS_BUTTON_NAME: &str = "network-pinning-options-button";
+    const FORM_VIEW_NAME: &str = "network-management-form";
     const MGMT_IFNAME_SELECTVIEW_NAME: &str = "network-management-ifname-selectview";
+    const ADVANCED_OPTIONS_BUTTON_NAME: &str = "network-pinning-options-button";
+
+    const INTERFACE_PINNING_VIEW_NAME: &str = "network-name-pinning-view";
 
     pub fn new(options: &NetworkOptions, network_info: &NetworkInfo) -> Self {
-        let mut ifaces = network_info
+        let mut interfaces = network_info
             .interfaces
             .values()
-            .collect::<Vec<&Interface>>();
+            .cloned()
+            .collect::<Vec<Interface>>();
 
         // First, sort interfaces by their link state and then name
-        ifaces.sort_unstable_by_key(|x| (&x.state, &x.name));
-
-        let selected_mac = network_info
-            .interfaces
-            .get(&options.ifname)
-            .map(|iface| iface.mac)
-            .unwrap_or_else(|| ifaces.first().expect("at least one network interface").mac);
+        interfaces.sort_unstable_by(|a, b| (&a.state, &a.name).cmp(&(&b.state, &b.name)));
 
         let options_ref = Arc::new(Mutex::new(NetworkViewOptions {
-            selected_mac,
             pinning_enabled: options.pinning_opts.is_some(),
+            interfaces,
             pinning_options: options.pinning_opts.clone().unwrap_or_default(),
         }));
 
-        let iface_selection =
-            Self::build_mgmt_ifname_selectview(ifaces.clone(), options_ref.clone());
+        let iface_selection = Self::build_mgmt_ifname_selectview(options_ref.clone(), None);
 
         let form = FormView::<()>::new()
             .child(
@@ -89,36 +87,13 @@ impl NetworkOptionsView {
                 EditView::new().content(options.dns_server.to_string()),
             );
 
-        let pinning_checkbox = LinearLayout::horizontal()
-            .child(Checkbox::new().checked().on_change({
-                let ifaces = ifaces
-                    .iter()
-                    .map(|iface| (*iface).clone())
-                    .collect::<Vec<Interface>>();
-                let options_ref = options_ref.clone();
-                move |siv, enable_pinning| {
-                    siv.call_on_name(Self::PINNING_OPTIONS_BUTTON_NAME, {
-                        let options_ref = options_ref.clone();
-                        move |view: &mut Button| {
-                            view.set_enabled(enable_pinning);
-
-                            options_ref.lock().expect("unpoisoned lock").pinning_enabled =
-                                enable_pinning;
-                        }
-                    });
-
-                    Self::refresh_ifname_selectview(siv, &ifaces, options_ref.clone());
-                }
-            }))
-            .child(TextView::new(" Pin network interface names").no_wrap())
+        let advanced_options = LinearLayout::horizontal()
             .child(DummyView.full_width())
             .child(
-                Button::new("Pinning options", {
+                Button::new("Advanced options", {
                     let options_ref = options_ref.clone();
-                    let network_info = network_info.clone();
                     move |siv| {
-                        let mut view =
-                            Self::custom_name_mapping_view(&network_info, options_ref.clone());
+                        let mut view = Self::advanced_options_view(options_ref.clone());
 
                         // Pre-compute the child's layout, since it might depend on the size. Without this,
                         // the view will be empty until focused.
@@ -128,13 +103,13 @@ impl NetworkOptionsView {
                         siv.add_layer(view);
                     }
                 })
-                .with_name(Self::PINNING_OPTIONS_BUTTON_NAME),
+                .with_name(Self::ADVANCED_OPTIONS_BUTTON_NAME),
             );
 
         let view = LinearLayout::vertical()
-            .child(form)
+            .child(form.with_name(Self::FORM_VIEW_NAME))
             .child(DummyView.full_width())
-            .child(pinning_checkbox);
+            .child(advanced_options);
 
         Self {
             view,
@@ -145,13 +120,10 @@ impl NetworkOptionsView {
     pub fn get_values(&mut self) -> Result<NetworkOptions, String> {
         let form = self
             .view
-            .get_child(0)
-            .and_then(|v| v.downcast_ref::<FormView>())
-            .ok_or("failed to retrieve network options form")?;
-
-        let iface = form
-            .get_value::<NamedView<SelectView<Interface>>, _>(0)
-            .ok_or("failed to retrieve management interface name")?;
+            .get_child_mut(0)
+            .and_then(|v| v.downcast_mut::<NamedView<FormView>>())
+            .ok_or("failed to retrieve network options form")?
+            .get_mut();
 
         let fqdn = form
             .get_value::<EditView, _>(1)
@@ -208,83 +180,69 @@ impl NetworkOptionsView {
         }
     }
 
-    fn custom_name_mapping_view(
-        network_info: &NetworkInfo,
-        options_ref: NetworkViewOptionsRef,
-    ) -> impl View {
-        const DIALOG_NAME: &str = "network-interface-name-pinning-dialog";
+    fn advanced_options_view(options_ref: NetworkViewOptionsRef) -> impl View {
+        let inner = ScrollView::new(
+            LinearLayout::vertical().child(Panel::new(
+                InterfacePinningOptionsView::new(options_ref.clone())
+                    .with_name(Self::INTERFACE_PINNING_VIEW_NAME),
+            )),
+        );
 
-        let mut interfaces = network_info
-            .interfaces
-            .values()
-            .collect::<Vec<&Interface>>();
-
-        interfaces.sort_by(|a, b| (&a.state, &a.name).cmp(&(&b.state, &b.name)));
-
-        Dialog::around(InterfacePinningOptionsView::new(
-            &interfaces,
-            options_ref.clone(),
-        ))
-        .title("Interface Name Pinning Options")
-        .button("Ok", {
-            let interfaces = interfaces
-                .iter()
-                .map(|v| (*v).clone())
-                .collect::<Vec<Interface>>();
-            move |siv| {
-                let options = siv
-                    .call_on_name(DIALOG_NAME, |view: &mut Dialog| {
-                        view.get_content_mut()
-                            .downcast_mut::<InterfacePinningOptionsView>()
-                            .map(InterfacePinningOptionsView::get_values)
-                    })
-                    .flatten();
-
-                let options = match options {
-                    Some(Ok(options)) => options,
-                    Some(Err(err)) => {
-                        siv.add_layer(Dialog::info(err));
-                        return;
+        Dialog::around(inner)
+            .title("Advanced Network Options")
+            .button("Ok", {
+                move |siv| {
+                    match Self::get_view_values(siv) {
+                        Ok(pin_opts) => {
+                            siv.pop_layer();
+                            let options = &mut options_ref.lock().expect("unpoisoned lock");
+                            options.pinning_options = pin_opts;
+                        }
+                        Err(err) => {
+                            siv.pop_layer();
+                            siv.add_layer(Dialog::info(err.to_string()));
+                            return;
+                        }
                     }
-                    None => {
-                        siv.add_layer(Dialog::info(
-                            "Failed to retrieve network interface name pinning options view",
-                        ));
-                        return;
-                    }
-                };
 
-                siv.pop_layer();
-                options_ref.lock().expect("unpoisoned lock").pinning_options = options;
-
-                Self::refresh_ifname_selectview(siv, &interfaces, options_ref.clone());
-            }
-        })
-        .with_name(DIALOG_NAME)
-        .max_size((80, 40))
+                    Self::refresh_ifname_selectview(siv, options_ref.clone());
+                }
+            })
+            .max_size((80, 40))
     }
 
-    fn refresh_ifname_selectview(
-        siv: &mut Cursive,
-        ifaces: &[Interface],
-        options_ref: NetworkViewOptionsRef,
-    ) {
+    fn get_view_values(siv: &mut Cursive) -> Result<NetworkInterfacePinningOptions> {
+        let pin_opts = siv
+            .call_on_name(
+                Self::INTERFACE_PINNING_VIEW_NAME,
+                InterfacePinningOptionsView::get_values,
+            )
+            .unwrap_or_else(|| {
+                bail!("failed to retrieve network interface name pinning options")
+            })?;
+
+        Ok(pin_opts)
+    }
+
+    fn refresh_ifname_selectview(siv: &mut Cursive, options_ref: NetworkViewOptionsRef) {
         siv.call_on_name(
             Self::MGMT_IFNAME_SELECTVIEW_NAME,
             |view: &mut SelectView<Interface>| {
-                *view = Self::build_mgmt_ifname_selectview(ifaces.iter().collect(), options_ref);
+                let selected_mac = view.selection().map(|iface| iface.mac);
+                *view = Self::build_mgmt_ifname_selectview(options_ref, selected_mac);
             },
         );
     }
 
     fn build_mgmt_ifname_selectview(
-        ifaces: Vec<&Interface>,
         options_ref: NetworkViewOptionsRef,
+        selected_mac: Option<MacAddress>,
     ) -> SelectView<Interface> {
         let options = options_ref.lock().expect("unpoisoned lock");
 
         // Map all interfaces to a list of (human-readable interface name, [Interface]) pairs
-        let ifnames = ifaces
+        let ifnames = options
+            .interfaces
             .iter()
             .map(|iface| {
                 if options.pinning_enabled
@@ -297,20 +255,20 @@ impl NetworkOptionsView {
             })
             .collect::<Vec<(String, Interface)>>();
 
-        let mut view = SelectView::new()
-            .popup()
-            .with_all(ifnames.clone())
-            .on_submit({
-                let options_ref = options_ref.clone();
-                move |_, iface| {
-                    options_ref.lock().expect("unpoisoned lock").selected_mac = iface.mac;
-                }
-            });
+        let mut view = SelectView::new().popup().with_all(ifnames.clone());
+
+        let selected_mac = selected_mac.unwrap_or_else(|| {
+            options
+                .interfaces
+                .first()
+                .expect("at least one network interface")
+                .mac
+        });
 
         // Finally, (try to) select the current one
         let selected = view
             .iter()
-            .position(|(_label, iface)| iface.mac == options.selected_mac)
+            .position(|(_label, iface)| iface.mac == selected_mac)
             .unwrap_or(0); // we sort UP interfaces first, so select the first UP interface
         //
         view.set_selection(selected);
@@ -324,19 +282,44 @@ impl ViewWrapper for NetworkOptionsView {
 }
 
 struct InterfacePinningOptionsView {
-    view: ScrollView<NamedView<FormView<MacAddress>>>,
+    view: LinearLayout,
 }
 
 impl InterfacePinningOptionsView {
+    const ENABLED_CHECKBOX_NAME: &str = "network-interface-name-pinning-enabled";
     const FORM_NAME: &str = "network-interface-name-pinning-form";
 
-    fn new(interfaces: &[&Interface], options_ref: NetworkViewOptionsRef) -> Self {
+    fn new(options_ref: NetworkViewOptionsRef) -> Self {
         let options = options_ref.lock().expect("unpoisoned lock");
 
         // Filter out all non-physical links, as it does not make sense to pin their names
         // in this way.
         // The low-level installer will skip them anyway.
-        let interfaces = interfaces.iter().filter(|iface| iface.pinned_id.is_some());
+        let interfaces = options
+            .interfaces
+            .iter()
+            .filter(|iface| iface.pinned_id.is_some());
+
+        let mut view = LinearLayout::vertical()
+            .child(
+                LinearLayout::horizontal()
+                    .child(
+                        Checkbox::new()
+                            .with_checked(options.pinning_enabled)
+                            .on_change({
+                                let options_ref = options_ref.clone();
+                                move |siv, enabled| {
+                                    options_ref.lock().expect("unpoisoned lock").pinning_enabled =
+                                        enabled;
+
+                                    Self::toggle_all(siv, enabled);
+                                }
+                            })
+                            .with_name(Self::ENABLED_CHECKBOX_NAME),
+                    )
+                    .child(TextView::new(" Pin network interface names").no_wrap()),
+            )
+            .child(DummyView::new());
 
         let mut form = FormView::<MacAddress>::new();
 
@@ -356,7 +339,7 @@ impl InterfacePinningOptionsView {
                                 .expect("always pinnable interface")
                                 .name,
                         )
-                        .max_content_width(MAX_IFNAME_LEN)
+                        .with_enabled(options.pinning_enabled)
                         .fixed_width(MAX_IFNAME_LEN),
                 );
 
@@ -375,13 +358,31 @@ impl InterfacePinningOptionsView {
             }
         }
 
-        Self {
-            view: ScrollView::new(form.with_name(Self::FORM_NAME)),
-        }
+        view.add_child(form.with_name(Self::FORM_NAME));
+
+        Self { view }
     }
 
-    fn get_values(&mut self) -> Result<NetworkInterfacePinningOptions, String> {
-        let form = self.view.get_inner_mut().get_mut();
+    fn toggle_all(siv: &mut Cursive, enabled: bool) {
+        siv.call_on_name(Self::FORM_NAME, |v: &mut FormView<MacAddress>| {
+            v.call_on_childs(&|v: &mut LinearLayout| {
+                if let Some(v) = v
+                    .get_child_mut(1)
+                    .and_then(|v| v.downcast_mut::<ResizedView<EditView>>())
+                {
+                    v.get_inner_mut().set_enabled(enabled);
+                }
+            });
+        });
+    }
+
+    fn get_values(&mut self) -> Result<NetworkInterfacePinningOptions> {
+        let form = self
+            .view
+            .get_child_mut(2)
+            .and_then(|v| v.downcast_mut::<NamedView<FormView<MacAddress>>>())
+            .ok_or(anyhow!("failed to retrieve interface pinning form"))?
+            .get_mut();
 
         let mut mapping = HashMap::new();
 
@@ -395,18 +396,21 @@ impl InterfacePinningOptionsView {
                 .get_child(1)
                 .and_then(|v| v.downcast_ref::<ResizedView<EditView>>())
                 .map(|v| v.get_inner().get_content())
-                .ok_or_else(|| format!("failed to retrieve pinning ID for interface {}", mac))?;
+                .ok_or(anyhow!(
+                    "failed to retrieve pinning ID for interface {}",
+                    mac
+                ))?;
 
             mapping.insert(*mac, (*name).clone());
         }
 
         let opts = NetworkInterfacePinningOptions { mapping };
-        opts.verify().map_err(|err| err.to_string())?;
+        opts.verify()?;
 
         Ok(opts)
     }
 }
 
 impl ViewWrapper for InterfacePinningOptionsView {
-    cursive::wrap_impl!(self.view: ScrollView<NamedView<FormView<MacAddress>>>);
+    cursive::wrap_impl!(self.view: LinearLayout);
 }
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

* [PATCH installer 15/15] tui: network: add bond setup options to advanced network dialog
  2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
                   ` (13 preceding siblings ...)
  2026-09-08 10:16 ` [PATCH installer 14/15] tui: network: factor out name pinning into advanced options dialog Christoph Heiss
@ 2026-09-08 10:16 ` Christoph Heiss
  14 siblings, 0 replies; 16+ messages in thread
From: Christoph Heiss @ 2026-09-08 10:16 UTC (permalink / raw)
  To: pve-devel

Partially fixes #2164 [0].

Adds a second panel to the advanced network options dialog, allowing to
enable bonding and setting all the required options for it; such as
members, mode, hash policy and primary bond member, as required.

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=2164

Signed-off-by: Christoph Heiss <c.heiss@proxmox.com>
---
 proxmox-tui-installer/Cargo.toml           |   1 +
 proxmox-tui-installer/src/main.rs          |   7 +-
 proxmox-tui-installer/src/options.rs       |  45 ++-
 proxmox-tui-installer/src/setup.rs         |   5 +
 proxmox-tui-installer/src/views/network.rs | 415 ++++++++++++++++++---
 5 files changed, 424 insertions(+), 49 deletions(-)

diff --git a/proxmox-tui-installer/Cargo.toml b/proxmox-tui-installer/Cargo.toml
index 56395a4..cb950b3 100644
--- a/proxmox-tui-installer/Cargo.toml
+++ b/proxmox-tui-installer/Cargo.toml
@@ -9,6 +9,7 @@ homepage = "https://www.proxmox.com"
 
 [dependencies]
 proxmox-installer-common.workspace = true
+proxmox-network-api.workspace = true
 proxmox-network-types.workspace = true
 proxmox-installer-types.workspace = true
 anyhow.workspace = true
diff --git a/proxmox-tui-installer/src/main.rs b/proxmox-tui-installer/src/main.rs
index 61a06f7..6e7c691 100644
--- a/proxmox-tui-installer/src/main.rs
+++ b/proxmox-tui-installer/src/main.rs
@@ -177,6 +177,7 @@ fn main() {
                 None,
                 // We enable network interface pinning by default in the TUI
                 Some(&NetworkInterfacePinningOptions::default()),
+                None,
             ),
             autoreboot: true,
         },
@@ -578,7 +579,11 @@ fn summary_dialog(siv: &mut Cursive) -> InstallerView {
                     ("name".to_owned(), "Option".to_owned()),
                     ("value".to_owned(), "Selected value".to_owned()),
                 ])
-                .items(state.options.to_summary(&state.locales)),
+                .items(
+                    state
+                        .options
+                        .to_summary(&state.runtime_info, &state.locales),
+                ),
         ))
         .child(
             LinearLayout::horizontal()
diff --git a/proxmox-tui-installer/src/options.rs b/proxmox-tui-installer/src/options.rs
index 2c156e8..8b650f1 100644
--- a/proxmox-tui-installer/src/options.rs
+++ b/proxmox-tui-installer/src/options.rs
@@ -2,9 +2,9 @@ use crate::SummaryOption;
 
 use proxmox_installer_common::{
     options::{BootdiskOptions, NetworkOptions, TimezoneOptions},
-    setup::LocaleInfo,
+    setup::{LocaleInfo, NetworkInfo, RuntimeInfo},
 };
-use proxmox_installer_types::EMAIL_DEFAULT_PLACEHOLDER;
+use proxmox_installer_types::{EMAIL_DEFAULT_PLACEHOLDER, answer::NetworkBondOptions};
 
 #[derive(Clone)]
 pub struct PasswordOptions {
@@ -31,13 +31,27 @@ pub struct InstallerOptions {
 }
 
 impl InstallerOptions {
-    pub fn to_summary(&self, locales: &LocaleInfo) -> Vec<SummaryOption> {
+    pub fn to_summary(
+        &self,
+        runtime_info: &RuntimeInfo,
+        locales: &LocaleInfo,
+    ) -> Vec<SummaryOption> {
         let kb_layout = locales
             .kmap
             .get(&self.timezone.kb_layout)
             .map(|l| &l.name)
             .unwrap_or(&self.timezone.kb_layout);
 
+        let mngmt_interface = if let Some(opts) = &self.network.bond_opts {
+            let members = self
+                .resolve_bond_member_names(opts, &runtime_info.network)
+                .join(" | ");
+
+            format!("bond0 (mode: {}, members: {members})", opts.mode)
+        } else {
+            self.network.ifname.to_owned()
+        };
+
         vec![
             SummaryOption::new("Bootdisk filesystem", self.bootdisk.fstype.to_string()),
             SummaryOption::new(
@@ -52,11 +66,34 @@ impl InstallerOptions {
             SummaryOption::new("Timezone", &self.timezone.timezone),
             SummaryOption::new("Keyboard layout", kb_layout),
             SummaryOption::new("Administrator email", &self.password.email),
-            SummaryOption::new("Management interface", &self.network.ifname),
+            SummaryOption::new("Management interface", &mngmt_interface),
             SummaryOption::new("Hostname", self.network.fqdn.to_string()),
             SummaryOption::new("Host IP (CIDR)", self.network.address.to_string()),
             SummaryOption::new("Gateway", self.network.gateway.to_string()),
             SummaryOption::new("DNS", self.network.dns_server.to_string()),
         ]
     }
+
+    fn resolve_bond_member_names(
+        &self,
+        bond: &NetworkBondOptions,
+        network: &NetworkInfo,
+    ) -> Vec<String> {
+        let pin_opts = self.network.pinning_opts.as_ref();
+
+        bond.members
+            .iter()
+            .map(|mac| {
+                // Resolve either to the pinned name, if enabled, otherwise to the kernel-assigned one.
+                pin_opts
+                    .and_then(|o| o.mapping.get(mac).cloned())
+                    .or_else(|| {
+                        network.interfaces.iter().find_map(|(_, iface)| {
+                            (iface.mac == *mac).then_some(iface.name.to_owned())
+                        })
+                    })
+                    .unwrap_or("<unknown>".to_owned())
+            })
+            .collect()
+    }
 }
diff --git a/proxmox-tui-installer/src/setup.rs b/proxmox-tui-installer/src/setup.rs
index ae4b717..fb9a305 100644
--- a/proxmox-tui-installer/src/setup.rs
+++ b/proxmox-tui-installer/src/setup.rs
@@ -5,6 +5,7 @@ use proxmox_installer_common::{
     options::AdvancedBootdiskOptions,
     setup::{InstallConfig, InstallFirstBootSetup, InstallRootPassword},
 };
+use proxmox_installer_types::answer::NetworkBondOptions;
 
 impl From<InstallerOptions> for InstallConfig {
     fn from(options: InstallerOptions) -> Self {
@@ -35,6 +36,10 @@ impl From<InstallerOptions> for InstallConfig {
             subscription_key: None,
 
             mngmt_nic: options.network.ifname,
+            mngmt_bond: options
+                .network
+                .bond_opts
+                .unwrap_or_else(NetworkBondOptions::disabled),
             network_interface_pin_map: pinning_opts.map(|o| o.mapping.clone()).unwrap_or_default(),
 
             hostname: options.network.fqdn.host().to_owned(),
diff --git a/proxmox-tui-installer/src/views/network.rs b/proxmox-tui-installer/src/views/network.rs
index f040881..f1b677d 100644
--- a/proxmox-tui-installer/src/views/network.rs
+++ b/proxmox-tui-installer/src/views/network.rs
@@ -18,16 +18,24 @@ use proxmox_installer_common::{
     options::{NetworkInterfacePinningOptions, NetworkOptions},
     setup::{Interface, NetworkInfo},
 };
+use proxmox_installer_types::answer::NetworkBondOptions;
+use proxmox_network_api::{BondXmitHashPolicy, LinuxBondMode};
 use proxmox_network_types::{MacAddress, fqdn::Fqdn, ip_address::Cidr};
 
-use super::{CidrAddressEditView, FormView};
+use super::{CheckboxGroup, CidrAddressEditView, FormView};
 
 struct NetworkViewOptions {
     pinning_enabled: bool,
+    bond_enabled: bool,
     interfaces: Vec<Interface>,
+    /// Last selected interface (by its MAC address) in the [SelectView].
+    /// Keeps track of the selected interface across enabling/disabling bonding, which replaces the
+    /// view.
+    last_selected_iface: MacAddress,
     // For UI purposes, we want to always save the state for the below options, even if not enabled
     // by the user - to preserve the state between toggling the checkbox
     pinning_options: NetworkInterfacePinningOptions,
+    bond_options: NetworkBondOptions,
 }
 
 /// Convenience wrapper when needing to take a (interior-mutable) reference to
@@ -46,6 +54,7 @@ impl NetworkOptionsView {
     const ADVANCED_OPTIONS_BUTTON_NAME: &str = "network-pinning-options-button";
 
     const INTERFACE_PINNING_VIEW_NAME: &str = "network-name-pinning-view";
+    const BOND_OPTIONS_VIEW_NAME: &str = "network-bond-options-view";
 
     pub fn new(options: &NetworkOptions, network_info: &NetworkInfo) -> Self {
         let mut interfaces = network_info
@@ -57,13 +66,30 @@ impl NetworkOptionsView {
         // First, sort interfaces by their link state and then name
         interfaces.sort_unstable_by(|a, b| (&a.state, &a.name).cmp(&(&b.state, &b.name)));
 
+        let selected_mac = network_info
+            .interfaces
+            .get(&options.ifname)
+            .map(|iface| iface.mac)
+            .unwrap_or_else(|| {
+                interfaces
+                    .first()
+                    .expect("at least one network interface")
+                    .mac
+            });
+
         let options_ref = Arc::new(Mutex::new(NetworkViewOptions {
             pinning_enabled: options.pinning_opts.is_some(),
+            bond_enabled: options.bond_opts.is_some(),
             interfaces,
+            last_selected_iface: selected_mac,
             pinning_options: options.pinning_opts.clone().unwrap_or_default(),
+            bond_options: options
+                .bond_opts
+                .clone()
+                .unwrap_or_else(NetworkBondOptions::disabled),
         }));
 
-        let iface_selection = Self::build_mgmt_ifname_selectview(options_ref.clone(), None);
+        let iface_selection = Self::build_mgmt_ifname_selectview(options_ref.clone());
 
         let form = FormView::<()>::new()
             .child(
@@ -148,18 +174,26 @@ impl NetworkOptionsView {
             .parse::<IpAddr>()
             .map_err(|err| err.to_string())?;
 
-        let pinning_opts = self
-            .options
-            .lock()
-            .map(|opt| opt.pinning_enabled.then_some(opt.pinning_options.clone()))
-            .map_err(|err| err.to_string())?;
+        let options = self.options.lock().map_err(|err| err.to_string())?;
 
-        let ifname = if let Some(opts) = &pinning_opts
-            && let Some(pinned) = iface.to_pinned(opts)
-        {
-            pinned.name
+        let pinning_opts = options
+            .pinning_enabled
+            .then_some(options.pinning_options.clone());
+
+        let bond_opts = options.bond_enabled.then_some(options.bond_options.clone());
+
+        let ifname = if options.bond_enabled {
+            "bond0".to_owned()
         } else {
-            iface.name
+            let iface = form
+                .get_value::<NamedView<SelectView<Interface>>, _>(0)
+                .ok_or("failed to retrieve management interface name")?;
+
+            pinning_opts
+                .as_ref()
+                .and_then(|opts| iface.to_pinned(opts))
+                .unwrap_or(iface)
+                .name
         };
 
         if address.address().is_ipv4() != gateway.is_ipv4() {
@@ -176,42 +210,51 @@ impl NetworkOptionsView {
                 gateway,
                 dns_server,
                 pinning_opts,
+                bond_opts,
             })
         }
     }
 
     fn advanced_options_view(options_ref: NetworkViewOptionsRef) -> impl View {
         let inner = ScrollView::new(
-            LinearLayout::vertical().child(Panel::new(
-                InterfacePinningOptionsView::new(options_ref.clone())
-                    .with_name(Self::INTERFACE_PINNING_VIEW_NAME),
-            )),
+            LinearLayout::vertical()
+                .child(Panel::new(
+                    InterfacePinningOptionsView::new(options_ref.clone())
+                        .with_name(Self::INTERFACE_PINNING_VIEW_NAME),
+                ))
+                .child(Panel::new(
+                    BondOptionsView::new(options_ref.clone())
+                        .with_name(Self::BOND_OPTIONS_VIEW_NAME),
+                )),
         );
 
         Dialog::around(inner)
             .title("Advanced Network Options")
             .button("Ok", {
                 move |siv| {
-                    match Self::get_view_values(siv) {
-                        Ok(pin_opts) => {
+                    match Self::get_view_values(siv, options_ref.clone()) {
+                        Ok((pin_opts, bond_opts)) => {
                             siv.pop_layer();
                             let options = &mut options_ref.lock().expect("unpoisoned lock");
                             options.pinning_options = pin_opts;
+                            options.bond_options = bond_opts;
                         }
                         Err(err) => {
-                            siv.pop_layer();
                             siv.add_layer(Dialog::info(err.to_string()));
                             return;
                         }
                     }
 
-                    Self::refresh_ifname_selectview(siv, options_ref.clone());
+                    Self::refresh_ifname_view(siv, options_ref.clone());
                 }
             })
             .max_size((80, 40))
     }
 
-    fn get_view_values(siv: &mut Cursive) -> Result<NetworkInterfacePinningOptions> {
+    fn get_view_values(
+        siv: &mut Cursive,
+        options_ref: NetworkViewOptionsRef,
+    ) -> Result<(NetworkInterfacePinningOptions, NetworkBondOptions)> {
         let pin_opts = siv
             .call_on_name(
                 Self::INTERFACE_PINNING_VIEW_NAME,
@@ -221,23 +264,42 @@ impl NetworkOptionsView {
                 bail!("failed to retrieve network interface name pinning options")
             })?;
 
-        Ok(pin_opts)
+        let bond_opts = siv
+            .call_on_name(Self::BOND_OPTIONS_VIEW_NAME, BondOptionsView::get_values)
+            .unwrap_or_else(|| bail!("failed to retrieve management network bond options"))?;
+
+        if options_ref
+            .lock()
+            .map(|opts| opts.bond_enabled)
+            .unwrap_or(false)
+        {
+            bond_opts.verify()?;
+        }
+
+        Ok((pin_opts, bond_opts))
     }
 
-    fn refresh_ifname_selectview(siv: &mut Cursive, options_ref: NetworkViewOptionsRef) {
-        siv.call_on_name(
-            Self::MGMT_IFNAME_SELECTVIEW_NAME,
-            |view: &mut SelectView<Interface>| {
-                let selected_mac = view.selection().map(|iface| iface.mac);
-                *view = Self::build_mgmt_ifname_selectview(options_ref, selected_mac);
-            },
-        );
+    fn refresh_ifname_view(siv: &mut Cursive, options_ref: NetworkViewOptionsRef) {
+        siv.call_on_name(Self::FORM_VIEW_NAME, |form: &mut FormView<()>| {
+            let options = options_ref.lock().expect("unpoisoned lock");
+
+            if options.bond_enabled {
+                form.replace_child(
+                    0,
+                    TextView::new(format!(
+                        "bond0 (mode: {}, {} members)",
+                        options.bond_options.mode,
+                        options.bond_options.members.len()
+                    )),
+                );
+            } else {
+                drop(options);
+                form.replace_child(0, Self::build_mgmt_ifname_selectview(options_ref.clone()));
+            }
+        });
     }
 
-    fn build_mgmt_ifname_selectview(
-        options_ref: NetworkViewOptionsRef,
-        selected_mac: Option<MacAddress>,
-    ) -> SelectView<Interface> {
+    fn build_mgmt_ifname_selectview(options_ref: NetworkViewOptionsRef) -> SelectView<Interface> {
         let options = options_ref.lock().expect("unpoisoned lock");
 
         // Map all interfaces to a list of (human-readable interface name, [Interface]) pairs
@@ -255,20 +317,21 @@ impl NetworkOptionsView {
             })
             .collect::<Vec<(String, Interface)>>();
 
-        let mut view = SelectView::new().popup().with_all(ifnames.clone());
-
-        let selected_mac = selected_mac.unwrap_or_else(|| {
-            options
-                .interfaces
-                .first()
-                .expect("at least one network interface")
-                .mac
-        });
+        let mut view = SelectView::new()
+            .popup()
+            .with_all(ifnames.clone())
+            .on_submit({
+                let options_ref = options_ref.clone();
+                move |_, iface| {
+                    let mut options = options_ref.lock().expect("unpoisoned lock");
+                    options.last_selected_iface = iface.mac;
+                }
+            });
 
         // Finally, (try to) select the current one
         let selected = view
             .iter()
-            .position(|(_label, iface)| iface.mac == selected_mac)
+            .position(|(_label, iface)| iface.mac == options.last_selected_iface)
             .unwrap_or(0); // we sort UP interfaces first, so select the first UP interface
         //
         view.set_selection(selected);
@@ -313,6 +376,7 @@ impl InterfacePinningOptionsView {
                                         enabled;
 
                                     Self::toggle_all(siv, enabled);
+                                    BondOptionsView::refresh_ifnames(siv, options_ref.clone());
                                 }
                             })
                             .with_name(Self::ENABLED_CHECKBOX_NAME),
@@ -414,3 +478,266 @@ impl InterfacePinningOptionsView {
 impl ViewWrapper for InterfacePinningOptionsView {
     cursive::wrap_impl!(self.view: LinearLayout);
 }
+
+struct BondOptionsView {
+    view: LinearLayout,
+    options_ref: NetworkViewOptionsRef,
+}
+
+impl BondOptionsView {
+    const FORM_NAME: &str = "network-bond-form-view";
+    const MEMBERS_VIEW_NAME: &str = "network-bond-members-checkboxes-view";
+
+    fn new(options_ref: NetworkViewOptionsRef) -> Self {
+        let options = &options_ref.lock().expect("unpoisoned lock");
+
+        let mut view = LinearLayout::vertical()
+            .child(
+                LinearLayout::horizontal()
+                    .child(
+                        Checkbox::new()
+                            .with_checked(options.bond_enabled)
+                            .on_change({
+                                let options_ref = options_ref.clone();
+                                move |siv, enabled| {
+                                    options_ref.lock().expect("unpoisoned lock").bond_enabled =
+                                        enabled;
+
+                                    Self::toggle_all(siv, enabled);
+                                }
+                            }),
+                    )
+                    .child(TextView::new(" Enable bonding for management interface").no_wrap()),
+            )
+            .child(DummyView::new())
+            .child(
+                FormView::<()>::new()
+                    .child(
+                        "Bond Mode",
+                        SelectView::new()
+                            .popup()
+                            .with_all(
+                                NetworkBondOptions::MODES
+                                    .iter()
+                                    .map(|m| (m.to_string(), *m)),
+                            )
+                            .selected(
+                                NetworkBondOptions::MODES
+                                    .iter()
+                                    .position(|m| *m == options.bond_options.mode)
+                                    .unwrap_or_default(),
+                            )
+                            .on_submit(Self::toggle_mode_dependent_options)
+                            .with_enabled(options.bond_enabled),
+                    )
+                    .child(
+                        "Hash Policy",
+                        SelectView::new()
+                            .popup()
+                            .with_all(
+                                NetworkBondOptions::HASH_POLICIES
+                                    .iter()
+                                    .map(|m| (m.to_string(), *m)),
+                            )
+                            .selected(
+                                NetworkBondOptions::HASH_POLICIES
+                                    .iter()
+                                    .position(|m| *m == options.bond_options.hash_policy)
+                                    .unwrap_or_default(),
+                            )
+                            .with_enabled(
+                                options.bond_enabled
+                                    && Self::hash_policy_view_enabled(options.bond_options.mode),
+                            ),
+                    )
+                    .child(
+                        "Primary interface",
+                        EditView::new()
+                            .content({
+                                options
+                                    .bond_options
+                                    .primary_interface
+                                    .and_then(|mac| {
+                                        options.interfaces.iter().find(|iface| iface.mac == mac)
+                                    })
+                                    .and_then(|iface| iface.to_pinned(&options.pinning_options))
+                                    .map(|iface| iface.name)
+                                    .unwrap_or_default()
+                            })
+                            .with_enabled(
+                                options.bond_enabled
+                                    && Self::primary_interface_view_enable(
+                                        options.bond_options.mode,
+                                    ),
+                            ),
+                    )
+                    .with_name(Self::FORM_NAME),
+            )
+            .child(DummyView::new());
+
+        let mut checkboxes = CheckboxGroup::<MacAddress>::new();
+        for iface in options
+            .interfaces
+            .iter()
+            .filter(|iface| iface.pinned_id.is_some())
+        {
+            let label = Self::format_interface_label(options, iface);
+            let is_member = options.bond_options.members.contains(&iface.mac);
+
+            checkboxes.add(&label, is_member, iface.mac);
+        }
+        checkboxes.set_enabled(options.bond_enabled);
+        view.add_child(checkboxes.with_name(Self::MEMBERS_VIEW_NAME));
+
+        Self {
+            view,
+            options_ref: options_ref.clone(),
+        }
+    }
+
+    fn hash_policy_view_enabled(mode: LinuxBondMode) -> bool {
+        matches!(mode, LinuxBondMode::Ieee802_3ad | LinuxBondMode::BalanceXor)
+    }
+
+    fn primary_interface_view_enable(mode: LinuxBondMode) -> bool {
+        mode == LinuxBondMode::ActiveBackup
+    }
+
+    fn toggle_all(siv: &mut Cursive, enabled: bool) {
+        siv.call_on_name(Self::FORM_NAME, |form: &mut FormView| {
+            if let Some(v) = form.get_child_mut::<SelectView<LinuxBondMode>>(0) {
+                v.set_enabled(enabled);
+            }
+            if let Some(v) = form.get_child_mut::<SelectView<BondXmitHashPolicy>>(1) {
+                v.set_enabled(enabled);
+            }
+            if let Some(v) = form.get_child_mut::<EditView>(2) {
+                v.set_enabled(enabled);
+            }
+        });
+
+        siv.call_on_name(
+            Self::MEMBERS_VIEW_NAME,
+            |group: &mut CheckboxGroup<MacAddress>| {
+                group.set_enabled(enabled);
+            },
+        );
+    }
+
+    fn toggle_mode_dependent_options(siv: &mut Cursive, selected: &LinuxBondMode) {
+        siv.call_on_name(Self::FORM_NAME, |form: &mut FormView| {
+            if let Some(v) = form.get_child_mut::<SelectView<BondXmitHashPolicy>>(1) {
+                v.set_enabled(Self::hash_policy_view_enabled(*selected));
+            }
+            if let Some(v) = form.get_child_mut::<EditView>(2) {
+                v.set_enabled(Self::primary_interface_view_enable(*selected));
+            }
+        });
+    }
+
+    fn refresh_ifnames(siv: &mut Cursive, options_ref: NetworkViewOptionsRef) {
+        siv.call_on_name(
+            Self::MEMBERS_VIEW_NAME,
+            |group: &mut CheckboxGroup<MacAddress>| {
+                let options = options_ref.lock().expect("unpoisoned lock");
+                group.update_labels(&|_, label, mac| {
+                    if let Some(iface) = options.interfaces.iter().find(|iface| iface.mac == *mac) {
+                        label.set_content(Self::format_interface_label(&options, iface));
+                    }
+                });
+            },
+        );
+    }
+
+    fn get_values(&mut self) -> Result<NetworkBondOptions> {
+        let form = self
+            .view
+            .get_child_mut(2)
+            .and_then(|v| v.downcast_mut::<NamedView<FormView<()>>>())
+            .ok_or(anyhow!("failed to retrieve bond options form"))?
+            .get_mut();
+
+        let mode = form
+            .get_value::<SelectView<LinuxBondMode>, _>(0)
+            .ok_or(anyhow!("failed to retrieve bond mode"))?;
+
+        let hash_policy = form
+            .get_value::<SelectView<BondXmitHashPolicy>, _>(1)
+            .ok_or(anyhow!("failed to retrieve bond hash policy"))?;
+
+        let primary_interface = form
+            .get_value::<EditView, String>(2)
+            // translate the interface name back to the mac address, which is used as identifier
+            .ok_or(anyhow!("failed to retrieve bond primary interface"))
+            .and_then(|name| {
+                if name.is_empty() {
+                    Ok(None)
+                } else {
+                    Ok(Some(self.get_mac_address_for_interface_name(&name)?))
+                }
+            })?;
+
+        let members = self
+            .view
+            .get_child_mut(4)
+            .and_then(|v| v.downcast_mut::<NamedView<CheckboxGroup<MacAddress>>>())
+            .ok_or(anyhow!("failed to retrieve bond member interface group"))?
+            .get_mut()
+            .entries()
+            .filter_map(|(checked, mac)| checked.then_some(*mac))
+            .collect();
+
+        Ok(NetworkBondOptions {
+            members,
+            mode,
+            hash_policy,
+            primary_interface,
+        })
+    }
+
+    fn format_interface_label(options: &NetworkViewOptions, iface: &Interface) -> String {
+        let name = if options.pinning_enabled {
+            iface
+                .to_pinned(&options.pinning_options)
+                .map(|p| p.name)
+                .unwrap_or_else(|| iface.name.clone())
+        } else {
+            iface.name.clone()
+        };
+
+        let mut label = format!("{name} ({}, {}, {})", iface.mac, iface.driver, iface.state);
+
+        if !iface.addresses.is_empty() {
+            label += "\n  ";
+            label += iface
+                .addresses
+                .iter()
+                .fold(String::new(), |acc, addr| format!("{acc}, {addr}"))
+                .trim_start_matches(", ");
+        }
+
+        label
+    }
+
+    fn get_mac_address_for_interface_name(&self, ifname: &str) -> Result<MacAddress> {
+        let options = &self.options_ref.lock().expect("unpoisoned lock");
+
+        if options.pinning_enabled {
+            options
+                .pinning_options
+                .mapping
+                .iter()
+                .find_map(|(mac, name)| (*name == ifname).then_some(*mac))
+        } else {
+            options
+                .interfaces
+                .iter()
+                .find_map(|iface| (iface.name == ifname).then_some(iface.mac))
+        }
+        .ok_or_else(|| anyhow!("interface '{ifname}' not found"))
+    }
+}
+
+impl ViewWrapper for BondOptionsView {
+    cursive::wrap_impl!(self.view: LinearLayout);
+}
-- 
2.55.0





^ permalink raw reply related	[flat|nested] 16+ messages in thread

end of thread, other threads:[~2026-09-08 10:19 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-09-08 10:16 [PATCH installer/proxmox 00/15] partially fix #2164: add bond setup across all installers Christoph Heiss
2026-09-08 10:16 ` [PATCH proxmox 01/15] installer-types: use `MacAddress` type where applicable Christoph Heiss
2026-09-08 10:16 ` [PATCH proxmox 02/15] installer-types: answer: add options for configuring management bond Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 03/15] install: run make tidy Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 04/15] tree-wide: use `MacAddress` type instead of string for MAC addresses Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 05/15] install: factor out /etc/network/interfaces setup into own subroutine Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 06/15] install: do not rely on interface name map to be fully populated Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 07/15] install: config: add option for setting up bond on management interface Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 08/15] install: network: set up management bond if requested Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 09/15] gui: network: factor out name pinning into advanced options dialog Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 10/15] gui: network: add bond setup options to " Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 11/15] auto: pass trough management network bond options to low-level installer Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 12/15] tui: drop long obsolete comment about logo formatting Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 13/15] tui: views: add widget for displaying a group of checkboxes Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 14/15] tui: network: factor out name pinning into advanced options dialog Christoph Heiss
2026-09-08 10:16 ` [PATCH installer 15/15] tui: network: add bond setup options to advanced network dialog Christoph Heiss

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal