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