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