]> git.proxmox.com Git - proxmox-widget-toolkit.git/commitdiff
add ACME domain editing
authorWolfgang Bumiller <w.bumiller@proxmox.com>
Tue, 16 Mar 2021 10:24:23 +0000 (11:24 +0100)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Tue, 16 Mar 2021 11:25:58 +0000 (12:25 +0100)
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 <w.bumiller@proxmox.com>
src/Makefile
src/panel/ACMEDomains.js [new file with mode: 0644]
src/window/ACMEDomains.js [new file with mode: 0644]

index 0e1fb45c850f016b989d3d35ce5260ec95078a97..44c11ead95dcb4c36d416d5e6c84a9f0b495bd4f 100644 (file)
@@ -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 (file)
index 0000000..f66975f
--- /dev/null
@@ -0,0 +1,492 @@
+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: undefined,
+    // if no domainUsages parameter is supllied, the orderUrl is required instead:
+    orderUrl: undefined,
+
+    acmeUrl: undefined,
+
+    cbindData: function(config) {
+       let me = this;
+       return {
+           acmeUrl: me.acmeUrl,
+           accountUrl: `/api2/json/${me.acmeUrl}/account`,
+       };
+    },
+
+    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.orderUrl,
+               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;
+           let view = me.getView();
+           Ext.create('Proxmox.window.ACMEAccountCreate', {
+               autoShow: true,
+               acmeUrl: view.acmeUrl,
+               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: '{accountUrl}',
+           },
+           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";
+       }
+
+       if (!me.domainUsages && !me.orderUrl) {
+           throw "neither domainUsages nor orderUrl 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) {
+           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: function() {
+                       return me.getController().order();
+                   },
+               },
+           );
+       }
+
+       me.callParent();
+       me.getViewModel().set('hasUsage', !!me.domainUsages);
+       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 (file)
index 0000000..930a4c3
--- /dev/null
@@ -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: undefined,
+
+    cbindData: function(config) {
+       let me = this;
+       return {
+           pluginsUrl: `/api2/json/${me.acmeUrl}/plugins`,
+           hasUsage: !!me.domainUsages,
+       };
+    },
+
+    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) {
+                   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 });
+       }
+    },
+});