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 B19DA1FF0E3 for ; Tue, 27 Oct 2026 10:03:16 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id BE12721740; Tue, 27 Oct 2026 09:59:21 +0100 (CET) From: Hannes Laimer To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v3 34/45] ui: sdn: add microsegmentation panel Date: Tue, 27 Oct 2026 09:55:16 +0100 Message-ID: <20261027085527.702738-35-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com> References: <20261027085527.702738-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: 1793091370551 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.302 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: ZUWPCG3MCKIRE7ZM2OA3GD7Q6L72HUVY X-Message-ID-Hash: ZUWPCG3MCKIRE7ZM2OA3GD7Q6L72HUVY 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. Signed-off-by: Hannes Laimer --- www/css/ext6-pve.css | 23 ++ www/manager6/Makefile | 9 + www/manager6/Utils.js | 74 ++++++ www/manager6/data/PermPathStore.js | 1 + www/manager6/dc/Config.js | 8 + 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, 1457 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 8b99371d3447..8eb4b68c51b4 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -231,6 +231,22 @@ 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) { + return value + ? '' + : ''; + }, + + 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 +982,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 7210e0cd48af..65c8d17c850f 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..fce20937f3ba 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -309,6 +309,14 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-road', itemId: 'sdnfabrics', }, + { + xtype: 'pveSDNMicroseg', + groups: ['sdn'], + title: gettext('Microsegmentation'), + hidden: true, + iconCls: 'fa fa-tags', + itemId: 'sdnmicroseg', + }, { xtype: 'pveSDNRouteMaps', groups: ['sdn'], 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..e261e4944e81 --- /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 = {}; + 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