all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Stefan Hanreich <s.hanreich@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH pve-manager 1/1] cli: add pveeth
Date: Wed,  9 Jul 2025 21:45:26 +0200	[thread overview]
Message-ID: <20250709194526.560709-8-s.hanreich@proxmox.com> (raw)
In-Reply-To: <20250709194526.560709-1-s.hanreich@proxmox.com>

pveeth is a tool for pinning / unpinning network interface names. It
works by generating a link file in /usr/local/lib/systemd/network and
then updating the following files by replacing the old name with the
pinned name (this also works for altnames!):

* /etc/network/interfaces
* /etc/pve/nodes/nodename/host.fw
* /etc/pve/sdn/controllers.cfg (IS-IS controllers)

There are still some places where interface names occur, where we do
not update the configuration:

/etc/pve/firewall/cluster.fw - This is because we cannot update a
cluster-wide file with the locally-generated mappings. In this case a
warning is printed.

In the node configuration there is a parameter for wakeonlan that
takes an interface as argument. It is not updated currently.

Otherwise all occurrences of interfaces or interface lists should be
included.

It is also possible to remove pins for interfaces by using the unpin
command. Successive invocations of pin and unpin with the same
parameters should be idempotent.

Example invocations of pveeth:

$ pveeth pin --nic enp1s0 --force 0 --dry_run 0

Generates a pinning for enp1s0 (if it doesn't exist already) and
updates the configuration file.

$ pveeth pin --force 1 --dry_run 0

Deletes all existing pins and re-generates them.

For more information on the parameters see the API description.

I've decided to let dry_run generate the configuration files in the
current working directory, since it is then easy to diff the generated
files with the existing configuration files using the diffviewer of
the users' choice.

Additionally, when writing the configuration files, they get backed up
by creating a .bak at the location of the configuration file.

Currently we only support a fixed prefix: 'nic'. This is because we
rely on PHYISCAL_NIC_RE for detecting physical network interfaces
across several places in our codebase. For now, nic has been added as
a valid prefix for NICs in pve-common, so we use that prefix here.

In order to support custom prefixes, we would have to remove every
place in the code relying on PHYISCAL_NIC_RE (at least), in order to
avoid breakage.

Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
---
 PVE/CLI/Makefile  |   1 +
 PVE/CLI/pveeth.pm | 538 ++++++++++++++++++++++++++++++++++++++++++++++
 bin/Makefile      |   5 +
 bin/pveeth        |   8 +
 4 files changed, 552 insertions(+)
 create mode 100644 PVE/CLI/pveeth.pm
 create mode 100644 bin/pveeth

diff --git a/PVE/CLI/Makefile b/PVE/CLI/Makefile
index 9ff2aeb58..5bd1ef4cd 100644
--- a/PVE/CLI/Makefile
+++ b/PVE/CLI/Makefile
@@ -10,6 +10,7 @@ SOURCES = \
 	pvesh.pm \
 	pve7to8.pm \
 	pve8to9.pm \
+	pveeth.pm \
 
 all:
 
diff --git a/PVE/CLI/pveeth.pm b/PVE/CLI/pveeth.pm
new file mode 100644
index 000000000..7244f4689
--- /dev/null
+++ b/PVE/CLI/pveeth.pm
@@ -0,0 +1,538 @@
+package PVE::CLI::pveeth;
+
+use strict;
+use warnings;
+
+use File::Copy;
+use POSIX qw(:errno_h);
+use Storable qw(dclone);
+
+use PVE::Firewall;
+use PVE::INotify;
+use PVE::Network;
+use PVE::Network::SDN;
+use PVE::Network::SDN::Controllers;
+use PVE::RPCEnvironment;
+use PVE::SectionConfig;
+use PVE::Tools;
+
+use PVE::CLIHandler;
+use base qw(PVE::CLIHandler);
+
+my $PVEETH_LOCK = "/run/lock/pveeth.lck";
+
+sub setup_environment {
+    PVE::RPCEnvironment->setup_default_cli_env();
+}
+
+my sub update_sdn_controllers {
+    my ($mapping, $dry_run) = @_;
+
+    print "Updating controllers.cfg\n";
+
+    my $code = sub {
+        my $controllers = PVE::Network::SDN::Controllers::config();
+
+        my $local_node = PVE::INotify::nodename();
+
+        for my $controller (values $controllers->{ids}->%*) {
+            next
+                if $local_node ne $controller->{node}
+                || $controller->{type} ne 'isis';
+
+            $controller->{'isis-ifaces'} = $mapping->list($controller->{'isis-ifaces'});
+        }
+
+        if ($dry_run) {
+            my $raw =
+                PVE::Network::SDN::Controllers::Plugin->write_config(undef, $controllers, 0);
+            PVE::Tools::file_set_contents('generated_controllers.cfg', $raw);
+        } else {
+            copy("/etc/pve/sdn/controllers.cfg", "/etc/pve/sdn/controllers.cfg.bak");
+            PVE::Network::SDN::Controllers::write_config($controllers);
+        }
+    };
+
+    if ($dry_run) {
+        $code->();
+    } else {
+        PVE::Network::SDN::lock_sdn_config($code);
+    }
+}
+
+my sub update_etc_network_interfaces {
+    my ($mapping, $dry_run) = @_;
+
+    print "Updating interfaces\n";
+
+    my $code = sub {
+        my $config = dclone(PVE::INotify::read_file('interfaces'));
+
+        my $old_ifaces = $config->{ifaces};
+        my $new_ifaces = {};
+
+        for my $iface_name (keys $old_ifaces->%*) {
+            my $iface = $old_ifaces->{$iface_name};
+
+            if ($iface->{type} =~ m/^(eth|OVSPort|alias)$/) {
+                $iface_name = $mapping->name($iface_name);
+            } elsif ($iface->{type} eq 'vlan') {
+                $iface_name = $mapping->name($iface_name);
+                $iface->{'vlan-raw-device'} = $mapping->name($iface->{'vlan-raw-device'});
+            } elsif ($iface->{type} eq 'bond') {
+                $iface->{'bond-primary'} = $mapping->name($iface->{'bond-primary'});
+                $iface->{slaves} = $mapping->list($iface->{slaves});
+            } elsif ($iface->{type} eq 'bridge') {
+                $iface->{bridge_ports} = $mapping->list($iface->{bridge_ports});
+            } elsif ($iface->{type} eq 'OVSBridge') {
+                $iface->{ovs_ports} = $mapping->list($iface->{ovs_ports});
+            } elsif ($iface->{type} eq 'OVSBond') {
+                $iface->{ovs_bonds} = $mapping->list($iface->{ovs_bonds});
+            }
+
+            $new_ifaces->{$iface_name} = $iface;
+        }
+
+        $config->{ifaces} = $new_ifaces;
+
+        if ($dry_run) {
+            my $raw = PVE::INotify::__write_etc_network_interfaces($config, 1);
+            PVE::Tools::file_set_contents('generated_interfaces', $raw);
+        } else {
+            copy("/etc/network/interfaces", "/etc/network/interfaces.bak");
+            PVE::INotify::write_file('interfaces', $config, 1);
+
+            my $current_config_file = "/etc/network/interfaces";
+            my $new_config_file = "/etc/network/interfaces.new";
+
+            rename($new_config_file, $current_config_file);
+        }
+    };
+
+    if ($dry_run) {
+        $code->();
+    } else {
+        PVE::Tools::lock_file("/etc/network/.pve-interfaces.lock", 10, $code);
+        die $@ if $@;
+    }
+}
+
+my sub update_host_fw_config {
+    my ($mapping, $dry_run) = @_;
+
+    print "Updating host firewall config\n";
+
+    my $code = sub {
+        my $cluster_conf = PVE::Firewall::load_clusterfw_conf();
+        my $host_conf = PVE::Firewall::load_hostfw_conf($cluster_conf);
+
+        for my $rule ($cluster_conf->{rules}->@*) {
+            next if !$rule->{iface};
+
+            warn "found reference to iface $rule->{iface} in cluster config - not updating."
+                if $mapping->{ $rule->{iface} };
+        }
+
+        for my $rule ($host_conf->{rules}->@*) {
+            next if !$rule->{iface};
+            $rule->{iface} = $mapping->name($rule->{iface});
+        }
+
+        my $filename;
+        if ($dry_run) {
+            $filename = "generated_host.fw";
+        } else {
+            my $local_node = PVE::INotify::nodename();
+            copy("/etc/pve/nodes/$local_node/host.fw",
+                "/etc/pve/nodes/$local_node/host.fw.bak");
+        }
+
+        PVE::Firewall::save_hostfw_conf($host_conf, $filename);
+    };
+
+    if ($dry_run) {
+        $code->();
+    } else {
+        PVE::Firewall::run_locked($code);
+    }
+}
+
+my sub parse_link_file {
+    my ($file_name) = @_;
+
+    my $content = PVE::Tools::file_get_contents($file_name);
+    my @lines = split(/\n/, $content);
+
+    my $section;
+    my $data = {};
+
+    for my $line (@lines) {
+        next if $line =~ m/^\s*$/;
+
+        if ($line =~ m/^\[(Match|Link)\]$/) {
+            $section = $1;
+            $data->{$section} = {};
+        } elsif ($line =~ m/^([a-zA-Z]+)=(.+)$/) {
+            die "key-value pair before section" if !$section;
+            $data->{$section}->{$1} = $2;
+        } else {
+            die "unrecognized line";
+        }
+    }
+
+    return $data;
+}
+
+my $LINK_DIRECTORY_DRY_RUN = './network/';
+my $LINK_DIRECTORY = "/usr/local/lib/systemd/network/";
+
+my sub get_pinned {
+    mkdir '/usr/local/lib/systemd' if !-d '/usr/local/lib/systemd';
+    mkdir $LINK_DIRECTORY if !-d $LINK_DIRECTORY;
+
+    my $link_files = {};
+
+    PVE::Tools::dir_glob_foreach(
+        $LINK_DIRECTORY,
+        qr/^50-pve-(.+)\.link$/,
+        sub {
+            my $parsed = parse_link_file($LINK_DIRECTORY . $_[0]);
+            $link_files->{ $parsed->{'Match'}->{'MACAddress'} } = $parsed->{'Link'}->{'Name'};
+        },
+    );
+
+    return $link_files;
+}
+
+my $LINK_FILE_TEMPLATE = <<EOF;
+[Match]
+MACAddress=%s
+Type=ether
+
+[Link]
+Name=%s
+EOF
+
+my sub link_file_name {
+    my ($iface_name) = @_;
+    return "50-pve-$iface_name.link";
+}
+
+my sub delete_link_files {
+    my ($pinned) = @_;
+
+    for my $iface_name (values %$pinned) {
+        my $link_file = $LINK_DIRECTORY . link_file_name($iface_name);
+
+        if (!unlink $link_file) {
+            return if $! == ENOENT;
+            warn "failed to delete $link_file";
+        }
+    }
+}
+
+my sub generate_link_files {
+    my ($ip_links, $mapping, $params) = @_;
+
+    print "Generating link files\n";
+
+    my $directory = ($params->{dry_run}) ? $LINK_DIRECTORY_DRY_RUN : $LINK_DIRECTORY;
+    mkdir $directory if !-d $directory;
+
+    for my $ip_link (values $ip_links->%*) {
+        my $mapped_name = $mapping->name($ip_link->{ifname});
+        my $link_file_content =
+            sprintf($LINK_FILE_TEMPLATE, get_ip_link_mac($ip_link), $mapped_name);
+
+        PVE::Tools::file_set_contents(
+            $directory . link_file_name($mapped_name),
+            $link_file_content,
+        );
+    }
+}
+
+package PVE::CLI::pveeth::InterfaceMapping {
+    use PVE::CLI::pveeth;
+    use PVE::Tools;
+
+    sub inverted {
+        my ($class, $ip_links, $pinned) = @_;
+
+        my $mapping = {};
+
+    OUTER:
+        for my $pinned_mac (keys $pinned->%*) {
+            my $pinned_name = $pinned->{$pinned_mac};
+            my $old_name;
+
+            if ($ip_links->{$pinned_name}) {
+                # the pinned names are already applied, get path name from udev
+                $old_name = PVE::CLI::pveeth::get_udevadm_path($pinned_name);
+                $mapping->{$pinned_name} = $old_name;
+            } else {
+                # the pinned names have not yet been applied, search for
+                # interface with the same MAC address
+                for my $iface_name (keys $ip_links->%*) {
+                    my $ip_link = $ip_links->{$iface_name};
+                    my $ip_link_mac = PVE::CLI::pveeth::get_ip_link_mac($ip_link);
+
+                    if ($ip_link_mac eq $pinned_mac) {
+                        $mapping->{$pinned_name} = $iface_name;
+                        next OUTER;
+                    }
+                }
+
+                warn "could not find link for mapped interface $pinned_name, not removing";
+            }
+        }
+
+        bless $mapping, $class;
+    }
+
+    sub generate {
+        my ($class, $ip_links, $pinned, $prefix) = @_;
+
+        my %existing_names = map { $_ => 1 } values $pinned->%*;
+
+        my $index = 0;
+        my $mapping = {};
+
+        for my $ifname (sort keys $ip_links->%*) {
+            my $ip_link = $ip_links->{$ifname};
+            my $generated_name;
+
+            do {
+                $generated_name = $prefix . $index++;
+            } while ($existing_names{$generated_name});
+
+            $mapping->{$ifname} = $generated_name;
+
+            for my $altname ($ip_link->{altnames}->@*) {
+                $mapping->{$altname} = $generated_name;
+            }
+        }
+
+        bless $mapping, $class;
+    }
+
+    sub name {
+        my ($self, $iface_name) = @_;
+
+        if ($iface_name =~ m/^([a-zA-Z0-9_]+)([:\.]\d+)$/) {
+            my $mapped_name = $self->{$1} // $1;
+            my $suffix = $2;
+
+            return "$mapped_name$suffix";
+        }
+
+        return $self->{$iface_name} // $iface_name;
+    }
+
+    sub list {
+        my ($self, $list) = @_;
+
+        my @mapped_list = map { $self->name($_) } PVE::Tools::split_list($list);
+        return join(' ', @mapped_list);
+    }
+}
+
+sub get_ip_link_mac {
+    my ($ip_link) = @_;
+
+    # members of bonds can have a different MAC than the physical interface, so
+    # we need to check if they're enslaved
+    return $ip_link->{link_info}->{info_slave_data}->{perm_hwaddr} // $ip_link->{address};
+}
+
+sub get_ip_links {
+    my $ip_links = PVE::Network::ip_link_details();
+
+    for my $iface_name (keys $ip_links->%*) {
+        delete $ip_links->{$iface_name}
+            if !PVE::Network::ip_link_is_physical($ip_links->{$iface_name});
+    }
+
+    return $ip_links;
+}
+
+sub get_udevadm_path {
+    my ($iface_name) = @_;
+
+    my $path;
+
+    PVE::Tools::run_command(
+        ['udevadm', 'test-builtin', 'net_id', "/sys/class/net/$iface_name"],
+        outfunc => sub {
+            my $line = shift;
+
+            if ($line =~ m/^ID_NET_NAME_PATH=(.+)$/) {
+                $path = $1;
+            }
+        },
+        errfunc => sub { },
+    );
+
+    die "could not obtain path name for interface $iface_name" if !$path;
+    return $path;
+}
+
+__PACKAGE__->register_method({
+    name => 'pin',
+    path => 'pin',
+    method => 'POST',
+    description => 'Pins the names of NICs via systemd.link files.',
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            force => {
+                description =>
+                    'If true, deletes all existing mappings before generating a new one. Otherwise it only generates mappings for NICs that do not have one yet.',
+                type => 'boolean',
+                optional => 0,
+                default => 0,
+            },
+            nic => {
+                description => 'Only pin a specific NIC.',
+                type => 'string',
+                format => 'pve-iface',
+                optional => 1,
+            },
+            dry_run => {
+                description =>
+                    'Generates the configuration files in the current directory, instead of writing them.',
+                type => 'boolean',
+                optional => 1,
+                default => 1,
+            },
+        },
+    },
+    returns => {
+        type => 'null',
+    },
+    code => sub {
+        my ($params) = @_;
+
+        my $code = sub {
+            my $prefix = 'nic';
+
+            my $ip_links = get_ip_links();
+
+            if ($params->{nic}) {
+                my $altnames = PVE::Network::altname_mapping($ip_links);
+                my $resolved_name = $altnames->{ $params->{nic} } // $params->{nic};
+
+                die "could not find nic with name $params->{nic}"
+                    if !$ip_links->{$resolved_name};
+                $ip_links = { $resolved_name => $ip_links->{$resolved_name} };
+            }
+
+            my $pinned;
+            if (!$params->{force}) {
+                $pinned = get_pinned();
+
+                for my $ip_link (values $ip_links->%*) {
+                    delete $ip_links->{ $ip_link->{ifname} }
+                        if $pinned->{ get_ip_link_mac($ip_link) };
+                }
+            } else {
+                delete_link_files($pinned) if !$params->{dry_run};
+                $pinned = {};
+            }
+
+            my $mapping = PVE::CLI::pveeth::InterfaceMapping->generate(
+                $ip_links, $pinned, $prefix,
+            );
+
+            for my $old_name (keys $mapping->%*) {
+                print "Mapping '$old_name' to '$mapping->{$old_name}'\n";
+            }
+
+            generate_link_files($ip_links, $mapping, $params);
+
+            update_host_fw_config($mapping, $params->{dry_run});
+            update_etc_network_interfaces($mapping, $params->{dry_run});
+            update_sdn_controllers($mapping, $params->{dry_run});
+        };
+
+        if ($params->{dry_run}) {
+            $code->();
+        } else {
+            PVE::Tools::lock_file($PVEETH_LOCK, 10, $code);
+            die $@ if $@;
+        }
+
+        return;
+    },
+});
+
+__PACKAGE__->register_method({
+    name => 'unpin',
+    path => 'unpin',
+    method => 'POST',
+    description => 'Unpins the names of NICs that have been previously pinned',
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            dry_run => {
+                description =>
+                    'Generates the configuration files in the current directory, instead of writing them.',
+                type => 'boolean',
+                optional => 1,
+                default => 1,
+            },
+            nic => {
+                description => 'Only unpin a specific NIC',
+                type => 'string',
+                format => 'pve-iface',
+                optional => 1,
+            },
+        },
+    },
+    returns => {
+        type => 'null',
+    },
+    code => sub {
+        my ($params) = @_;
+
+        my $code = sub {
+            my $pinned = get_pinned();
+            my $ip_links = get_ip_links();
+
+            my $mapping = PVE::CLI::pveeth::InterfaceMapping->inverted(
+                $ip_links, $pinned,
+            );
+
+            if ($params->{nic}) {
+                for my $mapped_name (keys $mapping->%*) {
+                    delete $mapping->{$mapped_name} if $mapped_name ne $params->{nic};
+                }
+            }
+
+            for my $old_name (keys $mapping->%*) {
+                print "Mapping '$old_name' to '$mapping->{$old_name}'\n";
+            }
+
+            delete_link_files($pinned) if !$params->{dry_run};
+
+            update_host_fw_config($mapping, $params->{dry_run});
+            update_etc_network_interfaces($mapping, $params->{dry_run});
+            update_sdn_controllers($mapping, $params->{dry_run});
+        };
+
+        if ($params->{dry_run}) {
+            $code->();
+        } else {
+            PVE::Tools::lock_file($PVEETH_LOCK, 10, $code);
+            die $@ if $@;
+        }
+
+        return;
+    },
+});
+
+our $cmddef = {
+    pin => [__PACKAGE__, 'pin', [], {}],
+    unpin => [__PACKAGE__, 'unpin', [], {}],
+};
+
+1;
diff --git a/bin/Makefile b/bin/Makefile
index 3931804b1..2d7e60677 100644
--- a/bin/Makefile
+++ b/bin/Makefile
@@ -14,6 +14,7 @@ CLITOOLS = \
 	pvesh \
 	pve7to8 \
 	pve8to9 \
+	pveeth \
 
 
 SCRIPTS =  			\
@@ -67,6 +68,10 @@ pve7to8.1:
 	printf ".SH SYNOPSIS\npve7to8 [--full]\n" >> $@.tmp
 	mv $@.tmp $@
 
+pveeth.1:
+	printf "pveeth" > $@.tmp
+	mv $@.tmp $@
+
 pve8to9.1:
 	printf ".TH PVE8TO9 1\n.SH NAME\npve8to9 \- Proxmox VE upgrade checker script for 8.4+ to current 9.x\n" > $@.tmp
 	printf ".SH DESCRIPTION\nThis tool will help you to detect common pitfalls and misconfguration\
diff --git a/bin/pveeth b/bin/pveeth
new file mode 100644
index 000000000..bf978a59a
--- /dev/null
+++ b/bin/pveeth
@@ -0,0 +1,8 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+
+use PVE::CLI::pveeth;
+
+PVE::CLI::pveeth->run_cli_handler();
-- 
2.39.5


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


  parent reply	other threads:[~2025-07-09 19:45 UTC|newest]

Thread overview: 17+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-07-09 19:45 [pve-devel] [RFC common/firewall/manager/network/proxmox{-ve-rs, -firewall} 0/7] NIC renaming mitigations Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH pve-common 1/2] network: add ip link and altname helpers Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH pve-common 2/2] network: add nic prefix to physical nic regex Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH proxmox-ve-rs 1/1] config: ip link struct Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH proxmox-firewall 1/1] firewall: add altname support for firewall rules Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH pve-firewall 1/1] firewall: add altname support Stefan Hanreich
2025-07-09 19:45 ` [pve-devel] [PATCH pve-network 1/1] controllers: isis: " Stefan Hanreich
2025-07-09 19:45 ` Stefan Hanreich [this message]
2025-07-10 14:53   ` [pve-devel] [PATCH pve-manager 1/1] cli: add pveeth Gabriel Goller
2025-07-10 15:08     ` Thomas Lamprecht
2025-07-10 16:25       ` Gabriel Goller
2025-07-15 12:30         ` Stefan Hanreich
2025-07-15 12:35           ` Stefan Hanreich
2025-07-15 13:51           ` Thomas Lamprecht
2025-07-15 14:06             ` Stefan Hanreich
2025-07-15 15:02             ` Stefan Hanreich
2025-07-16 15:19 ` [pve-devel] superseded: [RFC common/firewall/manager/network/proxmox{-ve-rs, -firewall} 0/7] NIC renaming mitigations Stefan Hanreich

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20250709194526.560709-8-s.hanreich@proxmox.com \
    --to=s.hanreich@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal