]> git.proxmox.com Git - proxmox-backup.git/blame - src/api2/node/certificates.rs
move worker_task.rs into proxmox-rest-server crate
[proxmox-backup.git] / src / api2 / node / certificates.rs
CommitLineData
4088d5bc
WB
1use std::convert::TryFrom;
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::{bail, format_err, Error};
6use openssl::pkey::PKey;
7use openssl::x509::X509;
8use serde::{Deserialize, Serialize};
9
10use proxmox::api::router::SubdirMap;
11use proxmox::api::{api, Permission, Router, RpcEnvironment};
12use proxmox::list_subdirs_api_method;
13
049a22a3 14use pbs_api_types::{NODE_SCHEMA, PRIV_SYS_MODIFY};
af06decd 15use pbs_buildcfg::configdir;
4805edc4 16use pbs_tools::cert;
af06decd 17
4088d5bc 18use crate::acme::AcmeClient;
39c5db7f 19use crate::api2::types::AcmeDomain;
4088d5bc 20use crate::config::node::NodeConfig;
b9700a9f 21use proxmox_rest_server::WorkerTask;
4088d5bc
WB
22
23pub const ROUTER: Router = Router::new()
24 .get(&list_subdirs_api_method!(SUBDIRS))
25 .subdirs(SUBDIRS);
26
27const 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
38const ACME_ROUTER: Router = Router::new()
39 .get(&list_subdirs_api_method!(ACME_SUBDIRS))
40 .subdirs(ACME_SUBDIRS);
41
42const 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")]
63pub 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
100impl 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
132fn 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
140fn 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.
161pub 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 204pub 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 250pub 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
273struct OrderedCertificate {
274 certificate: hyper::body::Bytes,
275 private_key_pem: Vec<u8>,
276}
277
278async 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
435async 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.
484pub 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).
507pub 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.
516pub 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
522fn 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).
557pub 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}