]>
Commit | Line | Data |
---|---|---|
4088d5bc WB |
1 | use std::convert::TryFrom; |
2 | use std::sync::Arc; | |
3 | use std::time::Duration; | |
4 | ||
5 | use anyhow::{bail, format_err, Error}; | |
6 | use openssl::pkey::PKey; | |
7 | use openssl::x509::X509; | |
8 | use serde::{Deserialize, Serialize}; | |
9 | ||
10 | use proxmox::api::router::SubdirMap; | |
11 | use proxmox::api::{api, Permission, Router, RpcEnvironment}; | |
12 | use proxmox::list_subdirs_api_method; | |
13 | ||
049a22a3 | 14 | use pbs_api_types::{NODE_SCHEMA, PRIV_SYS_MODIFY}; |
af06decd | 15 | use pbs_buildcfg::configdir; |
4805edc4 | 16 | use pbs_tools::cert; |
af06decd | 17 | |
4088d5bc | 18 | use crate::acme::AcmeClient; |
39c5db7f | 19 | use crate::api2::types::AcmeDomain; |
4088d5bc | 20 | use crate::config::node::NodeConfig; |
b9700a9f | 21 | use proxmox_rest_server::WorkerTask; |
4088d5bc WB |
22 | |
23 | pub const ROUTER: Router = Router::new() | |
24 | .get(&list_subdirs_api_method!(SUBDIRS)) | |
25 | .subdirs(SUBDIRS); | |
26 | ||
27 | const SUBDIRS: SubdirMap = &[ | |
28 | ("acme", &ACME_ROUTER), | |
29 | ( | |
30 | "custom", | |
31 | &Router::new() | |
32 | .post(&API_METHOD_UPLOAD_CUSTOM_CERTIFICATE) | |
33 | .delete(&API_METHOD_DELETE_CUSTOM_CERTIFICATE), | |
34 | ), | |
35 | ("info", &Router::new().get(&API_METHOD_GET_INFO)), | |
36 | ]; | |
37 | ||
38 | const ACME_ROUTER: Router = Router::new() | |
39 | .get(&list_subdirs_api_method!(ACME_SUBDIRS)) | |
40 | .subdirs(ACME_SUBDIRS); | |
41 | ||
42 | const ACME_SUBDIRS: SubdirMap = &[( | |
43 | "certificate", | |
44 | &Router::new() | |
45 | .post(&API_METHOD_NEW_ACME_CERT) | |
46 | .put(&API_METHOD_RENEW_ACME_CERT), | |
47 | )]; | |
48 | ||
49 | #[api( | |
50 | properties: { | |
51 | san: { | |
52 | type: Array, | |
53 | items: { | |
54 | description: "A SubjectAlternateName entry.", | |
55 | type: String, | |
56 | }, | |
57 | }, | |
58 | }, | |
59 | )] | |
60 | /// Certificate information. | |
61 | #[derive(Deserialize, Serialize)] | |
62 | #[serde(rename_all = "kebab-case")] | |
63 | pub struct CertificateInfo { | |
64 | /// Certificate file name. | |
65 | #[serde(skip_serializing_if = "Option::is_none")] | |
66 | filename: Option<String>, | |
67 | ||
68 | /// Certificate subject name. | |
69 | subject: String, | |
70 | ||
71 | /// List of certificate's SubjectAlternativeName entries. | |
72 | san: Vec<String>, | |
73 | ||
74 | /// Certificate issuer name. | |
75 | issuer: String, | |
76 | ||
77 | /// Certificate's notBefore timestamp (UNIX epoch). | |
78 | #[serde(skip_serializing_if = "Option::is_none")] | |
79 | notbefore: Option<i64>, | |
80 | ||
81 | /// Certificate's notAfter timestamp (UNIX epoch). | |
82 | #[serde(skip_serializing_if = "Option::is_none")] | |
83 | notafter: Option<i64>, | |
84 | ||
85 | /// Certificate in PEM format. | |
86 | #[serde(skip_serializing_if = "Option::is_none")] | |
87 | pem: Option<String>, | |
88 | ||
89 | /// Certificate's public key algorithm. | |
90 | public_key_type: String, | |
91 | ||
92 | /// Certificate's public key size if available. | |
93 | #[serde(skip_serializing_if = "Option::is_none")] | |
94 | public_key_bits: Option<u32>, | |
95 | ||
96 | /// The SSL Fingerprint. | |
97 | fingerprint: Option<String>, | |
98 | } | |
99 | ||
100 | impl TryFrom<&cert::CertInfo> for CertificateInfo { | |
101 | type Error = Error; | |
102 | ||
103 | fn try_from(info: &cert::CertInfo) -> Result<Self, Self::Error> { | |
104 | let pubkey = info.public_key()?; | |
105 | ||
106 | Ok(Self { | |
107 | filename: None, | |
108 | subject: info.subject_name()?, | |
109 | san: info | |
110 | .subject_alt_names() | |
111 | .map(|san| { | |
112 | san.into_iter() | |
113 | // FIXME: Support `.ipaddress()`? | |
114 | .filter_map(|name| name.dnsname().map(str::to_owned)) | |
115 | .collect() | |
116 | }) | |
117 | .unwrap_or_default(), | |
118 | issuer: info.issuer_name()?, | |
119 | notbefore: info.not_before_unix().ok(), | |
120 | notafter: info.not_after_unix().ok(), | |
121 | pem: None, | |
122 | public_key_type: openssl::nid::Nid::from_raw(pubkey.id().as_raw()) | |
123 | .long_name() | |
124 | .unwrap_or("<unsupported key type>") | |
125 | .to_owned(), | |
126 | public_key_bits: Some(pubkey.bits()), | |
127 | fingerprint: Some(info.fingerprint()?), | |
128 | }) | |
129 | } | |
130 | } | |
131 | ||
132 | fn get_certificate_pem() -> Result<String, Error> { | |
133 | let cert_path = configdir!("/proxy.pem"); | |
134 | let cert_pem = proxmox::tools::fs::file_get_contents(&cert_path)?; | |
135 | String::from_utf8(cert_pem) | |
136 | .map_err(|_| format_err!("certificate in {:?} is not a valid PEM file", cert_path)) | |
137 | } | |
138 | ||
139 | // to deduplicate error messages | |
140 | fn pem_to_cert_info(pem: &[u8]) -> Result<cert::CertInfo, Error> { | |
141 | cert::CertInfo::from_pem(pem) | |
142 | .map_err(|err| format_err!("error loading proxy certificate: {}", err)) | |
143 | } | |
144 | ||
145 | #[api( | |
146 | input: { | |
147 | properties: { | |
148 | node: { schema: NODE_SCHEMA }, | |
149 | }, | |
150 | }, | |
151 | access: { | |
152 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
153 | }, | |
154 | returns: { | |
155 | type: Array, | |
156 | items: { type: CertificateInfo }, | |
157 | description: "List of certificate infos.", | |
158 | }, | |
159 | )] | |
160 | /// Get certificate info. | |
161 | pub fn get_info() -> Result<Vec<CertificateInfo>, Error> { | |
162 | let cert_pem = get_certificate_pem()?; | |
163 | let cert = pem_to_cert_info(cert_pem.as_bytes())?; | |
164 | ||
165 | Ok(vec![CertificateInfo { | |
166 | filename: Some("proxy.pem".to_string()), // we only have the one | |
167 | pem: Some(cert_pem), | |
168 | ..CertificateInfo::try_from(&cert)? | |
169 | }]) | |
170 | } | |
171 | ||
172 | #[api( | |
173 | input: { | |
174 | properties: { | |
175 | node: { schema: NODE_SCHEMA }, | |
176 | certificates: { description: "PEM encoded certificate (chain)." }, | |
177 | key: { description: "PEM encoded private key." }, | |
fca1cef2 | 178 | // FIXME: widget-toolkit should have an option to disable using these 2 parameters... |
4088d5bc | 179 | restart: { |
fca1cef2 WB |
180 | description: "UI compatibility parameter, ignored", |
181 | type: Boolean, | |
4088d5bc WB |
182 | optional: true, |
183 | default: false, | |
184 | }, | |
4088d5bc WB |
185 | force: { |
186 | description: "Force replacement of existing files.", | |
187 | type: Boolean, | |
188 | optional: true, | |
189 | default: false, | |
190 | }, | |
191 | }, | |
192 | }, | |
193 | access: { | |
194 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
195 | }, | |
196 | returns: { | |
197 | type: Array, | |
198 | items: { type: CertificateInfo }, | |
199 | description: "List of certificate infos.", | |
200 | }, | |
201 | protected: true, | |
202 | )] | |
203 | /// Upload a custom certificate. | |
fca1cef2 | 204 | pub async fn upload_custom_certificate( |
4088d5bc WB |
205 | certificates: String, |
206 | key: String, | |
4088d5bc WB |
207 | ) -> Result<Vec<CertificateInfo>, Error> { |
208 | let certificates = X509::stack_from_pem(certificates.as_bytes()) | |
209 | .map_err(|err| format_err!("failed to decode certificate chain: {}", err))?; | |
210 | let key = PKey::private_key_from_pem(key.as_bytes()) | |
211 | .map_err(|err| format_err!("failed to parse private key: {}", err))?; | |
212 | ||
213 | let certificates = certificates | |
214 | .into_iter() | |
215 | .try_fold(Vec::<u8>::new(), |mut stack, cert| -> Result<_, Error> { | |
216 | if !stack.is_empty() { | |
217 | stack.push(b'\n'); | |
218 | } | |
219 | stack.extend(cert.to_pem()?); | |
220 | Ok(stack) | |
221 | }) | |
222 | .map_err(|err| format_err!("error formatting certificate chain as PEM: {}", err))?; | |
223 | ||
224 | let key = key.private_key_to_pem_pkcs8()?; | |
225 | ||
fca1cef2 WB |
226 | crate::config::set_proxy_certificate(&certificates, &key)?; |
227 | crate::server::reload_proxy_certificate().await?; | |
4088d5bc WB |
228 | |
229 | get_info() | |
230 | } | |
231 | ||
232 | #[api( | |
233 | input: { | |
234 | properties: { | |
235 | node: { schema: NODE_SCHEMA }, | |
236 | restart: { | |
fca1cef2 WB |
237 | description: "UI compatibility parameter, ignored", |
238 | type: Boolean, | |
4088d5bc WB |
239 | optional: true, |
240 | default: false, | |
241 | }, | |
242 | }, | |
243 | }, | |
244 | access: { | |
245 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
246 | }, | |
247 | protected: true, | |
248 | )] | |
249 | /// Delete the current certificate and regenerate a self signed one. | |
fca1cef2 | 250 | pub async fn delete_custom_certificate() -> Result<(), Error> { |
4088d5bc WB |
251 | let cert_path = configdir!("/proxy.pem"); |
252 | // Here we fail since if this fails nothing else breaks anyway | |
253 | std::fs::remove_file(&cert_path) | |
254 | .map_err(|err| format_err!("failed to unlink {:?} - {}", cert_path, err))?; | |
255 | ||
256 | let key_path = configdir!("/proxy.key"); | |
257 | if let Err(err) = std::fs::remove_file(&key_path) { | |
258 | // Here we just log since the certificate is already gone and we'd rather try to generate | |
259 | // the self-signed certificate even if this fails: | |
260 | log::error!( | |
261 | "failed to remove certificate private key {:?} - {}", | |
262 | key_path, | |
263 | err | |
264 | ); | |
265 | } | |
266 | ||
267 | crate::config::update_self_signed_cert(true)?; | |
fca1cef2 | 268 | crate::server::reload_proxy_certificate().await?; |
4088d5bc WB |
269 | |
270 | Ok(()) | |
271 | } | |
272 | ||
273 | struct OrderedCertificate { | |
274 | certificate: hyper::body::Bytes, | |
275 | private_key_pem: Vec<u8>, | |
276 | } | |
277 | ||
278 | async fn order_certificate( | |
279 | worker: Arc<WorkerTask>, | |
280 | node_config: &NodeConfig, | |
281 | ) -> Result<Option<OrderedCertificate>, Error> { | |
282 | use proxmox_acme_rs::authorization::Status; | |
283 | use proxmox_acme_rs::order::Identifier; | |
284 | ||
285 | let domains = node_config.acme_domains().try_fold( | |
286 | Vec::<AcmeDomain>::new(), | |
287 | |mut acc, domain| -> Result<_, Error> { | |
288 | let mut domain = domain?; | |
289 | domain.domain.make_ascii_lowercase(); | |
290 | if let Some(alias) = &mut domain.alias { | |
291 | alias.make_ascii_lowercase(); | |
292 | } | |
293 | acc.push(domain); | |
294 | Ok(acc) | |
295 | }, | |
296 | )?; | |
297 | ||
298 | let get_domain_config = |domain: &str| { | |
299 | domains | |
300 | .iter() | |
301 | .find(|d| d.domain == domain) | |
302 | .ok_or_else(|| format_err!("no config for domain '{}'", domain)) | |
303 | }; | |
304 | ||
305 | if domains.is_empty() { | |
306 | worker.log("No domains configured to be ordered from an ACME server."); | |
307 | return Ok(None); | |
308 | } | |
309 | ||
310 | let (plugins, _) = crate::config::acme::plugin::config()?; | |
311 | ||
312 | let mut acme = node_config.acme_client().await?; | |
313 | ||
314 | worker.log("Placing ACME order"); | |
315 | let order = acme | |
316 | .new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase())) | |
317 | .await?; | |
318 | worker.log(format!("Order URL: {}", order.location)); | |
319 | ||
320 | let identifiers: Vec<String> = order | |
321 | .data | |
322 | .identifiers | |
323 | .iter() | |
324 | .map(|identifier| match identifier { | |
325 | Identifier::Dns(domain) => domain.clone(), | |
326 | }) | |
327 | .collect(); | |
328 | ||
329 | for auth_url in &order.data.authorizations { | |
330 | worker.log(format!("Getting authorization details from '{}'", auth_url)); | |
331 | let mut auth = acme.get_authorization(&auth_url).await?; | |
332 | ||
333 | let domain = match &mut auth.identifier { | |
334 | Identifier::Dns(domain) => domain.to_ascii_lowercase(), | |
335 | }; | |
336 | ||
337 | if auth.status == Status::Valid { | |
338 | worker.log(format!("{} is already validated!", domain)); | |
339 | continue; | |
340 | } | |
341 | ||
342 | worker.log(format!("The validation for {} is pending", domain)); | |
343 | let domain_config: &AcmeDomain = get_domain_config(&domain)?; | |
344 | let plugin_id = domain_config.plugin.as_deref().unwrap_or("standalone"); | |
345 | let mut plugin_cfg = | |
346 | crate::acme::get_acme_plugin(&plugins, plugin_id)?.ok_or_else(|| { | |
347 | format_err!("plugin '{}' for domain '{}' not found!", plugin_id, domain) | |
348 | })?; | |
349 | ||
350 | worker.log("Setting up validation plugin"); | |
351 | let validation_url = plugin_cfg | |
352 | .setup(&mut acme, &auth, domain_config, Arc::clone(&worker)) | |
353 | .await?; | |
354 | ||
355 | let result = request_validation(&worker, &mut acme, auth_url, validation_url).await; | |
356 | ||
357 | if let Err(err) = plugin_cfg | |
358 | .teardown(&mut acme, &auth, domain_config, Arc::clone(&worker)) | |
359 | .await | |
360 | { | |
361 | worker.warn(format!( | |
362 | "Failed to teardown plugin '{}' for domain '{}' - {}", | |
363 | plugin_id, domain, err | |
364 | )); | |
365 | } | |
366 | ||
367 | let _: () = result?; | |
368 | } | |
369 | ||
370 | worker.log("All domains validated"); | |
371 | worker.log("Creating CSR"); | |
372 | ||
373 | let csr = proxmox_acme_rs::util::Csr::generate(&identifiers, &Default::default())?; | |
374 | let mut finalize_error_cnt = 0u8; | |
375 | let order_url = &order.location; | |
376 | let mut order; | |
377 | loop { | |
378 | use proxmox_acme_rs::order::Status; | |
379 | ||
380 | order = acme.get_order(order_url).await?; | |
381 | ||
382 | match order.status { | |
383 | Status::Pending => { | |
384 | worker.log("still pending, trying to finalize anyway"); | |
385 | let finalize = order | |
386 | .finalize | |
387 | .as_deref() | |
388 | .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; | |
389 | if let Err(err) = acme.finalize(finalize, &csr.data).await { | |
390 | if finalize_error_cnt >= 5 { | |
391 | return Err(err.into()); | |
392 | } | |
393 | ||
394 | finalize_error_cnt += 1; | |
395 | } | |
396 | tokio::time::sleep(Duration::from_secs(5)).await; | |
397 | } | |
398 | Status::Ready => { | |
399 | worker.log("order is ready, finalizing"); | |
400 | let finalize = order | |
401 | .finalize | |
402 | .as_deref() | |
403 | .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; | |
404 | acme.finalize(finalize, &csr.data).await?; | |
405 | tokio::time::sleep(Duration::from_secs(5)).await; | |
406 | } | |
407 | Status::Processing => { | |
408 | worker.log("still processing, trying again in 30 seconds"); | |
409 | tokio::time::sleep(Duration::from_secs(30)).await; | |
410 | } | |
411 | Status::Valid => { | |
412 | worker.log("valid"); | |
413 | break; | |
414 | } | |
415 | other => bail!("order status: {:?}", other), | |
416 | } | |
417 | } | |
418 | ||
419 | worker.log("Downloading certificate"); | |
420 | let certificate = acme | |
421 | .get_certificate( | |
422 | order | |
423 | .certificate | |
424 | .as_deref() | |
425 | .ok_or_else(|| format_err!("missing certificate url in finalized order"))?, | |
426 | ) | |
427 | .await?; | |
428 | ||
429 | Ok(Some(OrderedCertificate { | |
430 | certificate, | |
431 | private_key_pem: csr.private_key_pem, | |
432 | })) | |
433 | } | |
434 | ||
435 | async fn request_validation( | |
436 | worker: &WorkerTask, | |
437 | acme: &mut AcmeClient, | |
438 | auth_url: &str, | |
439 | validation_url: &str, | |
440 | ) -> Result<(), Error> { | |
441 | worker.log("Triggering validation"); | |
442 | acme.request_challenge_validation(&validation_url).await?; | |
443 | ||
444 | worker.log("Sleeping for 5 seconds"); | |
445 | tokio::time::sleep(Duration::from_secs(5)).await; | |
446 | ||
447 | loop { | |
448 | use proxmox_acme_rs::authorization::Status; | |
449 | ||
450 | let auth = acme.get_authorization(&auth_url).await?; | |
451 | match auth.status { | |
452 | Status::Pending => { | |
453 | worker.log("Status is still 'pending', trying again in 10 seconds"); | |
454 | tokio::time::sleep(Duration::from_secs(10)).await; | |
455 | } | |
456 | Status::Valid => return Ok(()), | |
457 | other => bail!( | |
458 | "validating challenge '{}' failed - status: {:?}", | |
459 | validation_url, | |
460 | other | |
461 | ), | |
462 | } | |
463 | } | |
464 | } | |
465 | ||
466 | #[api( | |
467 | input: { | |
468 | properties: { | |
469 | node: { schema: NODE_SCHEMA }, | |
470 | force: { | |
471 | description: "Force replacement of existing files.", | |
472 | type: Boolean, | |
473 | optional: true, | |
474 | default: false, | |
475 | }, | |
476 | }, | |
477 | }, | |
478 | access: { | |
479 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
480 | }, | |
481 | protected: true, | |
482 | )] | |
483 | /// Order a new ACME certificate. | |
484 | pub fn new_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> { | |
485 | spawn_certificate_worker("acme-new-cert", force, rpcenv) | |
486 | } | |
487 | ||
488 | #[api( | |
489 | input: { | |
490 | properties: { | |
491 | node: { schema: NODE_SCHEMA }, | |
492 | force: { | |
493 | description: "Force replacement of existing files.", | |
494 | type: Boolean, | |
495 | optional: true, | |
496 | default: false, | |
497 | }, | |
498 | }, | |
499 | }, | |
500 | access: { | |
501 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
502 | }, | |
503 | protected: true, | |
504 | )] | |
505 | /// Renew the current ACME certificate if it expires within 30 days (or always if the `force` | |
506 | /// parameter is set). | |
507 | pub fn renew_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> { | |
508 | if !cert_expires_soon()? && !force { | |
509 | bail!("Certificate does not expire within the next 30 days and 'force' is not set.") | |
510 | } | |
511 | ||
512 | spawn_certificate_worker("acme-renew-cert", force, rpcenv) | |
513 | } | |
514 | ||
515 | /// Check whether the current certificate expires within the next 30 days. | |
516 | pub fn cert_expires_soon() -> Result<bool, Error> { | |
517 | let cert = pem_to_cert_info(get_certificate_pem()?.as_bytes())?; | |
518 | cert.is_expired_after_epoch(proxmox::tools::time::epoch_i64() + 30 * 24 * 60 * 60) | |
519 | .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err)) | |
520 | } | |
521 | ||
522 | fn spawn_certificate_worker( | |
523 | name: &'static str, | |
524 | force: bool, | |
525 | rpcenv: &mut dyn RpcEnvironment, | |
526 | ) -> Result<String, Error> { | |
527 | // We only have 1 certificate path in PBS which makes figuring out whether or not it is a | |
528 | // custom one too hard... We keep the parameter because the widget-toolkit may be using it... | |
529 | let _ = force; | |
530 | ||
531 | let (node_config, _digest) = crate::config::node::config()?; | |
532 | ||
049a22a3 | 533 | let auth_id = rpcenv.get_auth_id().unwrap(); |
4088d5bc WB |
534 | |
535 | WorkerTask::spawn(name, None, auth_id, true, move |worker| async move { | |
536 | if let Some(cert) = order_certificate(worker, &node_config).await? { | |
fca1cef2 WB |
537 | crate::config::set_proxy_certificate(&cert.certificate, &cert.private_key_pem)?; |
538 | crate::server::reload_proxy_certificate().await?; | |
4088d5bc WB |
539 | } |
540 | Ok(()) | |
541 | }) | |
542 | } | |
543 | ||
544 | #[api( | |
545 | input: { | |
546 | properties: { | |
547 | node: { schema: NODE_SCHEMA }, | |
548 | }, | |
549 | }, | |
550 | access: { | |
551 | permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), | |
552 | }, | |
553 | protected: true, | |
554 | )] | |
555 | /// Renew the current ACME certificate if it expires within 30 days (or always if the `force` | |
556 | /// parameter is set). | |
557 | pub fn revoke_acme_cert(rpcenv: &mut dyn RpcEnvironment) -> Result<String, Error> { | |
558 | let (node_config, _digest) = crate::config::node::config()?; | |
559 | ||
560 | let cert_pem = get_certificate_pem()?; | |
561 | ||
049a22a3 | 562 | let auth_id = rpcenv.get_auth_id().unwrap(); |
4088d5bc WB |
563 | |
564 | WorkerTask::spawn( | |
565 | "acme-revoke-cert", | |
566 | None, | |
567 | auth_id, | |
568 | true, | |
569 | move |worker| async move { | |
570 | worker.log("Loading ACME account"); | |
571 | let mut acme = node_config.acme_client().await?; | |
572 | worker.log("Revoking old certificate"); | |
573 | acme.revoke_certificate(cert_pem.as_bytes(), None).await?; | |
574 | worker.log("Deleting certificate and regenerating a self-signed one"); | |
fca1cef2 | 575 | delete_custom_certificate().await?; |
4088d5bc WB |
576 | Ok(()) |
577 | }, | |
578 | ) | |
579 | } |