* [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
@ 2026-10-06 12:31 David Riley
2026-10-06 12:31 ` [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
` (2 more replies)
0 siblings, 3 replies; 9+ messages in thread
From: David Riley @ 2026-10-06 12:31 UTC (permalink / raw)
To: pve-devel
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 | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
pve-manager:
David Riley (1):
fix #8006: api: acme: lifetime-based certificate renewal threshold
PVE/API2/ACME.pm | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
Summary over all repositories:
2 files changed, 36 insertions(+), 2 deletions(-)
--
Generated by murpp 0.11.0
^ permalink raw reply [flat|nested] 9+ messages in thread* [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal
2026-10-06 12:31 [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-06 12:31 ` David Riley
2026-10-16 12:45 ` Michael Köppl
2026-10-06 12:31 ` [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
2026-10-16 12:45 ` [PATCH common/manager 0/2] fix #8006: " Michael Köppl
2 siblings, 1 reply; 9+ messages in thread
From: David Riley @ 2026-10-06 12:31 UTC (permalink / raw)
To: pve-devel
Add helpers to calculate a renewal lead time based on the actual
lifetime of an X.509 certificate (the difference between its notBefore
and notAfter dates [0]).
This commit is in preparation for fixing #8006. The scaling mirrors
the logic already used in Proxmox Backup Server [1]: certificates
living for less than 10 days are renewed at half their lifetime,
while longer-lived certificates use a third of their lifetime.
Link: https://bugzilla.proxmox.com/show_bug.cgi?id=8006
[0] https://www.cs.auckland.ac.nz/~pgut001/pubs/x509guide.txt
[1] https://lore.proxmox.com/pbs-devel/20260423134607.105229-2-m.federanko@proxmox.com/
Signed-off-by: David Riley <d.riley@proxmox.com>
---
src/PVE/Certificate.pm | 31 +++++++++++++++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/src/PVE/Certificate.pm b/src/PVE/Certificate.pm
index b8415e2..45c9bc7 100644
--- a/src/PVE/Certificate.pm
+++ b/src/PVE/Certificate.pm
@@ -328,6 +328,21 @@ sub get_certificate_info {
return $info;
}
+# Obtain the notBefore timestamp of a X.509 certificate as a UNIX epoch.
+sub get_not_before_as_epoch {
+ my ($cert_path) = @_;
+
+ my $cert = $read_certificate->($cert_path);
+ my $not_before = eval { convert_asn1_to_epoch(Net::SSLeay::X509_get_notBefore($cert)) };
+ my $err = $@;
+
+ Net::SSLeay::X509_free($cert);
+
+ die $err if $err;
+
+ return $not_before;
+}
+
# Obtain the expiration timestamp of a X.509 certificate as a UNIX epoch.
sub get_expiration_as_epoch {
my ($cert_path) = @_;
@@ -354,6 +369,22 @@ sub check_expiry {
return ($not_after < $timestamp) ? 1 : 0;
}
+sub get_cert_renew_lead_time {
+ my ($cert_path) = @_;
+
+ my $default_lead = 30 * 24 * 60 * 60;
+
+ my $not_before = get_not_before_as_epoch($cert_path);
+ my $not_after = get_expiration_as_epoch($cert_path);
+
+ my $lifetime = $not_after - $not_before;
+ return $default_lead if $lifetime <= 0;
+
+ my $scale = $lifetime < (10 * 24 * 60 * 60) ? 2 : 3;
+
+ return int($lifetime / $scale);
+}
+
# 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] 9+ messages in thread* Re: [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal
2026-10-06 12:31 ` [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
@ 2026-10-16 12:45 ` Michael Köppl
2026-10-16 13:17 ` David Riley
0 siblings, 1 reply; 9+ messages in thread
From: Michael Köppl @ 2026-10-16 12:45 UTC (permalink / raw)
To: David Riley, pve-devel
On Tue Oct 6, 2026 at 2:31 PM CEST, David Riley wrote:
[snip]
> +sub get_cert_renew_lead_time {
> + my ($cert_path) = @_;
> +
> + my $default_lead = 30 * 24 * 60 * 60;
> +
> + my $not_before = get_not_before_as_epoch($cert_path);
> + my $not_after = get_expiration_as_epoch($cert_path);
> +
> + my $lifetime = $not_after - $not_before;
> + return $default_lead if $lifetime <= 0;
> +
> + my $scale = $lifetime < (10 * 24 * 60 * 60) ? 2 : 3;
> +
> + return int($lifetime / $scale);
The scaling mirrors what's done in PBS, but shouldn't this also have
the 3-day floor that PBS has? AFAICT from the original series, the this
floor was applied as it was in v1, so as part of the helper function, so
it should probably also go here.
If I understand Shannon's comment [0] on the original series correctly,
for short-lived certificates (e.g. 48h) the renewal window (in this case
24h) might be shorter than the time between pveupdate runs and renewal
might fail because of that. I'm able to reproduce this using `faketime`.
Or is there a reason this was ommitted?
[0] https://lore.proxmox.com/pbs-devel/DHZKEJBTPLZY.TNSL8CWILFKT@proxmox.com/
> +}
> +
> # 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 {
^ permalink raw reply [flat|nested] 9+ messages in thread* Re: [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal
2026-10-16 12:45 ` Michael Köppl
@ 2026-10-16 13:17 ` David Riley
0 siblings, 0 replies; 9+ messages in thread
From: David Riley @ 2026-10-16 13:17 UTC (permalink / raw)
To: Michael Köppl, pve-devel
On 10/16/26 2:45 PM, Michael Köppl wrote:
> On Tue Oct 6, 2026 at 2:31 PM CEST, David Riley wrote:
>
> [snip]
>
>> +sub get_cert_renew_lead_time {
>> + my ($cert_path) = @_;
>> +
>> + my $default_lead = 30 * 24 * 60 * 60;
>> +
>> + my $not_before = get_not_before_as_epoch($cert_path);
>> + my $not_after = get_expiration_as_epoch($cert_path);
>> +
>> + my $lifetime = $not_after - $not_before;
>> + return $default_lead if $lifetime <= 0;
>> +
>> + my $scale = $lifetime < (10 * 24 * 60 * 60) ? 2 : 3;
>> +
>> + return int($lifetime / $scale);
> The scaling mirrors what's done in PBS, but shouldn't this also have
> the 3-day floor that PBS has? AFAICT from the original series, the this
> floor was applied as it was in v1, so as part of the helper function, so
> it should probably also go here.
>
> If I understand Shannon's comment [0] on the original series correctly,
> for short-lived certificates (e.g. 48h) the renewal window (in this case
> 24h) might be shorter than the time between pveupdate runs and renewal
> might fail because of that. I'm able to reproduce this using `faketime`.
>
> Or is there a reason this was ommitted?
>
> [0] https://lore.proxmox.com/pbs-devel/DHZKEJBTPLZY.TNSL8CWILFKT@proxmox.com/
>
You are right this should have definitely be included here as well.
Seems like I have missed that part when looking at the pbs series.
Thanks for the heads up, I will correct that in a v2.
>> +}
>> +
>> # 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 {
>
>
>
>
>
^ permalink raw reply [flat|nested] 9+ messages in thread
* [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold
2026-10-06 12:31 [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-06 12:31 ` [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
@ 2026-10-06 12:31 ` David Riley
2026-10-16 12:45 ` Michael Köppl
2026-10-16 12:45 ` [PATCH common/manager 0/2] fix #8006: " Michael Köppl
2 siblings, 1 reply; 9+ messages in thread
From: David Riley @ 2026-10-06 12:31 UTC (permalink / raw)
To: pve-devel
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 | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/PVE/API2/ACME.pm b/PVE/API2/ACME.pm
index a948a72a..4f2590f0 100644
--- a/PVE/API2/ACME.pm
+++ b/PVE/API2/ACME.pm
@@ -261,12 +261,15 @@ __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 $lead_days = int($lead_time / (24 * 60 * 60));
+
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.",
+ "Certificate does not expire within the next $lead_days days, and 'force' is not set.",
},
) if !$expires_soon && !$param->{force};
--
2.47.3
^ permalink raw reply related [flat|nested] 9+ messages in thread* Re: [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold
2026-10-06 12:31 ` [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-16 12:45 ` Michael Köppl
2026-10-16 13:17 ` David Riley
0 siblings, 1 reply; 9+ messages in thread
From: Michael Köppl @ 2026-10-16 12:45 UTC (permalink / raw)
To: David Riley, pve-devel
On Tue Oct 6, 2026 at 2:31 PM 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 | 7 +++++--
> 1 file changed, 5 insertions(+), 2 deletions(-)
>
> diff --git a/PVE/API2/ACME.pm b/PVE/API2/ACME.pm
> index a948a72a..4f2590f0 100644
> --- a/PVE/API2/ACME.pm
> +++ b/PVE/API2/ACME.pm
> @@ -261,12 +261,15 @@ __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 $lead_days = int($lead_time / (24 * 60 * 60));
> +
> 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);
Should this perhaps also be done for bin/pveupdate? It still uses the
hard-coded 30 days and the PBS patch you're referring to in the cover
letter also updates its daily job.
> raise_param_exc(
> {
> 'force' =>
> - "Certificate does not expire within the next 30 days, and 'force' is not set.",
> + "Certificate does not expire within the next $lead_days days, and 'force' is not set.",
This should also be done for the description of the 'force' param. It
still reads "... is more than 30 days away".
> },
> ) if !$expires_soon && !$param->{force};
>
^ permalink raw reply [flat|nested] 9+ messages in thread* Re: [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold
2026-10-16 12:45 ` Michael Köppl
@ 2026-10-16 13:17 ` David Riley
0 siblings, 0 replies; 9+ messages in thread
From: David Riley @ 2026-10-16 13:17 UTC (permalink / raw)
To: Michael Köppl, pve-devel
On 10/16/26 2:45 PM, Michael Köppl wrote:
> On Tue Oct 6, 2026 at 2:31 PM 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 | 7 +++++--
>> 1 file changed, 5 insertions(+), 2 deletions(-)
>>
>> diff --git a/PVE/API2/ACME.pm b/PVE/API2/ACME.pm
>> index a948a72a..4f2590f0 100644
>> --- a/PVE/API2/ACME.pm
>> +++ b/PVE/API2/ACME.pm
>> @@ -261,12 +261,15 @@ __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 $lead_days = int($lead_time / (24 * 60 * 60));
>> +
>> 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);
> Should this perhaps also be done for bin/pveupdate? It still uses the
> hard-coded 30 days and the PBS patch you're referring to in the cover
> letter also updates its daily job.
Missed that thanks. Will update this and the force parameter description in v2.
>> raise_param_exc(
>> {
>> 'force' =>
>> - "Certificate does not expire within the next 30 days, and 'force' is not set.",
>> + "Certificate does not expire within the next $lead_days days, and 'force' is not set.",
> This should also be done for the description of the 'force' param. It
> still reads "... is more than 30 days away".
>
>> },
>> ) if !$expires_soon && !$param->{force};
>>
>
>
>
>
>
^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
2026-10-06 12:31 [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-06 12:31 ` [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
2026-10-06 12:31 ` [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
@ 2026-10-16 12:45 ` Michael Köppl
2026-10-16 13:17 ` David Riley
2 siblings, 1 reply; 9+ messages in thread
From: Michael Köppl @ 2026-10-16 12:45 UTC (permalink / raw)
To: David Riley, pve-devel
Tested this a bit using faketime to force certificate renewals. Noticed
the following:
- Could create a scenario where an expired short-lived cert would be
served because of the missing 3-day floor present in PBS: with a 48h
cert, the renewal window is 24h. With the clock moved to 23h after the
cert was issued (25h left), renewal is refused with "does not expire
within the next 1 days". At 49h after the cert was issued (1h after
it expired), renew is no longer refused. The 2 attempts arre 26h
apart, which is within the randomized time frame for pve-daily-update
runs (01:00 + up to 5h delay), so it can happen that no run lands
inside the window. The expired cert is then served until the next run
renews it.
- The pveupdate script still uses the hard-coded 30 days, so creating a
certificate with 25 days left, it will attempt to renew, but the API
will refuse.
Added comments for the above on the individual patches.
For longer-lived certs as describes in the cover letter, this worked as
expected, though. I really like this as an improvement to PVE.
On Tue Oct 6, 2026 at 2:31 PM CEST, David Riley wrote:
> 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,
[snip]
^ permalink raw reply [flat|nested] 9+ messages in thread
* Re: [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold
2026-10-16 12:45 ` [PATCH common/manager 0/2] fix #8006: " Michael Köppl
@ 2026-10-16 13:17 ` David Riley
0 siblings, 0 replies; 9+ messages in thread
From: David Riley @ 2026-10-16 13:17 UTC (permalink / raw)
To: Michael Köppl, pve-devel
On 10/16/26 2:45 PM, Michael Köppl wrote:
> Tested this a bit using faketime to force certificate renewals. Noticed
> the following:
> - Could create a scenario where an expired short-lived cert would be
> served because of the missing 3-day floor present in PBS: with a 48h
> cert, the renewal window is 24h. With the clock moved to 23h after the
> cert was issued (25h left), renewal is refused with "does not expire
> within the next 1 days". At 49h after the cert was issued (1h after
> it expired), renew is no longer refused. The 2 attempts arre 26h
> apart, which is within the randomized time frame for pve-daily-update
> runs (01:00 + up to 5h delay), so it can happen that no run lands
> inside the window. The expired cert is then served until the next run
> renews it.
> - The pveupdate script still uses the hard-coded 30 days, so creating a
> certificate with 25 days left, it will attempt to renew, but the API
> will refuse.
> Added comments for the above on the individual patches.
>
> For longer-lived certs as describes in the cover letter, this worked as
> expected, though. I really like this as an improvement to PVE.
Thanks for taking the time to do a thorough review and testing it and pointing
out the edge cases that currently exist.
Seems like I have missed a couple of essential parts, I will address them in a v2.
>
> On Tue Oct 6, 2026 at 2:31 PM CEST, David Riley wrote:
>> 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,
> [snip]
>
>
>
>
^ permalink raw reply [flat|nested] 9+ messages in thread
end of thread, other threads:[~2026-10-16 13:17 UTC | newest]
Thread overview: 9+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-06 12:31 [PATCH common/manager 0/2] fix #8006: acme: lifetime-based certificate renewal threshold David Riley
2026-10-06 12:31 ` [PATCH pve-common 1/2] certificate: add helpers for lifetime-based ACME renewal David Riley
2026-10-16 12:45 ` Michael Köppl
2026-10-16 13:17 ` David Riley
2026-10-06 12:31 ` [PATCH pve-manager 2/2] fix #8006: api: acme: lifetime-based certificate renewal threshold David Riley
2026-10-16 12:45 ` Michael Köppl
2026-10-16 13:17 ` David Riley
2026-10-16 12:45 ` [PATCH common/manager 0/2] fix #8006: " Michael Köppl
2026-10-16 13:17 ` David Riley
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox