]> git.proxmox.com Git - pve-manager.git/blob - www/manager6/node/ACME.js
d2863a7c814253dfaecdd7350f0a5899433503e2
[pve-manager.git] / www / manager6 / node / ACME.js
1 Ext.define('PVE.node.ACMEAccountCreate', {
2 extend: 'Proxmox.window.Edit',
3 mixins: ['Proxmox.Mixin.CBind'],
4
5 width: 450,
6 title: gettext('Register Account'),
7 isCreate: true,
8 method: 'POST',
9 submitText: gettext('Register'),
10 url: '/cluster/acme/account',
11 showTaskViewer: true,
12 defaultExists: false,
13 referenceHolder: true,
14 onlineHelp: "sysadmin_certs_acme_account",
15
16 viewModel: {
17 data: {
18 customDirectory: false,
19 },
20 },
21
22 items: [
23 {
24 xtype: 'proxmoxtextfield',
25 fieldLabel: gettext('Account Name'),
26 name: 'name',
27 cbind: {
28 emptyText: (get) => get('defaultExists') ? '' : 'default',
29 allowBlank: (get) => !get('defaultExists'),
30 },
31 },
32 {
33 xtype: 'textfield',
34 name: 'contact',
35 vtype: 'email',
36 allowBlank: false,
37 fieldLabel: gettext('E-Mail'),
38 },
39 {
40 xtype: 'proxmoxComboGrid',
41 notFoundIsValid: true,
42 isFormField: false,
43 allowBlank: false,
44 valueField: 'url',
45 displayField: 'name',
46 fieldLabel: gettext('ACME Directory'),
47 store: {
48 listeners: {
49 'load': function() {
50 this.add({ name: gettext("Custom"), url: '' });
51 },
52 },
53 autoLoad: true,
54 fields: ['name', 'url'],
55 idProperty: ['name'],
56 proxy: {
57 type: 'proxmox',
58 url: '/api2/json/cluster/acme/directories',
59 },
60 },
61 listConfig: {
62 columns: [
63 {
64 header: gettext('Name'),
65 dataIndex: 'name',
66 flex: 1,
67 },
68 {
69 header: gettext('URL'),
70 dataIndex: 'url',
71 flex: 1,
72 },
73 ],
74 },
75 listeners: {
76 change: function(combogrid, value) {
77 let me = this;
78
79 let vm = me.up('window').getViewModel();
80 let dirField = me.up('window').lookupReference('directoryInput');
81 let tosButton = me.up('window').lookupReference('queryTos');
82
83 let isCustom = combogrid.getSelection().get('name') === gettext("Custom");
84 vm.set('customDirectory', isCustom);
85
86 dirField.setValue(value);
87
88 if (!isCustom) {
89 tosButton.click();
90 } else {
91 me.up('window').clearToSFields();
92 }
93 },
94 },
95 },
96 {
97 xtype: 'fieldcontainer',
98 layout: 'hbox',
99 fieldLabel: gettext('URL'),
100 bind: {
101 hidden: '{!customDirectory}',
102 },
103 items: [
104 {
105 xtype: 'proxmoxtextfield',
106 name: 'directory',
107 reference: 'directoryInput',
108 flex: 1,
109 allowBlank: false,
110 listeners: {
111 change: function(textbox, value) {
112 let me = this;
113 me.up('window').clearToSFields();
114 },
115 },
116 },
117 {
118 xtype: 'proxmoxButton',
119 margin: '0 0 0 5',
120 reference: 'queryTos',
121 text: gettext('Query URL'),
122 listeners: {
123 click: function(button) {
124 let me = this;
125
126 let w = me.up('window');
127 let disp = w.down('#tos_url_display');
128 let field = w.down('#tos_url');
129 let checkbox = w.down('#tos_checkbox');
130 let value = w.lookupReference('directoryInput').getValue();
131 w.clearToSFields();
132
133 if (!value) {
134 return;
135 } else {
136 disp.setValue(gettext("Loading"));
137 }
138
139 Proxmox.Utils.API2Request({
140 url: '/cluster/acme/meta',
141 method: 'GET',
142 params: {
143 directory: value,
144 },
145 success: function(response, opt) {
146 if (response.result.data.termsOfService) {
147 field.setValue(response.result.data.termsOfService);
148 disp.setValue(response.result.data.termsOfService);
149 checkbox.setHidden(false);
150 } else {
151 checkbox.setValue(false);
152 disp.setValue("No terms of service agreement required");
153 }
154 },
155 failure: function(response, opt) {
156 disp.setValue(undefined);
157 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
158 },
159 });
160 },
161 },
162 },
163 ],
164 },
165 {
166 xtype: 'displayfield',
167 itemId: 'tos_url_display',
168 renderer: PVE.Utils.render_optional_url,
169 name: 'tos_url_display',
170 },
171 {
172 xtype: 'hidden',
173 itemId: 'tos_url',
174 name: 'tos_url',
175 },
176 {
177 xtype: 'proxmoxcheckbox',
178 itemId: 'tos_checkbox',
179 boxLabel: gettext('Accept TOS'),
180 submitValue: false,
181 validateValue: function(value) {
182 if (value && this.checked) {
183 return true;
184 }
185 return false;
186 },
187 },
188 ],
189
190 clearToSFields: function() {
191 let me = this;
192
193 let disp = me.down('#tos_url_display');
194 let field = me.down('#tos_url');
195 let checkbox = me.down('#tos_checkbox');
196
197 disp.setValue("Terms of service not fetched yet");
198 field.setValue(undefined);
199 checkbox.setValue(undefined);
200 checkbox.setHidden(true);
201 },
202
203 });
204
205 Ext.define('PVE.node.ACMEAccountView', {
206 extend: 'Proxmox.window.Edit',
207
208 width: 600,
209 fieldDefaults: {
210 labelWidth: 140,
211 },
212
213 title: gettext('Account'),
214
215 items: [
216 {
217 xtype: 'displayfield',
218 fieldLabel: gettext('E-Mail'),
219 name: 'email',
220 },
221 {
222 xtype: 'displayfield',
223 fieldLabel: gettext('Created'),
224 name: 'createdAt',
225 },
226 {
227 xtype: 'displayfield',
228 fieldLabel: gettext('Status'),
229 name: 'status',
230 },
231 {
232 xtype: 'displayfield',
233 fieldLabel: gettext('Directory'),
234 renderer: PVE.Utils.render_optional_url,
235 name: 'directory',
236 },
237 {
238 xtype: 'displayfield',
239 fieldLabel: gettext('Terms of Services'),
240 renderer: PVE.Utils.render_optional_url,
241 name: 'tos',
242 },
243 ],
244
245 initComponent: function() {
246 var me = this;
247
248 if (!me.accountname) {
249 throw "no account name defined";
250 }
251
252 me.url = '/cluster/acme/account/' + me.accountname;
253
254 me.callParent();
255
256 // hide OK/Reset button, because we just want to show data
257 me.down('toolbar[dock=bottom]').setVisible(false);
258
259 me.load({
260 success: function(response) {
261 var data = response.result.data;
262 data.email = data.account.contact[0];
263 data.createdAt = data.account.createdAt;
264 data.status = data.account.status;
265 me.setValues(data);
266 },
267 });
268 },
269 });
270
271 Ext.define('PVE.node.ACMEDomainEdit', {
272 extend: 'Proxmox.window.Edit',
273 alias: 'widget.pveACMEDomainEdit',
274
275 subject: gettext('Domain'),
276 isCreate: false,
277 width: 450,
278 onlineHelp: 'sysadmin_certificate_management',
279
280 items: [
281 {
282 xtype: 'inputpanel',
283 onGetValues: function(values) {
284 let me = this;
285 let win = me.up('pveACMEDomainEdit');
286 let nodeconfig = win.nodeconfig;
287 let olddomain = win.domain || {};
288
289 let params = {
290 digest: nodeconfig.digest,
291 };
292
293 let configkey = olddomain.configkey;
294 let acmeObj = PVE.Parser.parseACME(nodeconfig.acme);
295
296 if (values.type === 'dns') {
297 if (!olddomain.configkey || olddomain.configkey === 'acme') {
298 // look for first free slot
299 for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
300 if (nodeconfig[`acmedomain${i}`] === undefined) {
301 configkey = `acmedomain${i}`;
302 break;
303 }
304 }
305 if (olddomain.domain) {
306 // we have to remove the domain from the acme domainlist
307 PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
308 params.acme = PVE.Parser.printACME(acmeObj);
309 }
310 }
311
312 delete values.type;
313 params[configkey] = PVE.Parser.printPropertyString(values, 'domain');
314 } else {
315 if (olddomain.configkey && olddomain.configkey !== 'acme') {
316 // delete the old dns entry
317 params.delete = [olddomain.configkey];
318 }
319
320 // add new, remove old and make entries unique
321 PVE.Utils.add_domain_to_acme(acmeObj, values.domain);
322 PVE.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
323 params.acme = PVE.Parser.printACME(acmeObj);
324 }
325
326 return params;
327 },
328 items: [
329 {
330 xtype: 'proxmoxKVComboBox',
331 name: 'type',
332 fieldLabel: gettext('Challenge Type'),
333 allowBlank: false,
334 value: 'standalone',
335 comboItems: [
336 ['standalone', 'HTTP'],
337 ['dns', 'DNS'],
338 ],
339 validator: function(value) {
340 let me = this;
341 let win = me.up('pveACMEDomainEdit');
342 let oldconfigkey = win.domain ? win.domain.configkey : undefined;
343 let val = me.getValue();
344 if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) {
345 // we have to check if there is a 'acmedomain' slot left
346 let found = false;
347 for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
348 if (!win.nodeconfig[`acmedomain${i}`]) {
349 found = true;
350 }
351 }
352 if (!found) {
353 return gettext('Only 5 Domains with type DNS can be configured');
354 }
355 }
356
357 return true;
358 },
359 listeners: {
360 change: function(cb, value) {
361 let me = this;
362 let view = me.up('pveACMEDomainEdit');
363 let pluginField = view.down('field[name=plugin]');
364 pluginField.setDisabled(value !== 'dns');
365 pluginField.setHidden(value !== 'dns');
366 },
367 },
368 },
369 {
370 xtype: 'hidden',
371 name: 'alias',
372 },
373 {
374 xtype: 'pveACMEPluginSelector',
375 name: 'plugin',
376 disabled: true,
377 hidden: true,
378 allowBlank: false,
379 },
380 {
381 xtype: 'proxmoxtextfield',
382 name: 'domain',
383 allowBlank: false,
384 vtype: 'DnsName',
385 value: '',
386 fieldLabel: gettext('Domain'),
387 },
388 ],
389 },
390 ],
391
392 initComponent: function() {
393 let me = this;
394
395 if (!me.nodename) {
396 throw 'no nodename given';
397 }
398
399 if (!me.nodeconfig) {
400 throw 'no nodeconfig given';
401 }
402
403 me.isCreate = !me.domain;
404 if (me.isCreate) {
405 me.domain = `${me.nodename}.`; // TODO: FQDN of node
406 }
407
408 me.url = `/api2/extjs/nodes/${me.nodename}/config`;
409
410 me.callParent();
411
412 if (!me.isCreate) {
413 me.setValues(me.domain);
414 } else {
415 me.setValues({ domain: me.domain });
416 }
417 },
418 });
419
420 Ext.define('pve-acme-domains', {
421 extend: 'Ext.data.Model',
422 fields: ['domain', 'type', 'alias', 'plugin', 'configkey'],
423 idProperty: 'domain',
424 });
425
426 Ext.define('PVE.node.ACME', {
427 extend: 'Ext.grid.Panel',
428 alias: 'widget.pveACMEView',
429
430 margin: '10 0 0 0',
431 title: 'ACME',
432
433 emptyText: gettext('No Domains configured'),
434
435 viewModel: {
436 data: {
437 domaincount: 0,
438 account: undefined, // the account we display
439 configaccount: undefined, // the account set in the config
440 accountEditable: false,
441 accountsAvailable: false,
442 },
443
444 formulas: {
445 canOrder: (get) => !!get('account') && get('domaincount') > 0,
446 editBtnIcon: (get) => 'fa black fa-' + (get('accountEditable') ? 'check' : 'pencil'),
447 editBtnText: (get) => get('accountEditable') ? gettext('Apply') : gettext('Edit'),
448 accountTextHidden: (get) => get('accountEditable') || !get('accountsAvailable'),
449 accountValueHidden: (get) => !get('accountEditable') || !get('accountsAvailable'),
450 },
451 },
452
453 controller: {
454 xclass: 'Ext.app.ViewController',
455
456 init: function(view) {
457 let accountSelector = this.lookup('accountselector');
458 accountSelector.store.on('load', this.onAccountsLoad, this);
459 },
460
461 onAccountsLoad: function(store, records, success) {
462 let me = this;
463 let vm = me.getViewModel();
464 let configaccount = vm.get('configaccount');
465 vm.set('accountsAvailable', records.length > 0);
466 if (me.autoChangeAccount && records.length > 0) {
467 me.changeAccount(records[0].data.name, () => {
468 vm.set('accountEditable', false);
469 me.reload();
470 });
471 me.autoChangeAccount = false;
472 } else if (configaccount) {
473 if (store.findExact('name', configaccount) !== -1) {
474 vm.set('account', configaccount);
475 } else {
476 vm.set('account', null);
477 }
478 }
479 },
480
481 addDomain: function() {
482 let me = this;
483 let view = me.getView();
484
485 Ext.create('PVE.node.ACMEDomainEdit', {
486 nodename: view.nodename,
487 nodeconfig: view.nodeconfig,
488 apiCallDone: function() {
489 me.reload();
490 },
491 }).show();
492 },
493
494 editDomain: function() {
495 let me = this;
496 let view = me.getView();
497
498 let selection = view.getSelection();
499 if (selection.length < 1) return;
500
501 Ext.create('PVE.node.ACMEDomainEdit', {
502 nodename: view.nodename,
503 nodeconfig: view.nodeconfig,
504 domain: selection[0].data,
505 apiCallDone: function() {
506 me.reload();
507 },
508 }).show();
509 },
510
511 removeDomain: function() {
512 let me = this;
513 let view = me.getView();
514 let selection = view.getSelection();
515 if (selection.length < 1) return;
516
517 let rec = selection[0].data;
518 let params = {};
519 if (rec.configkey !== 'acme') {
520 params.delete = rec.configkey;
521 } else {
522 let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
523 PVE.Utils.remove_domain_from_acme(acme, rec.domain);
524 params.acme = PVE.Parser.printACME(acme);
525 }
526
527 Proxmox.Utils.API2Request({
528 method: 'PUT',
529 url: `/nodes/${view.nodename}/config`,
530 params,
531 success: function(response, opt) {
532 me.reload();
533 },
534 failure: function(response, opt) {
535 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
536 },
537 });
538 },
539
540 toggleEditAccount: function() {
541 let me = this;
542 let vm = me.getViewModel();
543 let editable = vm.get('accountEditable');
544 if (editable) {
545 me.changeAccount(vm.get('account'), function() {
546 vm.set('accountEditable', false);
547 me.reload();
548 });
549 } else {
550 vm.set('accountEditable', true);
551 }
552 },
553
554 changeAccount: function(account, callback) {
555 let me = this;
556 let view = me.getView();
557 let params = {};
558
559 let acme = PVE.Parser.parseACME(view.nodeconfig.acme);
560 acme.account = account;
561 params.acme = PVE.Parser.printACME(acme);
562
563 Proxmox.Utils.API2Request({
564 method: 'PUT',
565 waitMsgTarget: view,
566 url: `/nodes/${view.nodename}/config`,
567 params,
568 success: function(response, opt) {
569 if (Ext.isFunction(callback)) {
570 callback();
571 }
572 },
573 failure: function(response, opt) {
574 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
575 },
576 });
577 },
578
579 order: function() {
580 let me = this;
581 let view = me.getView();
582
583 Proxmox.Utils.API2Request({
584 method: 'POST',
585 params: {
586 force: 1,
587 },
588 url: `/nodes/${view.nodename}/certificates/acme/certificate`,
589 success: function(response, opt) {
590 Ext.create('Proxmox.window.TaskViewer', {
591 upid: response.result.data,
592 taskDone: function(success) {
593 me.orderFinished(success);
594 },
595 }).show();
596 },
597 failure: function(response, opt) {
598 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
599 },
600 });
601 },
602
603 orderFinished: function(success) {
604 if (!success) return;
605 // reload only if the Web UI is open on the same node that the cert was ordered for
606 if (this.getView().nodename !== Proxmox.NodeName) {
607 return;
608 }
609 var txt = gettext('pveproxy will be restarted with new certificates, please reload the GUI!');
610 Ext.getBody().mask(txt, ['pve-static-mask']);
611 // reload after 10 seconds automatically
612 Ext.defer(function() {
613 window.location.reload(true);
614 }, 10000);
615 },
616
617 reload: function() {
618 let me = this;
619 let view = me.getView();
620 view.rstore.load();
621 },
622
623 addAccount: function() {
624 let me = this;
625 Ext.create('PVE.node.ACMEAccountCreate', {
626 autoShow: true,
627 taskDone: function() {
628 me.reload();
629 let accountSelector = me.lookup('accountselector');
630 me.autoChangeAccount = true;
631 accountSelector.store.load();
632 },
633 });
634 },
635 },
636
637 tbar: [
638 {
639 xtype: 'proxmoxButton',
640 text: gettext('Add'),
641 handler: 'addDomain',
642 selModel: false,
643 },
644 {
645 xtype: 'proxmoxButton',
646 text: gettext('Edit'),
647 disabled: true,
648 handler: 'editDomain',
649 },
650 {
651 xtype: 'proxmoxStdRemoveButton',
652 handler: 'removeDomain',
653 },
654 '-',
655 {
656 xtype: 'button',
657 reference: 'order',
658 text: gettext('Order Certificates Now'),
659 bind: {
660 disabled: '{!canOrder}',
661 },
662 handler: 'order',
663 },
664 '-',
665 {
666 xtype: 'displayfield',
667 value: gettext('Using Account') + ':',
668 bind: {
669 hidden: '{!accountsAvailable}',
670 },
671 },
672 {
673 xtype: 'displayfield',
674 reference: 'accounttext',
675 renderer: (val) => val || Proxmox.Utils.NoneText,
676 bind: {
677 value: '{account}',
678 hidden: '{accountTextHidden}',
679 },
680 },
681 {
682 xtype: 'pveACMEAccountSelector',
683 hidden: true,
684 reference: 'accountselector',
685 bind: {
686 value: '{account}',
687 hidden: '{accountValueHidden}',
688 },
689 },
690 {
691 xtype: 'button',
692 iconCls: 'fa black fa-pencil',
693 bind: {
694 iconCls: '{editBtnIcon}',
695 text: '{editBtnText}',
696 hidden: '{!accountsAvailable}',
697 },
698 handler: 'toggleEditAccount',
699 },
700 {
701 xtype: 'displayfield',
702 value: gettext('No Account available.'),
703 bind: {
704 hidden: '{accountsAvailable}',
705 },
706 },
707 {
708 xtype: 'button',
709 hidden: true,
710 reference: 'accountlink',
711 text: gettext('Add ACME Account'),
712 bind: {
713 hidden: '{accountsAvailable}',
714 },
715 handler: 'addAccount',
716 },
717 ],
718
719 updateStore: function(store, records, success) {
720 let me = this;
721 let data = [];
722 let rec;
723 if (success && records.length > 0) {
724 rec = records[0];
725 } else {
726 rec = {
727 data: {},
728 };
729 }
730
731 me.nodeconfig = rec.data; // save nodeconfig for updates
732
733 let account = 'default';
734
735 if (rec.data.acme) {
736 let obj = PVE.Parser.parseACME(rec.data.acme);
737 (obj.domains || []).forEach(domain => {
738 if (domain === '') return;
739 let record = {
740 domain,
741 type: 'standalone',
742 configkey: 'acme',
743 };
744 data.push(record);
745 });
746
747 if (obj.account) {
748 account = obj.account;
749 }
750 }
751
752 let vm = me.getViewModel();
753 let oldaccount = vm.get('account');
754
755 // account changed, and we do not edit currently, load again to verify
756 if (oldaccount !== account && !vm.get('accountEditable')) {
757 vm.set('configaccount', account);
758 me.lookup('accountselector').store.load();
759 }
760
761 for (let i = 0; i < PVE.Utils.acmedomain_count; i++) {
762 let acmedomain = rec.data[`acmedomain${i}`];
763 if (!acmedomain) continue;
764
765 let record = PVE.Parser.parsePropertyString(acmedomain, 'domain');
766 record.type = 'dns';
767 record.configkey = `acmedomain${i}`;
768 data.push(record);
769 }
770
771 vm.set('domaincount', data.length);
772 me.store.loadData(data, false);
773 },
774
775 listeners: {
776 itemdblclick: 'editDomain',
777 },
778
779 columns: [
780 {
781 dataIndex: 'domain',
782 flex: 5,
783 text: gettext('Domain'),
784 },
785 {
786 dataIndex: 'type',
787 flex: 1,
788 text: gettext('Type'),
789 },
790 {
791 dataIndex: 'plugin',
792 flex: 1,
793 text: gettext('Plugin'),
794 },
795 ],
796
797 initComponent: function() {
798 var me = this;
799
800 if (!me.nodename) {
801 throw "no nodename given";
802 }
803
804 me.rstore = Ext.create('Proxmox.data.UpdateStore', {
805 interval: 10 * 1000,
806 autoStart: true,
807 storeid: `pve-node-domains-${me.nodename}`,
808 proxy: {
809 type: 'proxmox',
810 url: `/api2/json/nodes/${me.nodename}/config`,
811 },
812 });
813
814 me.store = Ext.create('Ext.data.Store', {
815 model: 'pve-acme-domains',
816 sorters: 'domain',
817 });
818
819 me.callParent();
820 me.mon(me.rstore, 'load', 'updateStore', me);
821 Proxmox.Utils.monStoreErrors(me, me.rstore);
822 me.on('destroy', me.rstore.stopUpdate, me.rstore);
823 },
824 });