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 0EA9B6B248 for ; Tue, 16 Mar 2021 11:25:13 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 058162C62D for ; Tue, 16 Mar 2021 11:24:43 +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 3FE2F2C3E5 for ; Tue, 16 Mar 2021 11:24:29 +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 08541458EC for ; Tue, 16 Mar 2021 11:24:29 +0100 (CET) From: Wolfgang Bumiller To: pmg-devel@lists.proxmox.com Date: Tue, 16 Mar 2021 11:24:12 +0100 Message-Id: <20210316102424.25885-6-w.bumiller@proxmox.com> X-Mailer: git-send-email 2.20.1 In-Reply-To: <20210316102424.25885-1-w.bumiller@proxmox.com> References: <20210316102424.25885-1-w.bumiller@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.030 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. [api2.pm, when.pm, acmeplugin.pm, action.pm, letsencrypt.org, config.pm, acme.pm, what.pm] Subject: [pmg-devel] [PATCH v3 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: Tue, 16 Mar 2021 10:25:13 -0000 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 v2: * removed 'audit' api access for acme plugins * fixed 'challengeschema/challenge-schema' path/name issue * added a helper for account name/file extraction - but did keep the error messages for when the file is not there for now as atm it's a nicer error, can be removed in later patches) src/Makefile | 2 + src/PMG/API2/ACME.pm | 444 +++++++++++++++++++++++++++++++++++++ src/PMG/API2/ACMEPlugin.pm | 270 ++++++++++++++++++++++ src/PMG/API2/Config.pm | 7 + 4 files changed, 723 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..60b5986 --- /dev/null +++ b/src/PMG/API2/ACME.pm @@ -0,0 +1,444 @@ +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 => 'challenge-schema' }, + ]; + }}); + +__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 ]; + }}); + +# extract the optional account name and fill in the default, also return the file name +my sub extract_account_name : prototype($) { + my ($param) = @_; + + my $account_name = extract_param($param, 'name') // 'default'; + my $account_file = "${acme_account_dir}/${account_name}"; + + return ($account_name, $account_file); +} + +__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, $account_file) = extract_account_name($param); + 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, $account_file) = extract_account_name($param); + + 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, $account_file) = extract_account_name($param); + + 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 => 'challenge-schema', + path => 'challenge-schema', + 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..0842966 --- /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' ] }, + 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' ] }, + 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; }}); -- 2.20.1