]> git.proxmox.com Git - pve-manager.git/blob - PVE/API2/ACME.pm
Use the plugin architecture.
[pve-manager.git] / PVE / API2 / ACME.pm
1 package PVE::API2::ACME;
2
3 use strict;
4 use warnings;
5
6 use PVE::ACME;
7 use PVE::CertHelpers;
8 use PVE::Certificate;
9 use PVE::Exception qw(raise raise_param_exc);
10 use PVE::JSONSchema qw(get_standard_option);
11 use PVE::NodeConfig;
12 use PVE::Tools qw(extract_param);
13
14 use IO::Handle;
15
16 use base qw(PVE::RESTHandler);
17
18 my $acme_account_dir = PVE::CertHelpers::acme_account_dir();
19
20 __PACKAGE__->register_method ({
21 name => 'index',
22 path => '',
23 method => 'GET',
24 permissions => { user => 'all' },
25 description => "ACME index.",
26 parameters => {
27 additionalProperties => 0,
28 properties => {
29 node => get_standard_option('pve-node'),
30 },
31 },
32 returns => {
33 type => 'array',
34 items => {
35 type => "object",
36 properties => {},
37 },
38 links => [ { rel => 'child', href => "{name}" } ],
39 },
40 code => sub {
41 my ($param) = @_;
42
43 return [
44 { name => 'certificate' },
45 ];
46 }});
47
48 my $order_certificate = sub {
49 my ($acme, $domains) = @_;
50 print "Placing ACME order\n";
51 my ($order_url, $order) = $acme->new_order($domains);
52 print "Order URL: $order_url\n";
53 my $index = 0;
54 for my $auth_url (@{$order->{authorizations}}) {
55 print "\nGetting authorization details from '$auth_url'\n";
56 my $auth = $acme->get_authorization($auth_url);
57 my $domain = $auth->{identifier}->{value};
58 if ($auth->{status} eq 'valid') {
59 $domain = %{@{$order->{identifiers}}[$index]}{value};
60 print "$domain is already validated!\n";
61 } else {
62 print "The validation for $domain is pending!\n";
63
64 my ($plugin_type, $plugin_config) = &$get_plugin_type($domain, $acme_node_config);
65
66 my $plugin = PVE::ACME::Challenge->lookup($plugin_type);
67
68 my $challenge = $plugin->extract_challenge($auth->{challenges});
69 my $key_auth = $acme->key_authorization($challenge->{token});
70 my $data = {
71 key_authorization => $key_auth,
72 token => $challenge->{token},
73 url => $challenge->{url},
74 domain => $domain,
75 };
76
77 foreach my $key (keys %$plugin_config) {
78 $data->{plugin}->{$key} = $plugin_config->{$key};
79 }
80
81 $plugin->setup($data);
82
83 print "Triggering validation\n";
84 eval {
85 $acme->request_challenge_validation($data->{url}, $data->{key_authorization});
86 print "Sleeping for 5 seconds\n";
87 sleep 5;
88 while (1) {
89 $auth = $acme->get_authorization($auth_url);
90 if ($auth->{status} eq 'pending') {
91 print "Status is still 'pending', trying again in 30 seconds\n";
92 sleep 30;
93 next;
94 } elsif ($auth->{status} eq 'valid') {
95 print "Status is 'valid'!\n";
96 last;
97 }
98 die "validating challenge '$auth_url' failed\n";
99 }
100 };
101 my $err = $@;
102 eval { $plugin->teardown($data) };
103 warn "$@\n" if $@;
104 die $err if $err;
105 }
106 $index++;
107 }
108 print "\nAll domains validated!\n";
109 print "\nCreating CSR\n";
110 my ($csr, $key) = PVE::Certificate::generate_csr(identifiers => $order->{identifiers});
111
112 my $finalize_error_cnt = 0;
113 print "Checking order status\n";
114 while (1) {
115 $order = $acme->get_order($order_url);
116 if ($order->{status} eq 'pending') {
117 print "still pending, trying to finalize order\n";
118 # FIXME
119 # to be compatible with and without the order ready state
120 # we try to finalize even at the 'pending' state
121 # and give up after 5 unsuccessful tries
122 # this can be removed when the letsencrypt api
123 # definitely has implemented the 'ready' state
124 eval {
125 $acme->finalize_order($order, PVE::Certificate::pem_to_der($csr));
126 };
127 if (my $err = $@) {
128 die $err if $finalize_error_cnt >= 5;
129
130 $finalize_error_cnt++;
131 warn $err;
132 }
133 sleep 5;
134 next;
135 } elsif ($order->{status} eq 'ready') {
136 print "Order is ready, finalizing order\n";
137 $acme->finalize_order($order, PVE::Certificate::pem_to_der($csr));
138 sleep 5;
139 next;
140 } elsif ($order->{status} eq 'processing') {
141 print "still processing, trying again in 30 seconds\n";
142 sleep 30;
143 next;
144 } elsif ($order->{status} eq 'valid') {
145 print "valid!\n";
146 last;
147 }
148 die "order status: $order->{status}\n";
149 }
150
151 print "\nDownloading certificate\n";
152 my $cert = $acme->get_certificate($order);
153
154 return ($cert, $key);
155 };
156
157 __PACKAGE__->register_method ({
158 name => 'new_certificate',
159 path => 'certificate',
160 method => 'POST',
161 description => "Order a new certificate from ACME-compatible CA.",
162 protected => 1,
163 proxyto => 'node',
164 parameters => {
165 additionalProperties => 0,
166 properties => {
167 node => get_standard_option('pve-node'),
168 force => {
169 type => 'boolean',
170 description => 'Overwrite existing custom certificate.',
171 optional => 1,
172 default => 0,
173 },
174 },
175 },
176 returns => {
177 type => 'string',
178 },
179 code => sub {
180 my ($param) = @_;
181
182 my $node = extract_param($param, 'node');
183 my $cert_prefix = PVE::CertHelpers::cert_path_prefix($node);
184
185 raise_param_exc({'force' => "Custom certificate exists but 'force' is not set."})
186 if !$param->{force} && -e "${cert_prefix}.pem";
187
188 my $node_config = PVE::NodeConfig::load_config($node);
189 raise("ACME settings in node configuration are missing!", 400)
190 if !$node_config || !$node_config->{acme};
191 my $acme_node_config = PVE::NodeConfig::parse_acme($node_config->{acme});
192 raise("ACME domain list in node configuration is missing!", 400)
193 if !$acme_node_config;
194
195 my $rpcenv = PVE::RPCEnvironment::get();
196
197 my $authuser = $rpcenv->get_user();
198
199 my $realcmd = sub {
200 STDOUT->autoflush(1);
201 my $account = $acme_node_config->{account} // 'default';
202 my $account_file = "${acme_account_dir}/${account}";
203 die "ACME account config file '$account' does not exist.\n"
204 if ! -e $account_file;
205
206 my $acme = PVE::ACME->new($account_file);
207
208 print "Loading ACME account details\n";
209 $acme->load();
210
211 my ($cert, $key) = $order_certificate->($acme, $acme_node_config->{domains});
212
213 my $code = sub {
214 print "Setting pveproxy certificate and key\n";
215 PVE::CertHelpers::set_cert_files($cert, $key, $cert_prefix, $param->{force});
216
217 print "Restarting pveproxy\n";
218 PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']);
219 };
220 PVE::CertHelpers::cert_lock(10, $code);
221 die "$@\n" if $@;
222 };
223
224 return $rpcenv->fork_worker("acmenewcert", undef, $authuser, $realcmd);
225 }});
226
227 __PACKAGE__->register_method ({
228 name => 'renew_certificate',
229 path => 'certificate',
230 method => 'PUT',
231 description => "Renew existing certificate from CA.",
232 protected => 1,
233 proxyto => 'node',
234 parameters => {
235 additionalProperties => 0,
236 properties => {
237 node => get_standard_option('pve-node'),
238 force => {
239 type => 'boolean',
240 description => 'Force renewal even if expiry is more than 30 days away.',
241 optional => 1,
242 default => 0,
243 },
244 },
245 },
246 returns => {
247 type => 'string',
248 },
249 code => sub {
250 my ($param) = @_;
251
252 my $node = extract_param($param, 'node');
253 my $cert_prefix = PVE::CertHelpers::cert_path_prefix($node);
254
255 raise("No current (custom) certificate found, please order a new certificate!\n")
256 if ! -e "${cert_prefix}.pem";
257
258 my $expires_soon = PVE::Certificate::check_expiry("${cert_prefix}.pem", time() + 30*24*60*60);
259 raise_param_exc({'force' => "Certificate does not expire within the next 30 days, and 'force' is not set."})
260 if !$expires_soon && !$param->{force};
261
262 my $node_config = PVE::NodeConfig::load_config($node);
263 raise("ACME settings in node configuration are missing!", 400)
264 if !$node_config || !$node_config->{acme};
265 my $acme_node_config = PVE::NodeConfig::parse_acme($node_config->{acme});
266 raise("ACME domain list in node configuration is missing!", 400)
267 if !$acme_node_config;
268
269 my $rpcenv = PVE::RPCEnvironment::get();
270
271 my $authuser = $rpcenv->get_user();
272
273 my $old_cert = PVE::Tools::file_get_contents("${cert_prefix}.pem");
274
275 my $realcmd = sub {
276 STDOUT->autoflush(1);
277 my $account = $acme_node_config->{account} // 'default';
278 my $account_file = "${acme_account_dir}/${account}";
279 die "ACME account config file '$account' does not exist.\n"
280 if ! -e $account_file;
281
282 my $acme = PVE::ACME->new($account_file);
283
284 print "Loading ACME account details\n";
285 $acme->load();
286
287 my ($cert, $key) = $order_certificate->($acme, $acme_node_config->{domains});
288
289 my $code = sub {
290 print "Setting pveproxy certificate and key\n";
291 PVE::CertHelpers::set_cert_files($cert, $key, $cert_prefix, 1);
292
293 print "Restarting pveproxy\n";
294 PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']);
295 };
296 PVE::CertHelpers::cert_lock(10, $code);
297 die "$@\n" if $@;
298
299 print "Revoking old certificate\n";
300 $acme->revoke_certificate($old_cert);
301 };
302
303 return $rpcenv->fork_worker("acmerenew", undef, $authuser, $realcmd);
304 }});
305
306 __PACKAGE__->register_method ({
307 name => 'revoke_certificate',
308 path => 'certificate',
309 method => 'DELETE',
310 description => "Revoke existing certificate from CA.",
311 protected => 1,
312 proxyto => 'node',
313 parameters => {
314 additionalProperties => 0,
315 properties => {
316 node => get_standard_option('pve-node'),
317 },
318 },
319 returns => {
320 type => 'string',
321 },
322 code => sub {
323 my ($param) = @_;
324
325 my $node = extract_param($param, 'node');
326 my $cert_prefix = PVE::CertHelpers::cert_path_prefix($node);
327
328 my $node_config = PVE::NodeConfig::load_config($node);
329 raise("ACME settings in node configuration are missing!", 400)
330 if !$node_config || !$node_config->{acme};
331 my $acme_node_config = PVE::NodeConfig::parse_acme($node_config->{acme});
332 raise("ACME domain list in node configuration is missing!", 400)
333 if !$acme_node_config;
334
335 my $rpcenv = PVE::RPCEnvironment::get();
336
337 my $authuser = $rpcenv->get_user();
338
339 my $cert = PVE::Tools::file_get_contents("${cert_prefix}.pem");
340
341 my $realcmd = sub {
342 STDOUT->autoflush(1);
343 my $account = $acme_node_config->{account} // 'default';
344 my $account_file = "${acme_account_dir}/${account}";
345 die "ACME account config file '$account' does not exist.\n"
346 if ! -e $account_file;
347
348 my $acme = PVE::ACME->new($account_file);
349
350 print "Loading ACME account details\n";
351 $acme->load();
352
353 print "Revoking old certificate\n";
354 $acme->revoke_certificate($cert);
355
356 my $code = sub {
357 print "Deleting certificate files\n";
358 unlink "${cert_prefix}.pem";
359 unlink "${cert_prefix}.key";
360
361 print "Restarting pveproxy to revert to self-signed certificates\n";
362 PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'pveproxy']);
363 };
364
365 PVE::CertHelpers::cert_lock(10, $code);
366 die "$@\n" if $@;
367 };
368
369 return $rpcenv->fork_worker("acmerevoke", undef, $authuser, $realcmd);
370 }});
371
372 1;