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) server-digest SHA256) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id 658296ACC9 for ; Mon, 15 Mar 2021 21:52:20 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 4D5A5271CB for ; Mon, 15 Mar 2021 21:51:50 +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) server-digest SHA256) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id D0180271BB for ; Mon, 15 Mar 2021 21:51:47 +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 9D26B456EF for ; Mon, 15 Mar 2021 21:51:47 +0100 (CET) Date: Mon, 15 Mar 2021 21:51:44 +0100 From: Stoiko Ivanov To: Wolfgang Bumiller Cc: pmg-devel@lists.proxmox.com Message-ID: <20210315215144.4aba757d@rosa.proxmox.com> In-Reply-To: <20210312152421.30114-15-w.bumiller@proxmox.com> References: <20210312152421.30114-1-w.bumiller@proxmox.com> <20210312152421.30114-15-w.bumiller@proxmox.com> X-Mailer: Claws Mail 3.17.3 (GTK+ 2.24.32; x86_64-pc-linux-gnu) MIME-Version: 1.0 Content-Type: text/plain; charset=US-ASCII Content-Transfer-Encoding: 7bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.065 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 Subject: Re: [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: Mon, 15 Mar 2021 20:52:20 -0000 noted while testing a bit more that it is actually possible to delete the API cert via cli, which renders pmgproxy inoperable (no fallback certificate, like for pveproxy with the cluster-CA signed one): On Fri, 12 Mar 2021 16:24:03 +0100 Wolfgang Bumiller wrote: > 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') { we could maybe call 'PMG::Ticket::generate_api_cert(0)' here (creates the certificate if the file does not exist) and be done - or: > + 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}) { call it here, ... > + 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. and here > + 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;