1 package PMG
::API2
::Certificates
;
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);
15 use PMG
::API2
::ACMEPlugin
;
17 use base
qw(PVE::RESTHandler);
19 my $acme_account_dir = PMG
::CertHelpers
::acme_account_dir
();
21 sub first_typed_pem_entry
: prototype($$) {
22 my ($label, $data) = @_;
24 if ($data =~ /^(-----BEGIN \Q$label\E-----\n.*?\n-----END \Q$label\E-----)$/ms) {
30 sub pem_private_key
: prototype($) {
32 return first_typed_pem_entry
('PRIVATE KEY', $data);
35 sub pem_certificate
: prototype($) {
37 return first_typed_pem_entry
('CERTIFICATE', $data);
40 my sub restart_after_cert_update
: prototype($) {
44 print "Restarting pmgproxy\n";
45 PVE
::Tools
::run_command
(['systemctl', 'reload-or-restart', 'pmgproxy']);
49 my sub update_cert
: prototype($$$$$) {
50 my ($type, $cert_path, $certificate, $force, $restart) = @_;
52 print "Setting custom certificate file $cert_path\n";
53 PMG
::CertHelpers
::set_cert_file
($certificate, $cert_path, $force);
55 restart_after_cert_update
($type) if $restart;
57 PMG
::CertHelpers
::cert_lock
(10, $code);
60 my sub set_smtp
: prototype($$) {
61 my ($on, $reload) = @_;
64 my $cfg = PMG
::Config-
>new();
65 if (!$cfg->get('mail', 'tls') == !$on) {
69 print "Rewriting postfix config\n";
70 $cfg->set('mail', 'tls', $on);
72 my $changed = $cfg->rewrite_config_postfix();
74 if ($changed && $reload) {
75 print "Reloading postfix\n";
76 PMG
::Utils
::service_cmd
('postfix', 'reload');
79 PMG
::Config
::lock_config
($code, "failed to reload postfix");
82 __PACKAGE__-
>register_method ({
86 permissions
=> { user
=> 'all' },
87 description
=> "Node index.",
89 additionalProperties
=> 0,
91 node
=> get_standard_option
('pve-node'),
100 links
=> [ { rel
=> 'child', href
=> "{name}" } ],
107 { name
=> 'custom' },
109 { name
=> 'config' },
114 __PACKAGE__-
>register_method ({
118 permissions
=> { user
=> 'all' },
121 description
=> "Get information about the node's certificates.",
123 additionalProperties
=> 0,
125 node
=> get_standard_option
('pve-node'),
130 items
=> get_standard_option
('pve-certificate-info'),
136 for my $path (&PMG
::CertHelpers
::API_CERT
, &PMG
::CertHelpers
::SMTP_CERT
) {
138 my $info = PVE
::Certificate
::get_certificate_info
($path);
139 push @$res, $info if $info;
146 __PACKAGE__-
>register_method ({
147 name
=> 'custom_cert_index',
150 permissions
=> { user
=> 'all' },
151 description
=> "Certificate index.",
153 additionalProperties
=> 0,
155 node
=> get_standard_option
('pve-node'),
164 links
=> [ { rel
=> 'child', href
=> "{type}" } ],
176 __PACKAGE__-
>register_method ({
177 name
=> 'upload_custom_cert',
178 path
=> 'custom/{type}',
180 permissions
=> { check
=> [ 'admin' ] },
181 description
=> 'Upload or update custom certificate chain and key.',
185 additionalProperties
=> 0,
187 node
=> get_standard_option
('pve-node'),
190 format
=> 'pem-certificate-chain',
191 description
=> 'PEM encoded certificate (chain).',
195 description
=> 'PEM encoded private key.',
196 format
=> 'pem-string',
199 type
=> get_standard_option
('pmg-certificate-type'),
202 description
=> 'Overwrite existing custom or ACME certificate files.',
208 description
=> 'Restart services.',
214 returns
=> get_standard_option
('pve-certificate-info'),
218 my $type = extract_param
($param, 'type'); # also used to know which service to restart
219 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
221 my $certs = extract_param
($param, 'certificates');
222 $certs = PVE
::Certificate
::strip_leading_text
($certs);
224 my $key = extract_param
($param, 'key');
226 $key = PVE
::Certificate
::strip_leading_text
($key);
227 $certs = "$key\n$certs";
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)) {
235 'key' => "Attempted to upload custom certificate without (existing) key."
239 # copy the old certificate's key:
240 $certs = "$key\n$certs";
246 PMG
::CertHelpers
::cert_lock
(10, sub {
247 update_cert
($type, $cert_path, $certs, $param->{force
}, $param->{restart
});
250 if ($type eq 'smtp') {
251 set_smtp
(1, $param->{restart
});
257 __PACKAGE__-
>register_method ({
258 name
=> 'remove_custom_cert',
259 path
=> 'custom/{type}',
261 permissions
=> { check
=> [ 'admin' ] },
262 description
=> 'DELETE custom certificate chain and key.',
266 additionalProperties
=> 0,
268 node
=> get_standard_option
('pve-node'),
269 type
=> get_standard_option
('pmg-certificate-type'),
272 description
=> 'Restart pmgproxy.',
284 my $type = extract_param
($param, 'type');
285 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
288 print "Deleting custom certificate files\n";
290 PMG
::Ticket
::generate_api_cert
(0) if $type eq 'api';
292 if ($param->{restart
}) {
293 restart_after_cert_update
($type);
297 PMG
::CertHelpers
::cert_lock
(10, $code);
299 if ($type eq 'smtp') {
300 set_smtp
(0, $param->{restart
});
306 __PACKAGE__-
>register_method ({
307 name
=> 'acme_cert_index',
310 permissions
=> { user
=> 'all' },
311 description
=> "ACME Certificate index.",
313 additionalProperties
=> 0,
315 node
=> get_standard_option
('pve-node'),
324 links
=> [ { rel
=> 'child', href
=> "{type}" } ],
336 my $order_certificate = sub {
337 my ($acme, $acme_node_config) = @_;
339 my $plugins = PMG
::API2
::ACMEPlugin
::load_config
();
341 print "Placing ACME order\n";
342 my ($order_url, $order) = $acme->new_order([ 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);
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";
353 print "The validation for $domain is pending!\n";
355 my $domain_config = $acme_node_config->{domains
}->{$domain};
356 die "no config for domain '$domain'\n" if !$domain_config;
358 my $plugin_id = $domain_config->{plugin
};
360 my $plugin_cfg = $plugins->{ids
}->{$plugin_id};
361 die "plugin '$plugin_id' for domain '$domain' not found!\n"
365 plugin
=> $plugin_cfg,
366 alias
=> $domain_config->{alias
},
369 my $plugin = PVE
::ACME
::Challenge-
>lookup($plugin_cfg->{type
});
370 $plugin->setup($acme, $auth, $data);
372 print "Triggering validation\n";
374 die "no validation URL returned by plugin '$plugin_id' for domain '$domain'\n"
375 if !defined($data->{url
});
377 $acme->request_challenge_validation($data->{url
});
378 print "Sleeping for 5 seconds\n";
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";
386 } elsif ($auth->{status
} eq 'valid') {
387 print "Status is 'valid', domain '$domain' OK!\n";
390 die "validating challenge '$auth_url' failed - status: $auth->{status}\n";
394 eval { $plugin->teardown($acme, $auth, $data) };
399 print "\nAll domains validated!\n";
400 print "\nCreating CSR\n";
401 # Currently we only support dns entries, so extract those from the order:
407 } $order->{identifiers
}->@*
409 die "DNS identifiers are required to generate a CSR.\n" if !scalar @$san;
410 my ($csr_der, $key) = PMG
::RS
::CSR
::generate_csr
($san, {});
412 my $finalize_error_cnt = 0;
413 print "Checking order status\n";
415 $order = $acme->get_order($order_url);
416 if ($order->{status
} eq 'pending') {
417 print "still pending, trying to finalize order\n";
419 # to be compatible with and without the order ready state we try to
420 # finalize even at the 'pending' state and give up after 5
421 # unsuccessful tries this can be removed when the letsencrypt api
422 # definitely has implemented the 'ready' state
424 $acme->finalize_order($order->{finalize
}, $csr_der);
427 die $err if $finalize_error_cnt >= 5;
429 $finalize_error_cnt++;
434 } elsif ($order->{status
} eq 'ready') {
435 print "Order is ready, finalizing order\n";
436 $acme->finalize_order($order->{finalize
}, $csr_der);
439 } elsif ($order->{status
} eq 'processing') {
440 print "still processing, trying again in 30 seconds\n";
443 } elsif ($order->{status
} eq 'valid') {
447 die "order status: $order->{status}\n";
450 print "\nDownloading certificate\n";
451 my $cert = $acme->get_certificate($order->{certificate
});
453 return ($cert, $key);
456 # Filter domains and raise an error if the list becomes empty.
457 my $filter_domains = sub {
458 my ($acme_config, $type) = @_;
460 my $domains = $acme_config->{domains
};
461 foreach my $domain (keys %$domains) {
462 my $entry = $domains->{$domain};
463 if (!(grep { $_ eq $type } PVE
::Tools
::split_list
($entry->{usage
}))) {
464 delete $domains->{$domain};
469 raise
("No domains configured for type '$type'\n", 400);
473 __PACKAGE__-
>register_method ({
474 name
=> 'new_acme_cert',
475 path
=> 'acme/{type}',
477 permissions
=> { check
=> [ 'admin' ] },
478 description
=> 'Order a new certificate from ACME-compatible CA.',
482 additionalProperties
=> 0,
484 node
=> get_standard_option
('pve-node'),
485 type
=> get_standard_option
('pmg-certificate-type'),
488 description
=> 'Overwrite existing custom certificate.',
500 my $type = extract_param
($param, 'type'); # also used to know which service to restart
501 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
502 raise_param_exc
({'force' => "Custom certificate exists but 'force' is not set."})
503 if !$param->{force
} && -e
$cert_path;
505 my $node_config = PMG
::NodeConfig
::load_config
();
506 my $acme_config = PMG
::NodeConfig
::get_acme_conf
($node_config);
507 raise
("ACME domain list in configuration is missing!", 400)
508 if !$acme_config || !$acme_config->{domains
}->%*;
510 $filter_domains->($acme_config, $type);
512 my $rpcenv = PMG
::RESTEnvironment-
>get();
513 my $authuser = $rpcenv->get_user();
516 STDOUT-
>autoflush(1);
517 my $account = $acme_config->{account
};
518 my $account_file = "${acme_account_dir}/${account}";
519 die "ACME account config file '$account' does not exist.\n"
520 if ! -e
$account_file;
522 print "Loading ACME account details\n";
523 my $acme = PMG
::RS
::Acme-
>load($account_file);
525 my ($cert, $key) = $order_certificate->($acme, $acme_config);
526 my $certificate = "$key\n$cert";
528 update_cert
($type, $cert_path, $certificate, $param->{force
}, 1);
530 if ($type eq 'smtp') {
537 return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd);
540 __PACKAGE__-
>register_method ({
541 name
=> 'renew_acme_cert',
542 path
=> 'acme/{type}',
544 permissions
=> { check
=> [ 'admin' ] },
545 description
=> "Renew existing certificate from CA.",
549 additionalProperties
=> 0,
551 node
=> get_standard_option
('pve-node'),
552 type
=> get_standard_option
('pmg-certificate-type'),
555 description
=> 'Force renewal even if expiry is more than 30 days away.',
567 my $type = extract_param
($param, 'type'); # also used to know which service to restart
568 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
570 raise
("No current (custom) certificate found, please order a new certificate!\n")
573 my $expires_soon = PVE
::Certificate
::check_expiry
($cert_path, time() + 30*24*60*60);
574 raise_param_exc
({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."})
575 if !$expires_soon && !$param->{force
};
577 my $node_config = PMG
::NodeConfig
::load_config
();
578 my $acme_config = PMG
::NodeConfig
::get_acme_conf
($node_config);
579 raise
("ACME domain list in configuration is missing!", 400)
580 if !$acme_config || !$acme_config->{domains
}->%*;
582 $filter_domains->($acme_config, $type);
584 my $rpcenv = PMG
::RESTEnvironment-
>get();
585 my $authuser = $rpcenv->get_user();
587 my $old_cert = PVE
::Tools
::file_get_contents
($cert_path);
590 STDOUT-
>autoflush(1);
591 my $account = $acme_config->{account
};
592 my $account_file = "${acme_account_dir}/${account}";
593 die "ACME account config file '$account' does not exist.\n"
594 if ! -e
$account_file;
596 print "Loading ACME account details\n";
597 my $acme = PMG
::RS
::Acme-
>load($account_file);
599 my ($cert, $key) = $order_certificate->($acme, $acme_config);
600 my $certificate = "$key\n$cert";
602 update_cert
($type, $cert_path, $certificate, 1, 1);
604 if (defined($old_cert)) {
605 print "Revoking old certificate\n";
606 eval { $acme->revoke_certificate($old_cert, undef) };
607 warn "Revoke request to CA failed: $@" if $@;
611 return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd);
614 __PACKAGE__-
>register_method ({
615 name
=> 'revoke_acme_cert',
616 path
=> 'acme/{type}',
618 permissions
=> { check
=> [ 'admin' ] },
619 description
=> "Revoke existing certificate from CA.",
623 additionalProperties
=> 0,
625 node
=> get_standard_option
('pve-node'),
626 type
=> get_standard_option
('pmg-certificate-type'),
635 my $type = extract_param
($param, 'type'); # also used to know which service to restart
636 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
638 my $node_config = PMG
::NodeConfig
::load_config
();
639 my $acme_config = PMG
::NodeConfig
::get_acme_conf
($node_config);
640 raise
("ACME domain list in configuration is missing!", 400)
641 if !$acme_config || !$acme_config->{domains
}->%*;
643 $filter_domains->($acme_config, $type);
645 my $rpcenv = PMG
::RESTEnvironment-
>get();
646 my $authuser = $rpcenv->get_user();
648 my $cert = PVE
::Tools
::file_get_contents
($cert_path);
649 $cert = pem_certificate
($cert)
650 or die "no certificate section found in '$cert_path'\n";
653 STDOUT-
>autoflush(1);
654 my $account = $acme_config->{account
};
655 my $account_file = "${acme_account_dir}/${account}";
656 die "ACME account config file '$account' does not exist.\n"
657 if ! -e
$account_file;
659 print "Loading ACME account details\n";
660 my $acme = PMG
::RS
::Acme-
>load($account_file);
662 print "Revoking old certificate\n";
663 eval { $acme->revoke_certificate($cert, undef) };
665 # is there a better check?
666 die "Revoke request to CA failed: $err" if $err !~ /"Certificate is expired"/;
670 print "Deleting certificate files\n";
672 PMG
::Ticket
::generate_api_cert
(0) if $type eq 'api';
674 restart_after_cert_update
($type);
677 PMG
::CertHelpers
::cert_lock
(10, $code);
679 if ($type eq 'smtp') {
684 return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd);