From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id E4EF71FF0BC for ; Thu, 29 Oct 2026 15:44:56 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 558CE21620; Thu, 29 Oct 2026 15:44:42 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v4 4/9] ui: sdn: add IPv6 RA / SLAAC support Date: Thu, 29 Oct 2026 15:44:03 +0100 Message-ID: <20261029144408.2646288-5-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261029144408.2646288-1-h.laimer@proxmox.com> References: <20261029144408.2646288-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793285055927 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.354 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: FKTEVLLNUEM45WOSUFNDRNWC7U3O5TZL X-Message-ID-Hash: FKTEVLLNUEM45WOSUFNDRNWC7U3O5TZL X-MailFrom: h.laimer@proxmox.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: Add an "IPv6 Router Advertisement" tab to the vnet dialog for the per-vnet RA settings, and an "IPv6 Prefix Options" tab to the subnet dialog for the per-prefix flags and lifetimes. The dialogs offer the settings only where the API accepts them. The vnet tab is enabled only for EVPN zones. The subnet tab appears only in EVPN zones and is enabled only for an IPv6 prefix. Its SLAAC flag is disabled for any prefix other than a /64. Signed-off-by: Hannes Laimer --- Notes: v4: - the router lifetime field shows Default instead of 1800, FRR derives it from the interval www/manager6/form/SDNVnetSelector.js | 2 +- www/manager6/sdn/SubnetEdit.js | 130 ++++++++++++++++- www/manager6/sdn/SubnetView.js | 7 +- www/manager6/sdn/VnetEdit.js | 202 ++++++++++++++++++++++++++- www/manager6/sdn/VnetView.js | 6 +- 5 files changed, 339 insertions(+), 8 deletions(-) diff --git a/www/manager6/form/SDNVnetSelector.js b/www/manager6/form/SDNVnetSelector.js index 9e54159c1405..5ccc3cfb1699 100644 --- a/www/manager6/form/SDNVnetSelector.js +++ b/www/manager6/form/SDNVnetSelector.js @@ -52,7 +52,7 @@ Ext.define( function () { Ext.define('pve-sdn-vnet', { extend: 'Ext.data.Model', - fields: ['alias', 'tag', 'type', 'vnet', 'zone'], + fields: ['alias', 'tag', 'type', 'vnet', 'zone', 'zone-type'], proxy: { type: 'proxmox', url: '/api2/json/cluster/sdn/vnets', diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js index a360842891a3..020df23efd11 100644 --- a/www/manager6/sdn/SubnetEdit.js +++ b/www/manager6/sdn/SubnetEdit.js @@ -24,6 +24,14 @@ Ext.define('PVE.sdn.SubnetInputPanel', { flex: 1, allowBlank: false, fieldLabel: gettext('Subnet'), + listeners: { + change: function (field, value) { + let ipv6 = field.up('window')?.down('#ipv6Panel'); + if (ipv6) { + ipv6.updateForCidr(value); + } + }, + }, }, { xtype: 'proxmoxtextfield', @@ -59,6 +67,102 @@ Ext.define('PVE.sdn.SubnetInputPanel', { ], }); +Ext.define('PVE.sdn.SubnetIPv6Panel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + updateForCidr: function (cidr) { + let me = this; + + let addr = cidr ? cidr.split('/')[0] : ''; + let mask = cidr ? parseInt(cidr.split('/')[1], 10) : NaN; + let isV6 = !!addr && Proxmox.Utils.IP6_match.test(addr); + let isSlash64 = isV6 && mask === 64; + + me.setDisabled(!isV6); + if (!isV6) { + return; + } + + let autonomous = me.down('[name=nd-prefix-autonomous]'); + if (autonomous) { + autonomous.setDisabled(!isSlash64); + if (!isSlash64) { + if (autonomous.getValue()) { + me.autonomousAutoCleared = true; + autonomous.setValue(false); + } + } else if (me.autonomousAutoCleared) { + me.autonomousAutoCleared = false; + autonomous.setValue(true); + } + } + }, + + onGetValues: function (values) { + let me = this; + + if (me.isCreate) { + for (let name of ['nd-prefix-autonomous', 'nd-prefix-on-link']) { + if (String(values[name]) === '1') { + delete values[name]; + } + } + } + + return values; + }, + + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'nd-prefix-autonomous', + uncheckedValue: 0, + defaultValue: '1', + checked: true, + fieldLabel: gettext('SLAAC (A)'), + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'nd-prefix-on-link', + uncheckedValue: 0, + defaultValue: '1', + checked: true, + fieldLabel: gettext('On-link (L)'), + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'nd-prefix-valid-lifetime', + fieldLabel: gettext('Valid Lifetime (s)'), + minValue: 0, + maxValue: 4294967295, + allowBlank: true, + emptyText: '2592000', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'nd-prefix-preferred-lifetime', + fieldLabel: gettext('Preferred Lifetime (s)'), + minValue: 0, + maxValue: 4294967295, + allowBlank: true, + emptyText: '604800', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], +}); + Ext.define('PVE.sdn.SubnetDhcpRangePanel', { extend: 'Ext.form.FieldContainer', mixins: ['Ext.form.field.Field'], @@ -238,13 +342,16 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { Ext.define('PVE.sdn.SubnetEdit', { extend: 'Proxmox.window.Edit', + onlineHelp: 'pvesdn_config_subnet', subject: gettext('Subnet'), subnet: undefined, + cidr: undefined, width: 350, base_url: undefined, + zoneType: undefined, bodyPadding: 0, @@ -272,22 +379,41 @@ Ext.define('PVE.sdn.SubnetEdit', { name: 'dhcp-range', }); + let tabItems = [ipanel, dhcpPanel]; + let ipv6Panel; + let showIpv6Panel = me.zoneType === 'evpn'; + if (showIpv6Panel && !me.isCreate && me.cidr) { + let addr = me.cidr.split('/')[0]; + showIpv6Panel = Proxmox.Utils.IP6_match.test(addr); + } + if (showIpv6Panel) { + ipv6Panel = Ext.create('PVE.sdn.SubnetIPv6Panel', { + isCreate: me.isCreate, + itemId: 'ipv6Panel', + title: gettext('IPv6 Prefix Options'), + }); + tabItems.push(ipv6Panel); + } + Ext.apply(me, { items: [ { xtype: 'tabpanel', bodyPadding: 10, - items: [ipanel, dhcpPanel], + items: tabItems, }, ], }); me.callParent(); - if (!me.isCreate) { + if (me.isCreate) { + ipv6Panel?.updateForCidr(undefined); + } else { me.load({ success: function (response, options) { me.setValues(response.result.data); + ipv6Panel?.updateForCidr(response.result.data.cidr); }, }); } diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js index c61458e02457..e9e601bb90bb 100644 --- a/www/manager6/sdn/SubnetView.js +++ b/www/manager6/sdn/SubnetView.js @@ -8,13 +8,15 @@ Ext.define( stateId: 'grid-sdn-subnet', base_url: undefined, + zone_type: undefined, remove_btn: undefined, - setBaseUrl: function (url) { + setBaseUrl: function (url, zoneType) { let me = this; me.base_url = url; + me.zone_type = zoneType; if (url === undefined) { me.store.removeAll(); @@ -49,7 +51,9 @@ Ext.define( let win = Ext.create('PVE.sdn.SubnetEdit', { autoShow: true, subnet: rec.data.subnet, + cidr: rec.data.cidr, base_url: me.base_url, + zoneType: me.zone_type, }); win.on('destroy', reload); }; @@ -62,6 +66,7 @@ Ext.define( autoShow: true, base_url: me.base_url, type: 'subnet', + zoneType: me.zone_type, }); win.on('destroy', reload); }, diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js index 1c2f5293aa65..28fb8a63b738 100644 --- a/www/manager6/sdn/VnetEdit.js +++ b/www/manager6/sdn/VnetEdit.js @@ -80,6 +80,9 @@ Ext.define('PVE.sdn.VnetInputPanel', { let panel = me.up('panel'); panel.setZoneType(zoneType); + + let raPanel = me.up('window').down('#ipv6RaPanel'); + raPanel?.setDisabled(zoneType !== 'evpn'); }, }, }, @@ -145,14 +148,193 @@ Ext.define('PVE.sdn.VnetInputPanel', { }, }); +Ext.define('PVE.sdn.VnetIPv6RAPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + initComponent: function () { + let me = this; + me.callParent(); + me.updateGatedState(); + }, + + onGetValues: function (values) { + let me = this; + + if (values['ipv6-ra-rdnss']) { + values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss'] + .split(/[\s,]+/) + .filter((v) => v.length > 0); + } + + if (me.isCreate) { + for (let name of ['ipv6-ra', 'ipv6-ra-managed', 'ipv6-ra-other']) { + if (String(values[name]) === '0') { + delete values[name]; + } + } + return values; + } + + let addDelete = (key) => { + if (values.delete) { + if (Ext.isArray(values.delete)) { + values.delete.push(key); + } else { + values.delete = [values.delete, key]; + } + } else { + values.delete = [key]; + } + delete values[key]; + }; + + for (let name of [ + 'ipv6-ra', + 'ipv6-ra-managed', + 'ipv6-ra-other', + 'ipv6-ra-rdnss', + 'ipv6-ra-router-lifetime', + 'ipv6-ra-interval', + 'ipv6-ra-mtu', + ]) { + if (me.down(`[name=${name}]`).isDisabled()) { + addDelete(name); + } + } + + return values; + }, + + setValues: function (values) { + let me = this; + + if (Ext.isArray(values['ipv6-ra-rdnss'])) { + values = Ext.apply({}, values); + values['ipv6-ra-rdnss'] = values['ipv6-ra-rdnss'].join(', '); + } + me.callParent([values]); + }, + + updateGatedState: function () { + let me = this; + + let raEnabled = !!me.down('[name=ipv6-ra]').getValue(); + for (let name of [ + 'ipv6-ra-managed', + 'ipv6-ra-other', + 'ipv6-ra-rdnss', + 'ipv6-ra-router-lifetime', + 'ipv6-ra-interval', + 'ipv6-ra-mtu', + ]) { + let field = me.down(`[name=${name}]`); + if (field) { + field.setDisabled(!raEnabled); + } + } + }, + + items: [ + { + xtype: 'proxmoxcheckbox', + name: 'ipv6-ra', + uncheckedValue: 0, + defaultValue: 0, + checked: false, + fieldLabel: gettext('Send Router Advertisements'), + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + listeners: { + change: function (field) { + field.up('inputpanel').updateGatedState(); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ipv6-ra-managed', + uncheckedValue: 0, + defaultValue: 0, + checked: false, + fieldLabel: gettext('DHCP Managed (M)'), + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'ipv6-ra-other', + uncheckedValue: 0, + defaultValue: 0, + checked: false, + fieldLabel: gettext('DHCP Other (O)'), + cbind: { + deleteDefaultValue: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'ipv6-ra-rdnss', + fieldLabel: 'RDNSS', + emptyText: gettext('Comma-separated IPv6 addresses'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + advancedItems: [ + { + xtype: 'proxmoxintegerfield', + name: 'ipv6-ra-router-lifetime', + fieldLabel: gettext('Router Lifetime (s)'), + emptyText: Proxmox.Utils.defaultText, + minValue: 0, + maxValue: 9000, + allowBlank: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'ipv6-ra-interval', + fieldLabel: gettext('RA Interval (s)'), + emptyText: '600', + minValue: 4, + maxValue: 1800, + allowBlank: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'ipv6-ra-mtu', + fieldLabel: gettext('Advertised MTU'), + minValue: 1280, + maxValue: 65535, + allowBlank: true, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], +}); + Ext.define('PVE.sdn.VnetEdit', { extend: 'Proxmox.window.Edit', + onlineHelp: 'pvesdn_config_vnet', subject: gettext('VNet'), vnet: undefined, + zoneType: undefined, + + width: 400, - width: 350, + bodyPadding: 0, initComponent: function () { var me = this; @@ -169,10 +351,24 @@ Ext.define('PVE.sdn.VnetEdit', { let ipanel = Ext.create('PVE.sdn.VnetInputPanel', { isCreate: me.isCreate, + title: gettext('General'), + }); + + let raPanel = Ext.create('PVE.sdn.VnetIPv6RAPanel', { + isCreate: me.isCreate, + itemId: 'ipv6RaPanel', + title: gettext('IPv6 Router Advertisement'), + disabled: me.zoneType !== 'evpn', }); Ext.apply(me, { - items: [ipanel], + items: [ + { + xtype: 'tabpanel', + bodyPadding: 10, + items: [ipanel, raPanel], + }, + ], }); me.callParent(); @@ -182,6 +378,8 @@ Ext.define('PVE.sdn.VnetEdit', { success: function (response, options) { let values = response.result.data; ipanel.setValues(values); + raPanel.setValues(values); + raPanel.updateGatedState(); }, }); } diff --git a/www/manager6/sdn/VnetView.js b/www/manager6/sdn/VnetView.js index 1c576db66158..ce8c314eba6c 100644 --- a/www/manager6/sdn/VnetView.js +++ b/www/manager6/sdn/VnetView.js @@ -36,6 +36,7 @@ Ext.define('PVE.sdn.VnetView', { autoShow: true, onlineHelp: 'pvesdn_config_vnet', vnet: rec.data.vnet, + zoneType: rec.data['zone-type'], }); win.on('destroy', reload); }; @@ -141,10 +142,11 @@ Ext.define('PVE.sdn.VnetView', { show: reload, select: function (_sm, rec) { let url = `/cluster/sdn/vnets/${rec.data.vnet}/subnets`; - me.subnetview_panel.setBaseUrl(url); + let zoneType = rec.data['zone-type']; + me.subnetview_panel.setBaseUrl(url, zoneType); }, deselect: function () { - me.subnetview_panel.setBaseUrl(undefined); + me.subnetview_panel.setBaseUrl(undefined, undefined); }, }, }); -- 2.47.3