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