]> git.proxmox.com Git - pmg-api.git/blob - src/PMG/API2/Certificates.pm
775d5758ad5d282fcc20731d68567135ebc80b1f
[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 = $acme_config->{domains};
472 foreach my $domain (sort keys %$domains) {
473 my $entry = $domains->{$domain};
474 if (!(grep { $_ eq $type } PVE::Tools::split_list($entry->{usage}))) {
475 delete $domains->{$domain};
476 }
477 }
478
479 if (!%$domains) {
480 raise("No domains configured for type '$type'\n", 400);
481 }
482 };
483
484 __PACKAGE__->register_method ({
485 name => 'new_acme_cert',
486 path => 'acme/{type}',
487 method => 'POST',
488 permissions => { check => [ 'admin' ] },
489 description => 'Order a new certificate from ACME-compatible CA.',
490 protected => 1,
491 proxyto => 'node',
492 parameters => {
493 additionalProperties => 0,
494 properties => {
495 node => get_standard_option('pve-node'),
496 type => get_standard_option('pmg-certificate-type'),
497 force => {
498 type => 'boolean',
499 description => 'Overwrite existing custom certificate.',
500 optional => 1,
501 default => 0,
502 },
503 },
504 },
505 returns => {
506 type => 'string',
507 },
508 code => sub {
509 my ($param) = @_;
510
511 my $type = extract_param($param, 'type'); # also used to know which service to restart
512 my $cert_path = PMG::CertHelpers::cert_path($type);
513 raise_param_exc({'force' => "Custom certificate exists but 'force' is not set."})
514 if !$param->{force} && -e $cert_path;
515
516 my $node_config = PMG::NodeConfig::load_config();
517 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
518 raise("ACME domain list in configuration is missing!", 400)
519 if !($acme_config && $acme_config->{domains} && $acme_config->{domains}->%*);
520
521 $filter_domains->($acme_config, $type);
522
523 my $rpcenv = PMG::RESTEnvironment->get();
524 my $authuser = $rpcenv->get_user();
525
526 my $realcmd = sub {
527 STDOUT->autoflush(1);
528 my $account = $acme_config->{account};
529 my $account_file = "${acme_account_dir}/${account}";
530 die "ACME account config file '$account' does not exist.\n"
531 if ! -e $account_file;
532
533 print "Loading ACME account details\n";
534 my $acme = PMG::RS::Acme->load($account_file);
535
536 my ($cert, $key) = $order_certificate->($acme, $acme_config);
537 my $certificate = "$key\n$cert";
538
539 update_cert($type, $cert_path, $certificate, $param->{force}, 1);
540
541 if ($type eq 'smtp') {
542 set_smtp(1, 1);
543 }
544
545 die "$@\n" if $@;
546 };
547
548 return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd);
549 }});
550
551 __PACKAGE__->register_method ({
552 name => 'renew_acme_cert',
553 path => 'acme/{type}',
554 method => 'PUT',
555 permissions => { check => [ 'admin' ] },
556 description => "Renew existing certificate from CA.",
557 protected => 1,
558 proxyto => 'node',
559 parameters => {
560 additionalProperties => 0,
561 properties => {
562 node => get_standard_option('pve-node'),
563 type => get_standard_option('pmg-certificate-type'),
564 force => {
565 type => 'boolean',
566 description => 'Force renewal even if expiry is more than 30 days away.',
567 optional => 1,
568 default => 0,
569 },
570 },
571 },
572 returns => {
573 type => 'string',
574 },
575 code => sub {
576 my ($param) = @_;
577
578 my $type = extract_param($param, 'type'); # also used to know which service to restart
579 my $cert_path = PMG::CertHelpers::cert_path($type);
580
581 raise("No current (custom) certificate found, please order a new certificate!\n")
582 if ! -e $cert_path;
583
584 my $expires_soon = PVE::Certificate::check_expiry($cert_path, time() + 30*24*60*60);
585 raise_param_exc({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."})
586 if !$expires_soon && !$param->{force};
587
588 my $node_config = PMG::NodeConfig::load_config();
589 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
590 raise("ACME domain list in configuration is missing!", 400)
591 if !$acme_config || !$acme_config->{domains}->%*;
592
593 $filter_domains->($acme_config, $type);
594
595 my $rpcenv = PMG::RESTEnvironment->get();
596 my $authuser = $rpcenv->get_user();
597
598 my $old_cert = PVE::Tools::file_get_contents($cert_path);
599
600 my $realcmd = sub {
601 STDOUT->autoflush(1);
602 my $account = $acme_config->{account};
603 my $account_file = "${acme_account_dir}/${account}";
604 die "ACME account config file '$account' does not exist.\n"
605 if ! -e $account_file;
606
607 print "Loading ACME account details\n";
608 my $acme = PMG::RS::Acme->load($account_file);
609
610 my ($cert, $key) = $order_certificate->($acme, $acme_config);
611 my $certificate = "$key\n$cert";
612
613 update_cert($type, $cert_path, $certificate, 1, 1);
614
615 if (defined($old_cert)) {
616 print "Revoking old certificate\n";
617 eval { $acme->revoke_certificate($old_cert, undef) };
618 warn "Revoke request to CA failed: $@" if $@;
619 }
620 };
621
622 return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd);
623 }});
624
625 __PACKAGE__->register_method ({
626 name => 'revoke_acme_cert',
627 path => 'acme/{type}',
628 method => 'DELETE',
629 permissions => { check => [ 'admin' ] },
630 description => "Revoke existing certificate from CA.",
631 protected => 1,
632 proxyto => 'node',
633 parameters => {
634 additionalProperties => 0,
635 properties => {
636 node => get_standard_option('pve-node'),
637 type => get_standard_option('pmg-certificate-type'),
638 },
639 },
640 returns => {
641 type => 'string',
642 },
643 code => sub {
644 my ($param) = @_;
645
646 my $type = extract_param($param, 'type'); # also used to know which service to restart
647 my $cert_path = PMG::CertHelpers::cert_path($type);
648
649 my $node_config = PMG::NodeConfig::load_config();
650 my $acme_config = PMG::NodeConfig::get_acme_conf($node_config);
651 raise("ACME domain list in configuration is missing!", 400)
652 if !$acme_config || !$acme_config->{domains}->%*;
653
654 $filter_domains->($acme_config, $type);
655
656 my $rpcenv = PMG::RESTEnvironment->get();
657 my $authuser = $rpcenv->get_user();
658
659 my $cert = PVE::Tools::file_get_contents($cert_path);
660 $cert = pem_certificate($cert)
661 or die "no certificate section found in '$cert_path'\n";
662
663 my $realcmd = sub {
664 STDOUT->autoflush(1);
665 my $account = $acme_config->{account};
666 my $account_file = "${acme_account_dir}/${account}";
667 die "ACME account config file '$account' does not exist.\n"
668 if ! -e $account_file;
669
670 print "Loading ACME account details\n";
671 my $acme = PMG::RS::Acme->load($account_file);
672
673 print "Revoking old certificate\n";
674 eval { $acme->revoke_certificate($cert, undef) };
675 if (my $err = $@) {
676 # is there a better check?
677 die "Revoke request to CA failed: $err" if $err !~ /"Certificate is expired"/;
678 }
679
680 my $code = sub {
681 print "Deleting certificate files\n";
682 unlink $cert_path;
683 PMG::Ticket::generate_api_cert(0) if $type eq 'api';
684
685 restart_after_cert_update($type);
686 };
687
688 PMG::CertHelpers::cert_lock(10, $code);
689
690 if ($type eq 'smtp') {
691 set_smtp(0, 1);
692 }
693 };
694
695 return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd);
696 }});
697
698 1;