* [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
@ 2026-10-20 8:24 David Riley
2026-10-20 8:24 ` [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
` (3 more replies)
0 siblings, 4 replies; 7+ messages in thread
From: David Riley @ 2026-10-20 8:24 UTC (permalink / raw)
To: pve-devel; +Cc: David Riley
Thanks Michael K. for taking a look.
Differences from v1:
* Refactor: Reduce certificate reads by combining the read of
notBefore and notAfter.
* Add: Warning users of certificates with lifetime <= 0
* Add: minimum lead time of 3 days
* Update 'force' parameter description in API schema
* Add: dynamic lead time calculation to bin/pveupdate
Note on short-lived certificates:
Because pveupdate runs daily and there is a 3-day minimum lead time,
short-lived certificates (e.g., 24h, 48h) will get renewed every day
and therefore might be limited by upstream rate limiting.
Supporting such short-lived certificates safely will require both
implementing ARI [3] and adjusting the renewal check frequency.
Dependencies:
pve-manager requires a versioned dependency bump on libpve-common-perl
for the new helper.
---
Original Cover Letter:
This patch series addresses an issue where short-lived ACME
certificates (e.g., 31-day validity) trigger daily renewal attempts
due to PVE's hardcoded 30-day threshold.
Because PVE currently relies on non-ARI renewal logic, these daily
attempts quickly exhaust upstream rate limits. With Let's Encrypt,
for example, this would hit their duplicate certificate limit
(5 renewals per exact set of identifiers per 7 days) [0], resulting
in blocked issuances.
To prevent hitting these upstream limits, the static threshold is
replaced with a dynamic one based on the certificate's actual
lifetime (notAfter - notBefore) [1]. The scaling mirrors the logic
already present in Proxmox Backup Server [2].
This is intended as a stop-gap measure to gracefully handle the
shift toward shorter-lived certificates until the full ACME Renewal
Information (ARI) [3] flow is implemented.
Link: https://bugzilla.proxmox.com/show_bug.cgi?id=8006
[0] https://letsencrypt.org/docs/rate-limits/#new-certificates-per-exact-set-of-identifiers
[1] https://www.cs.auckland.ac.nz/~pgut001/pubs/x509guide.txt
[2] https://lore.proxmox.com/pbs-devel/20260423134607.105229-2-m.federanko@proxmox.com/
[3] https://www.rfc-editor.org/rfc/rfc9773.html
pve-common:
David Riley (1):
certificate: add helpers for lifetime-based ACME renewal
src/PVE/Certificate.pm | 39 +++++++++++++++++++++++++++++++++++++--
1 file changed, 37 insertions(+), 2 deletions(-)
pve-manager:
David Riley (1):
fix #8006: api: acme: lifetime-based certificate renewal threshold
PVE/API2/ACME.pm | 10 ++++++----
bin/pveupdate | 4 +++-
2 files changed, 9 insertions(+), 5 deletions(-)
Summary over all repositories:
3 files changed, 46 insertions(+), 7 deletions(-)
--
Generated by murpp 0.11.0
^ permalink raw reply [flat|nested] 7+ messages in thread
* [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal
2026-10-20 8:24 [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-20 8:24 ` David Riley
2026-10-20 13:34 ` Michael Köppl
2026-10-20 8:24 ` [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
` (2 subsequent siblings)
3 siblings, 1 reply; 7+ messages in thread
From: David Riley @ 2026-10-20 8:24 UTC (permalink / raw)
To: pve-devel; +Cc: David Riley
Add helpers to calculate a renewal lead time based on the actual
lifetime of an X.509 certificate.
Previously, certificate renewals relied on a hardcoded threshold.
To prepare for dynamic thresholds (#8006), introduce calculations
that scale the renewal lead time proportionally to the certificate's
validity period.
The scaling mirrors the logic already used in Proxmox Backup Server
[1]: certificates living for less than 10 days are renewed once 1/2 of
their lifetime has elapsed, while longer-lived certificates are
renewed once 2/3 has elapsed [2]. A 3-day minimum floor is present to
ensure the daily update service has multiple chances to retry
transient failures.
Link: https://bugzilla.proxmox.com/show_bug.cgi?id=8006
[1] https://lore.proxmox.com/pbs-devel/20260423134607.105229-2-m.federanko@proxmox.com/
[2] https://letsencrypt.org/docs/integration-guide/#when-to-renew
Signed-off-by: David Riley <d.riley@proxmox.com>
---
src/PVE/Certificate.pm | 39 +++++++++++++++++++++++++++++++++++++--
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/src/PVE/Certificate.pm b/src/PVE/Certificate.pm
index b8415e2..d2d0847 100644
--- a/src/PVE/Certificate.pm
+++ b/src/PVE/Certificate.pm
@@ -5,6 +5,7 @@ use warnings;
use Date::Parse;
use Encode qw(decode encode);
+use List::Util qw(max);
use MIME::Base64 qw(decode_base64 encode_base64);
use Net::SSLeay;
@@ -331,16 +332,28 @@ sub get_certificate_info {
# Obtain the expiration timestamp of a X.509 certificate as a UNIX epoch.
sub get_expiration_as_epoch {
my ($cert_path) = @_;
+ my (undef, $not_after) = get_validity_as_epoch($cert_path);
+ return $not_after;
+}
+
+# Obtain the notBefore and notAfter timestamps of a X.509 certificate as UNIX epochs.
+sub get_validity_as_epoch {
+ my ($cert_path) = @_;
my $cert = $read_certificate->($cert_path);
- my $not_after = eval { convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)) };
+ my ($not_before, $not_after) = eval {
+ (
+ convert_asn1_to_epoch(Net::SSLeay::X509_get_notBefore($cert)),
+ convert_asn1_to_epoch(Net::SSLeay::X509_get_notAfter($cert)),
+ );
+ };
my $err = $@;
Net::SSLeay::X509_free($cert);
die $err if $err;
- return $not_after;
+ return ($not_before, $not_after);
}
# Checks whether certificate expires before $timestamp (UNIX epoch)
@@ -354,6 +367,28 @@ sub check_expiry {
return ($not_after < $timestamp) ? 1 : 0;
}
+# 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.
+sub get_cert_renew_lead_time {
+ my ($cert_path) = @_;
+
+ my ($not_before, $not_after) = get_validity_as_epoch($cert_path);
+ my $lifetime = $not_after - $not_before;
+
+ if ($lifetime <= 0) {
+ warn "Unexpected validity period ($lifetime s) for '$cert_path', falling back to a 30 day"
+ . " renewal lead time\n";
+
+ return 30 * 24 * 60 * 60;
+ }
+
+ my $scale = $lifetime < (10 * 24 * 60 * 60) ? 2 : 3;
+ my $lead = int($lifetime / $scale);
+
+ return max($lead, 3 * 24 * 60 * 60); # 3 day minimum
+}
+
# Create a CSR and certificate key for a given order
# returns path to CSR file or path to CSR and key files
sub generate_csr {
--
2.47.3
^ permalink raw reply related [flat|nested] 7+ messages in thread
* [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold
2026-10-20 8:24 [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-20 8:24 ` [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
@ 2026-10-20 8:24 ` David Riley
2026-10-20 13:32 ` Michael Köppl
2026-10-20 13:34 ` [PATCH common/manager v2 0/2] fix #8006: " Michael Köppl
2026-10-21 9:58 ` superseded: " David Riley
3 siblings, 1 reply; 7+ messages in thread
From: David Riley @ 2026-10-20 8:24 UTC (permalink / raw)
To: pve-devel; +Cc: David Riley
Replace the hardcoded 30-day renewal limit with a threshold based on
the certificate's actual lifetime.
The previous hardcoded limit caused excessive daily renewals for
short-lived certificates (e.g., 31 days). This change ensures they
renew at sensible intervals, avoiding upstream rate limits.
Link: https://bugzilla.proxmox.com/show_bug.cgi?id=8006
Signed-off-by: David Riley <d.riley@proxmox.com>
---
PVE/API2/ACME.pm | 10 ++++++----
bin/pveupdate | 4 +++-
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/PVE/API2/ACME.pm b/PVE/API2/ACME.pm
index a948a72a..21ed3878 100644
--- a/PVE/API2/ACME.pm
+++ b/PVE/API2/ACME.pm
@@ -243,7 +243,8 @@ __PACKAGE__->register_method({
node => get_standard_option('pve-node'),
force => {
type => 'boolean',
- description => 'Force renewal even if expiry is more than 30 days away.',
+ description =>
+ 'Force renewal even if the certificate is not yet due for renewal.',
optional => 1,
default => 0,
},
@@ -261,12 +262,13 @@ __PACKAGE__->register_method({
raise("No current (custom) certificate found, please order a new certificate!\n")
if !-e "${cert_prefix}.pem";
+ my $lead_time = PVE::Certificate::get_cert_renew_lead_time("${cert_prefix}.pem");
+
my $expires_soon =
- PVE::Certificate::check_expiry("${cert_prefix}.pem", time() + 30 * 24 * 60 * 60);
+ PVE::Certificate::check_expiry("${cert_prefix}.pem", time() + $lead_time);
raise_param_exc(
{
- 'force' =>
- "Certificate does not expire within the next 30 days, and 'force' is not set.",
+ 'force' => "Certificate is not yet due for renewal, and 'force' is not set.",
},
) if !$expires_soon && !$param->{force};
diff --git a/bin/pveupdate b/bin/pveupdate
index b1960c35..a014d1d3 100755
--- a/bin/pveupdate
+++ b/bin/pveupdate
@@ -83,7 +83,9 @@ eval {
if ($acme_node_config && $acme_node_config->{domains}) {
my $cert = PVE::CertHelpers::cert_path_prefix($nodename) . ".pem";
if (-e $cert) {
- if (PVE::Certificate::check_expiry($cert, time() + 30 * 24 * 60 * 60)) {
+ my $lead_time = PVE::Certificate::get_cert_renew_lead_time($cert);
+
+ if (PVE::Certificate::check_expiry($cert, time() + $lead_time)) {
PVE::API2::ACME->renew_certificate({ node => $nodename });
} else {
syslog(
--
2.47.3
^ permalink raw reply related [flat|nested] 7+ messages in thread
* Re: [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold
2026-10-20 8:24 ` [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-20 13:32 ` Michael Köppl
0 siblings, 0 replies; 7+ messages in thread
From: Michael Köppl @ 2026-10-20 13:32 UTC (permalink / raw)
To: David Riley, pve-devel
nit: IMO the pveupdate change would make more sense as a separate
commit. Or at least the commit subject should be adapted, as it
currently only mentions "api: acme:", which is a bit misleading.
On Tue Oct 20, 2026 at 10:24 AM CEST, David Riley wrote:
> Replace the hardcoded 30-day renewal limit with a threshold based on
> the certificate's actual lifetime.
>
> The previous hardcoded limit caused excessive daily renewals for
> short-lived certificates (e.g., 31 days). This change ensures they
> renew at sensible intervals, avoiding upstream rate limits.
>
> Link: https://bugzilla.proxmox.com/show_bug.cgi?id=8006
>
> Signed-off-by: David Riley <d.riley@proxmox.com>
> ---
> PVE/API2/ACME.pm | 10 ++++++----
> bin/pveupdate | 4 +++-
> 2 files changed, 9 insertions(+), 5 deletions(-)
[snip]
^ permalink raw reply [flat|nested] 7+ messages in thread
* Re: [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal
2026-10-20 8:24 ` [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
@ 2026-10-20 13:34 ` Michael Köppl
0 siblings, 0 replies; 7+ messages in thread
From: Michael Köppl @ 2026-10-20 13:34 UTC (permalink / raw)
To: David Riley, pve-devel
nit: I think this would also benefit from being split into 2 commits,
with the first adding get_validity_as_epoch and adapting
get_expiration_as_epoch to use it, and the second commit adding the
actual renewal helper. It's definitely not a blocker IMO, it just seems
a bit misleading to me right now as the commit messages says that only
a new helper is added, suggesting that existing behavior doesn't change,
but it does.
On Tue Oct 20, 2026 at 10:24 AM CEST, David Riley wrote:
> Add helpers to calculate a renewal lead time based on the actual
> lifetime of an X.509 certificate.
>
> Previously, certificate renewals relied on a hardcoded threshold.
> To prepare for dynamic thresholds (#8006), introduce calculations
> that scale the renewal lead time proportionally to the certificate's
> validity period.
>
> The scaling mirrors the logic already used in Proxmox Backup Server
> [1]: certificates living for less than 10 days are renewed once 1/2 of
> their lifetime has elapsed, while longer-lived certificates are
> renewed once 2/3 has elapsed [2]. A 3-day minimum floor is present to
> ensure the daily update service has multiple chances to retry
> transient failures.
>
[snip]
^ permalink raw reply [flat|nested] 7+ messages in thread
* Re: [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
2026-10-20 8:24 [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-20 8:24 ` [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
2026-10-20 8:24 ` [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-20 13:34 ` Michael Köppl
2026-10-21 9:58 ` superseded: " David Riley
3 siblings, 0 replies; 7+ messages in thread
From: Michael Köppl @ 2026-10-20 13:34 UTC (permalink / raw)
To: David Riley, pve-devel
Tested this version as well, running through the following scenarios:
- Checked that with only 72h remaining, certs are always considered due
for renewal and are renewed by pveupdate. Generated a couple of
certs for this at 24h, 72h, 96h. For 96h the scaling should use 2, so
48h, but the floor raises this to 72h. So I forwarded time by 23h,
such that the certificate was not considered due for renewal yet and
thus refused. At 24h(+1s) after issuance and every point after,
renewal worked. Works as expected!
- Also checked that the scaling works by using a 216h and a 240h
certificate, with the switch to 1/3 scaling being applied to 240h
and longer-lived certs.
- Just to be sure (and because I wanted to try), I also used pebble [0]
and configured it to use 96h certs and ran renewals again at the 3-day
floor. Also had it issue longer-lived certificates and tried to make
it miss/fail renewal, but couldn't.
I also had a look at the code and left some minor comments regarding
the structure of the series. The implementation itself looks good to me
now.
Consider this:
Reviewed-by: Michael Köppl <m.koeppl@proxmox.com>
Tested-by: Michael Köppl <m.koeppl@proxmox.com>
[0] https://github.com/letsencrypt/pebble
On Tue Oct 20, 2026 at 10:24 AM CEST, David Riley wrote:
> Thanks Michael K. for taking a look.
>
> Differences from v1:
> * Refactor: Reduce certificate reads by combining the read of
> notBefore and notAfter.
> * Add: Warning users of certificates with lifetime <= 0
> * Add: minimum lead time of 3 days
> * Update 'force' parameter description in API schema
[snip]
^ permalink raw reply [flat|nested] 7+ messages in thread
* superseded: [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
2026-10-20 8:24 [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
` (2 preceding siblings ...)
2026-10-20 13:34 ` [PATCH common/manager v2 0/2] fix #8006: " Michael Köppl
@ 2026-10-21 9:58 ` David Riley
3 siblings, 0 replies; 7+ messages in thread
From: David Riley @ 2026-10-21 9:58 UTC (permalink / raw)
To: pve-devel
superseded by:
https://lore.proxmox.com/pve-devel/20261021095229.50842-1-d.riley@proxmox.com/
^ permalink raw reply [flat|nested] 7+ messages in thread
end of thread, other threads:[~2026-10-21 9:58 UTC | newest]
Thread overview: 7+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-20 8:24 [PATCH common/manager v2 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-20 8:24 ` [PATCH pve-common v2 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
2026-10-20 13:34 ` Michael Köppl
2026-10-20 8:24 ` [PATCH pve-manager v2 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
2026-10-20 13:32 ` Michael Köppl
2026-10-20 13:34 ` [PATCH common/manager v2 0/2] fix #8006: " Michael Köppl
2026-10-21 9:58 ` superseded: " David Riley
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox