From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 0097569FDF for ; Fri, 12 Mar 2021 16:24:57 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EAC2F34993 for ; Fri, 12 Mar 2021 16:24:48 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [212.186.127.180]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id C5771347BB for ; Fri, 12 Mar 2021 16:24:30 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 8931F463ED for ; Fri, 12 Mar 2021 16:24:30 +0100 (CET) From: Wolfgang Bumiller To: pmg-devel@lists.proxmox.com Date: Fri, 12 Mar 2021 16:23:55 +0100 Message-Id: <20210312152421.30114-7-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210312152421.30114-1-w.bumiller@proxmox.com> References: <20210312152421.30114-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.033 Adjusted score from AWL reputation of From: address KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record URIBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to URIBL was blocked. See http://wiki.apache.org/spamassassin/DnsBlocklists#dnsbl-block for more information. [what.pm, acme.pm, acmeplugin.pm, api2.pm, when.pm, action.pm, nodes.pm, certificates.pm] Subject: [pmg-devel] [PATCH v2 api 6/8] add certificates api endpoint X-BeenThere: pmg-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Mail Gateway development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 12 Mar 2021 15:24:57 -0000 This adds /nodes/{nodename}/certificates endpoint containing: /custom/{type} - update smtp or api certificates manually /acme/{type} - update via acme Signed-off-by: Wolfgang Bumiller --- Changes since v1: * certificate regex simplification * added $restart parameter to update_cert * added set_smtp() helper to enable/disable tls & reload postfix * dedup update_cert/set_smtp code copies src/Makefile | 1 + src/PMG/API2/Certificates.pm | 682 +++++++++++++++++++++++++++++++++++ src/PMG/API2/Nodes.pm | 7 + 3 files changed, 690 insertions(+) create mode 100644 src/PMG/API2/Certificates.pm diff --git a/src/Makefile b/src/Makefile index ebc6bd8..e0629b2 100644 --- a/src/Makefile +++ b/src/Makefile @@ -155,6 +155,7 @@ LIBSOURCES = \ PMG/API2/When.pm \ PMG/API2/What.pm \ PMG/API2/Action.pm \ + PMG/API2/Certificates.pm \ PMG/API2/ACME.pm \ PMG/API2/ACMEPlugin.pm \ PMG/API2.pm \ diff --git a/src/PMG/API2/Certificates.pm b/src/PMG/API2/Certificates.pm new file mode 100644 index 0000000..ca8b75b --- /dev/null +++ b/src/PMG/API2/Certificates.pm @@ -0,0 +1,682 @@ +package PMG::API2::Certificates; + +use strict; +use warnings; + +use PVE::Certificate; +use PVE::Exception qw(raise raise_param_exc); +use PVE::JSONSchema qw(get_standard_option); +use PVE::Tools qw(extract_param file_get_contents file_set_contents); + +use PMG::CertHelpers; +use PMG::NodeConfig; +use PMG::RS::CSR; + +use PMG::API2::ACMEPlugin; + +use base qw(PVE::RESTHandler); + +my $acme_account_dir = PMG::CertHelpers::acme_account_dir(); + +sub first_typed_pem_entry : prototype($$) { + my ($label, $data) = @_; + + if ($data =~ /^(-----BEGIN \Q$label\E-----\n.*?\n-----END \Q$label\E-----)$/ms) { + return $1; + } + return undef; +} + +sub pem_private_key : prototype($) { + my ($data) = @_; + return first_typed_pem_entry('PRIVATE KEY', $data); +} + +sub pem_certificate : prototype($) { + my ($data) = @_; + return first_typed_pem_entry('CERTIFICATE', $data); +} + +my sub restart_after_cert_update : prototype($) { + my ($type) = @_; + + if ($type eq 'api') { + print "Restarting pmgproxy\n"; + PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pmgproxy']); + } +}; + +my sub update_cert : prototype($$$$$) { + my ($type, $cert_path, $certificate, $force, $restart) = @_; + my $code = sub { + print "Setting custom certificate file $cert_path\n"; + PMG::CertHelpers::set_cert_file($certificate, $cert_path, $force); + + restart_after_cert_update($type) if $restart; + }; + PMG::CertHelpers::cert_lock(10, $code); +}; + +my sub set_smtp : prototype($$) { + my ($on, $reload) = @_; + + my $code = sub { + my $cfg = PMG::Config->new(); + + print "Rewriting postfix config\n"; + $cfg->set('mail', 'tls', $on); + my $changed = $cfg->rewrite_config_postfix(); + + if ($changed && $reload) { + print "Reloading postfix\n"; + PMG::Utils::service_cmd('postfix', 'reload'); + } + }; + PMG::Config::lock_config($code, "failed to reload postfix"); +} + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + permissions => { user => 'all' }, + description => "Node index.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => {}, + }, + links => [ { rel => 'child', href => "{name}" } ], + }, + code => sub { + my ($param) = @_; + + return [ + { name => 'acme' }, + { name => 'custom' }, + { name => 'info' }, + { name => 'config' }, + ]; + }, +}); + +__PACKAGE__->register_method ({ + name => 'info', + path => 'info', + method => 'GET', + permissions => { user => 'all' }, + proxyto => 'node', + protected => 1, + description => "Get information about the node's certificates.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => get_standard_option('pve-certificate-info'), + }, + code => sub { + my ($param) = @_; + + my $res = []; + for my $path (&PMG::CertHelpers::API_CERT, &PMG::CertHelpers::SMTP_CERT) { + eval { + my $info = PVE::Certificate::get_certificate_info($path); + push @$res, $info if $info; + }; + } + return $res; + }, +}); + +__PACKAGE__->register_method ({ + name => 'custom_cert_index', + path => 'custom', + method => 'GET', + permissions => { user => 'all' }, + description => "Certificate index.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => {}, + }, + links => [ { rel => 'child', href => "{type}" } ], + }, + code => sub { + my ($param) = @_; + + return [ + { type => 'api' }, + { type => 'smtp' }, + ]; + }, +}); + +__PACKAGE__->register_method ({ + name => 'upload_custom_cert', + path => 'custom/{type}', + method => 'POST', + permissions => { check => [ 'admin' ] }, + description => 'Upload or update custom certificate chain and key.', + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + certificates => { + type => 'string', + format => 'pem-certificate-chain', + description => 'PEM encoded certificate (chain).', + }, + key => { + type => 'string', + description => 'PEM encoded private key.', + format => 'pem-string', + optional => 0, + }, + type => get_standard_option('pmg-certificate-type'), + force => { + type => 'boolean', + description => 'Overwrite existing custom or ACME certificate files.', + optional => 1, + default => 0, + }, + restart => { + type => 'boolean', + description => 'Restart services.', + optional => 1, + default => 0, + }, + }, + }, + returns => get_standard_option('pve-certificate-info'), + code => sub { + my ($param) = @_; + + my $type = extract_param($param, 'type'); # also used to know which service to restart + my $cert_path = PMG::CertHelpers::cert_path($type); + + my $certs = extract_param($param, 'certificates'); + $certs = PVE::Certificate::strip_leading_text($certs); + + my $key = extract_param($param, 'key'); + if ($key) { + $key = PVE::Certificate::strip_leading_text($key); + $certs = "$key\n$certs"; + } else { + my $private_key = pem_private_key($certs); + if (!defined($private_key)) { + my $old = file_get_contents($cert_path); + $private_key = pem_private_key($old); + if (!defined($private_key)) { + raise_param_exc({ + 'key' => "Attempted to upload custom certificate without (existing) key." + }) + } + + # copy the old certificate's key: + $certs = "$key\n$certs"; + } + } + + my $info; + + PMG::CertHelpers::cert_lock(10, sub { + update_cert($type, $cert_path, $certs, $param->{force}, $param->{restart}); + }); + + if ($type eq 'smtp') { + set_smtp(1, $param->{restart}); + } + + return $info; + }}); + +__PACKAGE__->register_method ({ + name => 'remove_custom_cert', + path => 'custom/{type}', + method => 'DELETE', + permissions => { check => [ 'admin' ] }, + description => 'DELETE custom certificate chain and key.', + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + type => get_standard_option('pmg-certificate-type'), + restart => { + type => 'boolean', + description => 'Restart pmgproxy.', + optional => 1, + default => 0, + }, + }, + }, + returns => { + type => 'null', + }, + code => sub { + my ($param) = @_; + + my $type = extract_param($param, 'type'); + my $cert_path = PMG::CertHelpers::cert_path($type); + + my $code = sub { + print "Deleting custom certificate files\n"; + unlink $cert_path; + + if ($param->{restart}) { + restart_after_cert_update($type); + } + }; + + PMG::CertHelpers::cert_lock(10, $code); + + if ($type eq 'smtp') { + set_smtp(0, $param->{restart}); + } + + return undef; + }}); + +__PACKAGE__->register_method ({ + name => 'acme_cert_index', + path => 'acme', + method => 'GET', + permissions => { user => 'all' }, + description => "ACME Certificate index.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => {}, + }, + links => [ { rel => 'child', href => "{type}" } ], + }, + code => sub { + my ($param) = @_; + + return [ + { type => 'api' }, + { type => 'smtp' }, + ]; + }, +}); + +my $order_certificate = sub { + my ($acme, $acme_node_config) = @_; + + my $plugins = PMG::API2::ACMEPlugin::load_config(); + + print "Placing ACME order\n"; + my ($order_url, $order) = $acme->new_order([ keys %{$acme_node_config->{domains}} ]); + print "Order URL: $order_url\n"; + for my $auth_url (@{$order->{authorizations}}) { + print "\nGetting authorization details from '$auth_url'\n"; + my $auth = $acme->get_authorization($auth_url); + + # force lower case, like get_acme_conf does + my $domain = lc($auth->{identifier}->{value}); + if ($auth->{status} eq 'valid') { + print "$domain is already validated!\n"; + } else { + print "The validation for $domain is pending!\n"; + + my $domain_config = $acme_node_config->{domains}->{$domain}; + die "no config for domain '$domain'\n" if !$domain_config; + + my $plugin_id = $domain_config->{plugin}; + + my $plugin_cfg = $plugins->{ids}->{$plugin_id}; + die "plugin '$plugin_id' for domain '$domain' not found!\n" + if !$plugin_cfg; + + my $data = { + plugin => $plugin_cfg, + alias => $domain_config->{alias}, + }; + + my $plugin = PVE::ACME::Challenge->lookup($plugin_cfg->{type}); + $plugin->setup($acme, $auth, $data); + + print "Triggering validation\n"; + eval { + die "no validation URL returned by plugin '$plugin_id' for domain '$domain'\n" + if !defined($data->{url}); + + $acme->request_challenge_validation($data->{url}); + print "Sleeping for 5 seconds\n"; + sleep 5; + while (1) { + $auth = $acme->get_authorization($auth_url); + if ($auth->{status} eq 'pending') { + print "Status is still 'pending', trying again in 10 seconds\n"; + sleep 10; + next; + } elsif ($auth->{status} eq 'valid') { + print "Status is 'valid', domain '$domain' OK!\n"; + last; + } + die "validating challenge '$auth_url' failed - status: $auth->{status}\n"; + } + }; + my $err = $@; + eval { $plugin->teardown($acme, $auth, $data) }; + warn "$@\n" if $@; + die $err if $err; + } + } + print "\nAll domains validated!\n"; + print "\nCreating CSR\n"; + # Currently we only support dns entries, so extract those from the order: + my $san = [ + map { + $_->{value} + } grep { + $_->{type} eq 'dns' + } $order->{identifiers}->@* + ]; + die "DNS identifiers are required to generate a CSR.\n" if !scalar @$san; + my ($csr_der, $key) = PMG::RS::CSR::generate_csr($san, {}); + + my $finalize_error_cnt = 0; + print "Checking order status\n"; + while (1) { + $order = $acme->get_order($order_url); + if ($order->{status} eq 'pending') { + print "still pending, trying to finalize order\n"; + # FIXME + # to be compatible with and without the order ready state we try to + # finalize even at the 'pending' state and give up after 5 + # unsuccessful tries this can be removed when the letsencrypt api + # definitely has implemented the 'ready' state + eval { + $acme->finalize_order($order->{finalize}, $csr_der); + }; + if (my $err = $@) { + die $err if $finalize_error_cnt >= 5; + + $finalize_error_cnt++; + warn $err; + } + sleep 5; + next; + } elsif ($order->{status} eq 'ready') { + print "Order is ready, finalizing order\n"; + $acme->finalize_order($order->{finalize}, $csr_der); + sleep 5; + next; + } elsif ($order->{status} eq 'processing') { + print "still processing, trying again in 30 seconds\n"; + sleep 30; + next; + } elsif ($order->{status} eq 'valid') { + print "valid!\n"; + last; + } + die "order status: $order->{status}\n"; + } + + print "\nDownloading certificate\n"; + my $cert = $acme->get_certificate($order->{certificate}); + + return ($cert, $key); +}; + +# Filter domains and raise an error if the list becomes empty. +my $filter_domains = sub { + my ($acme_config, $type) = @_; + + my $domains = $acme_config->{domains}; + foreach my $domain (keys %$domains) { + my $entry = $domains->{$domain}; + if (!(grep { $_ eq $type } PVE::Tools::split_list($entry->{usage}))) { + delete $domains->{$domain}; + } + } + + if (!%$domains) { + raise("No domains configured for type '$type'\n", 400); + } +}; + +__PACKAGE__->register_method ({ + name => 'new_acme_cert', + path => 'acme/{type}', + method => 'POST', + permissions => { check => [ 'admin' ] }, + description => 'Order a new certificate from ACME-compatible CA.', + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + type => get_standard_option('pmg-certificate-type'), + force => { + type => 'boolean', + description => 'Overwrite existing custom certificate.', + optional => 1, + default => 0, + }, + }, + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + + my $type = extract_param($param, 'type'); # also used to know which service to restart + my $cert_path = PMG::CertHelpers::cert_path($type); + raise_param_exc({'force' => "Custom certificate exists but 'force' is not set."}) + if !$param->{force} && -e $cert_path; + + my $node_config = PMG::NodeConfig::load_config(); + my $acme_config = PMG::NodeConfig::get_acme_conf($node_config); + raise("ACME domain list in configuration is missing!", 400) + if !$acme_config || !$acme_config->{domains}->%*; + + $filter_domains->($acme_config, $type); + + my $rpcenv = PMG::RESTEnvironment->get(); + my $authuser = $rpcenv->get_user(); + + my $realcmd = sub { + STDOUT->autoflush(1); + my $account = $acme_config->{account}; + my $account_file = "${acme_account_dir}/${account}"; + die "ACME account config file '$account' does not exist.\n" + if ! -e $account_file; + + print "Loading ACME account details\n"; + my $acme = PMG::RS::Acme->load($account_file); + + my ($cert, $key) = $order_certificate->($acme, $acme_config); + my $certificate = "$key\n$cert"; + + update_cert($type, $cert_path, $certificate, $param->{force}, 1); + + if ($type eq 'smtp') { + set_smtp(1, 1); + } + + die "$@\n" if $@; + }; + + return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd); + }}); + +__PACKAGE__->register_method ({ + name => 'renew_acme_cert', + path => 'acme/{type}', + method => 'PUT', + permissions => { check => [ 'admin' ] }, + description => "Renew existing certificate from CA.", + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + type => get_standard_option('pmg-certificate-type'), + force => { + type => 'boolean', + description => 'Force renewal even if expiry is more than 30 days away.', + optional => 1, + default => 0, + }, + }, + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + + my $type = extract_param($param, 'type'); # also used to know which service to restart + my $cert_path = PMG::CertHelpers::cert_path($type); + + raise("No current (custom) certificate found, please order a new certificate!\n") + if ! -e $cert_path; + + my $expires_soon = PVE::Certificate::check_expiry($cert_path, time() + 30*24*60*60); + raise_param_exc({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."}) + if !$expires_soon && !$param->{force}; + + my $node_config = PMG::NodeConfig::load_config(); + my $acme_config = PMG::NodeConfig::get_acme_conf($node_config); + raise("ACME domain list in configuration is missing!", 400) + if !$acme_config || !$acme_config->{domains}->%*; + + $filter_domains->($acme_config, $type); + + my $rpcenv = PMG::RESTEnvironment->get(); + my $authuser = $rpcenv->get_user(); + + my $old_cert = PVE::Tools::file_get_contents($cert_path); + + my $realcmd = sub { + STDOUT->autoflush(1); + my $account = $acme_config->{account}; + my $account_file = "${acme_account_dir}/${account}"; + die "ACME account config file '$account' does not exist.\n" + if ! -e $account_file; + + print "Loading ACME account details\n"; + my $acme = PMG::RS::Acme->load($account_file); + + my ($cert, $key) = $order_certificate->($acme, $acme_config); + my $certificate = "$key\n$cert"; + + update_cert($type, $cert_path, $certificate, 1, 1); + + if (defined($old_cert)) { + print "Revoking old certificate\n"; + eval { $acme->revoke_certificate($old_cert, undef) }; + warn "Revoke request to CA failed: $@" if $@; + } + }; + + return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd); + }}); + +__PACKAGE__->register_method ({ + name => 'revoke_acme_cert', + path => 'acme/{type}', + method => 'DELETE', + permissions => { check => [ 'admin' ] }, + description => "Revoke existing certificate from CA.", + protected => 1, + proxyto => 'node', + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + type => get_standard_option('pmg-certificate-type'), + }, + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + + my $type = extract_param($param, 'type'); # also used to know which service to restart + my $cert_path = PMG::CertHelpers::cert_path($type); + + my $node_config = PMG::NodeConfig::load_config(); + my $acme_config = PMG::NodeConfig::get_acme_conf($node_config); + raise("ACME domain list in configuration is missing!", 400) + if !$acme_config || !$acme_config->{domains}->%*; + + $filter_domains->($acme_config, $type); + + my $rpcenv = PMG::RESTEnvironment->get(); + my $authuser = $rpcenv->get_user(); + + my $cert = PVE::Tools::file_get_contents($cert_path); + $cert = pem_certificate($cert) + or die "no certificate section found in '$cert_path'\n"; + + my $realcmd = sub { + STDOUT->autoflush(1); + my $account = $acme_config->{account}; + my $account_file = "${acme_account_dir}/${account}"; + die "ACME account config file '$account' does not exist.\n" + if ! -e $account_file; + + print "Loading ACME account details\n"; + my $acme = PMG::RS::Acme->load($account_file); + + print "Revoking old certificate\n"; + eval { $acme->revoke_certificate($cert, undef) }; + if (my $err = $@) { + # is there a better check? + die "Revoke request to CA failed: $err" if $err !~ /"Certificate is expired"/; + } + + my $code = sub { + print "Deleting certificate files\n"; + unlink $cert_path; + + # FIXME: Regenerate self-signed `api` certificate. + restart_after_cert_update($type); + }; + + PMG::CertHelpers::cert_lock(10, $code); + + if ($type eq 'smtp') { + set_smtp(0, 1); + } + }; + + return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd); + }}); + +1; diff --git a/src/PMG/API2/Nodes.pm b/src/PMG/API2/Nodes.pm index c0f5963..b6f0cd5 100644 --- a/src/PMG/API2/Nodes.pm +++ b/src/PMG/API2/Nodes.pm @@ -27,6 +27,7 @@ use PMG::API2::Postfix; use PMG::API2::MailTracker; use PMG::API2::Backup; use PMG::API2::PBS::Job; +use PMG::API2::Certificates; use base qw(PVE::RESTHandler); @@ -85,6 +86,11 @@ __PACKAGE__->register_method ({ path => 'pbs', }); +__PACKAGE__->register_method ({ + subclass => "PMG::API2::Certificates", + path => 'certificates', +}); + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -126,6 +132,7 @@ __PACKAGE__->register_method ({ { name => 'subscription' }, { name => 'termproxy' }, { name => 'rrddata' }, + { name => 'certificates' }, ]; return $result; -- 2.20.1