* [RFC PATCH manager 1/2] api: network: add VPP (fd.io) dataplane bridge support
2026-03-16 22:28 [RFC PATCH 0/2] network: add VPP (fd.io) as alternative dataplane Ryosuke Nakayama
@ 2026-03-16 22:28 ` Ryosuke Nakayama
2026-03-16 22:28 ` [RFC PATCH widget-toolkit 2/2] ui: network: add VPP (fd.io) bridge type support Ryosuke Nakayama
` (2 subsequent siblings)
3 siblings, 0 replies; 5+ messages in thread
From: Ryosuke Nakayama @ 2026-03-16 22:28 UTC (permalink / raw)
To: pve-devel
From: ryskn <ryosuke.nakayama@ryskn.com>
Integrate VPP (fd.io) as an alternative network dataplane alongside
OVS. This adds VPP bridge domain management via the Proxmox WebUI
and REST API.
Backend (PVE/API2/Network.pm):
- Detect VPP bridges via 'vppctl show bridge-domain' and expose them
as type=VPPBridge in the network interface list
- Create/delete VPP bridge domains via vppctl
- Persist bridge domains to /etc/vpp/pve-bridges.conf (exec'd at VPP
startup) so they survive reboots
- Support vpp_vlan_aware flag: maps to 'set bridge-domain property N
learn enable/disable' in VPP
- Add VPP VLAN subinterface create/delete/list via vppctl, persisted
to /etc/vpp/pve-vlans.conf
- Validate parent interface exists before creating a VLAN subinterface
- Exclude VPP bridges from the SDN-only access guard so they appear
in the WebUI NIC selector
- Use $VPP_SOCKET constant consistently (no hardcoded paths)
- Log warning on bridge-removal failure instead of silently swallowing
- Rely on get_vpp_vlans() for VPP VLAN detection in update/delete to
avoid false-positives on Linux dot-notation VLANs (e.g. eth0.100)
- Fetch VPP data once per request; filter path reuses $ifaces instead
of making redundant vppctl calls
- VPP conf writes are serialised by the existing $iflockfn lock
Vhost-user socket path convention: /var/run/vpp/qemu-<vmid>-<net>.sock
Signed-off-by: ryskn <ryosuke.nakayama@ryskn.com>
---
PVE/API2/Network.pm | 413 +++++++++++++++++++++++++++-
PVE/API2/Nodes.pm | 19 ++
PVE/CLI/pve8to9.pm | 48 ++++
www/manager6/form/BridgeSelector.js | 5 +
www/manager6/lxc/Network.js | 34 +++
www/manager6/node/Config.js | 1 +
www/manager6/qemu/NetworkEdit.js | 27 ++
www/manager6/window/Migrate.js | 48 ++++
8 files changed, 590 insertions(+), 5 deletions(-)
diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm
index fc053fec..f87a8f79 100644
--- a/PVE/API2/Network.pm
+++ b/PVE/API2/Network.pm
@@ -49,6 +49,8 @@ my $network_type_enum = [
'OVSBond',
'OVSPort',
'OVSIntPort',
+ 'VPPBridge',
+ 'VPPVlan',
'vnet',
];
@@ -117,6 +119,17 @@ my $confdesc = {
type => 'string',
format => 'pve-iface',
},
+ vpp_bridge => {
+ description => "The VPP bridge domain to add this VLAN interface to (e.g. vppbr1).",
+ optional => 1,
+ type => 'string',
+ format => 'pve-iface',
+ },
+ vpp_vlan_aware => {
+ description => "Enable VLAN-aware mode for VPP bridge domain.",
+ optional => 1,
+ type => 'boolean',
+ },
slaves => {
description => "Specify the interfaces used by the bonding device.",
optional => 1,
@@ -259,6 +272,170 @@ sub extract_altnames {
return undef;
}
+my $VPP_BRIDGES_CONF = '/etc/vpp/pve-bridges.conf';
+my $VPP_VLANS_CONF = '/etc/vpp/pve-vlans.conf';
+my $VPP_SOCKET = '/run/vpp/cli.sock';
+
+sub vpp_save_bridges_conf {
+ my ($bridges) = @_;
+
+ my $content = "# Auto-generated by PVE - do not edit manually\n";
+ for my $id (sort { $a <=> $b } keys %$bridges) {
+ next if $id == 0; # skip default bridge-domain
+ $content .= "create bridge-domain $id learn 1 forward 1 uu-flood 1 arp-term 0\n";
+ if ($bridges->{$id}{vlan_aware}) {
+ # 'vlan_aware' maps to VPP's per-port tag-rewrite workflow.
+ # We use 'set bridge-domain property learn enable' as a marker
+ # so the flag survives VPP restarts via pve-bridges.conf exec.
+ $content .= "set bridge-domain property $id learn enable\n";
+ }
+ }
+
+ PVE::Tools::file_set_contents($VPP_BRIDGES_CONF, $content);
+}
+
+sub vpp_load_bridges_conf {
+ my $bridges = {};
+ return $bridges if !-f $VPP_BRIDGES_CONF;
+
+ my $content = PVE::Tools::file_get_contents($VPP_BRIDGES_CONF);
+ for my $line (split(/\n/, $content)) {
+ next if $line =~ /^#/;
+ if ($line =~ /^create bridge-domain\s+(\d+)/ && $1 != 0) {
+ $bridges->{$1} //= {};
+ } elsif ($line =~ /^set bridge-domain property\s+(\d+)\s+learn\s+enable/) {
+ $bridges->{$1}{vlan_aware} = 1 if $bridges->{$1};
+ }
+ }
+ return $bridges;
+}
+
+sub vpp_save_vlan_conf {
+ my ($iface, $parent, $sub_id, $vlan_id, $bridge) = @_;
+
+ my $vlans = vpp_load_vlan_conf();
+ $vlans->{$iface} = {
+ parent => $parent,
+ sub_id => $sub_id,
+ vlan_id => $vlan_id,
+ bridge => $bridge // '',
+ };
+
+ my $content = "# Auto-generated by PVE - do not edit manually\n";
+ for my $name (sort keys %$vlans) {
+ my $v = $vlans->{$name};
+ $content .= "create sub-interfaces $v->{parent} $v->{sub_id} dot1q $v->{vlan_id}\n";
+ $content .= "set interface state $name up\n";
+ if ($v->{bridge} && $v->{bridge} =~ /^vppbr(\d+)$/) {
+ $content .= "set interface l2 bridge $name $1\n";
+ }
+ }
+ PVE::Tools::file_set_contents($VPP_VLANS_CONF, $content);
+}
+
+sub vpp_load_vlan_conf {
+ my $vlans = {};
+ return $vlans if !-f $VPP_VLANS_CONF;
+
+ my $content = PVE::Tools::file_get_contents($VPP_VLANS_CONF);
+ my %pending;
+ for my $line (split(/\n/, $content)) {
+ next if $line =~ /^#/;
+ if ($line =~ /^create sub-interfaces\s+(\S+)\s+(\d+)\s+dot1q\s+(\d+)/) {
+ my ($parent, $sub_id, $vlan_id) = ($1, $2, $3);
+ my $name = "$parent.$sub_id";
+ $pending{$name} = { parent => $parent, sub_id => $sub_id, vlan_id => $vlan_id, bridge => '' };
+ } elsif ($line =~ /^set interface l2 bridge\s+(\S+)\s+(\d+)/) {
+ my ($name, $bd_id) = ($1, $2);
+ $pending{$name}{bridge} = "vppbr$bd_id" if $pending{$name};
+ }
+ }
+ $vlans->{$_} = $pending{$_} for keys %pending;
+ return $vlans;
+}
+
+sub vpp_delete_vlan_conf {
+ my ($iface) = @_;
+ my $vlans = vpp_load_vlan_conf();
+ return if !$vlans->{$iface};
+ delete $vlans->{$iface};
+
+ my $content = "# Auto-generated by PVE - do not edit manually\n";
+ for my $name (sort keys %$vlans) {
+ my $v = $vlans->{$name};
+ $content .= "create sub-interfaces $v->{parent} $v->{sub_id} dot1q $v->{vlan_id}\n";
+ $content .= "set interface state $name up\n";
+ if ($v->{bridge} && $v->{bridge} =~ /^vppbr(\d+)$/) {
+ $content .= "set interface l2 bridge $name $1\n";
+ }
+ }
+ PVE::Tools::file_set_contents($VPP_VLANS_CONF, $content);
+}
+
+sub get_vpp_vlans {
+ return {} if !-x '/usr/bin/vppctl';
+
+ my $vlans = {};
+ eval {
+ my $output = '';
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET, 'show', 'interface'],
+ outfunc => sub { $output .= $_[0] . "\n"; },
+ timeout => 5,
+ );
+ my $saved = vpp_load_vlan_conf();
+ while ($output =~ /^(\S+)\.(\d+)\s+\d+\s+(\S+)/mg) {
+ my ($parent, $sub_id, $state) = ($1, $2, $3);
+ my $name = "$parent.$sub_id";
+ my $vlan_id = $saved->{$name} ? $saved->{$name}{vlan_id} : $sub_id;
+ $vlans->{$name} = {
+ type => 'VPPVlan',
+ active => ($state eq 'up') ? 1 : 0,
+ iface => $name,
+ 'vlan-raw-device' => $parent,
+ 'vlan-id' => $vlan_id,
+ vpp_bridge => $saved->{$name} ? $saved->{$name}{bridge} : '',
+ };
+ }
+ };
+ warn "VPP VLAN detection failed: $@" if $@;
+ return $vlans;
+}
+
+sub get_vpp_bridges {
+ return {} if !-x '/usr/bin/vppctl';
+
+ my $bridges = {};
+ eval {
+ my $output = '';
+ my $errout = '';
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET, 'show', 'bridge-domain'],
+ outfunc => sub { $output .= $_[0] . "\n"; },
+ errfunc => sub { $errout .= $_[0] . "\n"; },
+ timeout => 5,
+ );
+ warn "VPP bridge detection stderr: $errout" if $errout;
+ my $saved = vpp_load_bridges_conf();
+ for my $line (split(/\n/, $output)) {
+ next if $line !~ /^\s*(\d+)\s+/;
+ my $id = $1;
+ next if $id == 0; # skip default bridge-domain
+ my $name = "vppbr$id";
+ $bridges->{$name} = {
+ type => 'VPPBridge',
+ active => 1,
+ iface => $name,
+ priority => $id,
+ vpp_vlan_aware => $saved->{$id} ? ($saved->{$id}{vlan_aware} ? 1 : 0) : 0,
+ };
+ }
+ };
+ warn "VPP bridge detection failed: $@" if $@;
+
+ return $bridges;
+}
+
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -422,6 +599,16 @@ __PACKAGE__->register_method({
delete $ifaces->{lo}; # do not list the loopback device
+ # always include VPP bridges and VLANs if VPP is available.
+ # These are fetched once here; the filter path below reuses $ifaces
+ # rather than calling get_vpp_bridges/get_vpp_vlans a second time.
+ # Note: VPP conf writes (create/update/delete) are serialised by
+ # $iflockfn, so no separate lock is needed for the conf files.
+ my $vpp_bridges_all = get_vpp_bridges();
+ $ifaces->{$_} = $vpp_bridges_all->{$_} for keys $vpp_bridges_all->%*;
+ my $vpp_vlans_all = get_vpp_vlans();
+ $ifaces->{$_} = $vpp_vlans_all->{$_} for keys $vpp_vlans_all->%*;
+
if (my $tfilter = $param->{type}) {
my $vnets;
my $fabrics;
@@ -440,7 +627,7 @@ __PACKAGE__->register_method({
if ($tfilter ne 'include_sdn') {
for my $k (sort keys $ifaces->%*) {
my $type = $ifaces->{$k}->{type};
- my $is_bridge = $type eq 'bridge' || $type eq 'OVSBridge';
+ my $is_bridge = $type eq 'bridge' || $type eq 'OVSBridge' || $type eq 'VPPBridge';
my $bridge_match = $is_bridge && $tfilter =~ /^any(_local)?_bridge$/;
my $match = $tfilter eq $type || $bridge_match;
delete $ifaces->{$k} if !$match;
@@ -675,6 +862,89 @@ __PACKAGE__->register_method({
|| die "Open VSwitch is not installed (need package 'openvswitch-switch')\n";
}
+ if ($param->{type} eq 'VPPVlan') {
+ -x '/usr/bin/vppctl'
+ || die "VPP is not installed (need package 'vpp')\n";
+
+ $iface =~ /^(.+)\.(\d+)$/
+ || die "VPP VLAN name must be <parent>.<vlan-id>, e.g. tap0.100\n";
+ my ($parent, $sub_id) = ($1, $2);
+ my $vlan_id = $sub_id;
+
+ # check VLAN doesn't already exist and parent interface exists in VPP
+ my $existing_vlans = get_vpp_vlans();
+ die "VPP VLAN '$iface' already exists\n" if $existing_vlans->{$iface};
+
+ my $iface_out = '';
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET, 'show', 'interface'],
+ outfunc => sub { $iface_out .= $_[0] . "\n"; },
+ timeout => 5,
+ );
+ die "VPP interface '$parent' does not exist\n"
+ if $iface_out !~ /^\Q$parent\E\s/m;
+
+ # create sub-interface
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'create', 'sub-interfaces', $parent, $sub_id, 'dot1q', $vlan_id],
+ timeout => 10,
+ );
+
+ # bring up
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'interface', 'state', $iface, 'up'],
+ timeout => 10,
+ );
+
+ # optionally add to VPP bridge domain
+ if (my $bridge = $param->{vpp_bridge}) {
+ $bridge =~ /^vppbr(\d+)$/
+ || die "Invalid VPP bridge name '$bridge'\n";
+ my $bd_id = $1;
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'interface', 'l2', 'bridge', $iface, $bd_id],
+ timeout => 10,
+ );
+ }
+
+ vpp_save_vlan_conf($iface, $parent, $sub_id, $vlan_id, $param->{vpp_bridge});
+ return undef;
+ }
+
+ if ($param->{type} eq 'VPPBridge') {
+ -x '/usr/bin/vppctl'
+ || die "VPP is not installed (need package 'vpp')\n";
+
+ $iface =~ /^vppbr(\d+)$/
+ || die "VPP bridge name must match 'vppbrN' (e.g. vppbr1)\n";
+ my $bd_id = $1;
+
+ die "bridge-domain 0 is reserved by VPP, use vppbr1 or higher\n"
+ if $bd_id == 0;
+
+ # check for duplicate bridge-domain ID
+ my $existing = get_vpp_bridges();
+ die "VPP bridge-domain $bd_id already exists\n" if $existing->{"vppbr$bd_id"};
+
+ # create bridge-domain in running VPP
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'create', 'bridge-domain', $bd_id,
+ 'learn', '1', 'forward', '1', 'uu-flood', '1', 'arp-term', '0'],
+ timeout => 10,
+ );
+
+ # persist for VPP restarts
+ my $saved = vpp_load_bridges_conf();
+ $saved->{$bd_id} = { vlan_aware => $param->{vpp_vlan_aware} ? 1 : 0 };
+ vpp_save_bridges_conf($saved);
+
+ return undef; # VPP bridges are not stored in /etc/network/interfaces
+ }
+
if ($param->{type} eq 'OVSIntPort' || $param->{type} eq 'OVSBond') {
my $brname = $param->{ovs_bridge};
raise_param_exc({ ovs_bridge => "parameter is required" }) if !$brname;
@@ -743,6 +1013,67 @@ __PACKAGE__->register_method({
my $delete = extract_param($param, 'delete');
my $code = sub {
+ # VPP bridges and VLANs are not stored in /etc/network/interfaces
+ if ($iface =~ /^vppbr(\d+)$/) {
+ my $bd_id = $1;
+ my $existing = get_vpp_bridges();
+ raise_param_exc({ iface => "VPP bridge '$iface' does not exist" })
+ if !$existing->{$iface};
+
+ my $vlan_aware = $param->{vpp_vlan_aware} ? 1 : 0;
+
+ # apply to running VPP
+ my $vlan_cmd = $vlan_aware ? 'enable' : 'disable';
+ eval {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'bridge-domain', 'property', $bd_id, 'learn', $vlan_cmd],
+ timeout => 10,
+ );
+ };
+ warn "Failed to set VPP bridge-domain $bd_id vlan_aware: $@" if $@;
+
+ # persist
+ my $saved = vpp_load_bridges_conf();
+ $saved->{$bd_id} //= {};
+ $saved->{$bd_id}{vlan_aware} = $vlan_aware;
+ vpp_save_bridges_conf($saved);
+ return undef;
+ }
+
+ if (get_vpp_vlans()->{$iface}) {
+ # VPP VLAN: update bridge assignment
+ my $saved = vpp_load_vlan_conf();
+ my $entry = $saved->{$iface};
+ raise_param_exc({ iface => "VPP VLAN '$iface' not found in config" })
+ if !$entry;
+ my $new_bridge = $param->{vpp_bridge} // '';
+
+ # move bridge assignment if changed
+ if (($entry->{bridge} // '') ne $new_bridge) {
+ if ($entry->{bridge} && $entry->{bridge} =~ /^vppbr(\d+)$/) {
+ eval {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'interface', 'l2', 'bridge', $iface, $1, 'del'],
+ timeout => 10,
+ );
+ };
+ warn "Failed to remove '$iface' from bridge '$entry->{bridge}': $@" if $@;
+ }
+ if ($new_bridge && $new_bridge =~ /^vppbr(\d+)$/) {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'interface', 'l2', 'bridge', $iface, $1],
+ timeout => 10,
+ );
+ }
+ $entry->{bridge} = $new_bridge;
+ }
+ vpp_save_vlan_conf($iface, $entry->{parent}, $entry->{sub_id}, $entry->{vlan_id}, $new_bridge);
+ return undef;
+ }
+
my $config = PVE::INotify::read_file('interfaces');
my $ifaces = $config->{ifaces};
@@ -848,6 +1179,21 @@ __PACKAGE__->register_method({
my $iface = $param->{iface};
+ # check VPP interfaces if not found in /etc/network/interfaces
+ if (!$ifaces->{$iface}) {
+ if ($iface =~ /^vppbr\d+$/) {
+ my $vpp_bridges = get_vpp_bridges();
+ raise_param_exc({ iface => "interface does not exist" })
+ if !$vpp_bridges->{$iface};
+ return $vpp_bridges->{$iface};
+ } elsif ($iface =~ /^.+\.\d+$/) {
+ my $vpp_vlans = get_vpp_vlans();
+ raise_param_exc({ iface => "interface does not exist" })
+ if !$vpp_vlans->{$iface};
+ return $vpp_vlans->{$iface};
+ }
+ }
+
raise_param_exc({ iface => "interface does not exist" })
if !$ifaces->{$iface};
@@ -969,26 +1315,83 @@ __PACKAGE__->register_method({
my ($param) = @_;
my $code = sub {
+ my $iface = $param->{iface};
+
+ # Handle VPP VLAN deletion
+ if (get_vpp_vlans()->{$iface}) {
+ my $saved = vpp_load_vlan_conf();
+ my $entry = $saved->{$iface};
+
+ # remove from bridge domain if assigned
+ if ($entry && $entry->{bridge} && $entry->{bridge} =~ /^vppbr(\d+)$/) {
+ eval {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'set', 'interface', 'l2', 'bridge', $iface, $1, 'del'],
+ timeout => 10,
+ );
+ };
+ }
+
+ # delete sub-interface
+ eval {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'delete', 'sub-interface', $iface],
+ timeout => 10,
+ );
+ };
+ warn "Failed to delete VPP VLAN '$iface': $@" if $@;
+
+ vpp_delete_vlan_conf($iface);
+ return undef;
+ }
+
+ # Handle VPP bridge deletion separately (not in /etc/network/interfaces)
+ if ($iface =~ /^vppbr(\d+)$/) {
+ my $bd_id = $1;
+ my $existing = get_vpp_bridges();
+ raise_param_exc({ iface => "VPP bridge '$iface' does not exist" })
+ if !$existing->{$iface};
+
+ # delete bridge-domain in running VPP
+ eval {
+ PVE::Tools::run_command(
+ ['/usr/bin/vppctl', '-s', $VPP_SOCKET,
+ 'create', 'bridge-domain', $bd_id, 'del'],
+ timeout => 10,
+ );
+ };
+ warn "Failed to delete VPP bridge-domain $bd_id: $@" if $@;
+
+ # remove from persistence config
+ my $saved = vpp_load_bridges_conf();
+ delete $saved->{$bd_id};
+ vpp_save_bridges_conf($saved);
+
+ return undef;
+ }
+
my $config = PVE::INotify::read_file('interfaces');
my $ifaces = $config->{ifaces};
raise_param_exc({ iface => "interface does not exist" })
- if !$ifaces->{ $param->{iface} };
+ if !$ifaces->{$iface};
- my $d = $ifaces->{ $param->{iface} };
+ my $d = $ifaces->{$iface};
if ($d->{type} eq 'OVSIntPort' || $d->{type} eq 'OVSBond') {
if (my $brname = $d->{ovs_bridge}) {
if (my $br = $ifaces->{$brname}) {
if ($br->{ovs_ports}) {
my @ports = split(/\s+/, $br->{ovs_ports});
- my @new = grep { $_ ne $param->{iface} } @ports;
+ my @new = grep { $_ ne $iface } @ports;
$br->{ovs_ports} = join(' ', @new);
}
}
}
}
- delete $ifaces->{ $param->{iface} };
+ delete $ifaces->{$iface};
PVE::INotify::write_file('interfaces', $config);
};
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 5bd6fe49..c4dcd9e6 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -2496,6 +2496,25 @@ my $create_migrate_worker = sub {
my $preconditions = PVE::API2::Qemu->migrate_vm_precondition(
{ node => $nodename, vmid => $vmid, target => $target });
my $invalidConditions = '';
+
+ if ($online) {
+ my $vpp_bridges = PVE::API2::Network::get_vpp_bridges();
+ if (keys %$vpp_bridges) {
+ my $conf = PVE::QemuConfig->load_config($vmid);
+ my @vpp_nics;
+ for my $opt (sort keys %$conf) {
+ next if $opt !~ m/^net\d+$/;
+ my $net = PVE::QemuServer::Network::parse_net($conf->{$opt});
+ next if !$net || !$net->{bridge};
+ push @vpp_nics, $opt if $vpp_bridges->{$net->{bridge}};
+ }
+ if (@vpp_nics) {
+ $invalidConditions .= "\n Has VPP vhost-user NICs: ";
+ $invalidConditions .= join(', ', @vpp_nics);
+ }
+ }
+ }
+
if ($online && !$with_local_disks && scalar @{ $preconditions->{local_disks} }) {
$invalidConditions .= "\n Has local disks: ";
$invalidConditions .=
diff --git a/PVE/CLI/pve8to9.pm b/PVE/CLI/pve8to9.pm
index 0c4b2343..dd31c8e5 100644
--- a/PVE/CLI/pve8to9.pm
+++ b/PVE/CLI/pve8to9.pm
@@ -11,6 +11,7 @@ use PVE::API2::LXC;
use PVE::API2::Qemu;
use PVE::API2::Certificates;
use PVE::API2::Cluster::Ceph;
+use PVE::API2::Network;
use PVE::AccessControl;
use PVE::Ceph::Tools;
@@ -1909,6 +1910,52 @@ sub check_bridge_mtu {
}
}
+sub check_vpp_firewall_conflicts {
+ log_info("Checking for VMs with firewall enabled on VPP bridges...");
+
+ my $vpp_bridges = eval { PVE::API2::Network::get_vpp_bridges() } // {};
+ if (!keys %$vpp_bridges) {
+ log_skip("No VPP bridges detected.");
+ return;
+ }
+
+ my $affected = [];
+ my $vms = PVE::QemuServer::config_list();
+ for my $vmid (sort { $a <=> $b } keys %$vms) {
+ my $config = PVE::QemuConfig->load_config($vmid);
+ for my $opt (sort keys %$config) {
+ next if $opt !~ m/^net\d+$/;
+ my $net = PVE::QemuServer::Network::parse_net($config->{$opt});
+ next if !$net || !$net->{bridge};
+ if ($vpp_bridges->{$net->{bridge}} && $net->{firewall}) {
+ push @$affected, "VM $vmid ($opt on $net->{bridge})";
+ }
+ }
+ }
+
+ my $cts = PVE::LXC::config_list();
+ for my $vmid (sort { $a <=> $b } keys %$cts) {
+ my $conf = PVE::LXC::Config->load_config($vmid);
+ for my $opt (sort keys %$conf) {
+ next if $opt !~ m/^net\d+$/;
+ my $net = PVE::LXC::Config->parse_lxc_network($conf->{$opt});
+ next if !$net || !$net->{bridge};
+ if ($vpp_bridges->{$net->{bridge}} && $net->{firewall}) {
+ push @$affected, "CT $vmid ($opt on $net->{bridge})";
+ }
+ }
+ }
+
+ if (@$affected) {
+ log_warn(
+ "The following guests have firewall enabled on VPP bridges (kernel firewall not available):\n"
+ . " "
+ . join(", ", @$affected));
+ } else {
+ log_pass("No firewall conflicts with VPP bridges found.");
+ }
+}
+
sub check_rrd_migration {
if (-e "/var/lib/rrdcached/db/pve-node-9.0") {
log_info("Check post RRD metrics data format update situation...");
@@ -2016,6 +2063,7 @@ sub check_virtual_guests {
check_lxcfs_fuse_version();
check_bridge_mtu();
+ check_vpp_firewall_conflicts();
my $affected_guests_long_desc = [];
my $affected_cts_cgroup_keys = [];
diff --git a/www/manager6/form/BridgeSelector.js b/www/manager6/form/BridgeSelector.js
index b5949018..297a3e19 100644
--- a/www/manager6/form/BridgeSelector.js
+++ b/www/manager6/form/BridgeSelector.js
@@ -30,6 +30,11 @@ Ext.define('PVE.form.BridgeSelector', {
dataIndex: 'active',
renderer: Proxmox.Utils.format_boolean,
},
+ {
+ header: gettext('Type'),
+ width: 80,
+ dataIndex: 'type',
+ },
{
header: gettext('Comment'),
dataIndex: 'comments',
diff --git a/www/manager6/lxc/Network.js b/www/manager6/lxc/Network.js
index e56d47c0..8f377bfa 100644
--- a/www/manager6/lxc/Network.js
+++ b/www/manager6/lxc/Network.js
@@ -6,6 +6,15 @@ Ext.define('PVE.lxc.NetworkInputPanel', {
onlineHelp: 'pct_container_network',
+ viewModel: {
+ data: {
+ bridgeType: '',
+ },
+ formulas: {
+ isVPPBridge: (get) => get('bridgeType') === 'VPPBridge',
+ },
+ },
+
setNodename: function (nodename) {
let me = this;
@@ -116,6 +125,20 @@ Ext.define('PVE.lxc.NetworkInputPanel', {
fieldLabel: gettext('Bridge'),
value: cdata.bridge,
allowBlank: false,
+ listeners: {
+ change: function (field, value) {
+ let store = field.getStore();
+ let rec = store.findRecord('iface', value, 0, false, false, true);
+ let type = rec ? rec.data.type : '';
+ me.getViewModel().set('bridgeType', type);
+ if (type === 'VPPBridge') {
+ let fw = me.down('field[name=firewall]');
+ if (fw) {
+ fw.setValue(false);
+ }
+ }
+ },
+ },
},
{
xtype: 'pveVlanField',
@@ -127,6 +150,17 @@ Ext.define('PVE.lxc.NetworkInputPanel', {
fieldLabel: gettext('Firewall'),
name: 'firewall',
value: cdata.firewall,
+ bind: {
+ disabled: '{isVPPBridge}',
+ },
+ },
+ {
+ xtype: 'displayfield',
+ userCls: 'pmx-hint',
+ value: gettext('Kernel firewall is not available with VPP bridges'),
+ bind: {
+ hidden: '{!isVPPBridge}',
+ },
},
];
diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js
index f6cd8749..bd24fe68 100644
--- a/www/manager6/node/Config.js
+++ b/www/manager6/node/Config.js
@@ -193,6 +193,7 @@ Ext.define('PVE.node.Config', {
showAltNames: true,
groups: ['services'],
nodename: nodename,
+ types: ['bridge', 'bond', 'vlan', 'ovs', 'vpp'],
editOptions: {
enableBridgeVlanIds: true,
},
diff --git a/www/manager6/qemu/NetworkEdit.js b/www/manager6/qemu/NetworkEdit.js
index 2ba13c40..3d096465 100644
--- a/www/manager6/qemu/NetworkEdit.js
+++ b/www/manager6/qemu/NetworkEdit.js
@@ -38,10 +38,12 @@ Ext.define('PVE.qemu.NetworkInputPanel', {
data: {
networkModel: undefined,
mtu: '',
+ bridgeType: '',
},
formulas: {
isVirtio: (get) => get('networkModel') === 'virtio',
showMtuHint: (get) => get('mtu') === 1,
+ isVPPBridge: (get) => get('bridgeType') === 'VPPBridge',
},
},
@@ -82,6 +84,20 @@ Ext.define('PVE.qemu.NetworkInputPanel', {
nodename: me.nodename,
autoSelect: true,
allowBlank: false,
+ listeners: {
+ change: function (field, value) {
+ let store = field.getStore();
+ let rec = store.findRecord('iface', value, 0, false, false, true);
+ let type = rec ? rec.data.type : '';
+ me.getViewModel().set('bridgeType', type);
+ if (type === 'VPPBridge') {
+ let fw = me.down('field[name=firewall]');
+ if (fw) {
+ fw.setValue(false);
+ }
+ }
+ },
+ },
});
me.column1 = [
@@ -96,6 +112,17 @@ Ext.define('PVE.qemu.NetworkInputPanel', {
fieldLabel: gettext('Firewall'),
name: 'firewall',
checked: me.insideWizard || me.isCreate,
+ bind: {
+ disabled: '{isVPPBridge}',
+ },
+ },
+ {
+ xtype: 'displayfield',
+ userCls: 'pmx-hint',
+ value: gettext('Kernel firewall is not available with VPP bridges'),
+ bind: {
+ hidden: '{!isVPPBridge}',
+ },
},
];
diff --git a/www/manager6/window/Migrate.js b/www/manager6/window/Migrate.js
index ff80c70c..c1509be6 100644
--- a/www/manager6/window/Migrate.js
+++ b/www/manager6/window/Migrate.js
@@ -463,6 +463,54 @@ Ext.define('PVE.window.Migrate', {
}
}
+ if (vm.get('running')) {
+ try {
+ let { result: netResult } = await Proxmox.Async.api2({
+ url: `/nodes/${vm.get('nodename')}/network?type=any_bridge`,
+ method: 'GET',
+ });
+ let vppBridges = new Set();
+ for (const iface of netResult.data || []) {
+ if (iface.type === 'VPPBridge') {
+ vppBridges.add(iface.iface);
+ }
+ }
+ if (vppBridges.size > 0) {
+ let vmConfig = {};
+ try {
+ let { result: cfgResult } = await Proxmox.Async.api2({
+ url: `/nodes/${vm.get('nodename')}/qemu/${vm.get('vmid')}/config`,
+ method: 'GET',
+ });
+ vmConfig = cfgResult.data || {};
+ } catch (_err) { /* ignore */ }
+
+ let vppNics = [];
+ for (const [key, value] of Object.entries(vmConfig)) {
+ if (!key.match(/^net\d+$/)) {
+ continue;
+ }
+ let net = PVE.Parser.parseQemuNetwork(key, value);
+ if (net && net.bridge && vppBridges.has(net.bridge)) {
+ vppNics.push(key);
+ }
+ }
+ if (vppNics.length > 0) {
+ migration.possible = false;
+ migration.preconditions.push({
+ text: Ext.String.format(
+ gettext('Cannot live-migrate VM with VPP vhost-user NICs: {0}. Use offline migration or HA (stop/start).'),
+ vppNics.join(', '),
+ ),
+ severity: 'error',
+ });
+ }
+ }
+ } catch (_err) {
+ // VPP bridge check is best-effort
+ }
+ }
+
vm.set('migration', migration);
},
checkLxcPreconditions: async function (resetMigrationPossible) {
--
2.50.1 (Apple Git-155)
^ permalink raw reply [flat|nested] 5+ messages in thread* [RFC PATCH widget-toolkit 2/2] ui: network: add VPP (fd.io) bridge type support
2026-03-16 22:28 [RFC PATCH 0/2] network: add VPP (fd.io) as alternative dataplane Ryosuke Nakayama
2026-03-16 22:28 ` [RFC PATCH manager 1/2] api: network: add VPP (fd.io) dataplane bridge support Ryosuke Nakayama
@ 2026-03-16 22:28 ` Ryosuke Nakayama
2026-03-17 6:39 ` [RFC PATCH 0/2] network: add VPP (fd.io) as alternative dataplane Stefan Hanreich
2026-03-17 10:18 ` DERUMIER, Alexandre
3 siblings, 0 replies; 5+ messages in thread
From: Ryosuke Nakayama @ 2026-03-16 22:28 UTC (permalink / raw)
To: pve-devel
From: ryskn <ryosuke.nakayama@ryskn.com>
Add VPP bridge domain as a creatable/editable network type in the
Proxmox node network configuration UI.
- Utils.js: add VPPBridge/VPPVlan to network_iface_types with gettext()
- NetworkView.js: add 'vpp' to default types list; add VPPBridge and
VPPVlan entries to the Create menu; VPPVlan uses a dedicated menu
entry (no auto-generated default name); render vlan-raw-device in
Ports/Slaves column for VPPVlan; fix VLAN aware column to render
vpp_vlan_aware for VPP bridges; declare vpp_bridge/vpp_vlan_aware
in the proxmox-networks model
- NetworkEdit.js: introduce vppTypes Set as single source of truth for
VPP type checks; add vppbrN validator for VPPBridge name field; add
vppbrN validator for vpp_bridge field in VPPVlan; increase maxLength
to 40 for VPP interface names; hide MTU field for VPP types; exclude
Autostart and IP/GW fields for VPP types via vppTypes; use VlanName
vtype for VPPVlan to allow dot notation (e.g. tap0.100)
Signed-off-by: ryskn <ryosuke.nakayama@ryskn.com>
---
src/Utils.js | 2 ++
src/node/NetworkEdit.js | 64 ++++++++++++++++++++++++++++++++++-------
src/node/NetworkView.js | 35 ++++++++++++++++++----
3 files changed, 85 insertions(+), 16 deletions(-)
diff --git a/src/Utils.js b/src/Utils.js
index 5457ffa..fa88fb1 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -707,6 +707,8 @@ Ext.define('Proxmox.Utils', {
OVSBond: 'OVS Bond',
OVSPort: 'OVS Port',
OVSIntPort: 'OVS IntPort',
+ VPPBridge: gettext('VPP Bridge'),
+ VPPVlan: gettext('VPP VLAN'),
},
render_network_iface_type: function (value) {
diff --git a/src/node/NetworkEdit.js b/src/node/NetworkEdit.js
index c945139..c53cd90 100644
--- a/src/node/NetworkEdit.js
+++ b/src/node/NetworkEdit.js
@@ -21,7 +21,12 @@ Ext.define('Proxmox.node.NetworkEdit', {
me.isCreate = !me.iface;
+ // Canonical set of VPP interface types — used to gate autostart,
+ // IP config, MTU, and other kernel-only fields.
+ const vppTypes = new Set(['VPPBridge', 'VPPVlan']);
+
let iface_vtype;
+ let iface_validator; // optional extra validator for the Name field
if (me.iftype === 'bridge') {
iface_vtype = 'BridgeName';
@@ -39,6 +44,12 @@ Ext.define('Proxmox.node.NetworkEdit', {
iface_vtype = 'InterfaceName';
} else if (me.iftype === 'OVSPort') {
iface_vtype = 'InterfaceName';
+ } else if (me.iftype === 'VPPBridge') {
+ iface_vtype = 'InterfaceName';
+ iface_validator = (v) =>
+ /^vppbr\d+$/.test(v) || gettext('Name must match vppbrN format (e.g. vppbr1)');
+ } else if (me.iftype === 'VPPVlan') {
+ iface_vtype = 'VlanName';
} else {
console.log(me.iftype);
throw 'unknown network device type specified';
@@ -52,7 +63,7 @@ Ext.define('Proxmox.node.NetworkEdit', {
advancedColumn1 = [],
advancedColumn2 = [];
- if (!(me.iftype === 'OVSIntPort' || me.iftype === 'OVSPort' || me.iftype === 'OVSBond')) {
+ if (!(me.iftype === 'OVSIntPort' || me.iftype === 'OVSPort' || me.iftype === 'OVSBond' || vppTypes.has(me.iftype))) {
column2.push({
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Autostart'),
@@ -295,6 +306,32 @@ Ext.define('Proxmox.node.NetworkEdit', {
fieldLabel: gettext('OVS options'),
name: 'ovs_options',
});
+ } else if (me.iftype === 'VPPBridge') {
+ column2.push({
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('VLAN aware'),
+ name: 'vpp_vlan_aware',
+ deleteEmpty: !me.isCreate,
+ });
+ } else if (me.iftype === 'VPPVlan') {
+ column2.push({
+ xtype: 'displayfield',
+ userCls: 'pmx-hint',
+ value: gettext('Name format: <parent>.<vlan-id>, e.g. tap0.100'),
+ });
+ column2.push({
+ xtype: me.isCreate ? 'textfield' : 'displayfield',
+ fieldLabel: gettext('Bridge domain'),
+ name: 'vpp_bridge',
+ emptyText: gettext('none'),
+ allowBlank: true,
+ validator: (v) =>
+ !v || /^vppbr\d+$/.test(v) || gettext('Must match vppbrN format (e.g. vppbr1)'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('VPP bridge domain to attach this VLAN interface to, e.g. vppbr1'),
+ },
+ });
}
column2.push({
@@ -328,8 +365,9 @@ Ext.define('Proxmox.node.NetworkEdit', {
name: 'iface',
value: me.iface,
vtype: iface_vtype,
+ validator: iface_validator,
allowBlank: false,
- maxLength: iface_vtype === 'BridgeName' ? 10 : 15,
+ maxLength: iface_vtype === 'BridgeName' ? 10 : (vppTypes.has(me.iftype) ? 40 : 15),
autoEl: {
tag: 'div',
'data-qtip': gettext('For example, vmbr0.100, vmbr0, vlan0.100, vlan0'),
@@ -391,6 +429,8 @@ Ext.define('Proxmox.node.NetworkEdit', {
name: 'ovs_bonds',
},
);
+ } else if (vppTypes.has(me.iftype)) {
+ // VPP interfaces do not use kernel IP configuration
} else {
column1.push(
{
@@ -423,15 +463,17 @@ Ext.define('Proxmox.node.NetworkEdit', {
},
);
}
- advancedColumn1.push({
- xtype: 'proxmoxintegerfield',
- minValue: 1280,
- maxValue: 65520,
- deleteEmpty: !me.isCreate,
- emptyText: 1500,
- fieldLabel: 'MTU',
- name: 'mtu',
- });
+ if (!vppTypes.has(me.iftype)) {
+ advancedColumn1.push({
+ xtype: 'proxmoxintegerfield',
+ minValue: 1280,
+ maxValue: 65520,
+ deleteEmpty: !me.isCreate,
+ emptyText: 1500,
+ fieldLabel: 'MTU',
+ name: 'mtu',
+ });
+ }
Ext.applyIf(me, {
url: url,
diff --git a/src/node/NetworkView.js b/src/node/NetworkView.js
index 0ff9649..164b349 100644
--- a/src/node/NetworkView.js
+++ b/src/node/NetworkView.js
@@ -19,6 +19,8 @@ Ext.define('proxmox-networks', {
'type',
'vlan-id',
'vlan-raw-device',
+ 'vpp_bridge',
+ 'vpp_vlan_aware',
],
idProperty: 'iface',
});
@@ -30,7 +32,7 @@ Ext.define('Proxmox.node.NetworkView', {
// defines what types of network devices we want to create
// order is always the same
- types: ['bridge', 'bond', 'vlan', 'ovs'],
+ types: ['bridge', 'bond', 'vlan', 'ovs', 'vpp'],
showApplyBtn: false,
@@ -223,6 +225,27 @@ Ext.define('Proxmox.node.NetworkView', {
});
}
+ if (me.types.indexOf('vpp') !== -1) {
+ if (menu_items.length > 0) {
+ menu_items.push({ xtype: 'menuseparator' });
+ }
+
+ addEditWindowToMenu('VPPBridge', 'vppbr');
+ menu_items.push({
+ text: Proxmox.Utils.render_network_iface_type('VPPVlan'),
+ handler: () =>
+ Ext.create('Proxmox.node.NetworkEdit', {
+ autoShow: true,
+ nodename: me.nodename,
+ iftype: 'VPPVlan',
+ ...me.editOptions,
+ listeners: {
+ destroy: () => reload(),
+ },
+ }),
+ });
+ }
+
let renderer_generator = function (fieldname) {
return function (val, metaData, rec) {
let tmp = [];
@@ -326,14 +349,14 @@ Ext.define('Proxmox.node.NetworkView', {
undefinedText: Proxmox.Utils.noText,
},
{
- xtype: 'booleancolumn',
header: gettext('VLAN aware'),
width: 80,
sortable: true,
dataIndex: 'bridge_vlan_aware',
- trueText: Proxmox.Utils.yesText,
- falseText: Proxmox.Utils.noText,
- undefinedText: Proxmox.Utils.noText,
+ renderer: (value, metaData, { data }) => {
+ const v = data.bridge_vlan_aware || data.vpp_vlan_aware;
+ return v ? Proxmox.Utils.yesText : Proxmox.Utils.noText;
+ },
},
{
header: gettext('Ports/Slaves'),
@@ -347,6 +370,8 @@ Ext.define('Proxmox.node.NetworkView', {
return data.ovs_ports;
} else if (value === 'OVSBond') {
return data.ovs_bonds;
+ } else if (value === 'VPPVlan') {
+ return data['vlan-raw-device'];
}
return '';
},
--
2.50.1 (Apple Git-155)
^ permalink raw reply [flat|nested] 5+ messages in thread