[pbs-devel] [RFC backup 18/23] add node/{node}/certificates api call

Wolfgang Bumiller w.bumiller at proxmox.com
Fri Apr 16 15:35:11 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 | 572 ++++++++++++++++++++++++++++++++++
 src/config.rs                 |  18 +-
 3 files changed, 590 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..4dd75027
--- /dev/null
+++ b/src/api2/node/certificates.rs
@@ -0,0 +1,572 @@
+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::node::{AcmeDomain, 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 plugin_cfg = plugins.get_plugin(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).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).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