From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 4057C1FF0DF for ; Thu, 05 Nov 2026 12:20:55 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 13FB021B41; Thu, 05 Nov 2026 12:16:13 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v4 34/45] ui: sdn: add microsegmentation panel Date: Thu, 5 Nov 2026 12:13:13 +0100 Message-ID: <20261105111324.706922-35-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261105111324.706922-1-h.laimer@proxmox.com> References: <20261105111324.706922-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: 1793877244278 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.245 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) PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far 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: PCVDRQ6Q2L7NRQ4UR6E5TF5PPM6WXHEY X-Message-ID-Hash: PCVDRQ6Q2L7NRQ4UR6E5TF5PPM6WXHEY 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 a Microsegmentation panel under Datacenter > SDN to manage groups, rules and guest assignments, with a tree of the groups and their guests and NICs. Rules are grouped by priority, highest first. A NIC belongs to the first assignment that matches it, so the assignment list keeps the match order and can be reordered by drag and drop or a position field. An assignment that wins no NIC is dimmed. Selecting a group shows the rules that list it. Selecting a guest or NIC shows the rules the API reports for its groups, so the panel does not evaluate rules itself. The panel shows for SDN.Audit on the policy without Sys.Audit, the way the storage panel shows for Datastore.Allocate, so a delegated policy administrator reaches it. Signed-off-by: Hannes Laimer --- www/css/ext6-pve.css | 23 ++ www/manager6/Makefile | 9 + www/manager6/Utils.js | 79 ++++++ www/manager6/data/PermPathStore.js | 1 + www/manager6/dc/Config.js | 11 + www/manager6/form/SDNMicrosegGroupSelector.js | 83 ++++++ www/manager6/sdn/MicrosegView.js | 92 +++++++ www/manager6/sdn/microseg/AssignmentEdit.js | 80 ++++++ www/manager6/sdn/microseg/AssignmentView.js | 193 ++++++++++++++ www/manager6/sdn/microseg/Base.js | 215 +++++++++++++++ www/manager6/sdn/microseg/GroupEdit.js | 30 +++ www/manager6/sdn/microseg/RuleEdit.js | 158 +++++++++++ www/manager6/sdn/microseg/RuleView.js | 242 +++++++++++++++++ www/manager6/sdn/microseg/Tree.js | 249 ++++++++++++++++++ 14 files changed, 1465 insertions(+) create mode 100644 www/manager6/form/SDNMicrosegGroupSelector.js create mode 100644 www/manager6/sdn/MicrosegView.js create mode 100644 www/manager6/sdn/microseg/AssignmentEdit.js create mode 100644 www/manager6/sdn/microseg/AssignmentView.js create mode 100644 www/manager6/sdn/microseg/Base.js create mode 100644 www/manager6/sdn/microseg/GroupEdit.js create mode 100644 www/manager6/sdn/microseg/RuleEdit.js create mode 100644 www/manager6/sdn/microseg/RuleView.js create mode 100644 www/manager6/sdn/microseg/Tree.js diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index b1e0b4f81002..07f2c65d33ef 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -708,3 +708,26 @@ table.osds td:first-of-type { .pmx-column-wrapped > div { white-space: pre-wrap; } + +.pve-microseg-group { + display: inline-block; + border: 1px solid #999; + border-radius: 3px; + padding: 0px 5px; + margin: 0px 1px; +} + +.pve-microseg-muted { + opacity: 0.5; +} + +.pve-microseg-id-space { + font-size: smaller; + font-weight: normal; +} + +.pve-microseg-deleted td:not(.pve-microseg-state) .x-grid-cell-inner, +.pve-microseg-deleted td:not(.pve-microseg-state) .pve-microseg-group, +.pve-microseg-deleted td:not(.pve-microseg-state) .fa { + text-decoration: line-through; +} diff --git a/www/manager6/Makefile b/www/manager6/Makefile index d2ea786be00b..7c48b0622aa7 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -71,6 +71,7 @@ JSSRC= \ form/SDNControllerSelector.js \ form/SDNZoneSelector.js \ form/SDNVnetSelector.js \ + form/SDNMicrosegGroupSelector.js \ form/SDNIpamSelector.js \ form/SDNDnsSelector.js \ form/ScsiHwSelector.js \ @@ -357,6 +358,14 @@ JSSRC= \ sdn/fabrics/bgp/InterfacePanel.js \ sdn/fabrics/bgp/NodeEdit.js \ sdn/fabrics/bgp/FabricEdit.js \ + sdn/MicrosegView.js \ + sdn/microseg/Base.js \ + sdn/microseg/Tree.js \ + sdn/microseg/GroupEdit.js \ + sdn/microseg/RuleEdit.js \ + sdn/microseg/AssignmentEdit.js \ + sdn/microseg/RuleView.js \ + sdn/microseg/AssignmentView.js \ storage/ContentView.js \ storage/BackupView.js \ storage/Base.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 7704404cf61a..f752d3b881ac 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -231,6 +231,27 @@ Ext.define('PVE.Utils', { return `${icon}${Ext.htmlEncode(value)}`; }, + sdnmicroseg_row_cls: function (rec) { + return rec.data.state === 'deleted' ? 'pve-microseg-deleted' : ''; + }, + + render_sdnmicroseg_state: function (value) { + if (!value) { + return ''; + } + let state = + { + new: gettext('New'), + changed: gettext('Changed'), + deleted: gettext('Deleted'), + }[value] ?? value; + return ``; + }, + + render_sdnmicroseg_group: function (group) { + return `${Ext.String.htmlEncode(group)}`; + }, + render_ceph_health: function (healthObj) { var state = { iconCls: PVE.Utils.get_health_icon(), @@ -966,6 +987,64 @@ Ext.define('PVE.Utils', { }, }, + sdnmicrosegSchema: { + group: { + name: gettext('Group'), + ipanel: 'GroupInputPanel', + width: 400, + }, + rule: { + name: gettext('Rule'), + ipanel: 'RuleInputPanel', + }, + 'guest-assignment': { + name: gettext('Guest Assignment'), + ipanel: 'GuestAssignmentInputPanel', + width: 400, + faIcon: 'link', + assignment: true, + }, + }, + + sdnmicroseg_is_assignment: function (type) { + return !!PVE.Utils.sdnmicrosegSchema[type]?.assignment; + }, + + format_sdnmicroseg_type: function (value) { + let schema = PVE.Utils.sdnmicrosegSchema[value]; + if (schema) { + return schema.name; + } + return Proxmox.Utils.unknownText; + }, + + sdnmicroseg_merge_pending: function (obj) { + let merged = { ...obj }; + for (const [key, value] of Object.entries(obj.pending ?? {})) { + if (value === 'deleted') { + delete merged[key]; + } else if (value !== undefined && value !== null) { + merged[key] = value; + } + } + return merged; + }, + + format_sdnmicroseg_predicate: function (match, groups) { + if (match === 'every') { + return match; + } + return `${match || 'any'}{${(groups || []).join(', ')}}`; + }, + + format_sdnmicroseg_matcher: function (assignment) { + if (!PVE.Utils.sdnmicrosegSchema[assignment.type]) { + return Proxmox.Utils.unknownText; + } + let iface = (assignment.iface ?? '') === '' ? '' : ` net${assignment.iface}`; + return `vm${assignment.vmid}${iface}`; + }, + sdnipamSchema: { ipam: { name: 'ipam', diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js index bc2112ad8b3b..5d3095d9596c 100644 --- a/www/manager6/data/PermPathStore.js +++ b/www/manager6/data/PermPathStore.js @@ -17,6 +17,7 @@ Ext.define('PVE.data.PermPathStore', { { value: '/nodes' }, { value: '/pool' }, { value: '/sdn/fabrics' }, + { value: '/sdn/microseg' }, { value: '/sdn/zones' }, { value: '/storage' }, { value: '/vms' }, diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 83c48c3d633c..35e85be6f628 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -397,6 +397,17 @@ Ext.define('PVE.dc.Config', { ); } + if (caps.sdn['SDN.Audit'] || caps.dc['Sys.Audit']) { + me.items.push({ + xtype: 'pveSDNMicroseg', + groups: ['sdn'], + title: gettext('Microsegmentation'), + hidden: true, + iconCls: 'fa fa-tags', + itemId: 'sdnmicroseg', + }); + } + if ( caps.mapping['Mapping.Audit'] || caps.mapping['Mapping.Use'] || diff --git a/www/manager6/form/SDNMicrosegGroupSelector.js b/www/manager6/form/SDNMicrosegGroupSelector.js new file mode 100644 index 000000000000..1b0c39a1cfba --- /dev/null +++ b/www/manager6/form/SDNMicrosegGroupSelector.js @@ -0,0 +1,83 @@ +Ext.define( + 'PVE.form.SDNMicrosegGroupSelector', + { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveSDNMicrosegGroupSelector'], + + valueField: 'id', + displayField: 'id', + + syncUntagged: function () { + let me = this; + let store = me.getStore(); + let record = store.getById('untagged'); + if (me.includeUntagged && !record) { + store.add({ + id: 'untagged', + comment: gettext('No group (untagged)'), + }); + } else if (!me.includeUntagged && record) { + store.remove(record); + } + }, + + setIncludeUntagged: function (include) { + let me = this; + me.includeUntagged = include; + let value = me.getValue(); + if (!include && Ext.isArray(value)) { + me.setValue(value.filter((id) => id !== 'untagged')); + } + me.syncUntagged(); + }, + + initComponent: function () { + let me = this; + + let store = new Ext.data.Store({ + model: 'pve-sdn-microseg-group', + sorters: { + property: 'id', + direction: 'ASC', + }, + }); + + me.mon(store, 'load', () => me.syncUntagged()); + + Ext.apply(me, { + store: store, + autoSelect: false, + listConfig: { + columns: [ + { + header: gettext('Group'), + sortable: true, + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + flex: 2, + renderer: Ext.String.htmlEncode, + }, + ], + }, + }); + + me.callParent(); + + store.load(); + }, + }, + function () { + Ext.define('pve-sdn-microseg-group', { + extend: 'Ext.data.Model', + fields: ['id', 'comment'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/sdn/microseg/group', + }, + }); + }, +); diff --git a/www/manager6/sdn/MicrosegView.js b/www/manager6/sdn/MicrosegView.js new file mode 100644 index 000000000000..805b5b7ebdd9 --- /dev/null +++ b/www/manager6/sdn/MicrosegView.js @@ -0,0 +1,92 @@ +Ext.define('PVE.sdn.MicrosegView', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNMicroseg', + + onlineHelp: 'pvesdn_config_microseg', + + layout: 'border', + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function () { + let me = this; + PVE.sdn.microseg.Membership.load(me.getView(), (parts) => { + me.lookup('rules').setData(parts.rules); + me.lookup('assignments').setData(parts.assignments); + me.lookup('tree').setData( + parts.groups, + parts.assignments, + parts.groupSets, + parts.idSpace, + ); + }); + }, + + onTreeSelect: function (tree, selection) { + let me = this; + let sel = selection[0]?.data.microsegSelection ?? null; + me.lookup('rules').setTreeSelection(sel); + me.lookup('assignments').setTreeSelection(sel); + }, + + selectedGroup: function () { + let node = this.lookup('tree').getSelection()[0]; + return node && PVE.sdn.microseg.Tree.editable(node) ? node.data.microsegId : undefined; + }, + + addGroup: function () { + this.lookup('tree').openEdit('group'); + }, + + editGroup: function () { + let me = this; + let group = me.selectedGroup(); + if (group) { + me.lookup('tree').openEdit('group', group); + } + }, + }, + + items: [ + { + xtype: 'pveSDNMicrosegTree', + reference: 'tree', + region: 'west', + title: gettext('Groups'), + width: 400, + minWidth: 250, + split: true, + listeners: { + microsegreload: 'reload', + selectionchange: 'onTreeSelect', + itemdblclick: 'editGroup', + }, + }, + { + xtype: 'tabpanel', + region: 'center', + border: false, + items: [ + { + xtype: 'pveSDNMicrosegRules', + reference: 'rules', + listeners: { + microsegreload: 'reload', + }, + }, + { + xtype: 'pveSDNMicrosegAssignments', + reference: 'assignments', + listeners: { + microsegreload: 'reload', + }, + }, + ], + }, + ], + + listeners: { + activate: 'reload', + }, +}); diff --git a/www/manager6/sdn/microseg/AssignmentEdit.js b/www/manager6/sdn/microseg/AssignmentEdit.js new file mode 100644 index 000000000000..71cabaeeea52 --- /dev/null +++ b/www/manager6/sdn/microseg/AssignmentEdit.js @@ -0,0 +1,80 @@ +Ext.define('PVE.sdn.microseg.AssignmentInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'pvesdn_microseg_assignment', + + onGetValues: function (values) { + let me = this; + if (me.isCreate) { + values.type = me.type; + } + return values; + }, + + initComponent: function () { + let me = this; + + me.items = [ + ...me.matcherItems, + { + xtype: 'proxmoxintegerfield', + name: 'iface', + fieldLabel: gettext('Network Interface'), + minValue: 0, + maxValue: 31, + allowBlank: true, + emptyText: gettext('All NICs'), + disabled: !me.isCreate, + }, + { + xtype: 'pveSDNMicrosegGroupSelector', + name: 'groups', + fieldLabel: gettext('Groups'), + multiSelect: true, + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + ]; + + me.advancedItems = [ + { + xtype: 'proxmoxintegerfield', + name: 'position', + fieldLabel: gettext('Position'), + value: me.position, + minValue: 0, + allowBlank: true, + emptyText: me.isCreate ? '0' : gettext('Unchanged'), + }, + ]; + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.microseg.GuestAssignmentInputPanel', { + extend: 'PVE.sdn.microseg.AssignmentInputPanel', + + initComponent: function () { + let me = this; + + me.matcherItems = [ + { + xtype: 'vmComboSelector', + name: 'vmid', + fieldLabel: gettext('Guest'), + allowBlank: false, + disabled: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/AssignmentView.js b/www/manager6/sdn/microseg/AssignmentView.js new file mode 100644 index 000000000000..4744380461a7 --- /dev/null +++ b/www/manager6/sdn/microseg/AssignmentView.js @@ -0,0 +1,193 @@ +Ext.define( + 'PVE.sdn.microseg.AssignmentView', + { + extend: 'PVE.sdn.microseg.BaseGrid', + alias: 'widget.pveSDNMicrosegAssignments', + + title: gettext('Assignments'), + + baseurl: '/cluster/sdn/microseg/assignment/', + + getRecordName: (rec) => PVE.Utils.format_sdnmicroseg_matcher(rec.data), + + editPanelConfig: (rec) => ({ position: rec.data.rank }), + + setData: function (assignments) { + let me = this; + let rows = assignments.map((a) => ({ + ...a, + rank: me.rankOf(a), + memberCount: (a.members || []).reduce((n, m) => n + m.nics.length, 0), + })); + me.getStore().loadData(rows); + }, + + setTreeSelection: function (sel) { + let me = this; + me.treeSelection = sel; + me.getView().refresh(); + }, + + initComponent: function () { + let me = this; + + let store = Ext.create('Ext.data.Store', { + model: 'pve-sdn-microseg-assignment', + sorters: [ + { property: 'rank', direction: 'ASC' }, + { property: 'id', direction: 'ASC' }, + ], + }); + + let move = (rec, index) => + Proxmox.Utils.API2Request({ + url: `${me.baseurl}${rec.data.id}`, + method: 'PUT', + params: { position: index }, + waitMsgTarget: me, + failure: (response) => + Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus), + callback: () => me.reload(), + }); + + let movable = (rec) => rec.data.state !== 'deleted'; + + let winsNic = (rec, vmid, nic) => + (rec.data.members || []).some( + (m) => m.vmid === vmid && (nic === undefined || m.nics.includes(nic)), + ); + + let dimmed = (rec) => { + if (!rec.data.memberCount) { + return true; + } + let sel = me.treeSelection; + if (!sel) { + return false; + } + if (sel.vmid !== undefined) { + return !winsNic(rec, sel.vmid, sel.nic); + } + let group = sel.mode === 'membership' ? sel.groups[0] : undefined; + if (group && group !== 'untagged') { + return !(rec.data.groups ?? []).includes(group); + } + return false; + }; + + let renderMembers = (value, rec) => { + if (!value) { + return '0'; + } + let nics = []; + for (const member of rec.data.members || []) { + for (const nic of member.nics) { + nics.push(`vm${member.vmid} net${nic}`); + } + } + return `${value}`; + }; + + me.columns = [ + { + text: '#', + dataIndex: 'rank', + width: 50, + renderer: (v, m, rec) => (rec.data.state === 'deleted' ? '' : v), + }, + { + text: gettext('Matcher'), + dataIndex: 'type', + flex: 2, + renderer: (v, m, rec) => { + let text = Ext.String.htmlEncode( + PVE.Utils.format_sdnmicroseg_matcher(rec.data), + ); + let icon = PVE.Utils.sdnmicrosegSchema[v]?.faIcon; + return icon ? ` ${text}` : text; + }, + }, + { + text: gettext('Groups'), + dataIndex: 'groups', + flex: 2, + renderer: (v) => (v ?? []).map(PVE.Utils.render_sdnmicroseg_group).join(''), + }, + { + text: gettext('NICs'), + dataIndex: 'memberCount', + width: 90, + renderer: (v, m, rec) => renderMembers(v, rec), + }, + { xtype: 'pveSDNMicrosegStateColumn' }, + { + text: gettext('Comment'), + dataIndex: 'comment', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + ]; + + Ext.apply(me, { + store: store, + viewConfig: { + plugins: [ + { + ptype: 'gridviewdragdrop', + dragGroup: 'pveSDNMicrosegAssignmentDD', + dropGroup: 'pveSDNMicrosegAssignmentDD', + }, + ], + getRowClass: (rec) => + [ + PVE.Utils.sdnmicroseg_row_cls(rec), + dimmed(rec) ? 'pve-microseg-muted' : '', + ].join(' '), + listeners: { + beforedrop: (node, data, dropRec, dropPosition) => { + let rec = data.records[0]; + if (!dropRec || rec === dropRec || !movable(rec) || !movable(dropRec)) { + return false; + } + let list = store + .getRange() + .filter((r) => r !== rec && r.data.state !== 'deleted'); + move(rec, list.indexOf(dropRec) + (dropPosition === 'after' ? 1 : 0)); + return false; + }, + }, + }, + addButton: { + text: gettext('Add'), + menu: { + items: Object.keys(PVE.Utils.sdnmicrosegSchema) + .filter((type) => PVE.Utils.sdnmicroseg_is_assignment(type)) + .map((type) => ({ + text: PVE.Utils.format_sdnmicroseg_type(type), + handler: () => me.openEdit(type), + })), + }, + }, + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-sdn-microseg-assignment', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'type', + 'vmid', + 'iface', + 'groups', + 'comment', + 'state', + 'members', + 'rank', + 'memberCount', + ], + }); + }, +); diff --git a/www/manager6/sdn/microseg/Base.js b/www/manager6/sdn/microseg/Base.js new file mode 100644 index 000000000000..6751da1c7e88 --- /dev/null +++ b/www/manager6/sdn/microseg/Base.js @@ -0,0 +1,215 @@ +Ext.define('PVE.sdn.microseg.BaseEdit', { + extend: 'Proxmox.window.Edit', + + initComponent: function () { + let me = this; + + me.isCreate = !me.microsegId; + + let schema = PVE.Utils.sdnmicrosegSchema[me.type]; + if (!schema || !schema.ipanel) { + throw 'no editor registered for microseg type: ' + me.type; + } + + if (me.isCreate) { + me.url = '/api2/extjs' + me.baseurl; + me.method = 'POST'; + } else { + me.url = '/api2/extjs' + me.baseurl + me.microsegId; + me.method = 'PUT'; + } + + let ipanel = Ext.create( + 'PVE.sdn.microseg.' + schema.ipanel, + Ext.apply( + { + type: me.type, + isCreate: me.isCreate, + microsegId: me.microsegId, + }, + me.panelConfig, + ), + ); + + Ext.apply(me, { + subject: PVE.Utils.format_sdnmicroseg_type(me.type), + isAdd: true, + width: schema.width, + items: [ipanel], + }); + + me.callParent(); + + if (!me.isCreate) { + me.load(); + } + }, +}); + +Ext.define('PVE.sdn.microseg.Membership', { + singleton: true, + + load: function (view, callback) { + Proxmox.Utils.API2Request({ + url: '/cluster/sdn/microseg/all?pending=1', + method: 'GET', + failure: (response) => Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus), + success: (response) => { + if (view.destroyed) { + return; + } + let data = response.result.data; + let objects = data.objects.map(PVE.Utils.sdnmicroseg_merge_pending); + callback({ + groups: objects.filter((o) => o.type === 'group'), + rules: objects.filter((o) => o.type === 'rule'), + assignments: objects.filter((o) => PVE.Utils.sdnmicroseg_is_assignment(o.type)), + groupSets: data['group-sets'], + idSpace: data['id-space'], + }); + }, + }); + }, + + rulesFor: function (groupSets) { + let classRules = {}; + groupSets.forEach((cls) => { + classRules[cls.groups.join(',')] = cls; + }); + return (sets) => { + let outbound = new Set(); + let inbound = new Set(); + sets.forEach((groupSet) => { + let cls = classRules[groupSet.join(',')]; + cls?.outbound.forEach((r) => outbound.add(r)); + cls?.inbound.forEach((r) => inbound.add(r)); + }); + return { outbound: outbound, inbound: inbound }; + }; + }, + + nicGroupSets: function (assignments, rulesFor) { + let nicGroupSet = {}; + assignments.forEach((a) => { + let sortedGroups = (a.groups || []).slice().sort(); + let groupSet = { + groups: sortedGroups, + rules: rulesFor([sortedGroups]), + note: PVE.Utils.format_sdnmicroseg_matcher(a), + }; + (a.members || []).forEach((m) => { + m.nics.forEach((nic) => { + nicGroupSet[`${m.vmid}/${nic}`] = groupSet; + }); + }); + }); + return nicGroupSet; + }, + + guestSelection: function (vmid, nicGroupSet, rulesFor) { + let keys = Object.keys(nicGroupSet).filter((key) => key.startsWith(`${vmid}/`)); + let rules = rulesFor(keys.map((key) => nicGroupSet[key].groups)); + return { + scope: `vm${vmid}`, + mode: 'group-set', + outbound: rules.outbound, + inbound: rules.inbound, + vmid: vmid, + }; + }, +}); + +Ext.define('PVE.sdn.microseg.StateColumn', { + extend: 'Ext.grid.column.Column', + xtype: 'pveSDNMicrosegStateColumn', + + text: '', + dataIndex: 'state', + width: 40, + resizable: false, + tdCls: 'pve-microseg-state', + renderer: PVE.Utils.render_sdnmicroseg_state, +}); + +Ext.define('PVE.sdn.microseg.ViewMixin', { + extend: 'Ext.Mixin', + + reload: function () { + this.fireEvent('microsegreload'); + }, + + rankOf: function (data) { + return data.rank ?? Number.MAX_SAFE_INTEGER; + }, + + openEdit: function (type, microsegId, panelConfig) { + let me = this; + Ext.create('PVE.sdn.microseg.BaseEdit', { + type: type, + baseurl: me.baseurl, + microsegId: microsegId, + panelConfig: panelConfig, + autoShow: true, + listeners: { destroy: () => me.reload() }, + }); + }, +}); + +Ext.define('PVE.sdn.microseg.BaseGrid', { + extend: 'Ext.grid.GridPanel', + mixins: ['PVE.sdn.microseg.ViewMixin'], + + border: false, + sortableColumns: false, + + initComponent: function () { + let me = this; + + if (!me.baseurl) { + throw 'no baseurl given'; + } + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let editable = (rec) => rec.data.state !== 'deleted'; + + let runEditor = () => { + let rec = sm.getSelection()[0]; + if (!rec || !editable(rec)) { + return; + } + me.openEdit(rec.data.type, rec.data.id, me.editPanelConfig?.(rec)); + }; + + Ext.apply(me, { + selModel: sm, + tbar: [ + me.addButton, + new Proxmox.button.Button({ + text: gettext('Edit'), + disabled: true, + selModel: sm, + enableFn: editable, + handler: runEditor, + }), + Ext.create('Proxmox.button.StdRemoveButton', { + selModel: sm, + enableFn: editable, + baseurl: me.baseurl, + getRecordName: me.getRecordName, + callback: () => me.reload(), + }), + { + text: gettext('Reload'), + handler: () => me.reload(), + }, + ...(me.tbar ?? []), + ], + listeners: { + itemdblclick: runEditor, + }, + }); + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/GroupEdit.js b/www/manager6/sdn/microseg/GroupEdit.js new file mode 100644 index 000000000000..82639c344264 --- /dev/null +++ b/www/manager6/sdn/microseg/GroupEdit.js @@ -0,0 +1,30 @@ +Ext.define('PVE.sdn.microseg.GroupInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'pvesdn_microseg_group', + + initComponent: function () { + let me = this; + + me.items = [ + { + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'id', + value: me.microsegId || '', + fieldLabel: gettext('Name'), + allowBlank: false, + maxLength: 32, + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/RuleEdit.js b/www/manager6/sdn/microseg/RuleEdit.js new file mode 100644 index 000000000000..4ec5467f61be --- /dev/null +++ b/www/manager6/sdn/microseg/RuleEdit.js @@ -0,0 +1,158 @@ +Ext.define('PVE.sdn.microseg.RuleInputPanel', { + extend: 'Proxmox.panel.InputPanel', + + onlineHelp: 'pvesdn_microseg_rule', + + initComponent: function () { + let me = this; + + let matchItems = [ + ['any', gettext('Any (shares a group)')], + ['all', gettext('All (has every group)')], + ['exact', gettext('Exact (same groups)')], + ['every', gettext('Every (has a group)')], + ]; + let setEvery = (selector, every) => { + selector.setDisabled(every || !me.isCreate); + if (every) { + selector.setValue([]); + } + }; + + me.srcSelector = Ext.create({ + xtype: 'pveSDNMicrosegGroupSelector', + name: 'src', + fieldLabel: gettext('Source Groups'), + multiSelect: true, + allowBlank: false, + includeUntagged: true, + disabled: !me.isCreate, + validator: () => { + let groups = me.srcSelector.getValue() || []; + let match = me.down('field[name=src-match]')?.getValue() ?? 'any'; + if ( + match !== 'any' && + groups.includes('untagged') && + groups.some((group) => group !== 'untagged') + ) { + return gettext( + "With 'all' or 'exact', 'untagged' cannot be combined with other groups", + ); + } + return true; + }, + }); + + me.dstSelector = Ext.create({ + xtype: 'pveSDNMicrosegGroupSelector', + name: 'dst', + fieldLabel: gettext('Dest. Groups'), + multiSelect: true, + allowBlank: false, + includeUntagged: true, + disabled: !me.isCreate, + validator: () => { + let groups = me.dstSelector.getValue() || []; + if (groups.length === 1 && groups[0] === 'untagged') { + return gettext("'untagged' cannot be the only destination group"); + } + return true; + }, + }); + + me.column1 = [ + me.srcSelector, + { + xtype: 'proxmoxKVComboBox', + name: 'src-match', + fieldLabel: gettext('Source Match'), + value: 'any', + disabled: !me.isCreate, + comboItems: matchItems, + listeners: { + change: function (f, value) { + setEvery(me.srcSelector, value === 'every'); + me.srcSelector.validate(); + }, + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'bidirectional', + fieldLabel: gettext('Both Directions'), + uncheckedValue: null, + disabled: !me.isCreate, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'Also apply the rule with source and destination swapped.', + ), + }, + }, + ]; + + me.column2 = [ + me.dstSelector, + { + xtype: 'proxmoxKVComboBox', + name: 'dst-match', + fieldLabel: gettext('Dest. Match'), + value: 'any', + disabled: !me.isCreate, + comboItems: matchItems, + listeners: { + change: function (f, value) { + setEvery(me.dstSelector, value === 'every'); + me.dstSelector.setIncludeUntagged(value === 'any'); + me.dstSelector.validate(); + }, + }, + }, + { + xtype: 'proxmoxintegerfield', + name: 'prio', + fieldLabel: gettext('Priority'), + minValue: 0, + maxValue: 65535, + value: 0, + allowBlank: false, + autoEl: { + tag: 'div', + 'data-qtip': gettext( + 'The highest priority wins. At a tie, the most specific rules decide and a deny wins among equals.', + ), + }, + }, + { + xtype: 'proxmoxKVComboBox', + name: 'allow', + fieldLabel: gettext('Action'), + value: 1, + comboItems: [ + [1, gettext('Allow')], + [0, gettext('Deny')], + ], + }, + ]; + + me.columnB = [ + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Comment'), + allowBlank: true, + maxLength: 256, + deleteEmpty: !me.isCreate, + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext( + 'Rules are stateless. A one-way rule needs a second rule for the replies.', + ), + }, + ]; + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/microseg/RuleView.js b/www/manager6/sdn/microseg/RuleView.js new file mode 100644 index 000000000000..57ca0eddc1c9 --- /dev/null +++ b/www/manager6/sdn/microseg/RuleView.js @@ -0,0 +1,242 @@ +Ext.define( + 'PVE.sdn.microseg.RuleView', + { + extend: 'PVE.sdn.microseg.BaseGrid', + alias: 'widget.pveSDNMicrosegRules', + + title: gettext('Rules'), + + baseurl: '/cluster/sdn/microseg/rule/', + + features: [ + { + ftype: 'grouping', + groupHeaderTpl: Ext.String.format(gettext('Priority {0}'), '{name:htmlEncode}'), + enableGroupingMenu: false, + }, + ], + + deriveRow: function (data) { + let me = this; + let sel = me.treeSelection; + let src = data.src || []; + let dst = data.dst || []; + let out = false; + let inbound = false; + if (sel?.mode === 'membership') { + let every = (match) => match === 'every' && !sel.untagged; + out = every(data['src-match']) || src.some((g) => sel.groups.has(g)); + inbound = every(data['dst-match']) || dst.some((g) => sel.groups.has(g)); + if (data.bidirectional && (out || inbound)) { + out = true; + inbound = true; + } + } else if (sel?.mode === 'group-set') { + out = sel.outbound.has(data.id); + inbound = sel.inbound.has(data.id); + } + return { + direction: out && inbound ? 'both' : out ? 'out' : inbound ? 'in' : '', + }; + }, + + loadRows: function () { + let me = this; + let rows = (me.rules || []).map((data) => ({ ...data, ...me.deriveRow(data) })); + if (me.treeSelection) { + rows = rows.filter((row) => row.direction !== ''); + } + me.getStore().loadData(rows); + }, + + setData: function (rules) { + let me = this; + me.rules = rules; + me.loadRows(); + }, + + setTreeSelection: function (sel) { + let me = this; + + let mode = sel?.mode; + me.treeSelection = mode + ? { + mode: mode, + untagged: sel.untagged, + groups: new Set(sel.groups), + outbound: sel.outbound, + inbound: sel.inbound, + } + : null; + + let text = ''; + if (sel?.scope) { + text = Ext.String.format( + gettext('Rules for {0}'), + Ext.String.htmlEncode(sel.scope), + ); + } + me.down('#microsegScopeText').setText(text); + + me.loadRows(); + }, + + initComponent: function () { + let me = this; + + let store = Ext.create('Ext.data.Store', { + model: 'pve-sdn-microseg-rule', + grouper: { + property: 'prio', + sortProperty: 'prio', + direction: 'DESC', + }, + sorters: [{ property: 'id', direction: 'ASC' }], + }); + + let matchWords = { + any: gettext('any of'), + all: gettext('all of'), + exact: gettext('exactly'), + every: gettext('every'), + }; + + let renderPredicate = (match, groups) => { + if (match === 'every') { + return matchWords.every; + } + let list = groups || []; + let chips = list.map(PVE.Utils.render_sdnmicroseg_group).join(''); + let kind = match || 'any'; + if (kind !== 'any' || list.length > 1) { + return `${matchWords[kind]} ${chips}`; + } + return chips; + }; + + let allowIcon = + ''; + let denyIcon = + ''; + + let outIcon = + ''; + let inIcon = + ''; + let bothIcon = + ''; + let arrowIcon = ''; + let bothWaysIcon = + ''; + let renderDirection = (value, rec) => { + if (value === 'both') { + return bothIcon; + } + if (value === 'out') { + return outIcon; + } + if (value === 'in') { + return inIcon; + } + return rec.data.bidirectional ? bothWaysIcon : arrowIcon; + }; + + let ruleName = (rec) => { + let arrow = rec.data.bidirectional ? '<->' : '->'; + let src = PVE.Utils.format_sdnmicroseg_predicate( + rec.data['src-match'], + rec.data.src, + ); + let dst = PVE.Utils.format_sdnmicroseg_predicate( + rec.data['dst-match'], + rec.data.dst, + ); + return `${src} ${arrow} ${dst}`; + }; + + me.columns = [ + { + text: '', + dataIndex: 'allow', + width: 40, + resizable: false, + renderer: (v) => (v ? allowIcon : denyIcon), + }, + { + text: gettext('Source'), + dataIndex: 'src', + flex: 2, + renderer: (v, m, rec) => renderPredicate(rec.data['src-match'], v), + }, + { + text: '', + dataIndex: 'direction', + width: 40, + resizable: false, + renderer: (v, m, rec) => renderDirection(v, rec), + }, + { + text: gettext('Destination'), + dataIndex: 'dst', + flex: 2, + renderer: (v, m, rec) => renderPredicate(rec.data['dst-match'], v), + }, + { xtype: 'pveSDNMicrosegStateColumn' }, + { + text: gettext('Comment'), + dataIndex: 'comment', + flex: 1, + renderer: Ext.String.htmlEncode, + }, + ]; + + Ext.apply(me, { + store: store, + viewConfig: { + getRowClass: PVE.Utils.sdnmicroseg_row_cls, + }, + getRecordName: ruleName, + addButton: { + text: gettext('Add Rule'), + handler: () => me.openEdit('rule'), + }, + tbar: [ + '->', + { + xtype: 'tbtext', + itemId: 'microsegScopeText', + }, + ], + }); + + me.callParent(); + }, + }, + function () { + Ext.define('pve-sdn-microseg-rule', { + extend: 'Ext.data.Model', + fields: [ + 'id', + 'type', + 'comment', + 'state', + 'src', + 'src-match', + 'dst', + 'dst-match', + 'prio', + 'allow', + 'bidirectional', + 'direction', + ], + }); + }, +); diff --git a/www/manager6/sdn/microseg/Tree.js b/www/manager6/sdn/microseg/Tree.js new file mode 100644 index 000000000000..e894fd6a928b --- /dev/null +++ b/www/manager6/sdn/microseg/Tree.js @@ -0,0 +1,249 @@ +Ext.define( + 'PVE.sdn.microseg.Tree', + { + extend: 'Ext.tree.Panel', + alias: 'widget.pveSDNMicrosegTree', + mixins: ['PVE.sdn.microseg.ViewMixin'], + + baseurl: '/cluster/sdn/microseg/group/', + + rootVisible: false, + animate: false, + border: false, + + buildNodes: function (groups, assignments, groupSets) { + let guestDisplay = (vmid) => { + let store = PVE.data.ResourceStore; + let rec = store.getById(`qemu/${vmid}`) || store.getById(`lxc/${vmid}`); + if (!rec) { + return { text: `vm${vmid}`, iconCls: 'fa fa-fw fa-desktop' }; + } + return { + text: PVE.Utils.getFormattedGuestIdentifier(vmid, rec.data.name), + iconCls: `fa-fw ${PVE.Utils.get_object_icon_class(rec.data.type, rec.data)}`, + }; + }; + + groups.sort((a, b) => a.id.localeCompare(b.id)); + + let membership = PVE.sdn.microseg.Membership; + let rulesFor = membership.rulesFor(groupSets); + let nicGroupSet = membership.nicGroupSets(assignments, rulesFor); + + let groupNics = Object.create(null); + for (const [key, groupSet] of Object.entries(nicGroupSet)) { + let [vmid, nic] = key.split('/').map(Number); + groupSet.groups.forEach((g) => { + (groupNics[g] = groupNics[g] || []).push({ vmid: vmid, nic: nic }); + }); + } + + let guests = {}; + let guestInfo = (vmid) => { + if (!guests[vmid]) { + guests[vmid] = { + ...guestDisplay(vmid), + selection: membership.guestSelection(vmid, nicGroupSet, rulesFor), + }; + } + return guests[vmid]; + }; + + let nodes = [ + { + id: 'all', + text: gettext('All Rules'), + iconCls: 'fa fa-fw fa-list', + leaf: true, + }, + { + id: 'untagged', + text: 'untagged', + iconCls: 'fa fa-fw fa-ban', + microsegSelection: { + scope: 'untagged', + mode: 'membership', + untagged: true, + groups: ['untagged'], + }, + leaf: true, + }, + ]; + + for (const group of groups) { + let groupId = `g/${group.id}`; + let byVmid = {}; + for (const { vmid, nic } of groupNics[group.id] || []) { + (byVmid[vmid] = byVmid[vmid] || []).push(nic); + } + let guestNodes = Object.keys(byVmid) + .map(Number) + .sort((a, b) => a - b) + .map((vmid) => { + let guestId = `${groupId}/m/${vmid}`; + let nics = byVmid[vmid].sort((a, b) => a - b); + let guest = guestInfo(vmid); + return { + id: guestId, + text: guest.text, + iconCls: guest.iconCls, + microsegSelection: guest.selection, + children: nics.map((nic) => { + let groupSet = nicGroupSet[`${vmid}/${nic}`]; + return { + id: `${guestId}/n/${nic}`, + text: `net${nic}`, + microsegNote: groupSet.note, + iconCls: 'fa fa-fw fa-plug', + microsegSelection: { + scope: `vm${vmid}/net${nic} {${groupSet.groups.join(', ')}}`, + mode: 'group-set', + outbound: groupSet.rules.outbound, + inbound: groupSet.rules.inbound, + vmid: vmid, + nic: nic, + }, + leaf: true, + }; + }), + }; + }); + + let count = (groupNics[group.id] || []).length; + nodes.push({ + id: groupId, + text: group.id, + microsegNote: count ? `(${count})` : undefined, + iconCls: 'fa fa-fw fa-object-group', + microsegId: group.id, + microsegSelection: { + scope: group.id, + mode: 'membership', + groups: [group.id], + }, + state: group.state, + leaf: !guestNodes.length, + children: guestNodes, + }); + } + return nodes; + }, + + updateIdSpace: function (ids) { + let me = this; + let text = Ext.String.format(gettext('{0} of {1} IDs in use'), ids.used, ids.total); + let tip = ids.retired ? Ext.String.format(gettext('{0} retired IDs'), ids.retired) : ''; + me.down('#microsegIdSpace').update( + `${Ext.String.htmlEncode(text)}`, + ); + }, + + setData: function (groups, assignments, groupSets, idSpace) { + let me = this; + let expanded = []; + let oldRoot = me.getRootNode(); + if (oldRoot) { + oldRoot.cascadeBy((node) => { + if (!node.isRoot() && node.isExpanded()) { + expanded.push(node.getId()); + } + }); + } + let selectedId = me.getSelection()[0]?.getId(); + + me.updateIdSpace(idSpace); + me.setRootNode({ + expanded: true, + children: me.buildNodes(groups, assignments, groupSets), + }); + let store = me.getStore(); + for (const id of expanded) { + store.getNodeById(id)?.expand(); + } + if (selectedId) { + let node = store.getNodeById(selectedId); + if (node) { + me.setSelection(node); + } + } + }, + + statics: { + editable: (node) => !!node.data.microsegId && node.data.state !== 'deleted', + }, + + header: { + items: [ + { + xtype: 'component', + itemId: 'microsegIdSpace', + cls: 'pve-microseg-muted pve-microseg-id-space', + }, + ], + }, + + viewConfig: { + getRowClass: PVE.Utils.sdnmicroseg_row_cls, + toggleOnDblClick: false, + }, + + store: { + type: 'tree', + model: 'pve-sdn-microseg-tree', + root: { expanded: true, children: [] }, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name'), + dataIndex: 'text', + flex: 1, + renderer: (v, m, rec) => { + let html = Ext.String.htmlEncode(v); + if (rec.data.microsegNote) { + html += ` ${Ext.String.htmlEncode(rec.data.microsegNote)}`; + } + return html; + }, + }, + { xtype: 'pveSDNMicrosegStateColumn' }, + ], + + tbar: [ + { + text: gettext('Add Group'), + handler: 'addGroup', + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + parentXType: 'treepanel', + disabled: true, + enableFn: (node) => PVE.sdn.microseg.Tree.editable(node), + handler: 'editGroup', + }, + { + xtype: 'proxmoxStdRemoveButton', + parentXType: 'treepanel', + enableFn: (node) => PVE.sdn.microseg.Tree.editable(node), + getUrl: function (node) { + return this.up('treepanel').baseurl + node.data.microsegId; + }, + getRecordName: (node) => node.data.microsegId, + callback: 'reload', + }, + { + text: gettext('Reload'), + handler: 'reload', + }, + ], + }, + function () { + Ext.define('pve-sdn-microseg-tree', { + extend: 'Ext.data.TreeModel', + fields: ['microsegId', 'microsegNote', 'microsegSelection', 'state'], + }); + }, +); -- 2.47.3