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