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