Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions cmd/cmd_renew.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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())
}

if days < 0 {
return true
}

notAfter := int(time.Until(x509Cert.NotAfter).Hours() / 24.0)
if notAfter <= days {
return true
}

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.
Expand Down
55 changes: 54 additions & 1 deletion cmd/cmd_renew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
2 changes: 2 additions & 0 deletions docs/data/zz_cli_help.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
Loading