[pmg-devel] [PATCH v2 widget-toolkit 7/7] add ACME domain editing
Wolfgang Bumiller
w.bumiller at proxmox.com
Fri Mar 12 16:24:21 CET 2021
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 at proxmox.com>
---
Changes since v1:
* domainUsages defaults to undefined instead of []
* added orderUrl for PVE (since there we have no domainUsages entry and
thenrefore no URL for it)
* and a typo fix in account url
src/Makefile | 2 +
src/panel/ACMEDomains.js | 492 ++++++++++++++++++++++++++++++++++++++
src/window/ACMEDomains.js | 213 +++++++++++++++++
3 files changed, 707 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..f66975f
--- /dev/null
+++ b/src/panel/ACMEDomains.js
@@ -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
index 0000000..930a4c3
--- /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: 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 });
+ }
+ },
+});
--
2.20.1
More information about the pmg-devel
mailing list