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