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