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