@marcel91200 / caddy Mirror · read-only / commits / affbb992
Fork 0

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 }