]> git.proxmox.com Git - pmg-api.git/blame - src/PMG/API2/ACME.pm
certs: reload postfix to activate new certificate
[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 }),
135 },
136 },
137 returns => {
138 type => 'string',
139 },
140 code => sub {
141 my ($param) = @_;
142
143 my $rpcenv = PMG::RESTEnvironment->get();
144 my $authuser = $rpcenv->get_user();
145
146 my ($account_name, $account_file) = extract_account_name($param);
5d30f586 147 File::Path::make_path($acme_account_dir) if ! -e $acme_account_dir;
5000937e
WB
148
149 raise_param_exc({'name' => "ACME account config file '${account_name}' already exists."})
150 if -e $account_file;
151
152 my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
153 my $contact = $account_contact_from_param->($param);
154
155 my $realcmd = sub {
156 PMG::CertHelpers::lock_acme($account_name, 10, sub {
157 die "ACME account config file '${account_name}' already exists.\n"
158 if -e $account_file;
159
160 print "Registering new ACME account..\n";
161 my $acme = PMG::RS::Acme->new($directory);
162 eval {
163 $acme->new_account($account_file, defined($param->{tos_url}), $contact, undef);
164 };
165 if (my $err = $@) {
166 unlink $account_file;
167 die "Registration failed: $err\n";
168 }
169 my $location = $acme->location();
170 print "Registration successful, account URL: '$location'\n";
171 });
172 };
173
174 return $rpcenv->fork_worker('acmeregister', undef, $authuser, $realcmd);
175 }});
176
177my $update_account = sub {
b9725bac 178 my ($param, $msg, $force_deactivate, %info) = @_;
5000937e
WB
179
180 my ($account_name, $account_file) = extract_account_name($param);
181
182 raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."})
183 if ! -e $account_file;
184
185
186 my $rpcenv = PMG::RESTEnvironment->get();
187 my $authuser = $rpcenv->get_user();
188
189 my $realcmd = sub {
190 PMG::CertHelpers::lock_acme($account_name, 10, sub {
191 die "ACME account config file '${account_name}' does not exist.\n"
192 if ! -e $account_file;
193
194 my $acme = PMG::RS::Acme->load($account_file);
b9725bac
WB
195 eval {
196 $acme->update_account(\%info);
197 };
198 if (my $err = $@) {
199 die $err if !$force_deactivate;
200 warn "got error, but forced to continue - $err\n";
201 }
5000937e
WB
202 if ($info{status} && $info{status} eq 'deactivated') {
203 my $deactivated_name;
204 for my $i (0..100) {
205 my $candidate = "${acme_account_dir}/_deactivated_${account_name}_${i}";
206 if (! -e $candidate) {
207 $deactivated_name = $candidate;
208 last;
209 }
210 }
211 if ($deactivated_name) {
212 print "Renaming account file from '$account_file' to '$deactivated_name'\n";
213 rename($account_file, $deactivated_name) or
214 warn ".. failed - $!\n";
215 } else {
216 warn "No free slot to rename deactivated account file '$account_file', leaving in place\n";
217 }
218 }
219 });
220 };
221
222 return $rpcenv->fork_worker("acme${msg}", undef, $authuser, $realcmd);
223};
224
225__PACKAGE__->register_method ({
226 name => 'update_account',
227 path => 'account/{name}',
228 method => 'PUT',
229 description => "Update existing ACME account information with CA. Note: not specifying any new account information triggers a refresh.",
230 proxyto => 'master',
231 permissions => { check => [ 'admin' ] },
232 protected => 1,
233 parameters => {
234 additionalProperties => 0,
235 properties => {
236 name => get_standard_option('pmg-acme-account-name'),
237 contact => get_standard_option('pmg-acme-account-contact', {
238 optional => 1,
239 }),
240 },
241 },
242 returns => {
243 type => 'string',
244 },
245 code => sub {
246 my ($param) = @_;
247
248 my $contact = $account_contact_from_param->($param);
249 if (scalar @$contact) {
b9725bac 250 return $update_account->($param, 'update', 0, contact => $contact);
5000937e 251 } else {
b9725bac 252 return $update_account->($param, 'refresh', 0);
5000937e
WB
253 }
254 }});
255
256__PACKAGE__->register_method ({
257 name => 'get_account',
258 path => 'account/{name}',
259 method => 'GET',
260 description => "Return existing ACME account information.",
261 protected => 1,
262 proxyto => 'master',
263 parameters => {
264 additionalProperties => 0,
265 properties => {
266 name => get_standard_option('pmg-acme-account-name'),
267 },
268 },
269 returns => {
270 type => 'object',
271 additionalProperties => 0,
272 properties => {
273 account => {
274 type => 'object',
275 optional => 1,
276 renderer => 'yaml',
277 },
278 directory => get_standard_option('pmg-acme-directory-url', {
279 optional => 1,
280 }),
281 location => {
282 type => 'string',
283 optional => 1,
284 },
285 tos => {
286 type => 'string',
287 optional => 1,
288 },
289 },
290 },
291 code => sub {
292 my ($param) = @_;
293
294 my ($account_name, $account_file) = extract_account_name($param);
295
296 raise_param_exc({'name' => "ACME account config file '${account_name}' does not exist."})
297 if ! -e $account_file;
298
299 my $acme = PMG::RS::Acme->load($account_file);
300 my $data = $acme->account();
301
302 return {
303 account => $data->{account},
304 tos => $data->{tos},
305 location => $data->{location},
306 directory => $data->{directoryUrl},
307 };
308 }});
309
310__PACKAGE__->register_method ({
311 name => 'deactivate_account',
312 path => 'account/{name}',
313 method => 'DELETE',
314 description => "Deactivate existing ACME account at CA.",
315 protected => 1,
316 proxyto => 'master',
317 permissions => { check => [ 'admin' ] },
318 parameters => {
319 additionalProperties => 0,
320 properties => {
321 name => get_standard_option('pmg-acme-account-name'),
b9725bac
WB
322 force => {
323 type => 'boolean',
324 description =>
325 'Delete account data even if the server refuses to deactivate the account.',
326 optional => 1,
327 default => 0,
328 },
5000937e
WB
329 },
330 },
331 returns => {
332 type => 'string',
333 },
334 code => sub {
335 my ($param) = @_;
336
b9725bac
WB
337 my $force_deactivate = extract_param($param, 'force');
338
339 return $update_account->($param, 'deactivate', $force_deactivate, status => 'deactivated');
5000937e
WB
340 }});
341
342__PACKAGE__->register_method ({
343 name => 'get_tos',
344 path => 'tos',
345 method => 'GET',
346 description => "Retrieve ACME TermsOfService URL from CA.",
347 permissions => { user => 'all' },
348 parameters => {
349 additionalProperties => 0,
350 properties => {
351 directory => get_standard_option('pmg-acme-directory-url', {
352 default => $acme_default_directory_url,
353 optional => 1,
354 }),
355 },
356 },
357 returns => {
358 type => 'string',
359 optional => 1,
360 description => 'ACME TermsOfService URL.',
361 },
362 code => sub {
363 my ($param) = @_;
364
365 my $directory = extract_param($param, 'directory') // $acme_default_directory_url;
366
367 my $acme = PMG::RS::Acme->new($directory);
368 my $meta = $acme->get_meta();
369
370 return $meta ? $meta->{termsOfService} : undef;
371 }});
372
373__PACKAGE__->register_method ({
374 name => 'get_directories',
375 path => 'directories',
376 method => 'GET',
377 description => "Get named known ACME directory endpoints.",
378 permissions => { user => 'all' },
379 parameters => {
380 additionalProperties => 0,
381 properties => {},
382 },
383 returns => {
384 type => 'array',
385 items => {
386 type => 'object',
387 additionalProperties => 0,
388 properties => {
389 name => {
390 type => 'string',
391 },
392 url => get_standard_option('pmg-acme-directory-url'),
393 },
394 },
395 },
396 code => sub {
397 my ($param) = @_;
398
399 return $acme_directories;
400 }});
401
402__PACKAGE__->register_method ({
403 name => 'challenge-schema',
404 path => 'challenge-schema',
405 method => 'GET',
406 description => "Get schema of ACME challenge types.",
407 permissions => { user => 'all' },
408 parameters => {
409 additionalProperties => 0,
410 properties => {},
411 },
412 returns => {
413 type => 'array',
414 items => {
415 type => 'object',
416 additionalProperties => 0,
417 properties => {
418 id => {
419 type => 'string',
420 },
421 name => {
422 description => 'Human readable name, falls back to id',
423 type => 'string',
424 },
425 type => {
426 type => 'string',
427 },
428 schema => {
429 type => 'object',
430 },
431 },
432 },
433 },
434 code => sub {
435 my ($param) = @_;
436
437 my $plugin_type_enum = PVE::ACME::Challenge->lookup_types();
438
439 my $res = [];
440
441 for my $type (@$plugin_type_enum) {
442 my $plugin = PVE::ACME::Challenge->lookup($type);
443 next if !$plugin->can('get_supported_plugins');
444
445 my $plugin_type = $plugin->type();
446 my $plugins = $plugin->get_supported_plugins();
447 for my $id (sort keys %$plugins) {
448 my $schema = $plugins->{$id};
449 push @$res, {
450 id => $id,
451 name => $schema->{name} // $id,
452 type => $plugin_type,
453 schema => $schema,
454 };
455 }
456 }
457
458 return $res;
459 }});
460
4611;