diff --git a/www/manager6/node/ACME.js b/www/manager6/node/ACME.js new file mode 100644 index 0000000..773d0d2 --- /dev/null +++ b/www/manager6/node/ACME.js @@ -0,0 +1,808 @@ +Ext.define('PVE.node.ACMEAccountCreate', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + width: 450, + title: gettext('Register Account'), + isCreate: true, + method: 'POST', + submitText: gettext('Register'), + url: '/cluster/acme/account', + showTaskViewer: true, + defaultExists: false, + referenceHolder: true, + onlineHelp: 'sysadmin_certs_acme_account', + + viewModel: { + data: { + customDirectory: false, + eabRequired: false, + }, + formulas: { + eabEmptyText: function (get) { + return get('eabRequired') ? gettext('required') : gettext('optional'); + }, + }, + }, + + items: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Account Name'), + name: 'name', + cbind: { + emptyText: (get) => (get('defaultExists') ? '' : 'default'), + allowBlank: (get) => !get('defaultExists'), + }, + }, + { + xtype: 'textfield', + name: 'contact', + vtype: 'email', + allowBlank: false, + fieldLabel: gettext('E-Mail'), + }, + { + xtype: 'proxmoxComboGrid', + notFoundIsValid: true, + isFormField: false, + allowBlank: false, + valueField: 'url', + displayField: 'name', + fieldLabel: gettext('ACME Directory'), + store: { + listeners: { + load: function () { + this.add({ name: gettext('Custom'), url: '' }); + }, + }, + autoLoad: true, + fields: ['name', 'url'], + idProperty: ['name'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/acme/directories', + }, + }, + listConfig: { + columns: [ + { + header: gettext('Name'), + dataIndex: 'name', + flex: 1, + }, + { + header: gettext('URL'), + dataIndex: 'url', + flex: 1, + }, + ], + }, + listeners: { + change: function (combogrid, value) { + let me = this; + + let vm = me.up('window').getViewModel(); + let dirField = me.up('window').lookupReference('directoryInput'); + let tosButton = me.up('window').lookupReference('queryTos'); + + let isCustom = combogrid.getSelection().get('name') === gettext('Custom'); + vm.set('customDirectory', isCustom); + + dirField.setValue(value); + + if (!isCustom) { + tosButton.click(); + } else { + me.up('window').clearToSFields(); + } + }, + }, + }, + { + xtype: 'fieldcontainer', + layout: 'hbox', + fieldLabel: gettext('URL'), + bind: { + hidden: '{!customDirectory}', + }, + items: [ + { + xtype: 'proxmoxtextfield', + name: 'directory', + reference: 'directoryInput', + flex: 1, + allowBlank: false, + listeners: { + change: function (textbox, value) { + let me = this; + me.up('window').clearToSFields(); + }, + }, + }, + { + xtype: 'proxmoxButton', + margin: '0 0 0 5', + reference: 'queryTos', + text: gettext('Query URL'), + listeners: { + click: function (button) { + let me = this; + + let w = me.up('window'); + let vm = w.getViewModel(); + let disp = w.down('#tos_url_display'); + let field = w.down('#tos_url'); + let checkbox = w.down('#tos_checkbox'); + let value = w.lookupReference('directoryInput').getValue(); + w.clearToSFields(); + + if (!value) { + return; + } else { + disp.setValue(gettext('Loading')); + } + + Proxmox.Utils.API2Request({ + url: '/cluster/acme/meta', + method: 'GET', + params: { + directory: value, + }, + success: function (response, opt) { + if ( + response.result.data && + response.result.data.termsOfService + ) { + field.setValue(response.result.data.termsOfService); + disp.setValue(response.result.data.termsOfService); + checkbox.setHidden(false); + } else { + // Needed to pass input verification and enable register button + // has no influence on the submitted form + checkbox.setValue(true); + disp.setValue('No terms of service agreement required'); + } + vm.set( + 'eabRequired', + !!( + response.result.data && + response.result.data.externalAccountRequired + ), + ); + }, + failure: function (response, opt) { + disp.setValue(undefined); + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + }, + ], + }, + { + xtype: 'displayfield', + itemId: 'tos_url_display', + renderer: Proxmox.Utils.render_optional_url, + name: 'tos_url_display', + }, + { + xtype: 'hidden', + itemId: 'tos_url', + name: 'tos_url', + }, + { + xtype: 'proxmoxcheckbox', + itemId: 'tos_checkbox', + boxLabel: gettext('Accept TOS'), + submitValue: false, + validateValue: function (value) { + if (value && this.checked) { + return true; + } + return false; + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'eab-kid', + fieldLabel: gettext('EAB Key ID'), + bind: { + hidden: '{!customDirectory}', + allowBlank: '{!eabRequired}', + emptyText: '{eabEmptyText}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'eab-hmac-key', + fieldLabel: gettext('EAB Key'), + bind: { + hidden: '{!customDirectory}', + allowBlank: '{!eabRequired}', + emptyText: '{eabEmptyText}', + }, + }, + ], + + clearToSFields: function () { + let me = this; + + let disp = me.down('#tos_url_display'); + let field = me.down('#tos_url'); + let checkbox = me.down('#tos_checkbox'); + + disp.setValue('Terms of service not fetched yet'); + field.setValue(undefined); + checkbox.setValue(undefined); + checkbox.setHidden(true); + }, +}); + +Ext.define('PVE.node.ACMEDomainEdit', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pveACMEDomainEdit', + + subject: gettext('Domain'), + isCreate: false, + width: 450, + onlineHelp: 'sysadmin_certificate_management', + + items: [ + { + xtype: 'inputpanel', + onGetValues: function (values) { + let me = this; + let win = me.up('pveACMEDomainEdit'); + let nodeconfig = win.nodeconfig; + let olddomain = win.domain || {}; + + let params = { + digest: nodeconfig.digest, + }; + + let configkey = olddomain.configkey; + let acmeObj = PVE.Parser.parseACME(nodeconfig.acme); + + if (values.type === 'dns') { + if (!olddomain.configkey || olddomain.configkey === 'acme') { + // look for first free slot + for (let i = 0; i < PVE.Utils.acmedomain_count; i++) { + if (nodeconfig[`acmedomain${i}`] === undefined) { + configkey = `acmedomain${i}`; + break; + } + } + if (olddomain.domain) { + // we have to remove the domain from the acme domainlist + PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + params.acme = PVE.Parser.printACME(acmeObj); + } + } + + delete values.type; + params[configkey] = PVE.Parser.printPropertyString(values, 'domain'); + } else { + if (olddomain.configkey && olddomain.configkey !== 'acme') { + // delete the old dns entry + params.delete = [olddomain.configkey]; + } + + // add new, remove old and make entries unique + PVE.Utils.add_domain_to_acme(acmeObj, values.domain); + PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain); + params.acme = PVE.Parser.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('pveACMEDomainEdit'); + 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 < PVE.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('pveACMEDomainEdit'); + let pluginField = view.down('field[name=plugin]'); + pluginField.setDisabled(value !== 'dns'); + pluginField.setHidden(value !== 'dns'); + }, + }, + }, + { + xtype: 'hidden', + name: 'alias', + }, + { + xtype: 'pveACMEPluginSelector', + name: 'plugin', + disabled: true, + hidden: true, + allowBlank: false, + }, + { + xtype: 'proxmoxtextfield', + name: 'domain', + allowBlank: false, + vtype: 'DnsName', + value: '', + fieldLabel: gettext('Domain'), + }, + ], + }, + ], + + initComponent: function () { + let me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + if (!me.nodeconfig) { + throw 'no nodeconfig given'; + } + + me.isCreate = !me.domain; + if (me.isCreate) { + me.domain = `${me.nodename}.`; // TODO: FQDN of node + } + + me.url = `/api2/extjs/nodes/${me.nodename}/config`; + + me.callParent(); + + if (!me.isCreate) { + me.setValues(me.domain); + } else { + me.setValues({ domain: me.domain }); + } + }, +}); + +Ext.define('pve-acme-domains', { + extend: 'Ext.data.Model', + fields: ['domain', 'type', 'alias', 'plugin', 'configkey'], + idProperty: 'domain', +}); + +Ext.define('PVE.node.ACME', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveACMEView', + + margin: '10 0 0 0', + title: 'ACME', + + emptyText: gettext('No Domains configured'), + + viewModel: { + data: { + domaincount: 0, + account: undefined, // the account we display + configaccount: undefined, // the account set in the config + accountEditable: false, + accountsAvailable: false, + }, + + formulas: { + canOrder: (get) => !!get('account') && get('domaincount') > 0, + editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'), + editBtnText: (get) => (get('accountEditable') ? gettext('Apply') : gettext('Edit')), + accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'), + accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'), + }, + }, + + 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('PVE.node.ACMEDomainEdit', { + nodename: view.nodename, + nodeconfig: view.nodeconfig, + 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('PVE.node.ACMEDomainEdit', { + nodename: view.nodename, + nodeconfig: view.nodeconfig, + 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 = PVE.Parser.parseACME(view.nodeconfig.acme); + PVE.Utils.remove_domain_from_acme(acme, rec.domain); + params.acme = PVE.Parser.printACME(acme); + } + + Proxmox.Utils.API2Request({ + method: 'PUT', + url: `/nodes/${view.nodename}/config`, + 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 = PVE.Parser.parseACME(view.nodeconfig.acme); + acme.account = account; + params.acme = PVE.Parser.printACME(acme); + + Proxmox.Utils.API2Request({ + method: 'PUT', + waitMsgTarget: view, + url: `/nodes/${view.nodename}/config`, + params, + success: function (response, opt) { + if (Ext.isFunction(callback)) { + callback(); + } + }, + failure: function (response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + order: function () { + let me = this; + let view = me.getView(); + + Proxmox.Utils.API2Request({ + method: 'POST', + params: { + force: 1, + }, + url: `/nodes/${view.nodename}/certificates/acme/certificate`, + success: function (response, opt) { + Ext.create('Proxmox.window.TaskViewer', { + upid: response.result.data, + taskDone: function (success) { + me.orderFinished(success); + }, + }).show(); + }, + failure: function (response, opt) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + + orderFinished: function (success) { + if (!success) { + return; + } + // reload only if the Web UI is open on the same node that the cert was ordered for + if (this.getView().nodename !== Proxmox.NodeName) { + return; + } + var txt = gettext( + 'pveproxy will be restarted with new certificates, please reload the GUI!', + ); + Ext.getBody().mask(txt, ['pve-static-mask']); + // 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('PVE.node.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', + }, + '-', + { + xtype: 'button', + reference: 'order', + text: gettext('Order Certificates Now'), + bind: { + disabled: '{!canOrder}', + }, + handler: 'order', + }, + '-', + { + 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: 'pveACMEAccountSelector', + hidden: true, + reference: 'accountselector', + bind: { + value: '{account}', + hidden: '{accountValueHidden}', + }, + }, + { + xtype: 'button', + iconCls: 'fa black fa-pencil', + bind: { + iconCls: '{editBtnIcon}', + text: '{editBtnText}', + 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 = PVE.Parser.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 < PVE.Utils.acmedomain_count; i++) { + let acmedomain = rec.data[`acmedomain${i}`]; + if (!acmedomain) { + continue; + } + + let record = PVE.Parser.parsePropertyString(acmedomain, 'domain'); + record.type = 'dns'; + 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: 'type', + flex: 1, + text: gettext('Type'), + }, + { + dataIndex: 'plugin', + flex: 1, + text: gettext('Plugin'), + }, + ], + + initComponent: function () { + var me = this; + + if (!me.nodename) { + throw 'no nodename given'; + } + + me.rstore = Ext.create('Proxmox.data.UpdateStore', { + interval: 10 * 1000, + autoStart: true, + storeid: `pve-node-domains-${me.nodename}`, + proxy: { + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/config`, + }, + }); + + me.store = Ext.create('Ext.data.Store', { + model: 'pve-acme-domains', + sorters: 'domain', + }); + + me.callParent(); + me.mon(me.rstore, 'load', 'updateStore', me); + Proxmox.Utils.monStoreErrors(me, me.rstore); + me.on('destroy', me.rstore.stopUpdate, me.rstore); + }, +});