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
prev 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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox