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