Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS
Mirrored from https://github.com/Slazee/caddy.git
Synced Nov 11, 16:00 UTC
pki: add per-CA configurable `maintenance_interval` and `renewal_window_ratio` (#7479)
* pki: add per-CA configurable maintenance_interval and renewal_window_ratio - Add MaintenanceInterval and RenewalWindowRatio to CA struct (JSON + Caddyfile). - Run one maintenance goroutine per CA using its own interval. - needsRenewal uses per-CA RenewalWindowRatio; invalid/zero ratio falls back to defaults. - Caddyfile: maintenance_interval duration, renewal_window_ratio <0-1>. - Tests: TestCA_needsRenewal, TestParsePKIApp for new options. Fixes #7475 * fix codestyle
Amirhf committed
Feb 15, 2026 at 17:40 UTC
affbb99275bc34aa1cfce0824d23aa8dcf7b1a79
6 files changed
+239
-17
caddyconfig/httpcaddyfile/pkiapp.go
+28
-5
@@ -16,6 +16,7 @@ package httpcaddyfile
16
17
import (
18
"slices"
19
+ "strconv"
20
21
"github.com/caddyserver/caddy/v2"
22
"github.com/caddyserver/caddy/v2/caddyconfig"
@@ -27,14 +28,16 @@ func init() {
28
RegisterGlobalOption("pki", parsePKIApp)
29
}
30
30
-// parsePKIApp parses the global log option. Syntax:
31
+// parsePKIApp parses the global pki option. Syntax:
32
//
33
// pki {
34
// ca [<id>] {
34
-// name <name>
35
-// root_cn <name>
36
-// intermediate_cn <name>
37
-// intermediate_lifetime <duration>
35
+// name <name>
36
+// root_cn <name>
37
+// intermediate_cn <name>
38
+// intermediate_lifetime <duration>
39
+// maintenance_interval <duration>
40
+// renewal_window_ratio <ratio>
41
// root {
42
// cert <path>
43
// key <path>
@@ -99,6 +102,26 @@ func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) {
102
}
103
pkiCa.IntermediateLifetime = caddy.Duration(dur)
104
105
+ case "maintenance_interval":
106
+ if !d.NextArg() {
107
+ return nil, d.ArgErr()
108
+ }
109
+ dur, err := caddy.ParseDuration(d.Val())
110
+ if err != nil {
111
+ return nil, err
112
+ }
113
+ pkiCa.MaintenanceInterval = caddy.Duration(dur)
114
+
115
+ case "renewal_window_ratio":
116
+ if !d.NextArg() {
117
+ return nil, d.ArgErr()
118
+ }
119
+ ratio, err := strconv.ParseFloat(d.Val(), 64)
120
+ if err != nil || ratio <= 0 || ratio > 1 {
121
+ return nil, d.Errf("renewal_window_ratio must be a number in (0, 1], got %s", d.Val())
122
+ }
123
+ pkiCa.RenewalWindowRatio = ratio
124
+
125
case "root":
126
if pkiCa.Root == nil {
127
pkiCa.Root = new(caddypki.KeyPair)
caddyconfig/httpcaddyfile/pkiapp_test.go
new
+86
@@ -0,0 +1,86 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package httpcaddyfile
16
+
17
+import (
18
+ "encoding/json"
19
+ "testing"
20
+ "time"
21
+
22
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
23
+)
24
+
25
+func TestParsePKIApp_maintenanceIntervalAndRenewalWindowRatio(t *testing.T) {
26
+ input := `{
27
+ pki {
28
+ ca local {
29
+ maintenance_interval 5m
30
+ renewal_window_ratio 0.15
31
+ }
32
+ }
33
+ }
34
+ :8080 {
35
+ }
36
+ `
37
+ adapter := caddyfile.Adapter{ServerType: ServerType{}}
38
+ out, _, err := adapter.Adapt([]byte(input), nil)
39
+ if err != nil {
40
+ t.Fatalf("Adapt failed: %v", err)
41
+ }
42
+
43
+ var cfg struct {
44
+ Apps struct {
45
+ PKI struct {
46
+ CertificateAuthorities map[string]struct {
47
+ MaintenanceInterval int64 `json:"maintenance_interval,omitempty"`
48
+ RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"`
49
+ } `json:"certificate_authorities,omitempty"`
50
+ } `json:"pki,omitempty"`
51
+ } `json:"apps"`
52
+ }
53
+ if err := json.Unmarshal(out, &cfg); err != nil {
54
+ t.Fatalf("unmarshal config: %v", err)
55
+ }
56
+
57
+ ca, ok := cfg.Apps.PKI.CertificateAuthorities["local"]
58
+ if !ok {
59
+ t.Fatal("expected certificate_authorities.local to exist")
60
+ }
61
+ wantInterval := 5 * time.Minute.Nanoseconds()
62
+ if ca.MaintenanceInterval != wantInterval {
63
+ t.Errorf("maintenance_interval = %d, want %d (5m)", ca.MaintenanceInterval, wantInterval)
64
+ }
65
+ if ca.RenewalWindowRatio != 0.15 {
66
+ t.Errorf("renewal_window_ratio = %v, want 0.15", ca.RenewalWindowRatio)
67
+ }
68
+}
69
+
70
+func TestParsePKIApp_renewalWindowRatioInvalid(t *testing.T) {
71
+ input := `{
72
+ pki {
73
+ ca local {
74
+ renewal_window_ratio 1.5
75
+ }
76
+ }
77
+ }
78
+ :8080 {
79
+ }
80
+ `
81
+ adapter := caddyfile.Adapter{ServerType: ServerType{}}
82
+ _, _, err := adapter.Adapt([]byte(input), nil)
83
+ if err == nil {
84
+ t.Error("expected error for renewal_window_ratio > 1")
85
+ }
86
+}
modules/caddypki/ca.go
+17
@@ -63,6 +63,15 @@ type CA struct {
63
// The intermediate (signing) certificate; if null, one will be generated.
64
Intermediate *KeyPair `json:"intermediate,omitempty"`
65
66
+ // How often to check if intermediate (and root, when applicable) certificates need renewal.
67
+ // Default: 10m.
68
+ MaintenanceInterval caddy.Duration `json:"maintenance_interval,omitempty"`
69
+
70
+ // The fraction of certificate lifetime (0.0–1.0) after which renewal is attempted.
71
+ // For example, 0.2 means renew when 20% of the lifetime remains (e.g. ~73 days for a 1-year cert).
72
+ // Default: 0.2.
73
+ RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"`
74
+
75
// Optionally configure a separate storage module associated with this
76
// issuer, instead of using Caddy's global/default-configured storage.
77
// This can be useful if you want to keep your signing keys in a
@@ -126,6 +135,12 @@ func (ca *CA) Provision(ctx caddy.Context, id string, log *zap.Logger) error {
135
if ca.IntermediateLifetime == 0 {
136
ca.IntermediateLifetime = caddy.Duration(defaultIntermediateLifetime)
137
}
138
+ if ca.MaintenanceInterval == 0 {
139
+ ca.MaintenanceInterval = caddy.Duration(defaultMaintenanceInterval)
140
+ }
141
+ if ca.RenewalWindowRatio <= 0 || ca.RenewalWindowRatio > 1 {
142
+ ca.RenewalWindowRatio = defaultRenewalWindowRatio
143
+ }
144
145
// load the certs and key that will be used for signing
146
var rootCert *x509.Certificate
@@ -456,4 +471,6 @@ const (
471
472
defaultRootLifetime = 24 * time.Hour * 30 * 12 * 10
473
defaultIntermediateLifetime = 24 * time.Hour * 7
474
+ defaultMaintenanceInterval = 10 * time.Minute
475
+ defaultRenewalWindowRatio = 0.2
476
)
modules/caddypki/maintain.go
+18
-10
@@ -24,20 +24,24 @@ import (
24
"go.uber.org/zap"
25
)
26
27
-func (p *PKI) maintenance() {
27
+func (p *PKI) maintenanceForCA(ca *CA) {
28
defer func() {
29
if err := recover(); err != nil {
30
- log.Printf("[PANIC] PKI maintenance: %v\n%s", err, debug.Stack())
30
+ log.Printf("[PANIC] PKI maintenance for CA %s: %v\n%s", ca.ID, err, debug.Stack())
31
}
32
}()
33
34
- ticker := time.NewTicker(10 * time.Minute) // TODO: make configurable
34
+ interval := time.Duration(ca.MaintenanceInterval)
35
+ if interval <= 0 {
36
+ interval = defaultMaintenanceInterval
37
+ }
38
+ ticker := time.NewTicker(interval)
39
defer ticker.Stop()
40
41
for {
42
select {
43
case <-ticker.C:
40
- p.renewCerts()
44
+ _ = p.renewCertsForCA(ca)
45
case <-p.ctx.Done():
46
return
47
}
@@ -63,7 +67,7 @@ func (p *PKI) renewCertsForCA(ca *CA) error {
67
68
// only maintain the root if it's not manually provided in the config
69
if ca.Root == nil {
66
- if needsRenewal(ca.root) {
70
+ if ca.needsRenewal(ca.root) {
71
// TODO: implement root renewal (use same key)
72
log.Warn("root certificate expiring soon (FIXME: ROOT RENEWAL NOT YET IMPLEMENTED)",
73
zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)),
@@ -73,7 +77,7 @@ func (p *PKI) renewCertsForCA(ca *CA) error {
77
78
// only maintain the intermediate if it's not manually provided in the config
79
if ca.Intermediate == nil {
76
- if needsRenewal(ca.interChain[0]) {
80
+ if ca.needsRenewal(ca.interChain[0]) {
81
log.Info("intermediate expires soon; renewing",
82
zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)),
83
)
@@ -97,11 +101,15 @@ func (p *PKI) renewCertsForCA(ca *CA) error {
101
return nil
102
}
103
100
-func needsRenewal(cert *x509.Certificate) bool {
104
+// needsRenewal reports whether the certificate is within its renewal window
105
+// (i.e. the fraction of lifetime remaining is less than or equal to RenewalWindowRatio).
106
+func (ca *CA) needsRenewal(cert *x509.Certificate) bool {
107
+ ratio := ca.RenewalWindowRatio
108
+ if ratio <= 0 {
109
+ ratio = defaultRenewalWindowRatio
110
+ }
111
lifetime := cert.NotAfter.Sub(cert.NotBefore)
102
- renewalWindow := time.Duration(float64(lifetime) * renewalWindowRatio)
112
+ renewalWindow := time.Duration(float64(lifetime) * ratio)
113
renewalWindowStart := cert.NotAfter.Add(-renewalWindow)
114
return time.Now().After(renewalWindowStart)
115
}
106
-
107
-const renewalWindowRatio = 0.2 // TODO: make configurable
modules/caddypki/maintain_test.go
new
+86
@@ -0,0 +1,86 @@
1
+// Copyright 2015 Matthew Holt and The Caddy Authors
2
+//
3
+// Licensed under the Apache License, Version 2.0 (the "License");
4
+// you may not use this file except in compliance with the License.
5
+// You may obtain a copy of the License at
6
+//
7
+// http://www.apache.org/licenses/LICENSE-2.0
8
+//
9
+// Unless required by applicable law or agreed to in writing, software
10
+// distributed under the License is distributed on an "AS IS" BASIS,
11
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+// See the License for the specific language governing permissions and
13
+// limitations under the License.
14
+
15
+package caddypki
16
+
17
+import (
18
+ "crypto/x509"
19
+ "testing"
20
+ "time"
21
+)
22
+
23
+func TestCA_needsRenewal(t *testing.T) {
24
+ now := time.Now()
25
+
26
+ // cert with 100 days lifetime; last 20% = 20 days before expiry
27
+ // So renewal window starts at (NotAfter - 20 days)
28
+ makeCert := func(daysUntilExpiry int, lifetimeDays int) *x509.Certificate {
29
+ notAfter := now.AddDate(0, 0, daysUntilExpiry)
30
+ notBefore := notAfter.AddDate(0, 0, -lifetimeDays)
31
+ return &x509.Certificate{NotBefore: notBefore, NotAfter: notAfter}
32
+ }
33
+
34
+ tests := []struct {
35
+ name string
36
+ ca *CA
37
+ cert *x509.Certificate
38
+ expect bool
39
+ }{
40
+ {
41
+ name: "inside renewal window with ratio 0.2",
42
+ ca: &CA{RenewalWindowRatio: 0.2},
43
+ cert: makeCert(10, 100),
44
+ expect: true,
45
+ },
46
+ {
47
+ name: "outside renewal window with ratio 0.2",
48
+ ca: &CA{RenewalWindowRatio: 0.2},
49
+ cert: makeCert(50, 100),
50
+ expect: false,
51
+ },
52
+ {
53
+ name: "outside renewal window with 21 days left",
54
+ ca: &CA{RenewalWindowRatio: 0.2},
55
+ cert: makeCert(21, 100),
56
+ expect: false,
57
+ },
58
+ {
59
+ name: "just inside renewal window with ratio 0.5",
60
+ ca: &CA{RenewalWindowRatio: 0.5},
61
+ cert: makeCert(30, 100),
62
+ expect: true,
63
+ },
64
+ {
65
+ name: "zero ratio uses default",
66
+ ca: &CA{RenewalWindowRatio: 0},
67
+ cert: makeCert(10, 100),
68
+ expect: true,
69
+ },
70
+ {
71
+ name: "invalid ratio uses default",
72
+ ca: &CA{RenewalWindowRatio: 1.5},
73
+ cert: makeCert(10, 100),
74
+ expect: true,
75
+ },
76
+ }
77
+
78
+ for _, tt := range tests {
79
+ t.Run(tt.name, func(t *testing.T) {
80
+ got := tt.ca.needsRenewal(tt.cert)
81
+ if got != tt.expect {
82
+ t.Errorf("needsRenewal() = %v, want %v", got, tt.expect)
83
+ }
84
+ })
85
+ }
86
+}
modules/caddypki/pki.go
+4
-2
@@ -109,8 +109,10 @@ func (p *PKI) Start() error {
109
// see if root/intermediates need renewal...
110
p.renewCerts()
111
112
- // ...and keep them renewed
113
- go p.maintenance()
112
+ // ...and keep them renewed (one goroutine per CA with its own interval)
113
+ for _, ca := range p.CAs {
114
+ go p.maintenanceForCA(ca)
115
+ }
116
117
return nil
118
}