[pbs-devel] [PATCH proxmox-network-interface-pinning v4 1/1] initial commit

Wolfgang Bumiller w.bumiller at proxmox.com
Mon Aug 4 15:48:11 CEST 2025


On Thu, Jul 31, 2025 at 04:08:53PM +0200, Stefan Hanreich wrote:
> Introduce proxmox-network-interface-pinning, which is a
> reimplementation of the tool from pve-manager. It should function
> identically to the PVE version, except for virtual function support.
> It also uses the ifupdown2 configuration parser from Rust, instead of
> the perl implementation, which might have some subtle differences in
> their handling of ifupdown2 configuration files.
> 
> In order to support hosts that have both Proxmox VE and Proxmox Backup
> Server installed, this tool tries to detect the existence of the
> Proxmox VE tool and executes the Proxmox VE tool instead, if it is
> present on the host.
> 
> Signed-off-by: Stefan Hanreich <s.hanreich at proxmox.com>
> Tested-by: Christian Ebner <c.ebner at proxmox.com>
> ---
>  .cargo/config.toml   |   5 +
>  .gitignore           |   8 +
>  Cargo.toml           |  24 ++
>  Makefile             |  83 ++++++
>  debian/changelog     |   5 +
>  debian/control       |  36 +++
>  debian/copyright     |  17 ++
>  debian/debcargo.toml |   8 +
>  debian/rules         |  31 ++
>  src/main.rs          | 667 +++++++++++++++++++++++++++++++++++++++++++
>  10 files changed, 884 insertions(+)
>  create mode 100644 .cargo/config.toml
>  create mode 100644 .gitignore
>  create mode 100644 Cargo.toml
>  create mode 100644 Makefile
>  create mode 100644 debian/changelog
>  create mode 100644 debian/control
>  create mode 100644 debian/copyright
>  create mode 100644 debian/debcargo.toml
>  create mode 100755 debian/rules
>  create mode 100644 src/main.rs
> 
> diff --git a/.cargo/config.toml b/.cargo/config.toml
> new file mode 100644
> index 0000000..3b5b6e4
> --- /dev/null
> +++ b/.cargo/config.toml
> @@ -0,0 +1,5 @@
> +[source]
> +[source.debian-packages]
> +directory = "/usr/share/cargo/registry"
> +[source.crates-io]
> +replace-with = "debian-packages"
> diff --git a/.gitignore b/.gitignore
> new file mode 100644
> index 0000000..26ba170
> --- /dev/null
> +++ b/.gitignore
> @@ -0,0 +1,8 @@
> +/target
> +/proxmox-network-interface-pinning-[0-9]*/
> +*.build
> +*.buildinfo
> +*.changes
> +*.deb
> +*.dsc
> +*.tar*
> diff --git a/Cargo.toml b/Cargo.toml
> new file mode 100644
> index 0000000..ded4d7a
> --- /dev/null
> +++ b/Cargo.toml
> @@ -0,0 +1,24 @@
> +[package]
> +name = "proxmox-network-interface-pinning"
> +version = "0.1.0"
> +authors = ["Stefan Hanreich <s.hanreich at proxmox.com>"]
> +edition = "2024"
> +license = "AGPL-3"
> +description = "Tool for pinning the name of network interfaces."
> +
> +exclude = ["debian"]
> +
> +[dependencies]
> +anyhow = "1.0.95"
> +nix = "0.29"
> +serde = "1.0.217"
> +serde_json = "1.0.139"
> +walkdir = "2.5.0"
> +
> +proxmox-async = "0.5.0"
> +proxmox-log = "1.0.0"
> +proxmox-network-api = { version = "1.0.0", features = [ "impl" ] }
> +proxmox-network-types = "0.1.1"
> +proxmox-product-config = "1"
> +proxmox-router = "3.2.2"
> +proxmox-schema = { version = "4.1.1", features = [ "api-macro" ] }
> diff --git a/Makefile b/Makefile
> new file mode 100644
> index 0000000..7477cd4
> --- /dev/null
> +++ b/Makefile
> @@ -0,0 +1,83 @@
> +include /usr/share/dpkg/default.mk
> +
> +PACKAGE=proxmox-network-interface-pinning
> +CRATENAME=proxmox-network-interface-pinning
> +
> +BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
> +ORIG_SRC_TAR=$(PACKAGE)_$(DEB_VERSION_UPSTREAM).orig.tar.gz
> +
> +DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
> +DBG_DEB=$(PACKAGE)-dbgsym_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
> +DSC=$(PACKAGE)_$(DEB_VERSION).dsc
> +
> +CARGO ?= cargo
> +ifeq ($(BUILD_MODE), release)
> +CARGO_BUILD_ARGS += --release
> +COMPILEDIR := target/release
> +else
> +COMPILEDIR := target/debug
> +endif
> +
> +INSTALLDIR = /usr/bin
> +PROXMOX_NETWORK_INTERFACE_PINNING_BIN := $(addprefix $(COMPILEDIR)/,proxmox-network-interface-pinning)
> +
> +all:
> +
> +install: $(PROXMOX_NETWORK_INTERFACE_PINNING_BIN)
> +	install -dm755 $(DESTDIR)$(INSTALLDIR)
> +	install -m755 $(PROXMOX_NETWORK_INTERFACE_PINNING_BIN) $(DESTDIR)$(INSTALLDIR)/
> +
> +$(PROXMOX_NETWORK_INTERFACE_PINNING_BIN): .do-cargo-build
> +.do-cargo-build:
> +	$(CARGO) build $(CARGO_BUILD_ARGS)
> +	touch .do-cargo-build
> +
> +
> +.PHONY: cargo-build
> +cargo-build: .do-cargo-build
> +
> +$(BUILDDIR):
> +	rm -rf $@ $@.tmp
> +	mkdir $@.tmp
> +	cp -a debian/ src/ Makefile Cargo.toml $@.tmp
> +	mv $@.tmp $@
> +
> +
> +$(ORIG_SRC_TAR): $(BUILDDIR)
> +	tar czf $(ORIG_SRC_TAR) --exclude="$(BUILDDIR)/debian" $(BUILDDIR)
> +
> +.PHONY: deb
> +deb: $(DEB)
> +$(DEB) $(DBG_DEB) &: $(BUILDDIR)
> +	cd $(BUILDDIR); dpkg-buildpackage -b -uc -us
> +	lintian $(DEB)
> +	@echo $(DEB)
> +
> +.PHONY: dsc
> +dsc:
> +	rm -rf $(DSC) $(BUILDDIR)
> +	$(MAKE) $(DSC)
> +	lintian $(DSC)
> +
> +$(DSC): $(BUILDDIR) $(ORIG_SRC_TAR)
> +	cd $(BUILDDIR); dpkg-buildpackage -S -us -uc -d
> +
> +sbuild: $(DSC)
> +	sbuild $(DSC)
> +
> +.PHONY: upload
> +upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
> +upload: $(DEB) $(DBG_DEB)
> +	tar cf - $(DEB) $(DBG_DEB) |ssh -X repoman at repo.proxmox.com -- upload --product pbs --dist $(UPLOAD_DIST) --arch $(DEB_HOST_ARCH)
> +
> +.PHONY: clean distclean
> +distclean: clean
> +clean:
> +	$(CARGO) clean
> +	rm -rf $(PACKAGE)-[0-9]*/ build/
> +	rm -f *.deb *.changes *.dsc *.tar.* *.buildinfo *.build .do-cargo-build
> +
> +.PHONY: dinstall
> +dinstall: deb
> +	dpkg -i $(DEB)
> +
> diff --git a/debian/changelog b/debian/changelog
> new file mode 100644
> index 0000000..56422e9
> --- /dev/null
> +++ b/debian/changelog
> @@ -0,0 +1,5 @@
> +proxmox-network-interface-pinning (0.1.0) trixie; urgency=medium
> +
> +  * Initial release.
> +
> + -- Proxmox Support Team <support at proxmox.com>  Tue, 29 Jul 2025 14:39:57 +0200
> diff --git a/debian/control b/debian/control
> new file mode 100644
> index 0000000..9c025b6
> --- /dev/null
> +++ b/debian/control
> @@ -0,0 +1,36 @@
> +Source: proxmox-network-interface-pinning
> +Section: admin
> +Priority: optional
> +Build-Depends: debhelper-compat (= 13),
> +               dh-sequence-cargo,
> +               cargo:native,
> +               rustc:native,
> +               libstd-rust-dev,
> +               librust-anyhow-1+default-dev (>= 1.0.95-~~),
> +               librust-nix-0.29+default-dev,
> +               librust-proxmox-async-0.5+default-dev,
> +               librust-proxmox-log-1+default-dev,
> +               librust-proxmox-network-api-1+default-dev,
> +               librust-proxmox-network-api-1+impl-dev,
> +               librust-proxmox-network-types-0.1+default-dev,
> +               librust-proxmox-product-config-1+default-dev,
> +               librust-proxmox-router-3+default-dev (>= 3.2.2-~~),
> +               librust-proxmox-schema-4+api-macro-dev (>= 4.1.1-~~),
> +               librust-proxmox-schema-4+default-dev (>= 4.1.1-~~),
> +               librust-serde-1+default-dev (>= 1.0.217-~~),
> +               librust-serde-json-1+default-dev (>= 1.0.139-~~),
> +               librust-walkdir-2+default-dev (>= 2.5.0-~~)
> +Maintainer: Proxmox Support Team <support at proxmox.com>
> +Standards-Version: 4.7.0
> +Vcs-Git: 
> +Vcs-Browser: 
> +Rules-Requires-Root: no
> +
> +Package: proxmox-network-interface-pinning
> +Architecture: any
> +Multi-Arch: allowed
> +Depends: ${misc:Depends}, ${shlibs:Depends},
> +Description: Pinning the name of network interfaces
> + This package contains the following binaries built from the Rust crate
> + "proxmox-network-interface-pinning":
> +  - proxmox-network-interface-pinning
> diff --git a/debian/copyright b/debian/copyright
> new file mode 100644
> index 0000000..61c573d
> --- /dev/null
> +++ b/debian/copyright
> @@ -0,0 +1,17 @@
> +Copyright (C) 2025 Proxmox Server Solutions GmbH
> +
> +This software is written by Proxmox Server Solutions GmbH <support at proxmox.com>
> +
> +This program is free software: you can redistribute it and/or modify
> +it under the terms of the GNU Affero General Public License as published by
> +the Free Software Foundation, either version 3 of the License, or
> +(at your option) any later version.
> +
> +This program is distributed in the hope that it will be useful,
> +but WITHOUT ANY WARRANTY; without even the implied warranty of
> +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
> +GNU Affero General Public License for more details.
> +
> +You should have received a copy of the GNU Affero General Public License
> +along with this program.  If not, see <http://www.gnu.org/licenses/>.
> +
> diff --git a/debian/debcargo.toml b/debian/debcargo.toml
> new file mode 100644
> index 0000000..703440f
> --- /dev/null
> +++ b/debian/debcargo.toml
> @@ -0,0 +1,8 @@
> +overlay = "."
> +crate_src_path = ".."
> +maintainer = "Proxmox Support Team <support at proxmox.com>"
> +
> +[source]
> +# TODO: update once public
> +vcs_git = ""
> +vcs_browser = ""
> diff --git a/debian/rules b/debian/rules
> new file mode 100755
> index 0000000..e157e13
> --- /dev/null
> +++ b/debian/rules
> @@ -0,0 +1,31 @@
> +#!/usr/bin/make -f
> +# See debhelper(7) (uncomment to enable)
> +# output every command that modifies files on the build system.
> +DH_VERBOSE = 1
> +
> +include /usr/share/dpkg/pkg-info.mk
> +include /usr/share/rustc/architecture.mk
> +
> +export BUILD_MODE=release
> +
> +CARGO=/usr/share/cargo/bin/cargo
> +
> +export CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
> +export DEB_HOST_RUST_TYPE DEB_HOST_GNU_TYPE
> +export CARGO_HOME = $(CURDIR)/debian/cargo_home
> +
> +export DEB_CARGO_CRATE=proxmox-network-interface-pinning_$(DEB_VERSION_UPSTREAM)
> +export DEB_CARGO_PACKAGE=proxmox-network-interface-pinning
> +
> +%:
> +	dh $@
> +
> +override_dh_auto_configure:
> +	@perl -ne 'if (/^version\s*=\s*"(\d+(?:\.\d+)+)"/) { my $$v_cargo = $$1; my $$v_deb = "$(DEB_VERSION_UPSTREAM)"; \
> +	    die "ERROR: d/changelog <-> Cargo.toml version mismatch: $$v_cargo != $$v_deb\n" if $$v_cargo ne $$v_deb; exit(0); }' Cargo.toml
> +	$(CARGO) prepare-debian $(CURDIR)/debian/cargo_registry --link-from-system
> +	dh_auto_configure
> +
> +override_dh_missing:
> +	dh_missing --fail-missing
> +
> diff --git a/src/main.rs b/src/main.rs
> new file mode 100644
> index 0000000..4c83d17
> --- /dev/null
> +++ b/src/main.rs
> @@ -0,0 +1,667 @@
> +use std::collections::{HashMap, HashSet};
> +use std::fmt::{Display, Formatter};
> +use std::ops::{Deref, DerefMut};
> +use std::os::unix::process::CommandExt;
> +use std::process::Command;
> +use std::process::Stdio;
> +
> +use anyhow::{anyhow, bail, format_err, Error};
> +use nix::unistd::{Uid, User};
> +use serde::de::{value::MapDeserializer, Deserialize};
> +
> +use proxmox_log::{debug, LevelFilter};
> +use proxmox_network_types::mac_address::MacAddress;
> +use proxmox_router::cli::{
> +    run_cli_command, CliCommand, CliCommandMap, CliEnvironment, Confirmation,
> +};
> +use proxmox_schema::api;
> +use walkdir::WalkDir;
> +
> +const SYSTEMD_LINK_FILE_PATH: &str = "/usr/local/lib/systemd/network";
> +
> +/// A mapping of interface names.
> +///
> +/// The containing HashMap uses the old name as key and the designated new name as value.
> +#[derive(Debug, Clone, serde::Deserialize, Default)]
> +pub struct InterfaceMapping {
> +    mapping: HashMap<String, String>,
> +}
> +
> +impl Deref for InterfaceMapping {
> +    type Target = HashMap<String, String>;
> +
> +    fn deref(&self) -> &Self::Target {
> +        &self.mapping
> +    }
> +}
> +
> +impl DerefMut for InterfaceMapping {
> +    fn deref_mut(&mut self) -> &mut Self::Target {
> +        &mut self.mapping
> +    }
> +}
> +
> +impl InterfaceMapping {
> +    /// writes an interface mapping to `/usr/local/lib/systemd/network/`.

We probably never generate rustdocs here so might as well just say
`SYSTEMD_LINK_FILE_PATH` here.

> +    ///
> +    /// It uses [`ip_links`] to determine the MAC address of the interfaces that should be pinned,
> +    /// since we pin based on MAC addresses.
> +    pub fn write(
> +        &self,
> +        ip_links: HashMap<String, proxmox_network_api::IpLink>,
> +    ) -> Result<(), Error> {
> +        if self.mapping.is_empty() {
> +            return Ok(());
> +        }
> +
> +        println!("Generating link files");
> +
> +        std::fs::create_dir_all(SYSTEMD_LINK_FILE_PATH)?;
> +
> +        let mut sorted_links: Vec<&proxmox_network_api::IpLink> = ip_links.values().collect();
> +        sorted_links.sort_by_key(|a| a.index());
> +
> +        for ip_link in sorted_links {
> +            if let Some(new_name) = self.mapping.get(ip_link.name()) {
> +                let link_file = LinkFile::new_ether(ip_link.permanent_mac(), new_name.to_string());
> +
> +                std::fs::write(
> +                    format!("{}/{}", SYSTEMD_LINK_FILE_PATH, link_file.file_name()),
> +                    link_file.to_string().as_bytes(),
> +                )?;
> +            }
> +        }
> +
> +        println!("Successfully generated .link files in '/usr/local/lib/systemd/network/'");

Replace the path with `{SYSTEMD_LINK_FILE_PATH}`

> +        Ok(())
> +    }
> +}
> +
> +/// A struct holding information about existing pinned network interfaces.
> +///
> +/// Can be constructed from an iterator over [`LinkFile`] via [`PinnedInterfaces::from_iter`].
> +struct PinnedInterfaces {
> +    mapping: HashMap<MacAddress, String>,
> +}
> +
> +impl PinnedInterfaces {
> +    pub fn get(&self, mac_address: &MacAddress) -> Option<&String> {
> +        self.mapping.get(mac_address)
> +    }
> +
> +    pub fn values(&self) -> impl Iterator<Item = &String> {
> +        self.mapping.values()
> +    }
> +
> +    pub fn contains_key(&self, mac_address: &MacAddress) -> bool {
> +        self.mapping.contains_key(mac_address)
> +    }
> +}
> +
> +impl FromIterator<LinkFile> for PinnedInterfaces {
> +    fn from_iter<T: IntoIterator<Item = LinkFile>>(iter: T) -> Self {
> +        Self {
> +            mapping: iter
> +                .into_iter()
> +                .map(|link_file| {
> +                    (
> +                        link_file.match_section.mac_address,
> +                        link_file.link_section.name,
> +                    )
> +                })
> +                .collect(),
> +        }
> +    }
> +}
> +
> +/// The Match section of a systemd .link file, as created by this tool.
> +///
> +/// This struct only models the properties that are contained in .link files managed by this tool.
> +/// It cannot be used to model / parse arbitrary .link files.
> +#[derive(Debug, Clone, serde::Deserialize)]
> +struct MatchSection {
> +    #[serde(rename = "MACAddress")]
> +    mac_address: MacAddress,
> +    #[serde(rename = "Type")]
> +    ty: String,
> +}
> +
> +impl Display for MatchSection {
> +    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
> +        writeln!(f, "MACAddress={}", self.mac_address)?;
> +        writeln!(f, "Type={}", self.ty)?;
> +
> +        Ok(())
> +    }
> +}
> +
> +/// The Link section of a systemd .link file, as created by this tool.
> +///
> +/// This struct only models the properties that are contained in .link files managed by this tool.
> +/// It cannot be used to model / parse arbitrary .link files.
> +#[derive(Debug, Clone, serde::Deserialize)]
> +struct LinkSection {
> +    #[serde(rename = "Name")]
> +    name: String,
> +}
> +
> +impl Display for LinkSection {
> +    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
> +        writeln!(f, "Name={}", self.name)
> +    }
> +}
> +
> +/// Section types contained in .link files managed by this tool.
> +#[derive(Debug, Clone, serde::Deserialize, Hash, Eq, PartialEq)]

^ Should be Copy, too.

> +pub enum LinkFileSection {
> +    Match,
> +    Link,
> +}
> +
> +impl std::str::FromStr for LinkFileSection {
> +    type Err = Error;
> +
> +    fn from_str(value: &str) -> Result<Self, Self::Err> {
> +        Ok(match value {
> +            "[Match]" => LinkFileSection::Match,
> +            "[Link]" => LinkFileSection::Link,
> +            _ => bail!("invalid section type: {value}"),
> +        })
> +    }
> +}
> +
> +impl Display for LinkFileSection {
> +    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
> +        writeln!(
> +            f,
> +            "{}",
> +            match self {
> +                LinkFileSection::Link => "[Link]",
> +                LinkFileSection::Match => "[Match]",
> +            }
> +        )
> +    }
> +}
> +
> +/// A systemd .link file, as created by this tool.
> +///
> +/// This struct only models the sections that are contained in .link files managed by this tool.
> +/// It cannot be used to model / parse arbitrary .link files.
> +#[derive(Debug, Clone, serde::Deserialize)]
> +struct LinkFile {
> +    match_section: MatchSection,
> +    link_section: LinkSection,
> +}
> +
> +impl LinkFile {
> +    /// creates a new [`LinkFile`] to pin a network interface.
> +    ///
> +    /// `mac_address` is the MAC address of the interface that should be pinned and `name` is the
> +    /// name that the interface should get pinned to.
> +    pub fn new_ether(mac_address: MacAddress, name: String) -> Self {
> +        Self {
> +            match_section: MatchSection {
> +                mac_address,
> +                ty: "ether".to_string(),
> +            },
> +            link_section: LinkSection { name },
> +        }
> +    }
> +
> +    /// returns the file name, that this link file should be saved as when writing it to disk.
> +    pub fn file_name(&self) -> String {
> +        format!("50-pve-{}.link", self.link_section.name)
> +    }
> +}
> +
> +impl std::str::FromStr for LinkFile {
> +    type Err = Error;
> +
> +    fn from_str(value: &str) -> Result<Self, Self::Err> {

Note: This could probably go into a micro crate `proxmox-systemd-config`
as a generic "systemd config file to hashmap parser" (with the sections
as strings obviously).

The crate could later be extended to *optionally* also take into
account overriding files existing in paths which take precedence, as
well as taking drop-in `.d` into account.

If from that crate the *line* parsing functions are reusable,
proxmox-section-config could reuse that as well. There we also have a
systemd-format parser...

> +        let mut sections: HashMap<LinkFileSection, HashMap<String, String>> = HashMap::new();
> +        let mut current_section = None;
> +
> +        for line in value.lines() {

Technically systemd.syntax also states that lines ending with `\` are
concatenated with the next *non-comment* line with the `\` replaced by a space.

> +            let line = line.trim();
> +
> +            if line.is_empty() || line.starts_with(['#', ';']) {
> +                continue;
> +            }
> +
> +            if line.starts_with('[') {
> +                current_section = Some(line.parse()?);
> +                sections.insert(current_section.as_ref().cloned().unwrap(), HashMap::new());

.as_ref().cloned() -> .clone()
and to get rid of the .unwrap(), use Option::Insert:

    let current_section = *current_section.insert(line.parse()?);
    sections.insert(current_section, HashMap::new());

(Note the `*` - since it's Copy now)

> +            } else {
> +                if current_section.is_none() {
> +                    bail!("config line without section")
> +                }

^ to get rid of the `.expect()` further down, assign this:

    let current_section = current_section.context("config line without section")?;

> +
> +                let Some((key, value)) = line.split_once("=") else {
> +                    bail!("could not find key, value pair in link config");
> +                };

Note that while systemd in some place I cannot remember tells you that
you're not supposed to put spaces around the `=`, systemd.syntax(7) also
explicitly states that whitespace *immediately* before and afterwards is
ignored.

> +
> +                if key.is_empty() || value.is_empty() {
> +                    bail!("could not find key, value pair in link config");
> +                }
> +
> +                sections
> +                    .get_mut(current_section.as_ref().expect("current section is some"))

^ Then drop the .as_ref().expect()
And add an &` instead.

> +                    .expect("section has been inserted")
> +                    .insert(key.to_string(), value.to_string());
> +            }
> +        }
> +
> +        let link_data = sections
> +            .remove(&LinkFileSection::Link)
> +            .ok_or_else(|| anyhow!("no link section in link file"))?;
> +
> +        let link_section = LinkSection::deserialize(MapDeserializer::<
> +            std::collections::hash_map::IntoIter<String, String>,
> +            SerdeStringError,
> +        >::new(link_data.into_iter()))?;
> +
> +        let match_data = sections
> +            .remove(&LinkFileSection::Match)
> +            .ok_or_else(|| anyhow!("no match section in link file"))?;
> +
> +        let match_section = MatchSection::deserialize(MapDeserializer::<
> +            std::collections::hash_map::IntoIter<String, String>,

^ You don't need to name this type, you can just pass `_`.

> +            SerdeStringError,
> +        >::new(match_data.into_iter()))?;
> +
> +        Ok(Self {
> +            match_section,
> +            link_section,
> +        })
> +    }
> +}
> +
> +/// Custom serde Error type for parsing dynamic key-value pairs via [`MapDeserializer`]
> +#[derive(Debug)]
> +pub struct SerdeStringError(String);
> +
> +impl std::error::Error for SerdeStringError {}
> +
> +impl Display for SerdeStringError {
> +    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
> +        f.write_str(&self.0)
> +    }
> +}
> +
> +impl serde::de::Error for SerdeStringError {
> +    fn custom<T: Display>(msg: T) -> Self {
> +        Self(msg.to_string())
> +    }
> +}
> +
> +impl Display for LinkFile {
> +    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
> +        write!(f, "{}", LinkFileSection::Match)?;
> +        writeln!(f, "{}", self.match_section)?;
> +
> +        write!(f, "{}", LinkFileSection::Link)?;
> +        writeln!(f, "{}", self.link_section)?;
> +
> +        Ok(())
> +    }
> +}
> +
> +#[repr(transparent)]
> +#[derive(Debug, Clone, Eq, PartialEq, Hash)]
> +/// A wrapper struct for [`proxmox_network_api::IpLink`], that implements Ord by comparing
> +/// ifindexes.
> +struct IpLink(proxmox_network_api::IpLink);
> +
> +impl Deref for IpLink {
> +    type Target = proxmox_network_api::IpLink;
> +
> +    fn deref(&self) -> &Self::Target {
> +        &self.0
> +    }
> +}
> +
> +impl PartialOrd for IpLink {
> +    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
> +        Some(self.cmp(other))
> +    }
> +}
> +
> +impl Ord for IpLink {
> +    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
> +        self.0.index().cmp(&other.0.index())
> +    }
> +}
> +
> +impl From<proxmox_network_api::IpLink> for IpLink {
> +    fn from(value: proxmox_network_api::IpLink) -> Self {
> +        Self(value)
> +    }
> +}
> +
> +/// Container that provides the functionality for network interface pinning.
> +///
> +/// It holds all information required for generating
> +pub struct PinningTool {
> +    ip_links: HashMap<String, proxmox_network_api::IpLink>,
> +    pinned_interfaces: PinnedInterfaces,
> +    existing_names: HashSet<String>,
> +}
> +
> +impl PinningTool {
> +    fn read_link_files() -> Result<Vec<LinkFile>, Error> {
> +        debug!("reading link data");
> +
> +        let link_files = WalkDir::new(SYSTEMD_LINK_FILE_PATH)
> +            .max_depth(1)
> +            .into_iter()
> +            .filter_map(|entry| entry.ok())
> +            .filter(|entry| {
> +                if let Some(file_name) = entry.path().file_name() {
> +                    let name = file_name.to_str().unwrap();
> +                    return name.starts_with("50-pve-") && name.ends_with(".link");
> +                }
> +
> +                false
> +            });
> +
> +        let mut ip_links = Vec::new();
> +
> +        for link_file in link_files {
> +            debug!("reading file {}", link_file.path().display());
> +
> +            let file_content = std::fs::read(link_file.path())?;
> +            let ip_link = std::str::from_utf8(&file_content)?;
> +
> +            ip_links.push(ip_link.parse()?);
> +        }
> +
> +        Ok(ip_links)
> +    }
> +
> +    /// Constructs a new instance of the pinning tool.
> +    pub fn new() -> Result<Self, Error> {
> +        let ip_links = proxmox_network_api::get_network_interfaces()?;
> +        let pinned_interfaces: PinnedInterfaces = Self::read_link_files()?.into_iter().collect();
> +
> +        let mut existing_names = HashSet::new();
> +
> +        for name in ip_links.keys() {
> +            existing_names.insert(name.clone());
> +        }

Could just be
    existing_names.extend(ip_link.keys().cloned());

> +
> +        for pinned_interface in pinned_interfaces.values() {
> +            existing_names.insert(pinned_interface.clone());
> +        }

Could just be
    existing_names.extend(pinned_interfaces.values().cloned());

> +
> +        Ok(Self {
> +            ip_links,
> +            pinned_interfaces,
> +            existing_names,
> +        })
> +    }
> +
> +    /// Pins a specific interface by writing a .link file.
> +    ///
> +    /// If `prefix` is given, auto-generates the name for the interface with the given prefix,
> +    /// otherwise `nic` is used.
> +    /// If `target_name` is given, the interface is pinned to the specific name.
> +    pub fn pin_interface(
> +        self,
> +        interface_name: &str,
> +        target_name: Option<String>,
> +        prefix: Option<String>,
> +    ) -> Result<InterfaceMapping, Error> {
> +        let ip_link = self
> +            .ip_links
> +            .get(interface_name)
> +            .ok_or_else(|| anyhow!("cannot find interface with name {interface_name}"))?;
> +
> +        if self
> +            .pinned_interfaces
> +            .contains_key(&ip_link.permanent_mac())
> +        {
> +            bail!("pin already exists for interface {interface_name}");
> +        }
> +
> +        let mut mapping = InterfaceMapping::default();
> +
> +        if let Some(target_name) = target_name {
> +            if self.existing_names.contains(&target_name) {
> +                bail!("target name already exists");
> +            }
> +
> +            let mut current_altnames = Vec::new();
> +
> +            mapping.insert(ip_link.name().to_string(), target_name.to_string());
> +
> +            for altname in ip_link.altnames() {
> +                current_altnames.push(altname.as_str());
> +                mapping.insert(altname.to_string(), target_name.to_string());
> +            }
> +
> +            println!(
> +                "Name for {} ({}) will change to {target_name}",
> +                ip_link.name(),
> +                current_altnames.join(", ")
> +            );
> +        } else if let Some(prefix) = prefix {
> +            let mut idx = 0;
> +
> +            loop {
> +                let target_name = format!("{prefix}{idx}");
> +
> +                if !self.existing_names.contains(&target_name) {
> +                    let mut current_altnames = Vec::new();
> +
> +                    mapping.insert(ip_link.name().to_string(), target_name.to_string());
> +
> +                    for altname in ip_link.altnames() {
> +                        current_altnames.push(altname.as_str());
> +                        mapping.insert(altname.to_string(), target_name.to_string());
> +                    }
> +
> +                    println!(
> +                        "Name for {} ({}) will change to {target_name}",
> +                        ip_link.name(),
> +                        current_altnames.join(", ")
> +                    );
> +
> +                    break;
> +                }
> +
> +                idx += 1;
> +            }
> +        } else {
> +            return Err(anyhow!(
bail! ? :)
> +                "neither target-name nor prefix provided for interface"
> +            ));
> +        }
> +
> +        mapping.write(self.ip_links)?;
> +        Ok(mapping)
> +    }
> +
> +    /// Pins all physical interfaces available on the host by writing respective .link files.
> +    ///
> +    /// Names are generated according to the given `prefix`. Interfaces are iterated in ascending
> +    /// order, sorted by their ifindex.
> +    pub fn pin_all(mut self, prefix: &str) -> Result<InterfaceMapping, Error> {
> +        let mut mapping = InterfaceMapping::default();
> +
> +        let mut idx = 0;
> +
> +        let mut eligible_links: Vec<IpLink> = self
> +            .ip_links
> +            .values()
> +            .filter(|ip_link| {
> +                ip_link.is_physical()
> +                    && self
> +                        .pinned_interfaces
> +                        .get(&ip_link.permanent_mac())
> +                        .is_none()
> +            })
> +            .cloned()
> +            .map(IpLink::from)
> +            .collect();
> +
> +        eligible_links.sort();
> +
> +        for ip_link in eligible_links {
> +            loop {
> +                let target_name = format!("{prefix}{idx}");
> +
> +                if !self.existing_names.contains(&target_name) {
> +                    let mut current_altnames = Vec::new();
> +
> +                    mapping.insert(ip_link.name().to_string(), target_name.to_string());
> +
> +                    for altname in ip_link.altnames() {
> +                        current_altnames.push(altname.as_str());
> +                        mapping.insert(altname.to_string(), target_name.to_string());
> +                    }
> +
> +                    println!(
> +                        "Name for {} ({}) will change to {target_name}",
> +                        ip_link.name(),
> +                        current_altnames.join(", ")
> +                    );
> +
> +                    self.existing_names.insert(target_name);
> +
> +                    break;
> +                }
> +
> +                idx += 1;
> +            }
> +        }
> +
> +        mapping.write(self.ip_links)?;
> +        Ok(mapping)
> +    }
> +}
> +
> +#[api(
> +    input: {
> +        properties: {
> +            interface: {
> +                type: String,
> +                optional: true,
> +                description: "Only pin a specific interface.",
> +            },
> +            prefix: {
> +                type: String,
> +                optional: true,
> +                description: "Use a specific prefix for automatically choosing the pinned name.",
> +            },
> +            "target-name": {
> +                type: String,
> +                optional: true,
> +                description: "Pin the interface to a specific name.",
> +            },
> +        }
> +    }
> +)]
> +/// Generates link files to pin the names of network interfaces (based on MAC address).
> +fn generate_mapping(
> +    interface: Option<String>,
> +    prefix: Option<String>,
> +    target_name: Option<String>,
> +) -> Result<(), Error> {
> +    let pinning_tool = PinningTool::new()?;
> +
> +    let target = if let Some(ref interface) = interface {
> +        interface.as_str()
> +    } else {
> +        "all interfaces"
> +    };
> +
> +    let confirmation = Confirmation::query_with_default(
> +        format!("This will generate name pinning configuration for {target} - continue (y/N)?")
> +            .as_str(),
> +        Confirmation::No,
> +    )?;
> +
> +    if confirmation.is_no() {
> +        return Ok(());
> +    }
> +
> +    let mapping = if let Some(interface) = interface {
> +        pinning_tool.pin_interface(&interface, target_name, prefix)?
> +    } else {
> +        let prefix = prefix.unwrap_or("nic".to_string());
> +        pinning_tool.pin_all(&prefix)?
> +    };
> +
> +    if mapping.is_empty() {
> +        println!("Nothing to do. Aborting.");
> +        return Ok(());
> +    }
> +
> +    println!("Updating /etc/network/interfaces.new");
> +
> +    proxmox_network_api::lock_config()?;
> +
> +    let (mut config, _) = proxmox_network_api::config()?;
> +    config.rename_interfaces(mapping.deref())?;
> +
> +    proxmox_network_api::save_config(&config)?;
> +
> +    println!("Successfully updated network configuration files.");
> +
> +    println!("\nPlease reboot to apply the changes to your configuration\n");
> +
> +    Ok(())
> +}
> +
> +// TODO: make this load the unprivileged user dynamically, depending on product, default to backup
> +// for now since we only ship the tool with PBS currently
> +pub fn unprivileged_user() -> Result<nix::unistd::User, Error> {
> +    if cfg!(test) {
> +        Ok(User::from_uid(Uid::current())?.expect("current user does not exist"))
> +    } else {
> +        User::from_name("backup")?.ok_or_else(|| format_err!("Unable to lookup 'backup' user."))
> +    }
> +}
> +
> +pub fn privileged_user() -> Result<nix::unistd::User, Error> {
> +    if cfg!(test) {
> +        Ok(User::from_uid(Uid::current())?.expect("current user does not exist"))
> +    } else {
> +        User::from_name("root")?.ok_or_else(|| format_err!("Unable to lookup superuser."))
> +    }
> +}
> +
> +const PVE_NETWORK_INTERFACE_PINNING_BIN: &str =
> +    "/usr/libexec/proxmox/pve-network-interface-pinning";
> +
> +fn main() -> Result<(), Error> {
> +    // This is run on a PVE host, so we use the PVE-specific pinning tool instead with the
> +    // parameters supplied.
> +    if std::fs::exists(PVE_NETWORK_INTERFACE_PINNING_BIN)? {
> +        let args = std::env::args().skip(1);
> +
> +        return Err(Command::new(PVE_NETWORK_INTERFACE_PINNING_BIN)
> +            .args(args)
> +            .stdout(Stdio::inherit())
> +            .stderr(Stdio::inherit())
> +            .exec()
> +            .into());
> +    }
> +
> +    proxmox_log::Logger::from_env("PBS_LOG", LevelFilter::INFO)
> +        .stderr()
> +        .init()
> +        .expect("failed to initiate logger");
> +
> +    // required for locking the network config
> +    proxmox_product_config::init(unprivileged_user()?, privileged_user()?);
> +
> +    let generate_command = CliCommand::new(&API_METHOD_GENERATE_MAPPING);
> +    let commands = CliCommandMap::new().insert("generate", generate_command);
> +
> +    let rpcenv = CliEnvironment::new();
> +
> +    run_cli_command(commands, rpcenv, None);
> +
> +    Ok(())
> +}
> -- 
> 2.47.2




More information about the pbs-devel mailing list