diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go index a46de187c..7d968d2a3 100644 --- a/cmd/cmd_renew.go +++ b/cmd/cmd_renew.go @@ -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. diff --git a/cmd/cmd_renew_test.go b/cmd/cmd_renew_test.go index f88ad74c5..405dda5fa 100644 --- a/cmd/cmd_renew_test.go +++ b/cmd/cmd_renew_test.go @@ -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) + }) + } +} diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml index 726392df5..723f063cd 100644 --- a/docs/data/zz_cli_help.toml +++ b/docs/data/zz_cli_help.toml @@ -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)