feat: add option to define dynamically the renew date (#2574)

Co-authored-by: Dominik Menke <git@dmke.org>
This commit is contained in:
Ludovic Fernandez 2025-07-10 12:13:19 +02:00 committed by GitHub
commit 96b18d764d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 96 additions and 14 deletions

View file

@ -20,7 +20,8 @@ import (
// Flag names.
const (
flgDays = "days"
flgRenewDays = "days"
flgRenewDynamic = "dynamic"
flgARIDisable = "ari-disable"
flgARIWaitToRenewDuration = "ari-wait-to-renew-duration"
flgReuseKey = "reuse-key"
@ -52,10 +53,16 @@ func createRenew() *cli.Command {
},
Flags: []cli.Flag{
&cli.IntFlag{
Name: flgDays,
Name: flgRenewDays,
Value: 30,
Usage: "The number of days left on a certificate to renew it.",
},
// TODO(ldez): in v5, remove this flag, use this behavior as default.
&cli.BoolFlag{
Name: flgRenewDynamic,
Value: false,
Usage: "Compute dynamically, based on the lifetime of the certificate(s), when to renew: use 1/3rd of the lifetime left, or 1/2 of the lifetime for short-lived certificates). This supersedes --days and will be the default behavior in Lego v5.",
},
&cli.BoolFlag{
Name: flgARIDisable,
Usage: "Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed.",
@ -187,7 +194,7 @@ func renewForDomains(ctx *cli.Context, account *Account, keyType certcrypto.KeyT
certDomains := certcrypto.ExtractDomains(cert)
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) &&
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) &&
(!forceDomains || slices.Equal(certDomains, domains)) {
return nil
}
@ -304,7 +311,7 @@ func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType,
}
}
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgDays)) {
if ariRenewalTime == nil && !needRenewal(cert, domain, ctx.Int(flgRenewDays), ctx.Bool(flgRenewDynamic)) {
return nil
}
@ -342,21 +349,41 @@ func renewForCSR(ctx *cli.Context, account *Account, keyType certcrypto.KeyType,
return launchHook(ctx.String(flgRenewHook), ctx.Duration(flgRenewHookTimeout), meta)
}
func needRenewal(x509Cert *x509.Certificate, domain string, days int) bool {
func needRenewal(x509Cert *x509.Certificate, domain string, days int, dynamic bool) bool {
if x509Cert.IsCA {
log.Fatalf("[%s] Certificate bundle starts with a CA certificate", domain)
}
if days >= 0 {
notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
if notAfter > days {
log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
domain, notAfter, days)
return false
}
if dynamic {
return needRenewalDynamic(x509Cert, time.Now())
}
return true
if days < 0 {
return true
}
notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
if notAfter <= days {
return true
}
log.Printf("[%s] The certificate expires in %d days, the number of days defined to perform the renewal is %d: no renewal.",
domain, notAfter, days)
return false
}
func needRenewalDynamic(x509Cert *x509.Certificate, now time.Time) bool {
lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore)
var divisor int64 = 3
if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 {
divisor = 2
}
dueDate := x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor))
return dueDate.Before(now)
}
// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.

View file

@ -108,9 +108,62 @@ func Test_needRenewal(t *testing.T) {
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
actual := needRenewal(test.x509Cert, "foo.com", test.days)
actual := needRenewal(test.x509Cert, "foo.com", test.days, false)
assert.Equal(t, test.expected, actual)
})
}
}
func Test_needRenewalDynamic(t *testing.T) {
testCases := []struct {
desc string
now time.Time
notBefore, notAfter time.Time
expected assert.BoolAssertionFunc
}{
{
desc: "higher than 1/3 of the certificate lifetime left (lifetime > 10 days)",
now: time.Date(2025, 1, 19, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
expected: assert.False,
},
{
desc: "lower than 1/3 of the certificate lifetime left(lifetime > 10 days)",
now: time.Date(2025, 1, 21, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 30, 1, 1, 1, 1, time.UTC),
expected: assert.True,
},
{
desc: "higher than 1/2 of the certificate lifetime left (lifetime < 10 days)",
now: time.Date(2025, 1, 4, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
expected: assert.False,
},
{
desc: "lower than 1/2 of the certificate lifetime left (lifetime < 10 days)",
now: time.Date(2025, 1, 6, 1, 1, 1, 1, time.UTC),
notBefore: time.Date(2025, 1, 1, 1, 1, 1, 1, time.UTC),
notAfter: time.Date(2025, 1, 10, 1, 1, 1, 1, time.UTC),
expected: assert.True,
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
x509Cert := &x509.Certificate{
NotBefore: test.notBefore,
NotAfter: test.notAfter,
}
ok := needRenewalDynamic(x509Cert, test.now)
test.expected(t, ok)
})
}
}

View file

@ -23,6 +23,7 @@ GLOBAL OPTIONS:
--server value, -s value CA hostname (and optionally :port). The server certificate must be trusted in order to avoid further modifications to the client. (default: "https://acme-v02.api.letsencrypt.org/directory") [$LEGO_SERVER]
--accept-tos, -a By setting this flag to true you indicate that you accept the current Let's Encrypt terms of service. (default: false)
--email value, -m value Email used for registration and recovery contact. [$LEGO_EMAIL]
--disable-cn value Disable the use of the common name in the CSR. [$disable-cn]
--csr value, -c value Certificate signing request filename, if an external CSR is to be used.
--eab Use External Account Binding for account registration. Requires --kid and --hmac. (default: false) [$LEGO_EAB]
--kid value Key identifier from External CA. Used for External Account Binding. [$LEGO_EAB_KID]
@ -93,6 +94,7 @@ USAGE:
OPTIONS:
--days value The number of days left on a certificate to renew it. (default: 30)
--dynamic Dynamically defines the renewal date. (1/3rd of the lifetime left or 1/2 of the lifetime left, if the lifetime is shorter than 10 days) (default: false)
--ari-disable Do not use the renewalInfo endpoint (RFC9773) to check if a certificate should be renewed. (default: false)
--ari-wait-to-renew-duration value The maximum duration you're willing to sleep for a renewal time returned by the renewalInfo endpoint. (default: 0s)
--reuse-key Used to indicate you want to reuse your current private key for the new certificate. (default: false)