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);
16 use PMG
::API2
::ACMEPlugin
;
18 use base
qw(PVE::RESTHandler);
20 my $acme_account_dir = PMG
::CertHelpers
::acme_account_dir
();
22 sub first_typed_pem_entry
: prototype($$) {
23 my ($label, $data) = @_;
25 if ($data =~ /^(-----BEGIN \Q$label\E-----\n.*?\n-----END \Q$label\E-----)$/ms) {
31 sub pem_private_key
: prototype($) {
33 return first_typed_pem_entry
('PRIVATE KEY', $data);
36 sub pem_certificate
: prototype($) {
38 return first_typed_pem_entry
('CERTIFICATE', $data);
41 my sub restart_after_cert_update
: prototype($) {
45 print "Restarting pmgproxy\n";
46 PVE
::Tools
::run_command
(['systemctl', 'reload-or-restart', 'pmgproxy']);
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);
56 my sub update_cert
: prototype($$$$$) {
57 my ($type, $cert_path, $certificate, $force, $restart) = @_;
59 print "Setting custom certificate file $cert_path\n";
60 PMG
::CertHelpers
::set_cert_file
($certificate, $cert_path, $force);
62 restart_after_cert_update
($type) if $restart;
64 PMG
::CertHelpers
::cert_lock
(10, $code);
67 my sub set_smtp
: prototype($$) {
68 my ($on, $reload) = @_;
71 my $cfg = PMG
::Config-
>new();
72 if (!$cfg->get('mail', 'tls') == !$on) {
76 print "Rewriting postfix config\n";
77 $cfg->set('mail', 'tls', $on);
79 my $changed = $cfg->rewrite_config_postfix();
81 if ($changed && $reload) {
82 print "Reloading postfix\n";
83 PMG
::Utils
::service_cmd
('postfix', 'reload');
86 PMG
::Config
::lock_config
($code, "failed to reload postfix");
89 __PACKAGE__-
>register_method ({
93 permissions
=> { user
=> 'all' },
94 description
=> "Node index.",
96 additionalProperties
=> 0,
98 node
=> get_standard_option
('pve-node'),
107 links
=> [ { rel
=> 'child', href
=> "{name}" } ],
114 { name
=> 'custom' },
116 { name
=> 'config' },
121 __PACKAGE__-
>register_method ({
125 permissions
=> { user
=> 'all' },
128 description
=> "Get information about the node's certificates.",
130 additionalProperties
=> 0,
132 node
=> get_standard_option
('pve-node'),
137 items
=> get_standard_option
('pve-certificate-info'),
143 for my $path (&PMG
::CertHelpers
::API_CERT
, &PMG
::CertHelpers
::SMTP_CERT
) {
145 my $info = PVE
::Certificate
::get_certificate_info
($path);
146 push @$res, $info if $info;
153 __PACKAGE__-
>register_method ({
154 name
=> 'custom_cert_index',
157 permissions
=> { user
=> 'all' },
158 description
=> "Certificate index.",
160 additionalProperties
=> 0,
162 node
=> get_standard_option
('pve-node'),
171 links
=> [ { rel
=> 'child', href
=> "{type}" } ],
183 __PACKAGE__-
>register_method ({
184 name
=> 'upload_custom_cert',
185 path
=> 'custom/{type}',
187 permissions
=> { check
=> [ 'admin' ] },
188 description
=> 'Upload or update custom certificate chain and key.',
192 additionalProperties
=> 0,
194 node
=> get_standard_option
('pve-node'),
197 format
=> 'pem-certificate-chain',
198 description
=> 'PEM encoded certificate (chain).',
202 description
=> 'PEM encoded private key.',
203 format
=> 'pem-string',
206 type
=> get_standard_option
('pmg-certificate-type'),
209 description
=> 'Overwrite existing custom or ACME certificate files.',
215 description
=> 'Restart services.',
221 returns
=> get_standard_option
('pve-certificate-info'),
225 my $type = extract_param
($param, 'type'); # also used to know which service to restart
226 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
228 my $certs = extract_param
($param, 'certificates');
229 $certs = PVE
::Certificate
::strip_leading_text
($certs);
231 my $key = extract_param
($param, 'key');
233 $key = PVE
::Certificate
::strip_leading_text
($key);
234 $certs = "$key\n$certs";
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)) {
242 'key' => "Attempted to upload custom certificate without (existing) key."
246 # copy the old certificate's key:
247 $certs = "$key\n$certs";
253 PMG
::CertHelpers
::cert_lock
(10, sub {
254 update_cert
($type, $cert_path, $certs, $param->{force
}, $param->{restart
});
257 if ($type eq 'smtp') {
258 set_smtp
(1, $param->{restart
});
264 __PACKAGE__-
>register_method ({
265 name
=> 'remove_custom_cert',
266 path
=> 'custom/{type}',
268 permissions
=> { check
=> [ 'admin' ] },
269 description
=> 'DELETE custom certificate chain and key.',
273 additionalProperties
=> 0,
275 node
=> get_standard_option
('pve-node'),
276 type
=> get_standard_option
('pmg-certificate-type'),
279 description
=> 'Restart pmgproxy.',
291 my $type = extract_param
($param, 'type');
292 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
295 print "Deleting custom certificate files\n";
297 PMG
::Ticket
::generate_api_cert
(0) if $type eq 'api';
299 if ($param->{restart
}) {
300 restart_after_cert_update
($type);
304 PMG
::CertHelpers
::cert_lock
(10, $code);
306 if ($type eq 'smtp') {
307 set_smtp
(0, $param->{restart
});
313 __PACKAGE__-
>register_method ({
314 name
=> 'acme_cert_index',
317 permissions
=> { user
=> 'all' },
318 description
=> "ACME Certificate index.",
320 additionalProperties
=> 0,
322 node
=> get_standard_option
('pve-node'),
331 links
=> [ { rel
=> 'child', href
=> "{type}" } ],
343 my $order_certificate = sub {
344 my ($acme, $acme_node_config) = @_;
346 my $plugins = PMG
::API2
::ACMEPlugin
::load_config
();
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);
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";
360 print "The validation for $domain is pending!\n";
362 my $domain_config = $acme_node_config->{domains
}->{$domain};
363 die "no config for domain '$domain'\n" if !$domain_config;
365 my $plugin_id = $domain_config->{plugin
};
367 my $plugin_cfg = $plugins->{ids
}->{$plugin_id};
368 die "plugin '$plugin_id' for domain '$domain' not found!\n"
372 plugin
=> $plugin_cfg,
373 alias
=> $domain_config->{alias
},
376 my $plugin = PVE
::ACME
::Challenge-
>lookup($plugin_cfg->{type
});
377 $plugin->setup($acme, $auth, $data);
379 print "Triggering validation\n";
381 die "no validation URL returned by plugin '$plugin_id' for domain '$domain'\n"
382 if !defined($data->{url
});
384 $acme->request_challenge_validation($data->{url
});
385 print "Sleeping for 5 seconds\n";
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";
393 } elsif ($auth->{status
} eq 'valid') {
394 print "Status is 'valid', domain '$domain' OK!\n";
397 my $error = "validating challenge '$auth_url' failed - status: $auth->{status}";
398 for (@{$auth->{challenges
}}) {
399 $error .= ", $_->{error}->{detail}" if $_->{error
}->{detail
};
405 eval { $plugin->teardown($acme, $auth, $data) };
410 print "\nAll domains validated!\n";
411 print "\nCreating CSR\n";
412 # Currently we only support dns entries, so extract those from the order:
418 } $order->{identifiers
}->@*
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, {});
423 my $finalize_error_cnt = 0;
424 print "Checking order status\n";
426 $order = $acme->get_order($order_url);
427 if ($order->{status
} eq 'pending') {
428 print "still pending, trying to finalize order\n";
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
435 $acme->finalize_order($order->{finalize
}, $csr_der);
438 die $err if $finalize_error_cnt >= 5;
440 $finalize_error_cnt++;
445 } elsif ($order->{status
} eq 'ready') {
446 print "Order is ready, finalizing order\n";
447 $acme->finalize_order($order->{finalize
}, $csr_der);
450 } elsif ($order->{status
} eq 'processing') {
451 print "still processing, trying again in 30 seconds\n";
454 } elsif ($order->{status
} eq 'valid') {
458 die "order status: $order->{status}\n";
461 print "\nDownloading certificate\n";
462 my $cert = $acme->get_certificate($order->{certificate
});
464 return ($cert, $key);
467 # Filter domains and raise an error if the list becomes empty.
468 my $filter_domains = sub {
469 my ($acme_config, $type) = @_;
471 my $domains = PMG
::NodeConfig
::filter_domains_by_type
($acme_config->{domains
}, $type);
474 raise
("No domains configured for type '$type'\n", 400);
477 $acme_config->{domains
} = $domains;
480 __PACKAGE__-
>register_method ({
481 name
=> 'new_acme_cert',
482 path
=> 'acme/{type}',
484 permissions
=> { check
=> [ 'admin' ] },
485 description
=> 'Order a new certificate from ACME-compatible CA.',
489 additionalProperties
=> 0,
491 node
=> get_standard_option
('pve-node'),
492 type
=> get_standard_option
('pmg-certificate-type'),
495 description
=> 'Overwrite existing custom certificate.',
507 my $type = extract_param
($param, 'type'); # also used to know which service to restart
508 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
509 raise_param_exc
({'force' => "Custom certificate exists but 'force' is not set."})
510 if !$param->{force
} && -e
$cert_path;
512 my $node_config = PMG
::NodeConfig
::load_config
();
513 my $acme_config = PMG
::NodeConfig
::get_acme_conf
($node_config);
514 raise
("ACME domain list in configuration is missing!", 400)
515 if !($acme_config && $acme_config->{domains
} && $acme_config->{domains
}->%*);
517 $filter_domains->($acme_config, $type);
519 my $rpcenv = PMG
::RESTEnvironment-
>get();
520 my $authuser = $rpcenv->get_user();
523 STDOUT-
>autoflush(1);
524 my $account = $acme_config->{account
};
525 my $account_file = "${acme_account_dir}/${account}";
526 die "ACME account config file '$account' does not exist.\n"
527 if ! -e
$account_file;
529 print "Loading ACME account details\n";
530 my $acme = PMG
::RS
::Acme-
>load($account_file);
532 my ($cert, $key) = $order_certificate->($acme, $acme_config);
533 my $certificate = "$key\n$cert";
535 update_cert
($type, $cert_path, $certificate, $param->{force
}, 1);
537 if ($type eq 'smtp') {
544 return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd);
547 __PACKAGE__-
>register_method ({
548 name
=> 'renew_acme_cert',
549 path
=> 'acme/{type}',
551 permissions
=> { check
=> [ 'admin' ] },
552 description
=> "Renew existing certificate from CA.",
556 additionalProperties
=> 0,
558 node
=> get_standard_option
('pve-node'),
559 type
=> get_standard_option
('pmg-certificate-type'),
562 description
=> 'Force renewal even if expiry is more than 30 days away.',
574 my $type = extract_param
($param, 'type'); # also used to know which service to restart
575 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
577 raise
("No current (custom) certificate found, please order a new certificate!\n")
580 my $expires_soon = PVE
::Certificate
::check_expiry
($cert_path, time() + 30*24*60*60);
581 raise_param_exc
({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."})
582 if !$expires_soon && !$param->{force
};
584 my $node_config = PMG
::NodeConfig
::load_config
();
585 my $acme_config = PMG
::NodeConfig
::get_acme_conf
($node_config);
586 raise
("ACME domain list in configuration is missing!", 400)
587 if !$acme_config || !$acme_config->{domains
}->%*;
589 $filter_domains->($acme_config, $type);
591 my $rpcenv = PMG
::RESTEnvironment-
>get();
592 my $authuser = $rpcenv->get_user();
594 my $old_cert = PVE
::Tools
::file_get_contents
($cert_path);
597 STDOUT-
>autoflush(1);
598 my $account = $acme_config->{account
};
599 my $account_file = "${acme_account_dir}/${account}";
600 die "ACME account config file '$account' does not exist.\n"
601 if ! -e
$account_file;
603 print "Loading ACME account details\n";
604 my $acme = PMG
::RS
::Acme-
>load($account_file);
606 my ($cert, $key) = $order_certificate->($acme, $acme_config);
607 my $certificate = "$key\n$cert";
609 update_cert
($type, $cert_path, $certificate, 1, 1);
611 if (defined($old_cert)) {
612 print "Revoking old certificate\n";
614 $old_cert = pem_certificate
($old_cert)
615 or die "no certificate section found in '$cert_path'\n";
616 $acme->revoke_certificate($old_cert, undef);
618 warn "Revoke request to CA failed: $@" if $@;
622 return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd);
625 __PACKAGE__-
>register_method ({
626 name
=> 'revoke_acme_cert',
627 path
=> 'acme/{type}',
629 permissions
=> { check
=> [ 'admin' ] },
630 description
=> "Revoke existing certificate from CA.",
634 additionalProperties
=> 0,
636 node
=> get_standard_option
('pve-node'),
637 type
=> get_standard_option
('pmg-certificate-type'),
646 my $type = extract_param
($param, 'type'); # also used to know which service to restart
647 my $cert_path = PMG
::CertHelpers
::cert_path
($type);
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
}->%*;
654 $filter_domains->($acme_config, $type);
656 my $rpcenv = PMG
::RESTEnvironment-
>get();
657 my $authuser = $rpcenv->get_user();
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";
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;
670 print "Loading ACME account details\n";
671 my $acme = PMG
::RS
::Acme-
>load($account_file);
673 print "Revoking old certificate\n";
674 eval { $acme->revoke_certificate($cert, undef) };
676 # is there a better check?
677 die "Revoke request to CA failed: $err" if $err !~ /"Certificate is expired"/;
681 print "Deleting certificate files\n";
683 PMG
::Ticket
::generate_api_cert
(0) if $type eq 'api';
685 restart_after_cert_update
($type);
688 PMG
::CertHelpers
::cert_lock
(10, $code);
690 if ($type eq 'smtp') {
695 return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd);