]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/API2/ACME.pm
api: acme: add eab parameters
[pmg-api.git] / src / PMG / API2 / ACME.pm
CommitLineData
5000937e
WB
1package PMG::API2::ACME;
2
3use strict;
4use warnings;
5
5d30f586
SI
6use File::Path;
7
5000937e
WB
8use PVE::Exception qw(raise_param_exc);
9use PVE::JSONSchema qw(get_standard_option);
10use PVE::Tools qw(extract_param);
11
12use PVE::ACME::Challenge;
13
14use PMG::RESTEnvironment;
15use PMG::RS::Acme;
16use PMG::CertHelpers;
17
18use PMG::API2::ACMEPlugin;
19
20use base qw(PVE::RESTHandler);
21
22__PACKAGE__->register_method ({
23 subclass => "PMG::API2::ACMEPlugin",
24 path => 'plugins',
25});
26
27# FIXME: Put this list in pve-common or proxmox-acme{,-rs}?
28my $acme_directories = [
29 {
30 name => 'Let\'s Encrypt V2',
31 url => 'https://acme-v02.api.letsencrypt.org/directory',
32 },
33 {
34 name => 'Let\'s Encrypt V2 Staging',
35 url => 'https://acme-staging-v02.api.letsencrypt.org/directory',
36 },
37];
38my $acme_default_directory_url = $acme_directories->[0]->{url};
39my $account_contact_from_param = sub {
40 my @addresses = PVE::Tools::split_list(extract_param($_[0], 'contact'));
41 return [ map { "mailto:$_" } @addresses ];
42};
43my $acme_account_dir = PMG::CertHelpers::acme_account_dir();
44
45__PACKAGE__->register_method ({
46 name => 'index',
47 path => '',
48 method => 'GET',
49 permissions => { user => 'all' },
50 description => "ACME index.",
51 parameters => {
52 additionalProperties => 0,
53 properties => {
54 },
55 },
56 returns => {
57 type => 'array',
58 items => {
59 type => "object",
60 properties => {},
61 },
62 links => [ { rel => 'child', href => "{name}" } ],
63 },
64 code => sub {
65 my ($param) = @_;
66
67 return [
68 { name => 'account' },
69 { name => 'tos' },
70 { name => 'directories' },
71 { name => 'plugins' },
72 { name => 'challenge-schema' },
73 ];
74 }});
75
76__PACKAGE__->register_method ({
77 name => 'account_index',
78 path => 'account',
79 method => 'GET',
80 permissions => { check => [ 'admin', 'audit' ] },
81 description => "ACME account index.",
82 protected => 1,
83 parameters => {
84 additionalProperties => 0,
85 properties => {
86 },
87 },
88 returns => {
89 type => 'array',
90 items => {
91 type => "object",
92 properties => {},
93 },
94 links => [ { rel => 'child', href => "{name}" } ],
95 },
96 code => sub {
97 my ($param) = @_;
98
99 my $accounts = PMG::CertHelpers::list_acme_accounts();
100 return [ map { { name => $_ } } @$accounts ];
101 }});
102
103# extract the optional account name and fill in the default, also return the file name
104my sub extract_account_name : prototype($) {
105 my ($param) = @_;
106
107 my $account_name = extract_param($param, 'name') // 'default';
108 my $account_file = "${acme_account_dir}/${account_name}";
109
110 return ($account_name, $account_file);
111}
112
113__PACKAGE__->register_method ({
114 name => 'register_account',
115 path => 'account',
116 method => 'POST',
117 description => "Register a new ACME account with CA.",
118 proxyto => 'master',
119 permissions => { check => [ 'admin' ] },
120 protected => 1,
121 parameters => {
122 additionalProperties => 0,
123 properties => {
124 name => get_standard_option('pmg-acme-account-name'),
125 contact => get_standard_option('pmg-acme-account-contact'),
126 tos_url => {
127 type => 'string',
128 description => 'URL of CA TermsOfService - setting this indicates agreement.',
129 optional => 1,
130 },
131 directory => get_standard_option('pmg-acme-directory-url', {
132 default => $acme_default_directory_url,
133 optional => 1,
134 }),
5406f703
FG
135 'eab-kid' => {
136 type => 'string',
137 description => 'Key Identifier for External Account Binding.',
138 requires => 'eab-hmac-key',
139 optional => 1,
140 },
141 'eab-hmac-key' => {
142 type => 'string',
143 description => 'HMAC key for External Account Binding.',
144 requires => 'eab-kid',
145 optional => 1,
146 },
5000937e
WB
147 },
148 },
149 returns => {
150 type => 'string',
151 },
152 code => sub {
153 my ($param) = @_;
154
155 my $rpcenv = PMG::RESTEnvironment->get();
156 my $authuser = $rpcenv->get_user();
157
158 my ($account_name, $account_file) = extract_account_name($param);
b6e3ea16 159 mkpath $acme_account_dir if ! -e $acme_account_dir;
5000937e
WB
160
161 raise_param_exc({'name' => "ACME account config file '${account_name}' already exists."})
162 if -e $account_file;
163
164 my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
165 my $contact = $account_contact_from_param->($param);
5406f703
FG
166 my $eab_kid = extract_param($param, 'eab-kid');
167 my $eab_hmac_key = extract_param($param, 'eab-hmac-key');
5000937e
WB
168
169 my $realcmd = sub {
170 PMG::CertHelpers::lock_acme($account_name, 10, sub {
171 die "ACME account config file '${account_name}' already exists.\n"
172 if -e $account_file;
173
174 print "Registering new ACME account..\n";
175 my $acme = PMG::RS::Acme->new($directory);
176 eval {
5406f703 177 $acme->new_account($account_file, defined($param->{tos_url}), $contact, undef, $eab_kid, $eab_hmac_key);
5000937e
WB
178 };
179 if (my $err = $@) {
180 unlink $account_file;
181 die "Registration failed: $err\n";
182 }
183 my $location = $acme->location();
184 print "Registration successful, account URL: '$location'\n";
185 });
186 };
187
188 return $rpcenv->fork_worker('acmeregister', undef, $authuser, $realcmd);
189 }});
190
191my $update_account = sub {
b9725bac 192 my ($param, $msg, $force_deactivate, %info) = @_;
5000937e
WB
193
194 my ($account_name, $account_file) = extract_account_name($param);
195
196 raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."})
197 if ! -e $account_file;
198
199
200 my $rpcenv = PMG::RESTEnvironment->get();
201 my $authuser = $rpcenv->get_user();
202
203 my $realcmd = sub {
204 PMG::CertHelpers::lock_acme($account_name, 10, sub {
205 die "ACME account config file '${account_name}' does not exist.\n"
206 if ! -e $account_file;
207
208 my $acme = PMG::RS::Acme->load($account_file);
b9725bac
WB
209 eval {
210 $acme->update_account(\%info);
211 };
212 if (my $err = $@) {
213 die $err if !$force_deactivate;
214 warn "got error, but forced to continue - $err\n";
215 }
5000937e
WB
216 if ($info{status} && $info{status} eq 'deactivated') {
217 my $deactivated_name;
218 for my $i (0..100) {
219 my $candidate = "${acme_account_dir}/_deactivated_${account_name}_${i}";
220 if (! -e $candidate) {
221 $deactivated_name = $candidate;
222 last;
223 }
224 }
225 if ($deactivated_name) {
226 print "Renaming account file from '$account_file' to '$deactivated_name'\n";
227 rename($account_file, $deactivated_name) or
228 warn ".. failed - $!\n";
229 } else {
230 warn "No free slot to rename deactivated account file '$account_file', leaving in place\n";
231 }
232 }
233 });
234 };
235
236 return $rpcenv->fork_worker("acme${msg}", undef, $authuser, $realcmd);
237};
238
239__PACKAGE__->register_method ({
240 name => 'update_account',
241 path => 'account/{name}',
242 method => 'PUT',
243 description => "Update existing ACME account information with CA. Note: not specifying any new account information triggers a refresh.",
244 proxyto => 'master',
245 permissions => { check => [ 'admin' ] },
246 protected => 1,
247 parameters => {
248 additionalProperties => 0,
249 properties => {
250 name => get_standard_option('pmg-acme-account-name'),
251 contact => get_standard_option('pmg-acme-account-contact', {
252 optional => 1,
253 }),
254 },
255 },
256 returns => {
257 type => 'string',
258 },
259 code => sub {
260 my ($param) = @_;
261
262 my $contact = $account_contact_from_param->($param);
263 if (scalar @$contact) {
b9725bac 264 return $update_account->($param, 'update', 0, contact => $contact);
5000937e 265 } else {
b9725bac 266 return $update_account->($param, 'refresh', 0);
5000937e
WB
267 }
268 }});
269
270__PACKAGE__->register_method ({
271 name => 'get_account',
272 path => 'account/{name}',
273 method => 'GET',
274 description => "Return existing ACME account information.",
275 protected => 1,
276 proxyto => 'master',
277 parameters => {
278 additionalProperties => 0,
279 properties => {
280 name => get_standard_option('pmg-acme-account-name'),
281 },
282 },
283 returns => {
284 type => 'object',
285 additionalProperties => 0,
286 properties => {
287 account => {
288 type => 'object',
289 optional => 1,
290 renderer => 'yaml',
291 },
292 directory => get_standard_option('pmg-acme-directory-url', {
293 optional => 1,
294 }),
295 location => {
296 type => 'string',
297 optional => 1,
298 },
299 tos => {
300 type => 'string',
301 optional => 1,
302 },
303 },
304 },
305 code => sub {
306 my ($param) = @_;
307
308 my ($account_name, $account_file) = extract_account_name($param);
309
310 raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."})
311 if ! -e $account_file;
312
313 my $acme = PMG::RS::Acme->load($account_file);
314 my $data = $acme->account();
315
316 return {
317 account => $data->{account},
318 tos => $data->{tos},
319 location => $data->{location},
320 directory => $data->{directoryUrl},
321 };
322 }});
323
324__PACKAGE__->register_method ({
325 name => 'deactivate_account',
326 path => 'account/{name}',
327 method => 'DELETE',
328 description => "Deactivate existing ACME account at CA.",
329 protected => 1,
330 proxyto => 'master',
331 permissions => { check => [ 'admin' ] },
332 parameters => {
333 additionalProperties => 0,
334 properties => {
335 name => get_standard_option('pmg-acme-account-name'),
b9725bac
WB
336 force => {
337 type => 'boolean',
338 description =>
339 'Delete account data even if the server refuses to deactivate the account.',
340 optional => 1,
341 default => 0,
342 },
5000937e
WB
343 },
344 },
345 returns => {
346 type => 'string',
347 },
348 code => sub {
349 my ($param) = @_;
350
b9725bac
WB
351 my $force_deactivate = extract_param($param, 'force');
352
353 return $update_account->($param, 'deactivate', $force_deactivate, status => 'deactivated');
5000937e
WB
354 }});
355
356__PACKAGE__->register_method ({
357 name => 'get_tos',
358 path => 'tos',
359 method => 'GET',
360 description => "Retrieve ACME TermsOfService URL from CA.",
361 permissions => { user => 'all' },
362 parameters => {
363 additionalProperties => 0,
364 properties => {
365 directory => get_standard_option('pmg-acme-directory-url', {
366 default => $acme_default_directory_url,
367 optional => 1,
368 }),
369 },
370 },
371 returns => {
372 type => 'string',
373 optional => 1,
374 description => 'ACME TermsOfService URL.',
375 },
376 code => sub {
377 my ($param) = @_;
378
379 my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
380
381 my $acme = PMG::RS::Acme->new($directory);
382 my $meta = $acme->get_meta();
383
384 return $meta ? $meta->{termsOfService} : undef;
385 }});
386
387__PACKAGE__->register_method ({
388 name => 'get_directories',
389 path => 'directories',
390 method => 'GET',
391 description => "Get named known ACME directory endpoints.",
392 permissions => { user => 'all' },
393 parameters => {
394 additionalProperties => 0,
395 properties => {},
396 },
397 returns => {
398 type => 'array',
399 items => {
400 type => 'object',
401 additionalProperties => 0,
402 properties => {
403 name => {
404 type => 'string',
405 },
406 url => get_standard_option('pmg-acme-directory-url'),
407 },
408 },
409 },
410 code => sub {
411 my ($param) = @_;
412
413 return $acme_directories;
414 }});
415
416__PACKAGE__->register_method ({
417 name => 'challenge-schema',
418 path => 'challenge-schema',
419 method => 'GET',
420 description => "Get schema of ACME challenge types.",
421 permissions => { user => 'all' },
422 parameters => {
423 additionalProperties => 0,
424 properties => {},
425 },
426 returns => {
427 type => 'array',
428 items => {
429 type => 'object',
430 additionalProperties => 0,
431 properties => {
432 id => {
433 type => 'string',
434 },
435 name => {
436 description => 'Human readable name, falls back to id',
437 type => 'string',
438 },
439 type => {
440 type => 'string',
441 },
442 schema => {
443 type => 'object',
444 },
445 },
446 },
447 },
448 code => sub {
449 my ($param) = @_;
450
451 my $plugin_type_enum = PVE::ACME::Challenge->lookup_types();
452
453 my $res = [];
454
455 for my $type (@$plugin_type_enum) {
456 my $plugin = PVE::ACME::Challenge->lookup($type);
457 next if !$plugin->can('get_supported_plugins');
458
459 my $plugin_type = $plugin->type();
460 my $plugins = $plugin->get_supported_plugins();
461 for my $id (sort keys %$plugins) {
462 my $schema = $plugins->{$id};
463 push @$res, {
464 id => $id,
465 name => $schema->{name} // $id,
466 type => $plugin_type,
467 schema => $schema,
468 };
469 }
470 }
471
472 return $res;
473 }});
474
4751;