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 96BA06ABE4 for ; Mon, 15 Mar 2021 19:38:32 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 8603225C34 for ; Mon, 15 Mar 2021 19:38:02 +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 87AA025C27 for ; Mon, 15 Mar 2021 19:38:00 +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 472B340483 for ; Mon, 15 Mar 2021 19:38:00 +0100 (CET) Date: Mon, 15 Mar 2021 19:37:57 +0100 From: Stoiko Ivanov To: Wolfgang Bumiller Cc: pmg-devel@lists.proxmox.com Message-ID: <20210315193757.53bf11fe@rosa.proxmox.com> In-Reply-To: <20210312152421.30114-14-w.bumiller@proxmox.com> References: <20210312152421.30114-1-w.bumiller@proxmox.com> <20210312152421.30114-14-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.066 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 5/8] api: add ACME and ACMEPlugin module 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 18:38:32 -0000 short question unrelated to your series regarding PVE::ACME::Challenge, since I failed to figure this out: why does the SectionConfig there have a 'nodes' property? (AFAICT it's not used in PVE either) - stumbled upon that from the output of `pmgconfig help acme plugin set` small nit inline: On Fri, 12 Mar 2021 16:24:02 +0100 Wolfgang Bumiller wrote: > This adds the cluster-wide acme account and plugin > configuration: > > * /config/acme > |`+ account/ > | '- {name} > |`- tos > |`- directories > |`- challenge-schema > `+ plugins/ > '- {name} > > Signed-off-by: Wolfgang Bumiller > --- > Changes since v1: > * ACME account listing is now restricted to admin & audit > * Register/Update/Deactivate acme account limited to admin > * lock-call cleanups > * removed useless sort call > > src/Makefile | 2 + > src/PMG/API2/ACME.pm | 437 +++++++++++++++++++++++++++++++++++++ > src/PMG/API2/ACMEPlugin.pm | 270 +++++++++++++++++++++++ > src/PMG/API2/Config.pm | 7 + > 4 files changed, 716 insertions(+) > create mode 100644 src/PMG/API2/ACME.pm > create mode 100644 src/PMG/API2/ACMEPlugin.pm > > diff --git a/src/Makefile b/src/Makefile > index ce76f9f..ebc6bd8 100644 > --- a/src/Makefile > +++ b/src/Makefile > @@ -155,6 +155,8 @@ LIBSOURCES = \ > PMG/API2/When.pm \ > PMG/API2/What.pm \ > PMG/API2/Action.pm \ > + PMG/API2/ACME.pm \ > + PMG/API2/ACMEPlugin.pm \ > PMG/API2.pm \ > > SOURCES = ${LIBSOURCES} ${CLI_BINARIES} ${TEMPLATES_FILES} ${CONF_MANS} ${CLI_MANS} ${SERVICE_MANS} ${SERVICE_UNITS} ${TIMER_UNITS} pmg-sources.list pmg-apt.conf pmg-initramfs.conf > diff --git a/src/PMG/API2/ACME.pm b/src/PMG/API2/ACME.pm > new file mode 100644 > index 0000000..cf6a5e8 > --- /dev/null > +++ b/src/PMG/API2/ACME.pm > @@ -0,0 +1,437 @@ > +package PMG::API2::ACME; > + > +use strict; > +use warnings; > + > +use PVE::Exception qw(raise_param_exc); > +use PVE::JSONSchema qw(get_standard_option); > +use PVE::Tools qw(extract_param); > + > +use PVE::ACME::Challenge; > + > +use PMG::RESTEnvironment; > +use PMG::RS::Acme; > +use PMG::CertHelpers; > + > +use PMG::API2::ACMEPlugin; > + > +use base qw(PVE::RESTHandler); > + > +__PACKAGE__->register_method ({ > + subclass => "PMG::API2::ACMEPlugin", > + path => 'plugins', > +}); > + > +# FIXME: Put this list in pve-common or proxmox-acme{,-rs}? > +my $acme_directories = [ > + { > + name => 'Let\'s Encrypt V2', > + url => 'https://acme-v02.api.letsencrypt.org/directory', > + }, > + { > + name => 'Let\'s Encrypt V2 Staging', > + url => 'https://acme-staging-v02.api.letsencrypt.org/directory', > + }, > +]; > +my $acme_default_directory_url = $acme_directories->[0]->{url}; > +my $account_contact_from_param = sub { > + my @addresses = PVE::Tools::split_list(extract_param($_[0], 'contact')); > + return [ map { "mailto:$_" } @addresses ]; > +}; > +my $acme_account_dir = PMG::CertHelpers::acme_account_dir(); > + > +__PACKAGE__->register_method ({ > + name => 'index', > + path => '', > + method => 'GET', > + permissions => { user => 'all' }, > + description => "ACME index.", > + parameters => { > + additionalProperties => 0, > + properties => { > + }, > + }, > + returns => { > + type => 'array', > + items => { > + type => "object", > + properties => {}, > + }, > + links => [ { rel => 'child', href => "{name}" } ], > + }, > + code => sub { > + my ($param) = @_; > + > + return [ > + { name => 'account' }, > + { name => 'tos' }, > + { name => 'directories' }, > + { name => 'plugins' }, > + { name => 'challengeschema' }, (copied from pve-manger, but) that should read 'challenge-schema' to match with... > + ]; > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'account_index', > + path => 'account', > + method => 'GET', > + permissions => { check => [ 'admin', 'audit' ] }, > + description => "ACME account index.", > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + }, > + }, > + returns => { > + type => 'array', > + items => { > + type => "object", > + properties => {}, > + }, > + links => [ { rel => 'child', href => "{name}" } ], > + }, > + code => sub { > + my ($param) = @_; > + > + my $accounts = PMG::CertHelpers::list_acme_accounts(); > + return [ map { { name => $_ } } @$accounts ]; > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'register_account', > + path => 'account', > + method => 'POST', > + description => "Register a new ACME account with CA.", > + proxyto => 'master', > + permissions => { check => [ 'admin' ] }, > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + name => get_standard_option('pmg-acme-account-name'), > + contact => get_standard_option('pmg-acme-account-contact'), > + tos_url => { > + type => 'string', > + description => 'URL of CA TermsOfService - setting this indicates agreement.', > + optional => 1, > + }, > + directory => get_standard_option('pmg-acme-directory-url', { > + default => $acme_default_directory_url, > + optional => 1, > + }), > + }, > + }, > + returns => { > + type => 'string', > + }, > + code => sub { > + my ($param) = @_; > + > + my $rpcenv = PMG::RESTEnvironment->get(); > + my $authuser = $rpcenv->get_user(); > + > + my $account_name = extract_param($param, 'name') // 'default'; > + my $account_file = "${acme_account_dir}/${account_name}"; > + mkdir $acme_account_dir if ! -e $acme_account_dir; > + > + raise_param_exc({'name' => "ACME account config file '${account_name}' already exists."}) > + if -e $account_file; > + > + my $directory = extract_param($param, 'directory') // $acme_default_directory_url; > + my $contact = $account_contact_from_param->($param); > + > + my $realcmd = sub { > + PMG::CertHelpers::lock_acme($account_name, 10, sub { > + die "ACME account config file '${account_name}' already exists.\n" > + if -e $account_file; > + > + print "Registering new ACME account..\n"; > + my $acme = PMG::RS::Acme->new($directory); > + eval { > + $acme->new_account($account_file, defined($param->{tos_url}), $contact, undef); > + }; > + if (my $err = $@) { > + unlink $account_file; > + die "Registration failed: $err\n"; > + } > + my $location = $acme->location(); > + print "Registration successful, account URL: '$location'\n"; > + }); > + }; > + > + return $rpcenv->fork_worker('acmeregister', undef, $authuser, $realcmd); > + }}); > + > +my $update_account = sub { > + my ($param, $msg, %info) = @_; > + > + my $account_name = extract_param($param, 'name') // 'default'; > + my $account_file = "${acme_account_dir}/${account_name}"; > + > + raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."}) > + if ! -e $account_file; > + > + > + my $rpcenv = PMG::RESTEnvironment->get(); > + my $authuser = $rpcenv->get_user(); > + > + my $realcmd = sub { > + PMG::CertHelpers::lock_acme($account_name, 10, sub { > + die "ACME account config file '${account_name}' does not exist.\n" > + if ! -e $account_file; > + > + my $acme = PMG::RS::Acme->load($account_file); > + $acme->update_account(\%info); > + if ($info{status} && $info{status} eq 'deactivated') { > + my $deactivated_name; > + for my $i (0..100) { > + my $candidate = "${acme_account_dir}/_deactivated_${account_name}_${i}"; > + if (! -e $candidate) { > + $deactivated_name = $candidate; > + last; > + } > + } > + if ($deactivated_name) { > + print "Renaming account file from '$account_file' to '$deactivated_name'\n"; > + rename($account_file, $deactivated_name) or > + warn ".. failed - $!\n"; > + } else { > + warn "No free slot to rename deactivated account file '$account_file', leaving in place\n"; > + } > + } > + }); > + }; > + > + return $rpcenv->fork_worker("acme${msg}", undef, $authuser, $realcmd); > +}; > + > +__PACKAGE__->register_method ({ > + name => 'update_account', > + path => 'account/{name}', > + method => 'PUT', > + description => "Update existing ACME account information with CA. Note: not specifying any new account information triggers a refresh.", > + proxyto => 'master', > + permissions => { check => [ 'admin' ] }, > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + name => get_standard_option('pmg-acme-account-name'), > + contact => get_standard_option('pmg-acme-account-contact', { > + optional => 1, > + }), > + }, > + }, > + returns => { > + type => 'string', > + }, > + code => sub { > + my ($param) = @_; > + > + my $contact = $account_contact_from_param->($param); > + if (scalar @$contact) { > + return $update_account->($param, 'update', contact => $contact); > + } else { > + return $update_account->($param, 'refresh'); > + } > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'get_account', > + path => 'account/{name}', > + method => 'GET', > + description => "Return existing ACME account information.", > + protected => 1, > + proxyto => 'master', > + parameters => { > + additionalProperties => 0, > + properties => { > + name => get_standard_option('pmg-acme-account-name'), > + }, > + }, > + returns => { > + type => 'object', > + additionalProperties => 0, > + properties => { > + account => { > + type => 'object', > + optional => 1, > + renderer => 'yaml', > + }, > + directory => get_standard_option('pmg-acme-directory-url', { > + optional => 1, > + }), > + location => { > + type => 'string', > + optional => 1, > + }, > + tos => { > + type => 'string', > + optional => 1, > + }, > + }, > + }, > + code => sub { > + my ($param) = @_; > + > + my $account_name = extract_param($param, 'name') // 'default'; > + my $account_file = "${acme_account_dir}/${account_name}"; > + > + raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."}) > + if ! -e $account_file; > + > + my $acme = PMG::RS::Acme->load($account_file); > + my $data = $acme->account(); > + > + return { > + account => $data->{account}, > + tos => $data->{tos}, > + location => $data->{location}, > + directory => $data->{directoryUrl}, > + }; > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'deactivate_account', > + path => 'account/{name}', > + method => 'DELETE', > + description => "Deactivate existing ACME account at CA.", > + protected => 1, > + proxyto => 'master', > + permissions => { check => [ 'admin' ] }, > + parameters => { > + additionalProperties => 0, > + properties => { > + name => get_standard_option('pmg-acme-account-name'), > + }, > + }, > + returns => { > + type => 'string', > + }, > + code => sub { > + my ($param) = @_; > + > + return $update_account->($param, 'deactivate', status => 'deactivated'); > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'get_tos', > + path => 'tos', > + method => 'GET', > + description => "Retrieve ACME TermsOfService URL from CA.", > + permissions => { user => 'all' }, > + parameters => { > + additionalProperties => 0, > + properties => { > + directory => get_standard_option('pmg-acme-directory-url', { > + default => $acme_default_directory_url, > + optional => 1, > + }), > + }, > + }, > + returns => { > + type => 'string', > + optional => 1, > + description => 'ACME TermsOfService URL.', > + }, > + code => sub { > + my ($param) = @_; > + > + my $directory = extract_param($param, 'directory') // $acme_default_directory_url; > + > + my $acme = PMG::RS::Acme->new($directory); > + my $meta = $acme->get_meta(); > + > + return $meta ? $meta->{termsOfService} : undef; > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'get_directories', > + path => 'directories', > + method => 'GET', > + description => "Get named known ACME directory endpoints.", > + permissions => { user => 'all' }, > + parameters => { > + additionalProperties => 0, > + properties => {}, > + }, > + returns => { > + type => 'array', > + items => { > + type => 'object', > + additionalProperties => 0, > + properties => { > + name => { > + type => 'string', > + }, > + url => get_standard_option('pmg-acme-directory-url'), > + }, > + }, > + }, > + code => sub { > + my ($param) = @_; > + > + return $acme_directories; > + }}); > + > +__PACKAGE__->register_method ({ > + name => 'challengeschema', > + path => 'challenge-schema', ...this > + method => 'GET', > + description => "Get schema of ACME challenge types.", > + permissions => { user => 'all' }, > + parameters => { > + additionalProperties => 0, > + properties => {}, > + }, > + returns => { > + type => 'array', > + items => { > + type => 'object', > + additionalProperties => 0, > + properties => { > + id => { > + type => 'string', > + }, > + name => { > + description => 'Human readable name, falls back to id', > + type => 'string', > + }, > + type => { > + type => 'string', > + }, > + schema => { > + type => 'object', > + }, > + }, > + }, > + }, > + code => sub { > + my ($param) = @_; > + > + my $plugin_type_enum = PVE::ACME::Challenge->lookup_types(); > + > + my $res = []; > + > + for my $type (@$plugin_type_enum) { > + my $plugin = PVE::ACME::Challenge->lookup($type); > + next if !$plugin->can('get_supported_plugins'); > + > + my $plugin_type = $plugin->type(); > + my $plugins = $plugin->get_supported_plugins(); > + for my $id (sort keys %$plugins) { > + my $schema = $plugins->{$id}; > + push @$res, { > + id => $id, > + name => $schema->{name} // $id, > + type => $plugin_type, > + schema => $schema, > + }; > + } > + } > + > + return $res; > + }}); > + > +1; > diff --git a/src/PMG/API2/ACMEPlugin.pm b/src/PMG/API2/ACMEPlugin.pm > new file mode 100644 > index 0000000..7ab6e59 > --- /dev/null > +++ b/src/PMG/API2/ACMEPlugin.pm > @@ -0,0 +1,270 @@ > +package PMG::API2::ACMEPlugin; > + > +use strict; > +use warnings; > + > +use Storable qw(dclone); > + > +use PVE::ACME::Challenge; > +use PVE::ACME::DNSChallenge; > +use PVE::ACME::StandAlone; > +use PVE::INotify; > +use PVE::JSONSchema qw(get_standard_option); > +use PVE::Tools qw(extract_param); > + > +use base qw(PVE::RESTHandler); > + > +my $inotify_file_id = 'pmg-acme-plugins-config.conf'; > +my $config_filename = '/etc/pmg/acme-plugins.conf'; > +my $lockfile = "/var/lock/pmg-acme-plugins-config.lck"; > + > +PVE::ACME::DNSChallenge->register(); > +PVE::ACME::StandAlone->register(); > +PVE::ACME::Challenge->init(); > + > +PVE::JSONSchema::register_standard_option('pmg-acme-pluginid', { > + type => 'string', > + format => 'pve-configid', > + description => 'Unique identifier for ACME plugin instance.', > +}); > + > +sub read_pmg_acme_challenge_config { > + my ($filename, $fh) = @_; > + local $/ = undef; # slurp mode > + my $raw = defined($fh) ? <$fh> : ''; > + return PVE::ACME::Challenge->parse_config($filename, $raw); > +} > + > +sub write_pmg_acme_challenge_config { > + my ($filename, $fh, $cfg) = @_; > + my $raw = PVE::ACME::Challenge->write_config($filename, $cfg); > + PVE::Tools::safe_print($filename, $fh, $raw); > +} > + > +PVE::INotify::register_file($inotify_file_id, $config_filename, > + \&read_pmg_acme_challenge_config, > + \&write_pmg_acme_challenge_config, > + undef, > + always_call_parser => 1); > + > +sub lock_config { > + my ($code) = @_; > + my $p = PVE::Tools::lock_file($lockfile, undef, $code); > + die $@ if $@; > + return $p; > +} > + > +sub load_config { > + # auto-adds the standalone plugin if no config is there for backwards > + # compatibility, so ALWAYS call the cfs registered parser > + return PVE::INotify::read_file($inotify_file_id); > +} > + > +sub write_config { > + my ($self) = @_; > + return PVE::INotify::write_file($inotify_file_id, $self); > +} > + > +my $plugin_type_enum = PVE::ACME::Challenge->lookup_types(); > + > +my $modify_cfg_for_api = sub { > + my ($cfg, $pluginid) = @_; > + > + die "ACME plugin '$pluginid' not defined\n" if !defined($cfg->{ids}->{$pluginid}); > + > + my $plugin_cfg = dclone($cfg->{ids}->{$pluginid}); > + $plugin_cfg->{plugin} = $pluginid; > + $plugin_cfg->{digest} = $cfg->{digest}; > + > + return $plugin_cfg; > +}; > + > +__PACKAGE__->register_method ({ > + name => 'index', > + path => '', > + method => 'GET', > + permissions => { check => [ 'admin', 'audit' ] }, > + description => "ACME plugin index.", > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + type => { > + description => "Only list ACME plugins of a specific type", > + type => 'string', > + enum => $plugin_type_enum, > + optional => 1, > + }, > + }, > + }, > + returns => { > + type => 'array', > + items => { > + type => "object", > + properties => { > + plugin => get_standard_option('pmg-acme-pluginid'), > + }, > + }, > + links => [ { rel => 'child', href => "{plugin}" } ], > + }, > + code => sub { > + my ($param) = @_; > + > + my $cfg = load_config(); > + > + my $res = []; > + foreach my $pluginid (keys %{$cfg->{ids}}) { > + my $plugin_cfg = $modify_cfg_for_api->($cfg, $pluginid); > + next if $param->{type} && $param->{type} ne $plugin_cfg->{type}; > + push @$res, $plugin_cfg; > + } > + > + return $res; > + } > +}); > + > +__PACKAGE__->register_method({ > + name => 'get_plugin_config', > + path => '{id}', > + method => 'GET', > + description => "Get ACME plugin configuration.", > + permissions => { check => [ 'admin', 'audit' ] }, > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + id => get_standard_option('pmg-acme-pluginid'), > + }, > + }, > + returns => { > + type => 'object', > + }, > + code => sub { > + my ($param) = @_; > + > + my $cfg = load_config(); > + return $modify_cfg_for_api->($cfg, $param->{id}); > + } > +}); > + > +__PACKAGE__->register_method({ > + name => 'add_plugin', > + path => '', > + method => 'POST', > + description => "Add ACME plugin configuration.", > + permissions => { check => [ 'admin' ] }, > + protected => 1, > + parameters => PVE::ACME::Challenge->createSchema(), > + returns => { > + type => "null" > + }, > + code => sub { > + my ($param) = @_; > + > + my $id = extract_param($param, 'id'); > + my $type = extract_param($param, 'type'); > + > + lock_config(sub { > + my $cfg = load_config(); > + die "ACME plugin ID '$id' already exists\n" if defined($cfg->{ids}->{$id}); > + > + my $plugin = PVE::ACME::Challenge->lookup($type); > + my $opts = $plugin->check_config($id, $param, 1, 1); > + > + $cfg->{ids}->{$id} = $opts; > + $cfg->{ids}->{$id}->{type} = $type; > + > + write_config($cfg); > + }); > + die "$@" if $@; > + > + return undef; > + } > +}); > + > +__PACKAGE__->register_method({ > + name => 'update_plugin', > + path => '{id}', > + method => 'PUT', > + description => "Update ACME plugin configuration.", > + permissions => { check => [ 'admin' ] }, > + protected => 1, > + parameters => PVE::ACME::Challenge->updateSchema(), > + returns => { > + type => "null" > + }, > + code => sub { > + my ($param) = @_; > + > + my $id = extract_param($param, 'id'); > + my $delete = extract_param($param, 'delete'); > + my $digest = extract_param($param, 'digest'); > + > + lock_config(sub { > + my $cfg = load_config(); > + PVE::Tools::assert_if_modified($cfg->{digest}, $digest); > + my $plugin_cfg = $cfg->{ids}->{$id}; > + die "ACME plugin ID '$id' does not exist\n" if !$plugin_cfg; > + > + my $type = $plugin_cfg->{type}; > + my $plugin = PVE::ACME::Challenge->lookup($type); > + > + if (defined($delete)) { > + my $schema = $plugin->private(); > + my $options = $schema->{options}->{$type}; > + for my $k (PVE::Tools::split_list($delete)) { > + my $d = $options->{$k} || die "no such option '$k'\n"; > + die "unable to delete required option '$k'\n" if !$d->{optional}; > + > + delete $cfg->{ids}->{$id}->{$k}; > + } > + } > + > + my $opts = $plugin->check_config($id, $param, 0, 1); > + for my $k (keys %$opts) { > + $plugin_cfg->{$k} = $opts->{$k}; > + } > + > + write_config($cfg); > + }); > + die "$@" if $@; > + > + return undef; > + } > +}); > + > +__PACKAGE__->register_method({ > + name => 'delete_plugin', > + path => '{id}', > + method => 'DELETE', > + description => "Delete ACME plugin configuration.", > + permissions => { check => [ 'admin' ] }, > + protected => 1, > + parameters => { > + additionalProperties => 0, > + properties => { > + id => get_standard_option('pmg-acme-pluginid'), > + }, > + }, > + returns => { > + type => "null" > + }, > + code => sub { > + my ($param) = @_; > + > + my $id = extract_param($param, 'id'); > + > + lock_config(sub { > + my $cfg = load_config(); > + > + delete $cfg->{ids}->{$id}; > + > + write_config($cfg); > + }); > + die "$@" if $@; > + > + return undef; > + } > +}); > + > +1; > diff --git a/src/PMG/API2/Config.pm b/src/PMG/API2/Config.pm > index e11eb3f..c5697e1 100644 > --- a/src/PMG/API2/Config.pm > +++ b/src/PMG/API2/Config.pm > @@ -26,6 +26,7 @@ use PMG::API2::DestinationTLSPolicy; > use PMG::API2::DKIMSign; > use PMG::API2::SACustom; > use PMG::API2::PBS::Remote; > +use PMG::API2::ACME; > > use base qw(PVE::RESTHandler); > > @@ -99,6 +100,11 @@ __PACKAGE__->register_method ({ > path => 'pbs', > }); > > +__PACKAGE__->register_method ({ > + subclass => "PMG::API2::ACME", > + path => 'acme', > +}); > + > __PACKAGE__->register_method ({ > name => 'index', > path => '', > @@ -138,6 +144,7 @@ __PACKAGE__->register_method ({ > push @$res, { section => 'tlspolicy' }; > push @$res, { section => 'dkim' }; > push @$res, { section => 'pbs' }; > + push @$res, { section => 'acme' }; > > return $res; > }});