From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 697851FF141 for ; Mon, 16 Mar 2026 23:28:39 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id E7E11356BC; Mon, 16 Mar 2026 23:28:35 +0100 (CET) X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1773700101; x=1774304901; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-gg:x-gm-message-state:from :to:cc:subject:date:message-id:reply-to; bh=1/xSwJZTj9RufNoiBS8jZO4wtFEzXrOjLi6xFAC1YHc=; b=cbxdxB/NRQZGvOjRwyBoh4ZU94NvSdUi2vRlwil8aYgWMz1gPTM2SGjuSVdEACOcU2 3etiD3KTM3Vmq2XsJf4+YSQLwwKNgXZNEy5qvifb67SZklk95XpO0jDSbOisTE8j80WO X4YIRRDwCwnyu9/TAXgci53DBsDugUQF5EgndnW9AL+6rsILoTdiUJWzhfbBrmzQXSOG nP60wEFr/CPdIbpWnIyx83CW9Ppntekl38iFf8MPBLFI6ab5Eyzn/9PPExe/jQD0aEnh MjdVbrw5BgoqPNRl/6PRXCtrOCRM3+gWvycPEDCAenRE7LaE53t1pkk9fHfHbH3/GAyG dCLw== X-Gm-Message-State: AOJu0YzuERZW/sjDAclOtgfB6Mt46jmk2z+dSClsoEQN5G/T/B+Qp/0c s/pRjCaJeFqYPEn1udzOTxbSIy+EpLE4ysxl3t5khUkiJT7zJoRcdNQ37J18+epPOBnn X-Gm-Gg: ATEYQzxqi9B1Ydp8c1EK1oCH2rmv0eH4kucYVAnDZttZa2wNk7I/R0vBeTMjvvw6dTH 0bLm7z13vKKcAJmPaPbqY5EQvQ2BcNYKa2ub3lMDM1Ca5DL0dX5XxtZXHJpAVT8rNkz5qC4JS6a G3Md03enux+GG9zCZUKUYZ0d++vD/AFUoYJ6ZRtVVQVOZLPc+9Csqe/i9FUAprNmGn8ZcPoObxp tPhxbYlTkZKiMO4dFWY7Dtcn2+jva4BCy34H/zxo8O8UI+F5RYWV7HB2X5nWtV+8DO4Yd8uIstw MNDZphSAdz2s0ao1A3YfXlK6zSCM6JO3W6daa91Dm8GJ+GmJNjVdTLFeaU8mjqhNqNam/td3oHr NPOTXyckL2Gnm6NXp3yK65jMjjTPON0d8cXmhANxqutO0YlewxxFf4tcsRnJuG4B9i88q4IOSvL PyuBcSX42qHbIHYNZ84g5yT+274VE+14pt7u00SfFcq6MhGHyGTOnapq67bnFXPkumoLt8rPsv1 XHZ3Jy63gYKAhsXnBjbJLnkPLMdnvd8Vhp/8tcFoNSaHjcDRw== X-Received: by 2002:a17:903:2289:b0:2a9:e8b:5326 with SMTP id d9443c01a7336-2aeca9a7ff6mr163272895ad.23.1773700100801; Mon, 16 Mar 2026 15:28:20 -0700 (PDT) From: Ryosuke Nakayama To: pve-devel@lists.proxmox.com Subject: [RFC PATCH manager 1/2] api: network: add VPP (fd.io) dataplane bridge support Date: Tue, 17 Mar 2026 07:28:15 +0900 Message-ID: <20260316222816.42944-2-ryosuke.nakayama@ryskn.com> X-Mailer: git-send-email 2.50.1 In-Reply-To: <20260316222816.42944-1-ryosuke.nakayama@ryskn.com> References: <20260316222816.42944-1-ryosuke.nakayama@ryskn.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.000 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy FREEMAIL_ENVFROM_END_DIGIT 1 Envelope-from freemail username ends in digit FREEMAIL_FORGED_FROMDOMAIN 0.249 2nd level domains in From and EnvelopeFrom freemail headers are different FREEMAIL_FROM 0.001 Sender email is commonly abused enduser mail provider HEADER_FROM_DIFFERENT_DOMAINS 0.25 From and EnvelopeFrom 2nd level mail domains are different KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_NONE -0.0001 Sender listed at https://www.dnswl.org/, no trust RCVD_IN_MSPIKE_H3 0.001 Good reputation (+3) RCVD_IN_MSPIKE_WL 0.001 Mailspike good senders SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 46XXCG7KCV6KCSCPJJRPTRDC6R6YP6U7 X-Message-ID-Hash: 46XXCG7KCV6KCSCPJJRPTRDC6R6YP6U7 X-MailFrom: koyakiu666@gmail.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: ryskn 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--.sock Signed-off-by: ryskn --- 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 ., 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)