all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Manuel Federanko <m.federanko@proxmox.com>
To: pbs-devel@lists.proxmox.com, pdm-devel@lists.proxmox.com
Subject: [PATCH proxmox-datacenter-manager 7/7] acme: fix #6372 use ARI for renewal if available.
Date: Thu, 25 Jun 2026 16:13:37 +0200	[thread overview]
Message-ID: <20260625141337.181684-8-m.federanko@proxmox.com> (raw)
In-Reply-To: <20260625141337.181684-1-m.federanko@proxmox.com>

Try to fetch ARI renewal information if it is available and renew based
on that. If not fall back to 1/3 of the remaining lifetime for
long-lived certificates or 1/2 of it for short-lived ones.
This thus also incorporates some of the changes already present in
backup server [0].

Since the ARI check needs to talk to the ACME directory the check is
move to the worker.

[0] https://lore.proxmox.com/pbs-devel/b8e5bd1b-bfbc-4b9e-befa-cd4b0157ed22@proxmox.com/

Fixes: https://bugzilla.proxmox.com/show_bug.cgi?id=6372
Signed-off-by: Manuel Federanko <m.federanko@proxmox.com>
---
 cli/admin/src/acme.rs                         |   7 -
 server/src/api/nodes/certificates.rs          | 129 +++++++++++++++---
 ...proxmox-datacenter-manager-daily-update.rs |   5 -
 3 files changed, 113 insertions(+), 28 deletions(-)

diff --git a/cli/admin/src/acme.rs b/cli/admin/src/acme.rs
index e61bb1ef..be6e0018 100644
--- a/cli/admin/src/acme.rs
+++ b/cli/admin/src/acme.rs
@@ -405,13 +405,6 @@ pub fn plugin_cli() -> CommandLineInterface {
 )]
 /// Order a new ACME certificate.
 async fn order_acme_cert(param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
-    if !param["force"].as_bool().unwrap_or(false)
-        && !dc_api::nodes::certificates::cert_expires_soon()?
-    {
-        println!("Certificate does not expire within the next 30 days, not renewing.");
-        return Ok(());
-    }
-
     let info = &dc_api::nodes::certificates::API_METHOD_RENEW_ACME_CERT;
     let result = match info.handler {
         ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
diff --git a/server/src/api/nodes/certificates.rs b/server/src/api/nodes/certificates.rs
index 0f391499..08838bea 100644
--- a/server/src/api/nodes/certificates.rs
+++ b/server/src/api/nodes/certificates.rs
@@ -1,4 +1,4 @@
-use anyhow::{Context, Error, bail, format_err};
+use anyhow::{Context, Error, format_err};
 use openssl::pkey::PKey;
 use openssl::x509::X509;

@@ -8,7 +8,7 @@ use proxmox_router::list_subdirs_api_method;
 use proxmox_router::{Permission, Router, RpcEnvironment};
 use proxmox_schema::api;

-use proxmox_acme_api::{AcmeDomain, CertificateInfo};
+use proxmox_acme_api::{AcmeConfig, AcmeDomain, CertificateInfo};

 use proxmox_rest_server::WorkerTask;
 use proxmox_schema::api_types::NODE_SCHEMA;
@@ -43,12 +43,14 @@ const ACME_SUBDIRS: SubdirMap = &[(
         .put(&API_METHOD_RENEW_ACME_CERT),
 )];

+const SECONDS_PER_DAY: i64 = 24 * 60 * 60;
+
 fn get_certificate_pem() -> Result<Vec<u8>, Error> {
     let cert_pem = proxmox_sys::fs::file_get_contents(API_CERT_FN)?;
     Ok(cert_pem)
 }

-fn get_certificate_info() -> Result<CertificateInfo, Error> {
+pub fn get_certificate_info() -> Result<CertificateInfo, Error> {
     let cert_pem = get_certificate_pem()?;
     CertificateInfo::from_pem("proxy.pem", &cert_pem)
 }
@@ -233,18 +235,93 @@ pub fn new_acme_cert(force: bool, rpcenv: &mut dyn RpcEnvironment) -> Result<Str
 /// 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)
+}
+
+/// Renewal lead time in seconds for the given certificate.
+///
+/// Long-lived certs are renewed once 2/3 of their lifetime has elapsed; short-lived ones (under
+/// ten days) already at 1/2, following Let's Encrypt's integration guide. A 3-day floor still
+/// applies so the daily-update service has a couple of chances to retry transient failures.
+fn cert_renew_lead_time(cert: &CertificateInfo) -> i64 {
+    if let (Some(notafter), Some(notbefore)) = (cert.notafter, cert.notbefore) {
+        let lifetime = notafter - notbefore;
+        let scale = if lifetime < 10 * SECONDS_PER_DAY {
+            2
+        } else {
+            3
+        };
+        std::cmp::max(lifetime / scale, 3 * SECONDS_PER_DAY)
+    } else {
+        log::warn!(
+            "certificate notBefore/notAfter unavailable, falling back to 30-day renewal lead time"
+        );
+        30 * SECONDS_PER_DAY
     }
+}

-    spawn_certificate_worker("acme-renew-cert", force, rpcenv)
+/// ARI renewal time if available
+///
+/// Query the ARI endpoint for a suggested renewal window, draw a uniform random time in this window
+/// Return None if ARI does not apply.
+async fn cert_renew_lead_time_ari(
+    acme_config: &AcmeConfig,
+    cert_info: &CertificateInfo,
+) -> Result<Option<i64>, Error> {
+    let now = proxmox_time::epoch_i64();
+    if cert_info.is_expired_after_epoch(now)? {
+        return Ok(Some(0));
+    }
+    let ari_id = match cert_info.ari_id.as_deref() {
+        Some(x) => x,
+        None => return Ok(None),
+    };
+
+    let window = match proxmox_acme_api::get_renewal_info(acme_config, ari_id).await? {
+        Some(x) => x,
+        None => return Ok(None),
+    };
+    if let Some(reason) = window.data.explanation_url {
+        info!(
+            "Obtained renewal window, for information on this chosen window please visit {reason}"
+        );
+    }
+    let window_start = proxmox_time::parse_rfc3339(&window.data.suggested_window.start)?;
+    let window_end = proxmox_time::parse_rfc3339(&window.data.suggested_window.end)?;
+    let rand = proxmox_sys::linux::random_data(8)?
+        .into_iter()
+        .enumerate()
+        .fold(0, |acc, (index, x)| acc + ((x as u64) << (index * 8))) as f64
+        / (u64::MAX as f64);
+    let renew = window_start + (((window_end - window_start) as f64) * rand) as i64;
+    // need max since the randomness could result in negative values
+    Ok(Some(std::cmp::max(0, renew - now)))
 }

-/// Check whether the current certificate expires within the next 30 days.
-pub fn cert_expires_soon() -> Result<bool, Error> {
-    let cert = get_certificate_info()?;
-    cert.is_expired_after_epoch(proxmox_time::epoch_i64() + 30 * 24 * 60 * 60)
-        .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err))
+/// Should the certificate be renewed now.
+///
+/// Is true if the ceriticates expires within its lead time.
+/// Returns if the certificate should be renewed and the lead time in days.
+pub async fn check_renewal_needed(
+    acme_config: &AcmeConfig,
+    cert_info: &CertificateInfo,
+) -> Result<bool, Error> {
+    let lead_ari = cert_renew_lead_time_ari(acme_config, cert_info).await?;
+    if let Some(lead_ari) = lead_ari {
+        // rfc9773 section 4.2 tells us to renew if the chosen renewal time would be before the next check
+        let expires_soon = lead_ari < SECONDS_PER_DAY;
+        let ts = proxmox_time::TimeSpan::from(std::time::Duration::new(lead_ari as u64, 0));
+        info!("Certificate is scheduled for renewal in {ts:.0} by ARI");
+        Ok(expires_soon)
+    } else {
+        let lead = cert_renew_lead_time(cert_info);
+        let expires_soon = cert_info
+            .is_expired_after_epoch(proxmox_time::epoch_i64() + lead)
+            .map_err(|err| format_err!("Failed to check certificate expiration date: {}", err))?;
+        let ts = proxmox_time::TimeSpan::from(std::time::Duration::new(lead as u64, 0));
+        info!("Certificate renewal lead time is {ts:.0}");
+        Ok(expires_soon)
+    }
 }

 fn spawn_certificate_worker(
@@ -281,11 +358,31 @@ fn spawn_certificate_worker(

     WorkerTask::spawn(name, None, auth_id, true, move |worker| async move {
         let work = || async {
-            if let Some(cert) =
-                proxmox_acme_api::order_certificate(worker, &acme_config, &domains).await?
-            {
-                crate::auth::certs::set_api_certificate(&cert.certificate, &cert.private_key_pem)?;
-                crate::reload_api_certificate().await?;
+            let cert_info = get_certificate_info()?;
+            let expires_soon = if !force {
+                let expires_soon = check_renewal_needed(&acme_config, &cert_info).await?;
+                if !expires_soon {
+                    info!("Certificate does not expire soon and 'force' was not set, not renewing");
+                }
+                expires_soon
+            } else {
+                false
+            };
+            if force || expires_soon {
+                if let Some(cert) = proxmox_acme_api::order_certificate(
+                    worker,
+                    &acme_config,
+                    &domains,
+                    cert_info.ari_id.as_deref(),
+                )
+                .await?
+                {
+                    crate::auth::certs::set_api_certificate(
+                        &cert.certificate,
+                        &cert.private_key_pem,
+                    )?;
+                    crate::reload_api_certificate().await?;
+                }
             }

             Ok(())
diff --git a/server/src/bin/proxmox-datacenter-manager-daily-update.rs b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
index 314b3399..e70033a4 100644
--- a/server/src/bin/proxmox-datacenter-manager-daily-update.rs
+++ b/server/src/bin/proxmox-datacenter-manager-daily-update.rs
@@ -72,11 +72,6 @@ async fn check_acme_certificates(rpcenv: &mut dyn RpcEnvironment) -> Result<(),
         return Ok(());
     }

-    if !api::nodes::certificates::cert_expires_soon()? {
-        log::info!("Certificate does not expire within the next 30 days, not renewing.");
-        return Ok(());
-    }
-
     let info = &api::nodes::certificates::API_METHOD_RENEW_ACME_CERT;
     let result = match info.handler {
         ApiHandler::Sync(handler) => (handler)(json!({}), info, rpcenv)?,
--
2.47.3




      parent reply	other threads:[~2026-06-25 14:14 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-25 14:13 [PATCH proxmox{,-backup,-datacenter-manager} 0/7] acme: fix #6372 implement basic ARI support Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox 1/7] acme: client: add methods to fetch renewal information Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox 2/7] acme: add retry-after header to " Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox 3/7] acme: allow specifying the certificate that is replaced by an order Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox 4/7] acme: cert: add dedicated ari_id field to the certificate info Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox-backup 5/7] acme: add ari_id to cert info Manuel Federanko
2026-06-25 14:13 ` [PATCH proxmox-backup 6/7] acme: fix #6372 implement ARI renewal information fetching Manuel Federanko
2026-06-25 14:13 ` Manuel Federanko [this message]

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260625141337.181684-8-m.federanko@proxmox.com \
    --to=m.federanko@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    --cc=pdm-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal