[pbs-devel] [REBASED v2 backup 5/9] add node/{node}/certificates api call
Wolfgang Bumiller
w.bumiller at proxmox.com
Mon May 3 11:39:55 CEST 2021
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 <w.bumiller at proxmox.com>
---
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<String>,
+
+ /// Certificate subject name.
+ subject: String,
+
+ /// List of certificate's SubjectAlternativeName entries.
+ san: Vec<String>,
+
+ /// Certificate issuer name.
+ issuer: String,
+
+ /// Certificate's notBefore timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ notbefore: Option<i64>,
+
+ /// Certificate's notAfter timestamp (UNIX epoch).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ notafter: Option<i64>,
+
+ /// Certificate in PEM format.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pem: Option<String>,
+
+ /// 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<u32>,
+
+ /// The SSL Fingerprint.
+ fingerprint: Option<String>,
+}
+
+impl TryFrom<&cert::CertInfo> for CertificateInfo {
+ type Error = Error;
+
+ fn try_from(info: &cert::CertInfo) -> Result<Self, Self::Error> {
+ 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("<unsupported key type>")
+ .to_owned(),
+ public_key_bits: Some(pubkey.bits()),
+ fingerprint: Some(info.fingerprint()?),
+ })
+ }
+}
+
+fn get_certificate_pem() -> Result<String, Error> {
+ 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, Error> {
+ 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<Vec<CertificateInfo>, 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<Vec<CertificateInfo>, 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::<u8>::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<u8>,
+}
+
+async fn order_certificate(
+ worker: Arc<WorkerTask>,
+ node_config: &NodeConfig,
+) -> Result<Option<OrderedCertificate>, Error> {
+ use proxmox_acme_rs::authorization::Status;
+ use proxmox_acme_rs::order::Identifier;
+
+ let domains = node_config.acme_domains().try_fold(
+ Vec::<AcmeDomain>::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<String> = 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<String, Error> {
+ 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<String, Error> {
+ 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<bool, Error> {
+ 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<String, Error> {
+ // 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<String, Error> {
+ 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))
+}
--
2.20.1
More information about the pbs-devel
mailing list