From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 8D82C68D16 for ; Tue, 9 Mar 2021 15:14:52 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 7F1ECB257 for ; Tue, 9 Mar 2021 15:14:22 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id 6B210B0E7 for ; Tue, 9 Mar 2021 15:14:11 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 37988458EB for ; Tue, 9 Mar 2021 15:14:11 +0100 (CET) From: Wolfgang Bumiller To: pmg-devel@lists.proxmox.com Date: Tue, 9 Mar 2021 15:14:01 +0100 Message-Id: <20210309141401.19237-18-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210309141401.19237-1-w.bumiller@proxmox.com> References: <20210309141401.19237-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.040 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment 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 URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [me.store, rec.data, obj.domains] Subject: [pmg-devel] [PATCH widget-toolkit 7/7] add ACME domain editing X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Tue, 09 Mar 2021 14:14:52 -0000 Same deal, however, here the PVE code is has a little bug where changing the plugin type of a domain makes it disappear, so this also contains some fixups. Additionally, this now also adds the ability to change a domain's "usage" (smtp, api or both), so similar to the uploadButtons info in the Certificates panel, we now have a domainUsages info. If it is set, the edit window will show a multiselect combobox, and the panel will show a usage column. Signed-off-by: Wolfgang Bumiller --- src/Makefile | 2 + src/panel/ACMEDomains.js | 482 ++++++++++++++++++++++++++++++++++++++ src/window/ACMEDomains.js | 213 +++++++++++++++++ 3 files changed, 697 insertions(+) create mode 100644 src/panel/ACMEDomains.js create mode 100644 src/window/ACMEDomains.js diff --git a/src/Makefile b/src/Makefile index 0e1fb45..44c11ea 100644 --- a/src/Makefile +++ b/src/Makefile @@ -52,6 +52,7 @@ JSSRC= \ panel/Certificates.js \ panel/ACMEAccount.js \ panel/ACMEPlugin.js \ + panel/ACMEDomains.js \ window/Edit.js \ window/PasswordEdit.js \ window/SafeDestroy.js \ @@ -62,6 +63,7 @@ JSSRC= \ window/Certificates.js \ window/ACMEAccount.js \ window/ACMEPluginEdit.js \ + window/ACMEDomains.js \ node/APT.js \ node/NetworkEdit.js \ node/NetworkView.js \ diff --git a/src/panel/ACMEDomains.js b/src/panel/ACMEDomains.js new file mode 100644 index 0000000..dd01e36 --- /dev/null +++ b/src/panel/ACMEDomains.js @@ -0,0 +1,482 @@ +Ext.define('proxmox-acme-domains', { + extend: 'Ext.data.Model', + fields: ['domain', 'type', 'alias', 'plugin', 'configkey'], + idProperty: 'domain', +}); + +Ext.define('Proxmox.panel.ACMEDomains', { + extend: 'Ext.grid.Panel', + xtype: 'pmxACMEDomains', + mixins: ['Proxmox.Mixin.CBind'], + + margin: '10 0 0 0', + title: 'ACME', + + emptyText: gettext('No Domains configured'), + + // URL to the config containing 'acme' and 'acmedomainX' properties + url: undefined, + + // array of { name, url, usageLabel } + domainUsages: [], + + acmeUrl: undefined, + + cbindData: function(config) { + let me = this; + return { + acmeUrl: me.acmeUrl, + accountsUrl: `/api2/json/${me.acmeUrl}/accounts`, + }; + }, + + viewModel: { + data: { + domaincount: 0, + account: undefined, // the account we display + configaccount: undefined, // the account set in the config + accountEditable: false, + accountsAvailable: false, + hasUsage: false, + }, + + formulas: { + canOrder: (get) => !!get('account') && get('domaincount') > 0, + editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'), + accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'), + accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'), + hasUsage: (get) => get('hasUsage'), + }, + }, + + controller: { + xclass: 'Ext.app.ViewController', + + init: function(view) { + let accountSelector = this.lookup('accountselector'); + accountSelector.store.on('load', this.onAccountsLoad, this); + }, + + onAccountsLoad: function(store, records, success) { + let me = this; + let vm = me.getViewModel(); + let configaccount = vm.get('configaccount'); + vm.set('accountsAvailable', records.length > 0); + if (me.autoChangeAccount && records.length > 0) { + me.changeAccount(records[0].data.name, () => { + vm.set('accountEditable', false); + me.reload(); + }); + me.autoChangeAccount = false; + } else if (configaccount) { + if (store.findExact('name', configaccount) !== -1) { + vm.set('account', configaccount); + } else { + vm.set('account', null); + } + } + }, + + addDomain: function() { + let me = this; + let view = me.getView(); + + Ext.create('Proxmox.window.ACMEDomainEdit', { + url: view.url, + acmeUrl: view.acmeUrl, + nodeconfig: view.nodeconfig, + domainUsages: view.domainUsages, + apiCallDone: function() { + me.reload(); + }, + }).show(); + }, + + editDomain: function() { + let me = this; + let view = me.getView(); + + let selection = view.getSelection(); + if (selection.length < 1) return; + + Ext.create('Proxmox.window.ACMEDomainEdit', { + url: view.url, + acmeUrl: view.acmeUrl, + nodeconfig: view.nodeconfig, + domainUsages: view.domainUsages, + domain: selection[0].data, + apiCallDone: function() { + me.reload(); + }, + }).show(); + }, + + removeDomain: function() { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (selection.length < 1) return; + + let rec = selection[0].data; + let params = {}; + if (rec.configkey !== 'acme') { + params.delete = rec.configkey; + } else { + let acme = Proxmox.Utils.parseACME(view.nodeconfig.acme); + Proxmox.Utils.remove_domain_from_acme(acme, rec.domain); + params.acme = Proxmox.Utils.printACME(acme); + } + + Proxmox.Utils.API2Request({ + method: 'PUT', + url: view.url, + params, + success: function(response, opt) { + me.reload(); + }, + failure: function(response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + toggleEditAccount: function() { + let me = this; + let vm = me.getViewModel(); + let editable = vm.get('accountEditable'); + if (editable) { + me.changeAccount(vm.get('account'), function() { + vm.set('accountEditable', false); + me.reload(); + }); + } else { + vm.set('accountEditable', true); + } + }, + + changeAccount: function(account, callback) { + let me = this; + let view = me.getView(); + let params = {}; + + let acme = Proxmox.Utils.parseACME(view.nodeconfig.acme); + acme.account = account; + params.acme = Proxmox.Utils.printACME(acme); + + Proxmox.Utils.API2Request({ + method: 'PUT', + waitMsgTarget: view, + url: view.url, + params, + success: function(response, opt) { + if (Ext.isFunction(callback)) { + callback(); + } + }, + failure: function(response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + order: function(cert) { + let me = this; + let view = me.getView(); + + Proxmox.Utils.API2Request({ + method: 'POST', + params: { + force: 1, + }, + url: cert ? cert.url : view.url, + success: function(response, opt) { + Ext.create('Proxmox.window.TaskViewer', { + upid: response.result.data, + taskDone: function(success) { + me.orderFinished(success, cert); + }, + }).show(); + }, + failure: function(response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + orderFinished: function(success, cert) { + if (!success || !cert.reloadUi) return; + var txt = gettext('gui will be restarted with new certificates, please reload!'); + Ext.getBody().mask(txt, ['x-mask-loading']); + // reload after 10 seconds automatically + Ext.defer(function() { + window.location.reload(true); + }, 10000); + }, + + reload: function() { + let me = this; + let view = me.getView(); + view.rstore.load(); + }, + + addAccount: function() { + let me = this; + Ext.create('Proxmox.window.ACMEAccountCreate', { + autoShow: true, + taskDone: function() { + me.reload(); + let accountSelector = me.lookup('accountselector'); + me.autoChangeAccount = true; + accountSelector.store.load(); + }, + }); + }, + }, + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Add'), + handler: 'addDomain', + selModel: false, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + handler: 'editDomain', + }, + { + xtype: 'proxmoxStdRemoveButton', + handler: 'removeDomain', + }, + '-', + 'order-menu', // placeholder, filled in initComponent + '-', + { + xtype: 'displayfield', + value: gettext('Using Account') + ':', + bind: { + hidden: '{!accountsAvailable}', + }, + }, + { + xtype: 'displayfield', + reference: 'accounttext', + renderer: (val) => val || Proxmox.Utils.NoneText, + bind: { + value: '{account}', + hidden: '{accountTextHidden}', + }, + }, + { + xtype: 'pmxACMEAccountSelector', + hidden: true, + reference: 'accountselector', + cbind: { + url: '{accountsUrl}', + }, + bind: { + value: '{account}', + hidden: '{accountValueHidden}', + }, + }, + { + xtype: 'button', + iconCls: 'fa black fa-pencil', + baseCls: 'x-plain', + userCls: 'pointer', + bind: { + iconCls: '{editBtnIcon}', + hidden: '{!accountsAvailable}', + }, + handler: 'toggleEditAccount', + }, + { + xtype: 'displayfield', + value: gettext('No Account available.'), + bind: { + hidden: '{accountsAvailable}', + }, + }, + { + xtype: 'button', + hidden: true, + reference: 'accountlink', + text: gettext('Add ACME Account'), + bind: { + hidden: '{accountsAvailable}', + }, + handler: 'addAccount', + }, + ], + + updateStore: function(store, records, success) { + let me = this; + let data = []; + let rec; + if (success && records.length > 0) { + rec = records[0]; + } else { + rec = { + data: {}, + }; + } + + me.nodeconfig = rec.data; // save nodeconfig for updates + + let account = 'default'; + + if (rec.data.acme) { + let obj = Proxmox.Utils.parseACME(rec.data.acme); + (obj.domains || []).forEach(domain => { + if (domain === '') return; + let record = { + domain, + type: 'standalone', + configkey: 'acme', + }; + data.push(record); + }); + + if (obj.account) { + account = obj.account; + } + } + + let vm = me.getViewModel(); + let oldaccount = vm.get('account'); + + // account changed, and we do not edit currently, load again to verify + if (oldaccount !== account && !vm.get('accountEditable')) { + vm.set('configaccount', account); + me.lookup('accountselector').store.load(); + } + + for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) { + let acmedomain = rec.data[`acmedomain${i}`]; + if (!acmedomain) continue; + + let record = Proxmox.Utils.parsePropertyString(acmedomain, 'domain'); + record.type = record.plugin ? 'dns' : 'standalone'; + record.configkey = `acmedomain${i}`; + data.push(record); + } + + vm.set('domaincount', data.length); + me.store.loadData(data, false); + }, + + listeners: { + itemdblclick: 'editDomain', + }, + + columns: [ + { + dataIndex: 'domain', + flex: 5, + text: gettext('Domain'), + }, + { + dataIndex: 'usage', + flex: 1, + text: gettext('Usage'), + bind: { + hidden: '{!hasUsage}', + }, + }, + { + dataIndex: 'type', + flex: 1, + text: gettext('Type'), + }, + { + dataIndex: 'plugin', + flex: 1, + text: gettext('Plugin'), + }, + ], + + initComponent: function() { + let me = this; + + if (!me.acmeUrl) { + throw "no acmeUrl given"; + } + + if (!me.url) { + throw "no url given"; + } + + if (!me.nodename) { + throw "no nodename given"; + } + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10 * 1000, + autoStart: true, + storeid: `proxmox-node-domains-${me.nodename}`, + proxy: { + type: 'proxmox', + url: `/api2/json/${me.url}`, + }, + }); + + me.store = Ext.create('Ext.data.Store', { + model: 'proxmox-acme-domains', + sorters: 'domain', + }); + + if (me.domainUsages.length > 0) { + let items = []; + + for (const cert of me.domainUsages) { + if (!cert.name) { + throw "missing certificate url"; + } + + if (!cert.url) { + throw "missing certificate url"; + } + + items.push({ + text: Ext.String.format('Order {0} Certificate Now', cert.name), + handler: function() { + return me.getController().order(cert); + }, + }); + } + me.tbar.splice( + me.tbar.indexOf("order-menu"), + 1, + { + text: gettext('Order Certificates Now'), + menu: { + xtype: 'menu', + items, + }, + }, + ); + } else { + me.tbar.splice( + me.tbar.indexOf("order-menu"), + 1, + { + xtype: 'button', + reference: 'order', + text: gettext('Order Certificates Now'), + bind: { + disabled: '{!canOrder}', + }, + handler: 'order', + }, + ); + } + + me.callParent(); + me.getViewModel().set('hasUsage', me.domainUsages.length > 0); + me.mon(me.rstore, 'load', 'updateStore', me); + Proxmox.Utils.monStoreErrors(me, me.rstore); + me.on('destroy', me.rstore.stopUpdate, me.rstore); + }, +}); diff --git a/src/window/ACMEDomains.js b/src/window/ACMEDomains.js new file mode 100644 index 0000000..0b4b3f6 --- /dev/null +++ b/src/window/ACMEDomains.js @@ -0,0 +1,213 @@ +Ext.define('Proxmox.window.ACMEDomainEdit', { + extend: 'Proxmox.window.Edit', + xtype: 'pmxACMEDomainEdit', + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Domain'), + isCreate: false, + width: 450, + //onlineHelp: 'sysadmin_certificate_management', + + acmeUrl: undefined, + + // config url + url: undefined, + + // For PMG the we have multiple certificates, so we have a "usage" attribute & column. + domainUsages: [], + + cbindData: function(config) { + let me = this; + return { + pluginsUrl: `/api2/json/${me.acmeUrl}/plugins`, + hasUsage: me.domainUsages.length > 0, + }; + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function(values) { + let me = this; + let win = me.up('pmxACMEDomainEdit'); + let nodeconfig = win.nodeconfig; + let olddomain = win.domain || {}; + + let params = { + digest: nodeconfig.digest, + }; + + let configkey = olddomain.configkey; + let acmeObj = Proxmox.Utils.parseACME(nodeconfig.acme); + + let find_free_slot = () => { + for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) { + if (nodeconfig[`acmedomain${i}`] === undefined) { + return `acmedomain${i}`; + } + } + throw "too many domains configured"; + }; + + // If we have a 'usage' property (pmg), we only use the `acmedomainX` config keys. + if (win.domainUsages.length > 0) { + if (!configkey || configkey === 'acme') { + configkey = find_free_slot(); + } + delete values.type; + params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain'); + return params; + } + + // Otherwise we put the standalone entries into the `domains` list of the `acme` + // property string. + + // Then insert the domain depending on its type: + if (values.type === 'dns') { + if (!olddomain.configkey || olddomain.configkey === 'acme') { + configkey = find_free_slot(); + if (olddomain.domain) { + // we have to remove the domain from the acme domainlist + Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + params.acme = Proxmox.Utils.printACME(acmeObj); + } + } + + delete values.type; + params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain'); + } else { + if (olddomain.configkey && olddomain.configkey !== 'acme') { + // delete the old dns entry, unless we need to declare its usage: + params.delete = [olddomain.configkey]; + } + + // add new, remove old and make entries unique + Proxmox.Utils.add_domain_to_acme(acmeObj, values.domain); + if (olddomain.domain !== values.domain) { + Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + } + params.acme = Proxmox.Utils.printACME(acmeObj); + } + + return params; + }, + items: [ + { + xtype: 'proxmoxKVComboBox', + name: 'type', + fieldLabel: gettext('Challenge Type'), + allowBlank: false, + value: 'standalone', + comboItems: [ + ['standalone', 'HTTP'], + ['dns', 'DNS'], + ], + validator: function(value) { + let me = this; + let win = me.up('pmxACMEDomainEdit'); + let oldconfigkey = win.domain ? win.domain.configkey : undefined; + let val = me.getValue(); + if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) { + // we have to check if there is a 'acmedomain' slot left + let found = false; + for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) { + if (!win.nodeconfig[`acmedomain${i}`]) { + found = true; + } + } + if (!found) { + return gettext('Only 5 Domains with type DNS can be configured'); + } + } + + return true; + }, + listeners: { + change: function(cb, value) { + let me = this; + let view = me.up('pmxACMEDomainEdit'); + let pluginField = view.down('field[name=plugin]'); + pluginField.setDisabled(value !== 'dns'); + pluginField.setHidden(value !== 'dns'); + }, + }, + }, + { + xtype: 'hidden', + name: 'alias', + }, + { + xtype: 'pmxACMEPluginSelector', + name: 'plugin', + disabled: true, + hidden: true, + allowBlank: false, + cbind: { + url: '{pluginsUrl}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'domain', + allowBlank: false, + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Domain'), + }, + { + xtype: 'combobox', + name: 'usage', + multiSelect: true, + editable: false, + fieldLabel: gettext('Usage'), + cbind: { + hidden: '{!hasUsage}', + allowBlank: '{!hasUsage}', + }, + fields: ['usage', 'name'], + displayField: 'name', + valueField: 'usage', + store: { + data: [ + { usage: 'api', name: 'API' }, + { usage: 'smtp', name: 'SMTP' }, + ], + }, + }, + ], + }, + ], + + initComponent: function() { + let me = this; + + if (!me.url) { + throw 'no url given'; + } + + if (!me.acmeUrl) { + throw 'no acmeUrl given'; + } + + if (!me.nodeconfig) { + throw 'no nodeconfig given'; + } + + me.isCreate = !me.domain; + if (me.isCreate) { + me.domain = `${Proxmox.NodeName}.`; // TODO: FQDN of node + } + + me.callParent(); + + if (!me.isCreate) { + let values = { ...me.domain }; + if (Ext.isDefined(values.usage)) { + values.usage = values.usage.split(';'); + } + me.setValues(values); + } else { + me.setValues({ domain: me.domain }); + } + }, +}); -- 2.20.1