add ACME domain editing

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>
This commit is contained in:
Wolfgang Bumiller 2021-03-16 11:24:23 +01:00 committed by Thomas Lamprecht
parent 658bfdff32
commit 8915422f90
3 changed files with 707 additions and 0 deletions

View 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 \

492
src/panel/ACMEDomains.js Normal file
View File

@ -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);
},
});

213
src/window/ACMEDomains.js Normal file
View File

@ -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 });
}
},
});