]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/API2/ACME.pm
api: add ACME and ACMEPlugin module
[pmg-api.git] / src / PMG / API2 / ACME.pm
CommitLineData
5000937e
WB
1package PMG::API2::ACME;
2
3use strict;
4use warnings;
5
6use PVE::Exception qw(raise_param_exc);
7use PVE::JSONSchema qw(get_standard_option);
8use PVE::Tools qw(extract_param);
9
10use PVE::ACME::Challenge;
11
12use PMG::RESTEnvironment;
13use PMG::RS::Acme;
14use PMG::CertHelpers;
15
16use PMG::API2::ACMEPlugin;
17
18use 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}?
26my $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];
36my $acme_default_directory_url = $acme_directories->[0]->{url};
37my $account_contact_from_param = sub {
38 my @addresses = PVE::Tools::split_list(extract_param($_[0], 'contact'));
39 return [ map { "mailto:$_" } @addresses ];
40};
41my $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
102my 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
175my $update_account = sub {
176 my ($param, $msg, %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 $acme->update_account(\%info);
194 if ($info{status} && $info{status} eq 'deactivated') {
195 my $deactivated_name;
196 for my $i (0..100) {
197 my $candidate = "${acme_account_dir}/_deactivated_${account_name}_${i}";
198 if (! -e $candidate) {
199 $deactivated_name = $candidate;
200 last;
201 }
202 }
203 if ($deactivated_name) {
204 print "Renaming account file from '$account_file' to '$deactivated_name'\n";
205 rename($account_file, $deactivated_name) or
206 warn ".. failed - $!\n";
207 } else {
208 warn "No free slot to rename deactivated account file '$account_file', leaving in place\n";
209 }
210 }
211 });
212 };
213
214 return $rpcenv->fork_worker("acme${msg}", undef, $authuser, $realcmd);
215};
216
217__PACKAGE__->register_method ({
218 name => 'update_account',
219 path => 'account/{name}',
220 method => 'PUT',
221 description => "Update existing ACME account information with CA. Note: not specifying any new account information triggers a refresh.",
222 proxyto => 'master',
223 permissions => { check => [ 'admin' ] },
224 protected => 1,
225 parameters => {
226 additionalProperties => 0,
227 properties => {
228 name => get_standard_option('pmg-acme-account-name'),
229 contact => get_standard_option('pmg-acme-account-contact', {
230 optional => 1,
231 }),
232 },
233 },
234 returns => {
235 type => 'string',
236 },
237 code => sub {
238 my ($param) = @_;
239
240 my $contact = $account_contact_from_param->($param);
241 if (scalar @$contact) {
242 return $update_account->($param, 'update', contact => $contact);
243 } else {
244 return $update_account->($param, 'refresh');
245 }
246 }});
247
248__PACKAGE__->register_method ({
249 name => 'get_account',
250 path => 'account/{name}',
251 method => 'GET',
252 description => "Return existing ACME account information.",
253 protected => 1,
254 proxyto => 'master',
255 parameters => {
256 additionalProperties => 0,
257 properties => {
258 name => get_standard_option('pmg-acme-account-name'),
259 },
260 },
261 returns => {
262 type => 'object',
263 additionalProperties => 0,
264 properties => {
265 account => {
266 type => 'object',
267 optional => 1,
268 renderer => 'yaml',
269 },
270 directory => get_standard_option('pmg-acme-directory-url', {
271 optional => 1,
272 }),
273 location => {
274 type => 'string',
275 optional => 1,
276 },
277 tos => {
278 type => 'string',
279 optional => 1,
280 },
281 },
282 },
283 code => sub {
284 my ($param) = @_;
285
286 my ($account_name, $account_file) = extract_account_name($param);
287
288 raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."})
289 if ! -e $account_file;
290
291 my $acme = PMG::RS::Acme->load($account_file);
292 my $data = $acme->account();
293
294 return {
295 account => $data->{account},
296 tos => $data->{tos},
297 location => $data->{location},
298 directory => $data->{directoryUrl},
299 };
300 }});
301
302__PACKAGE__->register_method ({
303 name => 'deactivate_account',
304 path => 'account/{name}',
305 method => 'DELETE',
306 description => "Deactivate existing ACME account at CA.",
307 protected => 1,
308 proxyto => 'master',
309 permissions => { check => [ 'admin' ] },
310 parameters => {
311 additionalProperties => 0,
312 properties => {
313 name => get_standard_option('pmg-acme-account-name'),
314 },
315 },
316 returns => {
317 type => 'string',
318 },
319 code => sub {
320 my ($param) = @_;
321
322 return $update_account->($param, 'deactivate', status => 'deactivated');
323 }});
324
325__PACKAGE__->register_method ({
326 name => 'get_tos',
327 path => 'tos',
328 method => 'GET',
329 description => "Retrieve ACME TermsOfService URL from CA.",
330 permissions => { user => 'all' },
331 parameters => {
332 additionalProperties => 0,
333 properties => {
334 directory => get_standard_option('pmg-acme-directory-url', {
335 default => $acme_default_directory_url,
336 optional => 1,
337 }),
338 },
339 },
340 returns => {
341 type => 'string',
342 optional => 1,
343 description => 'ACME TermsOfService URL.',
344 },
345 code => sub {
346 my ($param) = @_;
347
348 my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
349
350 my $acme = PMG::RS::Acme->new($directory);
351 my $meta = $acme->get_meta();
352
353 return $meta ? $meta->{termsOfService} : undef;
354 }});
355
356__PACKAGE__->register_method ({
357 name => 'get_directories',
358 path => 'directories',
359 method => 'GET',
360 description => "Get named known ACME directory endpoints.",
361 permissions => { user => 'all' },
362 parameters => {
363 additionalProperties => 0,
364 properties => {},
365 },
366 returns => {
367 type => 'array',
368 items => {
369 type => 'object',
370 additionalProperties => 0,
371 properties => {
372 name => {
373 type => 'string',
374 },
375 url => get_standard_option('pmg-acme-directory-url'),
376 },
377 },
378 },
379 code => sub {
380 my ($param) = @_;
381
382 return $acme_directories;
383 }});
384
385__PACKAGE__->register_method ({
386 name => 'challenge-schema',
387 path => 'challenge-schema',
388 method => 'GET',
389 description => "Get schema of ACME challenge types.",
390 permissions => { user => 'all' },
391 parameters => {
392 additionalProperties => 0,
393 properties => {},
394 },
395 returns => {
396 type => 'array',
397 items => {
398 type => 'object',
399 additionalProperties => 0,
400 properties => {
401 id => {
402 type => 'string',
403 },
404 name => {
405 description => 'Human readable name, falls back to id',
406 type => 'string',
407 },
408 type => {
409 type => 'string',
410 },
411 schema => {
412 type => 'object',
413 },
414 },
415 },
416 },
417 code => sub {
418 my ($param) = @_;
419
420 my $plugin_type_enum = PVE::ACME::Challenge->lookup_types();
421
422 my $res = [];
423
424 for my $type (@$plugin_type_enum) {
425 my $plugin = PVE::ACME::Challenge->lookup($type);
426 next if !$plugin->can('get_supported_plugins');
427
428 my $plugin_type = $plugin->type();
429 my $plugins = $plugin->get_supported_plugins();
430 for my $id (sort keys %$plugins) {
431 my $schema = $plugins->{$id};
432 push @$res, {
433 id => $id,
434 name => $schema->{name} // $id,
435 type => $plugin_type,
436 schema => $schema,
437 };
438 }
439 }
440
441 return $res;
442 }});
443
4441;