Repository: go-acme/lego (go)

Issue:
Dynamic adjust `--days` value for different duration of the certificate
### Welcome

- [x] Yes, I've searched similar issues on GitHub and didn't find any.

### How do you use lego?

Docker image

### Effective version of lego

4.24.0 linux/amd64

### Detailed Description

Currently Lego will still perform `renew` everytimes when testing on Let's Encrypt Staging environments with `shortlived` profile even if ARI is available.

run output:
<details>

```console
$ sudo docker run --rm -e CLOUDFLARE_DNS_API_TOKEN=BLABLA -e LEGO_DEBUG_ACME_HTTP_CLIENT=true -v ./cert:/cert goacme/lego -a --server=https://acme-staging-v02.api.letsencrypt.org/directory --path "/cert" -m xx@xx.com --dns cloudflare -d "*.shortlived.xlion.tw" run --profile shortlived

2025/07/09 12:16:44 [DEBUG] GET https://acme-staging-v02.api.letsencrypt.org/directory
2025/07/09 12:16:44 [INFO] [*.shortlived.xlion.tw] acme: Obtaining bundled SAN certificate
2025/07/09 12:16:44 [DEBUG] HEAD https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce
2025/07/09 12:16:44 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/new-order
2025/07/09 12:16:45 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:16:45 [INFO] [*.shortlived.xlion.tw] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:16:45 [INFO] [*.shortlived.xlion.tw] acme: use dns-01 solver
2025/07/09 12:16:45 [INFO] [*.shortlived.xlion.tw] acme: Preparing to solve DNS-01
2025/07/09 12:16:47 [INFO] cloudflare: new record for shortlived.xlion.tw, ID 1b292118e2db8e8460a322601806ad93
2025/07/09 12:16:47 [INFO] [*.shortlived.xlion.tw] acme: Trying to solve DNS-01
2025/07/09 12:16:48 [INFO] [*.shortlived.xlion.tw] acme: Checking DNS record propagation. [nameservers=192.168.2.10:53,192.168.91.252:53,192.168.2.1:53]
2025/07/09 12:16:50 [INFO] Wait for propagation [timeout: 2m0s, interval: 2s]
2025/07/09 12:16:50 [INFO] [*.shortlived.xlion.tw] acme: Waiting for DNS record propagation.
2025/07/09 12:16:53 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/chall/211393583/18437293943/t1Juhw
2025/07/09 12:16:53 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:16:57 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:16:58 [INFO] [*.shortlived.xlion.tw] The server validated our request
2025/07/09 12:16:58 [INFO] [*.shortlived.xlion.tw] acme: Cleaning DNS-01 challenge
2025/07/09 12:16:59 [INFO] [*.shortlived.xlion.tw] acme: Validations succeeded; requesting certificates
2025/07/09 12:16:59 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/finalize/211393583/25938580343
2025/07/09 12:16:59 [INFO] Wait for certificate [timeout: 30s, interval: 500ms]
2025/07/09 12:16:59 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/order/211393583/25938580343
2025/07/09 12:16:59 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2cb3ebb85d640c22846cd8acf33c0b0b5f77
2025/07/09 12:16:59 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2cb3ebb85d640c22846cd8acf33c0b0b5f77/1
2025/07/09 12:16:59 [INFO] [*.shortlived.xlion.tw] Server responded with a certificate.
```

</details>

renew output:

<details>

```console
$ sudo docker run --rm -e CLOUDFLARE_DNS_API_TOKEN=BLABLA -e LEGO_DEBUG_ACME_HTTP_CLIENT=true -v ./cert:/cert goacme/lego -a --server=https://acme-staging-v02.api.letsencrypt.org/directory --path "/cert" -m xx@xx.com --dns cloudflare -d "*.shortlived.xlion.tw" renew --profile shortlived --no-random-sleep 

2025/07/09 12:18:14 [DEBUG] GET https://acme-staging-v02.api.letsencrypt.org/directory
2025/07/09 12:18:14 [DEBUG] GET https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info/_EbRAUNfu3umPTBorhG64LxtydM.LLPruF1kDCKEbNis8zwLC193
2025/07/09 12:18:14 [INFO] [*.shortlived.xlion.tw] acme: renewalInfo endpoint indicates that renewal is not needed
2025/07/09 12:18:14 [INFO] [*.shortlived.xlion.tw] acme: Trying renewal with 159 hours remaining
2025/07/09 12:18:14 [INFO] [*.shortlived.xlion.tw] acme: Obtaining bundled SAN certificate
2025/07/09 12:18:14 [DEBUG] HEAD https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce
2025/07/09 12:18:15 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/new-order
2025/07/09 12:18:15 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:18:15 [INFO] [*.shortlived.xlion.tw] AuthURL: https://acme-staging-v02.api.letsencrypt.org/acme/authz/211393583/18437293943
2025/07/09 12:18:15 [INFO] [*.shortlived.xlion.tw] acme: authorization already valid; skipping challenge
2025/07/09 12:18:15 [INFO] [*.shortlived.xlion.tw] acme: Validations succeeded; requesting certificates
2025/07/09 12:18:15 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/finalize/211393583/25938605033
2025/07/09 12:18:15 [INFO] Wait for certificate [timeout: 30s, interval: 500ms]
2025/07/09 12:18:15 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/order/211393583/25938605033
2025/07/09 12:18:16 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/order/211393583/25938605033
2025/07/09 12:18:16 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2cdcb8906c239ee84d6dc95be2788cecd5bf
2025/07/09 12:18:16 [DEBUG] POST https://acme-staging-v02.api.letsencrypt.org/acme/cert/2cdcb8906c239ee84d6dc95be2788cecd5bf/1
2025/07/09 12:18:17 [INFO] [*.shortlived.xlion.tw] Server responded with a certificate.
```

</details>

### Additional information

The Certbot is now following this when choosing renew times

* 1/3rd of lifetime left
* 1/2 of lifetime left, if the lifetime is shorter than 10 days

https://www.eff.org/deeplinks/2025/04/certbot-40-long-live-short-lived-certs

Pull request description:
feat: add option to define dynamically the renew date
Adds a new flag `--dynamic` for the `renew` command: 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)

The flag `--dynamic` overrides the flag `--days`.

Fixes #2572

Patch:
diff --git a/cmd/cmd_renew.go b/cmd/cmd_renew.go
index a46de187..7d968d2a 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())
+	}
+
+	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.
diff --git a/docs/data/zz_cli_help.toml b/docs/data/zz_cli_help.toml
index 726392df..723f063c 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)


Test patch:
diff --git a/cmd/cmd_renew_test.go b/cmd/cmd_renew_test.go
index f88ad74c..405dda5f 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)
+		})
+	}
+}