pve-manager/PVE/API2/ACMEAccount.pm
Folke Gleumes b4050780a6 acme: mark caaIdentities as an array
caaIdentities was mistakenly labled as a string in a previous patch
and not as an array of strings, as it is defined in the rfc [0].

[0] https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1

Signed-off-by: Folke Gleumes <f.gleumes@proxmox.com>
2023-11-13 15:15:22 +01:00

515 lines
12 KiB
Perl

package PVE::API2::ACMEAccount;
use strict;
use warnings;
use PVE::ACME;
use PVE::CertHelpers;
use PVE::Exception qw(raise_param_exc);
use PVE::JSONSchema qw(get_standard_option);
use PVE::RPCEnvironment;
use PVE::Tools qw(extract_param);
use PVE::ACME::Challenge;
use PVE::API2::ACMEPlugin;
use base qw(PVE::RESTHandler);
__PACKAGE__->register_method ({
subclass => "PVE::API2::ACMEPlugin",
path => 'plugins',
});
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 = PVE::CertHelpers::acme_account_dir();
__PACKAGE__->register_method ({
name => 'index',
path => '',
method => 'GET',
permissions => { user => 'all' },
description => "ACMEAccount 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 => 'meta' },
{ name => 'directories' },
{ name => 'plugins' },
{ name => 'challenge-schema' },
];
}});
__PACKAGE__->register_method ({
name => 'account_index',
path => 'account',
method => 'GET',
permissions => { user => 'all' },
description => "ACMEAccount 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 = PVE::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.",
protected => 1,
parameters => {
additionalProperties => 0,
properties => {
name => get_standard_option('pve-acme-account-name'),
contact => get_standard_option('pve-acme-account-contact'),
tos_url => {
description => 'URL of CA TermsOfService - setting this indicates agreement.',
type => 'string',
optional => 1,
},
directory => get_standard_option('pve-acme-directory-url', {
default => $acme_default_directory_url,
optional => 1,
}),
'eab-kid' => {
description => 'Key Identifier for External Account Binding.',
type => 'string',
requires => 'eab-hmac-key',
optional => 1,
},
'eab-hmac-key' => {
description => 'HMAC key for External Account Binding.',
type => 'string',
requires => 'eab-kid',
optional => 1,
},
},
},
returns => {
type => 'string',
},
code => sub {
my ($param) = @_;
my $rpcenv = PVE::RPCEnvironment::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;
my $eab_kid = extract_param($param, 'eab-kid');
my $eab_hmac_key = extract_param($param, 'eab-hmac-key');
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 {
PVE::Cluster::cfs_lock_acme($account_name, 10, sub {
die "ACME account config file '${account_name}' already exists.\n"
if -e $account_file;
my $acme = PVE::ACME->new($account_file, $directory);
print "Generating ACME account key..\n";
$acme->init(4096);
print "Registering ACME account..\n";
my %info = (contact => $contact);
if (defined($eab_kid)) {
$info{eab} = {
kid => $eab_kid,
hmac_key => $eab_hmac_key
};
}
eval { $acme->new_account($param->{tos_url}, %info); };
if (my $err = $@) {
unlink $account_file;
die "Registration failed: $err\n";
}
print "Registration successful, account URL: '$acme->{location}'\n";
});
die $@ if $@;
};
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 = PVE::RPCEnvironment::get();
my $authuser = $rpcenv->get_user();
my $realcmd = sub {
PVE::Cluster::cfs_lock_acme($account_name, 10, sub {
die "ACME account config file '${account_name}' does not exist.\n"
if ! -e $account_file;
my $acme = PVE::ACME->new($account_file);
$acme->load();
$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";
}
}
});
die $@ if $@;
};
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.",
protected => 1,
parameters => {
additionalProperties => 0,
properties => {
name => get_standard_option('pve-acme-account-name'),
contact => get_standard_option('pve-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,
parameters => {
additionalProperties => 0,
properties => {
name => get_standard_option('pve-acme-account-name'),
},
},
returns => {
type => 'object',
additionalProperties => 0,
properties => {
account => {
type => 'object',
optional => 1,
renderer => 'yaml',
},
directory => get_standard_option('pve-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 = PVE::ACME->new($account_file);
$acme->load();
my $res = {};
$res->{account} = $acme->{account};
$res->{directory} = $acme->{directory};
$res->{location} = $acme->{location};
$res->{tos} = $acme->{tos};
return $res;
}});
__PACKAGE__->register_method ({
name => 'deactivate_account',
path => 'account/{name}',
method => 'DELETE',
description => "Deactivate existing ACME account at CA.",
protected => 1,
parameters => {
additionalProperties => 0,
properties => {
name => get_standard_option('pve-acme-account-name'),
},
},
returns => {
type => 'string',
},
code => sub {
my ($param) = @_;
return $update_account->($param, 'deactivate', status => 'deactivated');
}});
# TODO: deprecated, remove with pve 9
__PACKAGE__->register_method ({
name => 'get_tos',
path => 'tos',
method => 'GET',
description => "Retrieve ACME TermsOfService URL from CA. Deprecated, please use /cluster/acme/meta.",
permissions => { user => 'all' },
parameters => {
additionalProperties => 0,
properties => {
directory => get_standard_option('pve-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 = PVE::ACME->new(undef, $directory);
my $meta = $acme->get_meta();
return $meta ? $meta->{termsOfService} : undef;
}});
__PACKAGE__->register_method ({
name => 'get_meta',
path => 'meta',
method => 'GET',
description => "Retrieve ACME Directory Meta Information",
permissions => {
check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]],
},
parameters => {
additionalProperties => 0,
properties => {
directory => get_standard_option('pve-acme-directory-url', {
default => $acme_default_directory_url,
optional => 1,
}),
},
},
returns => {
type => 'object',
additionalProperties => 1,
properties => {
termsOfService => {
description => 'ACME TermsOfService URL.',
type => 'string',
optional => 1,
},
externalAccountRequired => {
description => 'EAB Required',
type => 'boolean',
optional => 1,
},
website => {
description => 'URL to more information about the ACME server.',
type => 'string',
optional => 1,
},
caaIdentities => {
description => 'Hostnames referring to the ACME servers.',
type => 'array',
items => {
type => 'string',
},
optional => 1,
},
},
},
code => sub {
my ($param) = @_;
my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
my $acme = PVE::ACME->new(undef, $directory);
my $meta = $acme->get_meta();
return $meta;
}});
__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('pve-acme-directory-url'),
},
},
},
code => sub {
my ($param) = @_;
return $acme_directories;
}});
__PACKAGE__->register_method ({
name => 'challengeschema',
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;