From 4088d5bc62afc7bd1acda474bf20ced3c3c1d034 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Mon, 3 May 2021 11:39:55 +0200 Subject: [PATCH] add node/{node}/certificates api call API like in PVE: GET .../info => current cert information POST .../custom => upload custom certificate DELETE .../custom => delete custom certificate POST .../acme/certificate => order acme certificate PUT .../acme/certificate => renew expiring acme cert Signed-off-by: Wolfgang Bumiller --- src/api2/node.rs | 2 + src/api2/node/certificates.rs | 579 ++++++++++++++++++++++++++++++++++ src/config.rs | 18 +- 3 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 src/api2/node/certificates.rs diff --git a/src/api2/node.rs b/src/api2/node.rs index 1f3e46a9..ebb51aaf 100644 --- a/src/api2/node.rs +++ b/src/api2/node.rs @@ -27,6 +27,7 @@ use crate::tools; use crate::tools::ticket::{self, Empty, Ticket}; pub mod apt; +pub mod certificates; pub mod disks; pub mod dns; pub mod network; @@ -314,6 +315,7 @@ fn upgrade_to_websocket( pub const SUBDIRS: SubdirMap = &[ ("apt", &apt::ROUTER), + ("certificates", &certificates::ROUTER), ("disks", &disks::ROUTER), ("dns", &dns::ROUTER), ("journal", &journal::ROUTER), diff --git a/src/api2/node/certificates.rs b/src/api2/node/certificates.rs new file mode 100644 index 00000000..edc6e536 --- /dev/null +++ b/src/api2/node/certificates.rs @@ -0,0 +1,579 @@ +use std::convert::TryFrom; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, format_err, Error}; +use openssl::pkey::PKey; +use openssl::x509::X509; +use serde::{Deserialize, Serialize}; + +use proxmox::api::router::SubdirMap; +use proxmox::api::{api, Permission, Router, RpcEnvironment}; +use proxmox::list_subdirs_api_method; + +use crate::acme::AcmeClient; +use crate::api2::types::Authid; +use crate::api2::types::NODE_SCHEMA; +use crate::config::acl::PRIV_SYS_MODIFY; +use crate::config::acme::AcmeDomain; +use crate::config::node::NodeConfig; +use crate::server::WorkerTask; +use crate::tools::cert; + +pub const ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); + +const SUBDIRS: SubdirMap = &[ + ("acme", &ACME_ROUTER), + ( + "custom", + &Router::new() + .post(&API_METHOD_UPLOAD_CUSTOM_CERTIFICATE) + .delete(&API_METHOD_DELETE_CUSTOM_CERTIFICATE), + ), + ("info", &Router::new().get(&API_METHOD_GET_INFO)), +]; + +const ACME_ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(ACME_SUBDIRS)) + .subdirs(ACME_SUBDIRS); + +const ACME_SUBDIRS: SubdirMap = &[( + "certificate", + &Router::new() + .post(&API_METHOD_NEW_ACME_CERT) + .put(&API_METHOD_RENEW_ACME_CERT), +)]; + +#[api( + properties: { + san: { + type: Array, + items: { + description: "A SubjectAlternateName entry.", + type: String, + }, + }, + }, +)] +/// Certificate information. +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct CertificateInfo { + /// Certificate file name. + #[serde(skip_serializing_if = "Option::is_none")] + filename: Option, + + /// Certificate subject name. + subject: String, + + /// List of certificate's SubjectAlternativeName entries. + san: Vec, + + /// Certificate issuer name. + issuer: String, + + /// Certificate's notBefore timestamp (UNIX epoch). + #[serde(skip_serializing_if = "Option::is_none")] + notbefore: Option, + + /// Certificate's notAfter timestamp (UNIX epoch). + #[serde(skip_serializing_if = "Option::is_none")] + notafter: Option, + + /// Certificate in PEM format. + #[serde(skip_serializing_if = "Option::is_none")] + pem: Option, + + /// Certificate's public key algorithm. + public_key_type: String, + + /// Certificate's public key size if available. + #[serde(skip_serializing_if = "Option::is_none")] + public_key_bits: Option, + + /// The SSL Fingerprint. + fingerprint: Option, +} + +impl TryFrom<&cert::CertInfo> for CertificateInfo { + type Error = Error; + + fn try_from(info: &cert::CertInfo) -> Result { + let pubkey = info.public_key()?; + + Ok(Self { + filename: None, + subject: info.subject_name()?, + san: info + .subject_alt_names() + .map(|san| { + san.into_iter() + // FIXME: Support `.ipaddress()`? + .filter_map(|name| name.dnsname().map(str::to_owned)) + .collect() + }) + .unwrap_or_default(), + issuer: info.issuer_name()?, + notbefore: info.not_before_unix().ok(), + notafter: info.not_after_unix().ok(), + pem: None, + public_key_type: openssl::nid::Nid::from_raw(pubkey.id().as_raw()) + .long_name() + .unwrap_or("") + .to_owned(), + public_key_bits: Some(pubkey.bits()), + fingerprint: Some(info.fingerprint()?), + }) + } +} + +fn get_certificate_pem() -> Result { + let cert_path = configdir!("/proxy.pem"); + let cert_pem = proxmox::tools::fs::file_get_contents(&cert_path)?; + String::from_utf8(cert_pem) + .map_err(|_| format_err!("certificate in {:?} is not a valid PEM file", cert_path)) +} + +// to deduplicate error messages +fn pem_to_cert_info(pem: &[u8]) -> Result { + cert::CertInfo::from_pem(pem) + .map_err(|err| format_err!("error loading proxy certificate: {}", err)) +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + returns: { + type: Array, + items: { type: CertificateInfo }, + description: "List of certificate infos.", + }, +)] +/// Get certificate info. +pub fn get_info() -> Result, Error> { + let cert_pem = get_certificate_pem()?; + let cert = pem_to_cert_info(cert_pem.as_bytes())?; + + Ok(vec![CertificateInfo { + filename: Some("proxy.pem".to_string()), // we only have the one + pem: Some(cert_pem), + ..CertificateInfo::try_from(&cert)? + }]) +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + certificates: { description: "PEM encoded certificate (chain)." }, + key: { description: "PEM encoded private key." }, + restart: { + description: "Restart proxmox-backup-proxy", + optional: true, + default: false, + }, + // FIXME: widget-toolkit should have an option to disable using this parameter... + force: { + description: "Force replacement of existing files.", + type: Boolean, + optional: true, + default: false, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + returns: { + type: Array, + items: { type: CertificateInfo }, + description: "List of certificate infos.", + }, + protected: true, +)] +/// Upload a custom certificate. +pub fn upload_custom_certificate( + certificates: String, + key: String, + restart: bool, +) -> Result, Error> { + let certificates = X509::stack_from_pem(certificates.as_bytes()) + .map_err(|err| format_err!("failed to decode certificate chain: {}", err))?; + let key = PKey::private_key_from_pem(key.as_bytes()) + .map_err(|err| format_err!("failed to parse private key: {}", err))?; + + let certificates = certificates + .into_iter() + .try_fold(Vec::::new(), |mut stack, cert| -> Result<_, Error> { + if !stack.is_empty() { + stack.push(b'\n'); + } + stack.extend(cert.to_pem()?); + Ok(stack) + }) + .map_err(|err| format_err!("error formatting certificate chain as PEM: {}", err))?; + + let key = key.private_key_to_pem_pkcs8()?; + + crate::config::set_proxy_certificate(&certificates, &key, restart)?; + + get_info() +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + restart: { + description: "Restart proxmox-backup-proxy", + optional: true, + default: false, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + protected: true, +)] +/// Delete the current certificate and regenerate a self signed one. +pub fn delete_custom_certificate(restart: bool) -> Result<(), Error> { + let cert_path = configdir!("/proxy.pem"); + // Here we fail since if this fails nothing else breaks anyway + std::fs::remove_file(&cert_path) + .map_err(|err| format_err!("failed to unlink {:?} - {}", cert_path, err))?; + + let key_path = configdir!("/proxy.key"); + if let Err(err) = std::fs::remove_file(&key_path) { + // Here we just log since the certificate is already gone and we'd rather try to generate + // the self-signed certificate even if this fails: + log::error!( + "failed to remove certificate private key {:?} - {}", + key_path, + err + ); + } + + crate::config::update_self_signed_cert(true)?; + + if restart { + crate::config::reload_proxy()?; + } + + Ok(()) +} + +struct OrderedCertificate { + certificate: hyper::body::Bytes, + private_key_pem: Vec, +} + +async fn order_certificate( + worker: Arc, + node_config: &NodeConfig, +) -> Result, Error> { + use proxmox_acme_rs::authorization::Status; + use proxmox_acme_rs::order::Identifier; + + let domains = node_config.acme_domains().try_fold( + Vec::::new(), + |mut acc, domain| -> Result<_, Error> { + let mut domain = domain?; + domain.domain.make_ascii_lowercase(); + if let Some(alias) = &mut domain.alias { + alias.make_ascii_lowercase(); + } + acc.push(domain); + Ok(acc) + }, + )?; + + let get_domain_config = |domain: &str| { + domains + .iter() + .find(|d| d.domain == domain) + .ok_or_else(|| format_err!("no config for domain '{}'", domain)) + }; + + if domains.is_empty() { + worker.log("No domains configured to be ordered from an ACME server."); + return Ok(None); + } + + let (plugins, _) = crate::config::acme::plugin::config()?; + + let mut acme = node_config.acme_client().await?; + + worker.log("Placing ACME order"); + let order = acme + .new_order(domains.iter().map(|d| d.domain.to_ascii_lowercase())) + .await?; + worker.log(format!("Order URL: {}", order.location)); + + let identifiers: Vec = order + .data + .identifiers + .iter() + .map(|identifier| match identifier { + Identifier::Dns(domain) => domain.clone(), + }) + .collect(); + + for auth_url in &order.data.authorizations { + worker.log(format!("Getting authorization details from '{}'", auth_url)); + let mut auth = acme.get_authorization(&auth_url).await?; + + let domain = match &mut auth.identifier { + Identifier::Dns(domain) => domain.to_ascii_lowercase(), + }; + + if auth.status == Status::Valid { + worker.log(format!("{} is already validated!", domain)); + continue; + } + + worker.log(format!("The validation for {} is pending", domain)); + let domain_config: &AcmeDomain = get_domain_config(&domain)?; + let plugin_id = domain_config.plugin.as_deref().unwrap_or("standalone"); + let mut plugin_cfg = + crate::acme::get_acme_plugin(&plugins, plugin_id)?.ok_or_else(|| { + format_err!("plugin '{}' for domain '{}' not found!", plugin_id, domain) + })?; + + worker.log("Setting up validation plugin"); + let validation_url = plugin_cfg + .setup(&mut acme, &auth, domain_config, Arc::clone(&worker)) + .await?; + + let result = request_validation(&worker, &mut acme, auth_url, validation_url).await; + + if let Err(err) = plugin_cfg + .teardown(&mut acme, &auth, domain_config, Arc::clone(&worker)) + .await + { + worker.warn(format!( + "Failed to teardown plugin '{}' for domain '{}' - {}", + plugin_id, domain, err + )); + } + + let _: () = result?; + } + + worker.log("All domains validated"); + worker.log("Creating CSR"); + + let csr = proxmox_acme_rs::util::Csr::generate(&identifiers, &Default::default())?; + let mut finalize_error_cnt = 0u8; + let order_url = &order.location; + let mut order; + loop { + use proxmox_acme_rs::order::Status; + + order = acme.get_order(order_url).await?; + + match order.status { + Status::Pending => { + worker.log("still pending, trying to finalize anyway"); + let finalize = order + .finalize + .as_deref() + .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; + if let Err(err) = acme.finalize(finalize, &csr.data).await { + if finalize_error_cnt >= 5 { + return Err(err.into()); + } + + finalize_error_cnt += 1; + } + tokio::time::sleep(Duration::from_secs(5)).await; + } + Status::Ready => { + worker.log("order is ready, finalizing"); + let finalize = order + .finalize + .as_deref() + .ok_or_else(|| format_err!("missing 'finalize' URL in order"))?; + acme.finalize(finalize, &csr.data).await?; + tokio::time::sleep(Duration::from_secs(5)).await; + } + Status::Processing => { + worker.log("still processing, trying again in 30 seconds"); + tokio::time::sleep(Duration::from_secs(30)).await; + } + Status::Valid => { + worker.log("valid"); + break; + } + other => bail!("order status: {:?}", other), + } + } + + worker.log("Downloading certificate"); + let certificate = acme + .get_certificate( + order + .certificate + .as_deref() + .ok_or_else(|| format_err!("missing certificate url in finalized order"))?, + ) + .await?; + + Ok(Some(OrderedCertificate { + certificate, + private_key_pem: csr.private_key_pem, + })) +} + +async fn request_validation( + worker: &WorkerTask, + acme: &mut AcmeClient, + auth_url: &str, + validation_url: &str, +) -> Result<(), Error> { + worker.log("Triggering validation"); + acme.request_challenge_validation(&validation_url).await?; + + worker.log("Sleeping for 5 seconds"); + tokio::time::sleep(Duration::from_secs(5)).await; + + loop { + use proxmox_acme_rs::authorization::Status; + + let auth = acme.get_authorization(&auth_url).await?; + match auth.status { + Status::Pending => { + worker.log("Status is still 'pending', trying again in 10 seconds"); + tokio::time::sleep(Duration::from_secs(10)).await; + } + Status::Valid => return Ok(()), + other => bail!( + "validating challenge '{}' failed - status: {:?}", + validation_url, + other + ), + } + } +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + force: { + description: "Force replacement of existing files.", + type: Boolean, + optional: true, + default: false, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + protected: true, +)] +/// Order a new ACME certificate. +pub fn new_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result { + spawn_certificate_worker("acme-new-cert", force, rpcenv) +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + force: { + description: "Force replacement of existing files.", + type: Boolean, + optional: true, + default: false, + }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + protected: true, +)] +/// Renew the current ACME certificate if it expires within 30 days (or always if the `force` +/// parameter is set). +pub fn renew_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result { + if !cert_expires_soon()? && !force { + bail!("Certificate does not expire within the next 30 days and 'force' is not set.") + } + + spawn_certificate_worker("acme-renew-cert", force, rpcenv) +} + +/// Check whether the current certificate expires within the next 30 days. +pub fn cert_expires_soon() -> Result { + let cert = pem_to_cert_info(get_certificate_pem()?.as_bytes())?; + cert.is_expired_after_epoch(proxmox::tools::time::epoch_i64() + 30 * 24 * 60 * 60) + .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err)) +} + +fn spawn_certificate_worker( + name: &'static str, + force: bool, + rpcenv: &mut dyn RpcEnvironment, +) -> Result { + // We only have 1 certificate path in PBS which makes figuring out whether or not it is a + // custom one too hard... We keep the parameter because the widget-toolkit may be using it... + let _ = force; + + let (node_config, _digest) = crate::config::node::config()?; + + let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; + + WorkerTask::spawn(name, None, auth_id, true, move |worker| async move { + if let Some(cert) = order_certificate(worker, &node_config).await? { + crate::config::set_proxy_certificate(&cert.certificate, &cert.private_key_pem, true)?; + } + Ok(()) + }) +} + +#[api( + input: { + properties: { + node: { schema: NODE_SCHEMA }, + }, + }, + access: { + permission: &Permission::Privilege(&["system", "certificates"], PRIV_SYS_MODIFY, false), + }, + protected: true, +)] +/// Renew the current ACME certificate if it expires within 30 days (or always if the `force` +/// parameter is set). +pub fn revoke_acme_cert(rpcenv: &mut dyn RpcEnvironment) -> Result { + let (node_config, _digest) = crate::config::node::config()?; + + let cert_pem = get_certificate_pem()?; + + let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?; + + WorkerTask::spawn( + "acme-revoke-cert", + None, + auth_id, + true, + move |worker| async move { + worker.log("Loading ACME account"); + let mut acme = node_config.acme_client().await?; + worker.log("Revoking old certificate"); + acme.revoke_certificate(cert_pem.as_bytes(), None).await?; + worker.log("Deleting certificate and regenerating a self-signed one"); + delete_custom_certificate(true)?; + Ok(()) + }, + ) +} diff --git a/src/config.rs b/src/config.rs index 94b7fb6c..22c293c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -187,12 +187,16 @@ pub fn update_self_signed_cert(force: bool) -> Result<(), Error> { let x509 = x509.build(); let cert_pem = x509.to_pem()?; - set_proxy_certificate(&cert_pem, &priv_pem)?; + set_proxy_certificate(&cert_pem, &priv_pem, false)?; Ok(()) } -pub(crate) fn set_proxy_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<(), Error> { +pub(crate) fn set_proxy_certificate( + cert_pem: &[u8], + key_pem: &[u8], + reload: bool, +) -> Result<(), Error> { let backup_user = crate::backup::backup_user()?; let options = CreateOptions::new() .perm(Mode::from_bits_truncate(0o0640)) @@ -206,5 +210,15 @@ pub(crate) fn set_proxy_certificate(cert_pem: &[u8], key_pem: &[u8]) -> Result<( .map_err(|err| format_err!("error writing certificate private key - {}", err))?; replace_file(&cert_path, &cert_pem, options) .map_err(|err| format_err!("error writing certificate file - {}", err))?; + + if reload { + reload_proxy()?; + } + Ok(()) } + +pub(crate) fn reload_proxy() -> Result<(), Error> { + crate::tools::systemd::reload_unit("proxmox-backup-proxy") + .map_err(|err| format_err!("error signaling reload to pbs proxy: {}", err)) +}