[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