]> git.proxmox.com Git - pmg-api.git/blob - src/PMG/API2/Certificates.pm
add PMG::NodeConfig::filter_domains_by_type helper
[pmg-api.git] / src / PMG / API2 / Certificates.pm
1 package PMG::API2::Certificates;
2
3 use strict;
4 use warnings;
5
6 use PVE::Certificate;
7 use PVE::Exception qw(raise raise_param_exc);
8 use PVE::JSONSchema qw(get_standard_option);
9 use PVE::Tools qw(extract_param file_get_contents file_set_contents);
10
11 use PMG::CertHelpers;
12 use PMG::NodeConfig;
13 use PMG::RS::Acme;
14 use PMG::RS::CSR;
15
16 use PMG::API2::ACMEPlugin;
17
18 use base qw(PVE::RESTHandler);
19
20 my $acme_account_dir = PMG::CertHelpers::acme_account_dir();
21
22 sub first_typed_pem_entry : prototype($$) {
23 my ($label, $data) = @_;
24
25 if ($data =~ /^(-----BEGIN \Q$label\E-----\n.*?\n-----END \Q$label\E-----)$/ms) {
26 return $1;
27 }
28 return undef;
29 }
30
31 sub pem_private_key : prototype($) {
32 my ($data) = @_;
33 return first_typed_pem_entry('PRIVATE KEY', $data);
34 }
35
36 sub pem_certificate : prototype($) {
37 my ($data) = @_;
38 return first_typed_pem_entry('CERTIFICATE', $data);
39 }
40
41 my sub restart_after_cert_update : prototype($) {
42 my ($type) = @_;
43
44 if ($type eq 'api') {
45 print "Restarting pmgproxy\n";
46 PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pmgproxy']);
47
48 my $cinfo = PMG::ClusterConfig->new();
49 if (scalar(keys %{$cinfo->{ids}})) {
50 print "Notify cluster about new fingerprint\n";
51 PMG::Cluster::trigger_update_fingerprints($cinfo);
52 }
53 }
54 };
55
56 my sub update_cert : prototype($$$$$) {
57 my ($type, $cert_path, $certificate, $force, $restart) = @_;
58 my $code = sub {
59 print "Setting custom certificate file $cert_path\n";
60 PMG::CertHelpers::set_cert_file($certificate, $cert_path, $force);
61
62 restart_after_cert_update($type) if $restart;
63 };
64 PMG::CertHelpers::cert_lock(10, $code);
65 };
66
67 my sub set_smtp : prototype($$) {
68 my ($on, $reload) = @_;
69
70 my $code = sub {
71 my $cfg = PMG::Config->new();
72 if (!$cfg->get('mail', 'tls') == !$on) {
73 return;
74 }
75
76 print "Rewriting postfix config\n";
77 $cfg->set('mail', 'tls', $on);
78 $cfg->write();
79 my $changed = $cfg->rewrite_config_postfix();
80
81 if ($changed && $reload) {
82 print "Reloading postfix\n";
83 PMG::Utils::service_cmd('postfix', 'reload');
84 }
85 };
86 PMG::Config::lock_config($code, "failed to reload postfix");
87 }
88
89 __PACKAGE__->register_method ({
90 name => 'index',
91 path => '',
92 method => 'GET',
93 permissions => { user => 'all' },
94 description => "Node index.",
95 parameters => {
96 additionalProperties => 0,
97 properties => {
98 node => get_standard_option('pve-node'),
99 },
100 },
101 returns => {
102 type => 'array',
103 items => {
104 type => "object",
105 properties => {},
106 },
107 links => [ { rel => 'child', href => "{name}" } ],
108 },
109 code => sub {
110 my ($param) = @_;
111
112 return [
113 { name => 'acme' },
114 { name => 'custom' },
115 { name => 'info' },
116 { name => 'config' },
117 ];
118 },
119 });
120
121 __PACKAGE__->register_method ({
122 name => 'info',
123 path => 'info',
124 method => 'GET',
125 permissions => { user => 'all' },
126 proxyto => 'node',
127 protected => 1,
128 description => "Get information about the node's certificates.",
129 parameters => {
130 additionalProperties => 0,
131 properties => {
132 node => get_standard_option('pve-node'),
133 },
134 },
135 returns => {
136 type => 'array',
137 items => get_standard_option('pve-certificate-info'),
138 },
139 code => sub {
140 my ($param) = @_;
141
142 my $res = [];
143 for my $path (&PMG::CertHelpers::API_CERT, &PMG::CertHelpers::SMTP_CERT) {
144 eval {
145 my $info = PVE::Certificate::get_certificate_info($path);
146 push @$res, $info if $info;
147 };
148 }
149 return $res;
150 },
151 });
152
153 __PACKAGE__->register_method ({
154 name => 'custom_cert_index',
155 path => 'custom',
156 method => 'GET',
157 permissions => { user => 'all' },
158 description => "Certificate index.",
159 parameters => {
160 additionalProperties => 0,
161 properties => {
162 node => get_standard_option('pve-node'),
163 },
164 },
165 returns => {
166 type => 'array',
167 items => {
168 type => "object",
169 properties => {},
170 },
171 links => [ { rel => 'child', href => "{type}" } ],
172 },
173 code => sub {
174 my ($param) = @_;
175
176 return [
177 { type => 'api' },
178 { type => 'smtp' },
179 ];
180 },
181 });
182
183 __PACKAGE__->register_method ({
184 name => 'upload_custom_cert',
185 path => 'custom/{type}',
186 method => 'POST',
187 permissions => { check => [ 'admin' ] },
188 description => 'Upload or update custom certificate chain and key.',
189 protected => 1,
190 proxyto => 'node',
191 parameters => {
192 additionalProperties => 0,
193 properties => {
194 node => get_standard_option('pve-node'),
195 certificates => {
196 type => 'string',
197 format => 'pem-certificate-chain',
198 description => 'PEM encoded certificate (chain).',
199 },
200 key => {
201 type => 'string',
202 description => 'PEM encoded private key.',
203 format => 'pem-string',
204 optional => 0,
205 },
206 type => get_standard_option('pmg-certificate-type'),
207 force => {
208 type => 'boolean',
209 description => 'Overwrite existing custom or ACME certificate files.',
210 optional => 1,
211 default => 0,
212 },
213 restart => {
214 type => 'boolean',
215 description => 'Restart services.',
216 optional => 1,
217 default => 0,
218 },
219 },
220 },
221 returns => get_standard_option('pve-certificate-info'),
222 code => sub {
223 my ($param) = @_;
224
225 my $type = extract_param($param, 'type'); # also used to know which service to restart
226 my $cert_path = PMG::CertHelpers::cert_path($type);
227
228 my $certs = extract_param($param, 'certificates');
229 $certs = PVE::Certificate::strip_leading_text($certs);
230
231 my $key = extract_param($param, 'key');
232 if ($key) {
233 $key = PVE::Certificate::strip_leading_text($key);
234 $certs = "$key\n$certs";
235 } else {
236 my $private_key = pem_private_key($certs);
237 if (!defined($private_key)) {
238 my $old = file_get_contents($cert_path);
239 $private_key = pem_private_key($old);
240 if (!defined($private_key)) {
241 raise_param_exc({
242 'key' => "Attempted to upload custom certificate without (existing) key."
243 })
244 }
245
246 # copy the old certificate's key:
247 $certs = "$key\n$certs";
248 }
249 }
250
251 my $info;
252
253 PMG::CertHelpers::cert_lock(10, sub {
254 update_cert($type, $cert_path, $certs, $param->{force}, $param->{restart});
255 });
256
257 if ($type eq 'smtp') {
258 set_smtp(1, $param->{restart});
259 }
260
261 return $info;
262 }});
263
264 __PACKAGE__->register_method ({
265 name => 'remove_custom_cert',
266 path => 'custom/{type}',
267 method => 'DELETE',
268 permissions => { check => [ 'admin' ] },
269 description => 'DELETE custom certificate chain and key.',
270 protected => 1,
271 proxyto => 'node',
272 parameters => {
273 additionalProperties => 0,
274 properties => {
275 node => get_standard_option('pve-node'),
276 type => get_standard_option('pmg-certificate-type'),
277 restart => {
278 type => 'boolean',
279 description => 'Restart pmgproxy.',
280 optional => 1,
281 default => 0,
282 },
283 },
284 },
285 returns => {
286 type => 'null',
287 },
288 code => sub {
289 my ($param) = @_;
290
291 my $type = extract_param($param, 'type');
292 my $cert_path = PMG::CertHelpers::cert_path($type);
293
294 my $code = sub {
295 print "Deleting custom certificate files\n";
296 unlink $cert_path;
297 PMG::Ticket::generate_api_cert(0) if $type eq 'api';
298
299 if ($param->{restart}) {
300 restart_after_cert_update($type);
301 }
302 };
303
304 PMG::CertHelpers::cert_lock(10, $code);
305
306 if ($type eq 'smtp') {
307 set_smtp(0, $param->{restart});
308 }
309
310 return undef;
311 }});
312
313 __PACKAGE__->register_method ({
314 name => 'acme_cert_index',
315 path => 'acme',
316 method => 'GET',
317 permissions => { user => 'all' },
318 description => "ACME Certificate index.",
319 parameters => {
320 additionalProperties => 0,
321 properties => {
322 node => get_standard_option('pve-node'),
323 },
324 },
325 returns => {
326 type => 'array',
327 items => {
328 type => "object",
329 properties => {},
330 },
331 links => [ { rel => 'child', href => "{type}" } ],
332 },
333 code => sub {
334 my ($param) = @_;
335
336 return [
337 { type => 'api' },
338 { type => 'smtp' },
339 ];
340 },
341 });
342
343 my $order_certificate = sub {
344 my ($acme, $acme_node_config) = @_;
345
346 my $plugins = PMG::API2::ACMEPlugin::load_config();
347
348 print "Placing ACME order\n";
349 my ($order_url, $order) = $acme->new_order([ sort keys %{$acme_node_config->{domains}} ]);
350 print "Order URL: $order_url\n";
351 for my $auth_url (@{$order->{authorizations}}) {
352 print "\nGetting authorization details from '$auth_url'\n";
353 my $auth = $acme->get_authorization($auth_url);
354
355 # force lower case, like get_acme_conf does
356 my $domain = lc($auth->{identifier}->{value});
357 if ($auth->{status} eq 'valid') {
358 print "$domain is already validated!\n";
359 } else {
360 print "The validation for $domain is pending!\n";
361
362 my $domain_config = $acme_node_config->{domains}->{$domain};
363 die "no config for domain '$domain'\n" if !$domain_config;
364
365 my $plugin_id = $domain_config->{plugin};
366
367 my $plugin_cfg = $plugins->{ids}->{$plugin_id};
368 die "plugin '$plugin_id' for domain '$domain' not found!\n"
369 if !$plugin_cfg;
370
371 my $data = {
372 plugin => $plugin_cfg,
373 alias => $domain_config->{alias},
374 };
375
376 my $plugin = PVE::ACME::Challenge->lookup($plugin_cfg->{type});
377 $plugin->setup($acme, $auth, $data);
378
379 print "Triggering validation\n";
380 eval {
381 die "no validation URL returned by plugin '$plugin_id' for domain '$domain'\n"
382 if !defined($data->{url});
383
384 $acme->request_challenge_validation($data->{url});
385 print "Sleeping for 5 seconds\n";
386 sleep 5;
387 while (1) {
388 $auth = $acme->get_authorization($auth_url);
389 if ($auth->{status} eq 'pending') {
390 print "Status is still 'pending', trying again in 10 seconds\n";
391 sleep 10;
392 next;
393 } elsif ($auth->{status} eq 'valid') {
394 print "Status is 'valid', domain '$domain' OK!\n";
395 last;
396 }
397 my $error = "validating challenge '$auth_url' failed - status: $auth->{status}";
398 for (@{$auth->{challenges}}) {
399 $error .= ", $_->{error}->{detail}" if $_->{error}->{detail};
400 }
401 die "$error\n";
402 }
403 };
404 my $err = $@;
405 eval { $plugin->teardown($acme, $auth, $data) };
406 warn "$@\n" if $@;
407 die $err if $err;
408 }
409 }
410 print "\nAll domains validated!\n";
411 print "\nCreating CSR\n";
412 # Currently we only support dns entries, so extract those from the order:
413 my $san = [
414 map {
415 $_->{value}
416 } grep {
417 $_->{type} eq 'dns'
418 } $order->{identifiers}->@*
419 ];
420 die "DNS identifiers are required to generate a CSR.\n" if !scalar @$san;
421 my ($csr_der, $key) = PMG::RS::CSR::generate_csr($san, {});
422
423 my $finalize_error_cnt = 0;
424 print "Checking order status\n";
425 while (1) {
426 $order = $acme->get_order($order_url);
427 if ($order->{status} eq 'pending') {
428 print "still pending, trying to finalize order\n";
429 # FIXME
430 # to be compatible with and without the order ready state we try to
431 # finalize even at the 'pending' state and give up after 5
432 # unsuccessful tries this can be removed when the letsencrypt api
433 # definitely has implemented the 'ready' state
434 eval {
435 $acme->finalize_order($order->{finalize}, $csr_der);
436 };
437 if (my $err = $@) {
438 die $err if $finalize_error_cnt >= 5;
439
440 $finalize_error_cnt++;
441 warn $err;
442 }
443 sleep 5;
444 next;
445 } elsif ($order->{status} eq 'ready') {
446 print "Order is ready, finalizing order\n";
447 $acme->finalize_order($order->{finalize}, $csr_der);
448 sleep 5;
449 next;
450 } elsif ($order->{status} eq 'processing') {
451 print "still processing, trying again in 30 seconds\n";
452 sleep 30;
453 next;
454 } elsif ($order->{status} eq 'valid') {
455 print "valid!\n";
456 last;
457 }
458 die "order status: $order->{status}\n";
459 }
460
461 print "\nDownloading certificate\n";
462 my $cert = $acme->get_certificate($order->{certificate});
463
464 return ($cert, $key);
465 };
466
467 # Filter domains and raise an error if the list becomes empty.
468 my $filter_domains = sub {
469 my ($acme_config, $type) = @_;
470
471 my $domains = PMG::NodeConfig::filter_domains_by_type($acme_config->{domains}, $type);
472
473 if (!$domains) {
474 raise("No domains configured for type '$type'\n", 400);
475 }
476
477 $acme_config->{domains} = $domains;
478 };
479
480 __PACKAGE__->register_method ({
481 name => 'new_acme_cert',
482 path => 'acme/{type}',
483 method => 'POST',
484 permissions => { check => [ 'admin' ] },
485 description => 'Order a new certificate from ACME-compatible CA.',
486 protected => 1,
487 proxyto => 'node',
488 parameters => {
489 additionalProperties => 0,
490 properties => {
491 node => get_standard_option('pve-node'),
492 type => get_standard_option('pmg-certificate-type'),
493 force => {
494 type => 'boolean',
495 description => 'Overwrite existing custom certificate.',
496 optional => 1,
497 default => 0,
498 },
499 },
500 },
501 returns => {
502 type => 'string',
503 },
504 code => sub {
505 my ($param) = @_;
506
507 my $type = extract_param($param, 'type'); # also used to know which service to restart
508 my $cert_path = PMG::CertHelpers::cert_path($type);
509 raise_param_exc({'force' => "Custom certificate exists but 'force' is not set."})
510 if !$param->{force} && -e $cert_path;
511
512 my $node_config = PMG::NodeConfig::load_config();
513 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
514 raise("ACME domain list in configuration is missing!", 400)
515 if !($acme_config && $acme_config->{domains} && $acme_config->{domains}->%*);
516
517 $filter_domains->($acme_config, $type);
518
519 my $rpcenv = PMG::RESTEnvironment->get();
520 my $authuser = $rpcenv->get_user();
521
522 my $realcmd = sub {
523 STDOUT->autoflush(1);
524 my $account = $acme_config->{account};
525 my $account_file = "${acme_account_dir}/${account}";
526 die "ACME account config file '$account' does not exist.\n"
527 if ! -e $account_file;
528
529 print "Loading ACME account details\n";
530 my $acme = PMG::RS::Acme->load($account_file);
531
532 my ($cert, $key) = $order_certificate->($acme, $acme_config);
533 my $certificate = "$key\n$cert";
534
535 update_cert($type, $cert_path, $certificate, $param->{force}, 1);
536
537 if ($type eq 'smtp') {
538 set_smtp(1, 1);
539 }
540
541 die "$@\n" if $@;
542 };
543
544 return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd);
545 }});
546
547 __PACKAGE__->register_method ({
548 name => 'renew_acme_cert',
549 path => 'acme/{type}',
550 method => 'PUT',
551 permissions => { check => [ 'admin' ] },
552 description => "Renew existing certificate from CA.",
553 protected => 1,
554 proxyto => 'node',
555 parameters => {
556 additionalProperties => 0,
557 properties => {
558 node => get_standard_option('pve-node'),
559 type => get_standard_option('pmg-certificate-type'),
560 force => {
561 type => 'boolean',
562 description => 'Force renewal even if expiry is more than 30 days away.',
563 optional => 1,
564 default => 0,
565 },
566 },
567 },
568 returns => {
569 type => 'string',
570 },
571 code => sub {
572 my ($param) = @_;
573
574 my $type = extract_param($param, 'type'); # also used to know which service to restart
575 my $cert_path = PMG::CertHelpers::cert_path($type);
576
577 raise("No current (custom) certificate found, please order a new certificate!\n")
578 if ! -e $cert_path;
579
580 my $expires_soon = PVE::Certificate::check_expiry($cert_path, time() + 30*24*60*60);
581 raise_param_exc({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."})
582 if !$expires_soon && !$param->{force};
583
584 my $node_config = PMG::NodeConfig::load_config();
585 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
586 raise("ACME domain list in configuration is missing!", 400)
587 if !$acme_config || !$acme_config->{domains}->%*;
588
589 $filter_domains->($acme_config, $type);
590
591 my $rpcenv = PMG::RESTEnvironment->get();
592 my $authuser = $rpcenv->get_user();
593
594 my $old_cert = PVE::Tools::file_get_contents($cert_path);
595
596 my $realcmd = sub {
597 STDOUT->autoflush(1);
598 my $account = $acme_config->{account};
599 my $account_file = "${acme_account_dir}/${account}";
600 die "ACME account config file '$account' does not exist.\n"
601 if ! -e $account_file;
602
603 print "Loading ACME account details\n";
604 my $acme = PMG::RS::Acme->load($account_file);
605
606 my ($cert, $key) = $order_certificate->($acme, $acme_config);
607 my $certificate = "$key\n$cert";
608
609 update_cert($type, $cert_path, $certificate, 1, 1);
610
611 if (defined($old_cert)) {
612 print "Revoking old certificate\n";
613 eval { $acme->revoke_certificate($old_cert, undef) };
614 warn "Revoke request to CA failed: $@" if $@;
615 }
616 };
617
618 return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd);
619 }});
620
621 __PACKAGE__->register_method ({
622 name => 'revoke_acme_cert',
623 path => 'acme/{type}',
624 method => 'DELETE',
625 permissions => { check => [ 'admin' ] },
626 description => "Revoke existing certificate from CA.",
627 protected => 1,
628 proxyto => 'node',
629 parameters => {
630 additionalProperties => 0,
631 properties => {
632 node => get_standard_option('pve-node'),
633 type => get_standard_option('pmg-certificate-type'),
634 },
635 },
636 returns => {
637 type => 'string',
638 },
639 code => sub {
640 my ($param) = @_;
641
642 my $type = extract_param($param, 'type'); # also used to know which service to restart
643 my $cert_path = PMG::CertHelpers::cert_path($type);
644
645 my $node_config = PMG::NodeConfig::load_config();
646 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
647 raise("ACME domain list in configuration is missing!", 400)
648 if !$acme_config || !$acme_config->{domains}->%*;
649
650 $filter_domains->($acme_config, $type);
651
652 my $rpcenv = PMG::RESTEnvironment->get();
653 my $authuser = $rpcenv->get_user();
654
655 my $cert = PVE::Tools::file_get_contents($cert_path);
656 $cert = pem_certificate($cert)
657 or die "no certificate section found in '$cert_path'\n";
658
659 my $realcmd = sub {
660 STDOUT->autoflush(1);
661 my $account = $acme_config->{account};
662 my $account_file = "${acme_account_dir}/${account}";
663 die "ACME account config file '$account' does not exist.\n"
664 if ! -e $account_file;
665
666 print "Loading ACME account details\n";
667 my $acme = PMG::RS::Acme->load($account_file);
668
669 print "Revoking old certificate\n";
670 eval { $acme->revoke_certificate($cert, undef) };
671 if (my $err = $@) {
672 # is there a better check?
673 die "Revoke request to CA failed: $err" if $err !~ /"Certificate is expired"/;
674 }
675
676 my $code = sub {
677 print "Deleting certificate files\n";
678 unlink $cert_path;
679 PMG::Ticket::generate_api_cert(0) if $type eq 'api';
680
681 restart_after_cert_update($type);
682 };
683
684 PMG::CertHelpers::cert_lock(10, $code);
685
686 if ($type eq 'smtp') {
687 set_smtp(0, 1);
688 }
689 };
690
691 return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd);
692 }});
693
694 1;