From affbb99275bc34aa1cfce0824d23aa8dcf7b1a79 Mon Sep 17 00:00:00 2001 From: Amirhf Date: Sun, 15 Feb 2026 17:40:12 +0330 Subject: [PATCH] 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 --- caddyconfig/httpcaddyfile/pkiapp.go | 33 +++++++-- caddyconfig/httpcaddyfile/pkiapp_test.go | 86 ++++++++++++++++++++++++ modules/caddypki/ca.go | 17 +++++ modules/caddypki/maintain.go | 28 +++++--- modules/caddypki/maintain_test.go | 86 ++++++++++++++++++++++++ modules/caddypki/pki.go | 6 +- 6 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 caddyconfig/httpcaddyfile/pkiapp_test.go create mode 100644 modules/caddypki/maintain_test.go diff --git a/caddyconfig/httpcaddyfile/pkiapp.go b/caddyconfig/httpcaddyfile/pkiapp.go index 25b6c221c..3f856ff36 100644 --- a/caddyconfig/httpcaddyfile/pkiapp.go +++ b/caddyconfig/httpcaddyfile/pkiapp.go @@ -16,6 +16,7 @@ package httpcaddyfile import ( "slices" + "strconv" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" @@ -27,14 +28,16 @@ func init() { RegisterGlobalOption("pki", parsePKIApp) } -// parsePKIApp parses the global log option. Syntax: +// parsePKIApp parses the global pki option. Syntax: // // pki { // ca [] { -// name -// root_cn -// intermediate_cn -// intermediate_lifetime +// name +// root_cn +// intermediate_cn +// intermediate_lifetime +// maintenance_interval +// renewal_window_ratio // root { // cert // key @@ -99,6 +102,26 @@ func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) { } pkiCa.IntermediateLifetime = caddy.Duration(dur) + case "maintenance_interval": + if !d.NextArg() { + return nil, d.ArgErr() + } + dur, err := caddy.ParseDuration(d.Val()) + if err != nil { + return nil, err + } + pkiCa.MaintenanceInterval = caddy.Duration(dur) + + case "renewal_window_ratio": + if !d.NextArg() { + return nil, d.ArgErr() + } + ratio, err := strconv.ParseFloat(d.Val(), 64) + if err != nil || ratio <= 0 || ratio > 1 { + return nil, d.Errf("renewal_window_ratio must be a number in (0, 1], got %s", d.Val()) + } + pkiCa.RenewalWindowRatio = ratio + case "root": if pkiCa.Root == nil { pkiCa.Root = new(caddypki.KeyPair) diff --git a/caddyconfig/httpcaddyfile/pkiapp_test.go b/caddyconfig/httpcaddyfile/pkiapp_test.go new file mode 100644 index 000000000..57662f71e --- /dev/null +++ b/caddyconfig/httpcaddyfile/pkiapp_test.go @@ -0,0 +1,86 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpcaddyfile + +import ( + "encoding/json" + "testing" + "time" + + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func TestParsePKIApp_maintenanceIntervalAndRenewalWindowRatio(t *testing.T) { + input := `{ + pki { + ca local { + maintenance_interval 5m + renewal_window_ratio 0.15 + } + } + } + :8080 { + } + ` + adapter := caddyfile.Adapter{ServerType: ServerType{}} + out, _, err := adapter.Adapt([]byte(input), nil) + if err != nil { + t.Fatalf("Adapt failed: %v", err) + } + + var cfg struct { + Apps struct { + PKI struct { + CertificateAuthorities map[string]struct { + MaintenanceInterval int64 `json:"maintenance_interval,omitempty"` + RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"` + } `json:"certificate_authorities,omitempty"` + } `json:"pki,omitempty"` + } `json:"apps"` + } + if err := json.Unmarshal(out, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + ca, ok := cfg.Apps.PKI.CertificateAuthorities["local"] + if !ok { + t.Fatal("expected certificate_authorities.local to exist") + } + wantInterval := 5 * time.Minute.Nanoseconds() + if ca.MaintenanceInterval != wantInterval { + t.Errorf("maintenance_interval = %d, want %d (5m)", ca.MaintenanceInterval, wantInterval) + } + if ca.RenewalWindowRatio != 0.15 { + t.Errorf("renewal_window_ratio = %v, want 0.15", ca.RenewalWindowRatio) + } +} + +func TestParsePKIApp_renewalWindowRatioInvalid(t *testing.T) { + input := `{ + pki { + ca local { + renewal_window_ratio 1.5 + } + } + } + :8080 { + } + ` + adapter := caddyfile.Adapter{ServerType: ServerType{}} + _, _, err := adapter.Adapt([]byte(input), nil) + if err == nil { + t.Error("expected error for renewal_window_ratio > 1") + } +} diff --git a/modules/caddypki/ca.go b/modules/caddypki/ca.go index 8f6fd3afe..4b98244aa 100644 --- a/modules/caddypki/ca.go +++ b/modules/caddypki/ca.go @@ -63,6 +63,15 @@ type CA struct { // The intermediate (signing) certificate; if null, one will be generated. Intermediate *KeyPair `json:"intermediate,omitempty"` + // How often to check if intermediate (and root, when applicable) certificates need renewal. + // Default: 10m. + MaintenanceInterval caddy.Duration `json:"maintenance_interval,omitempty"` + + // The fraction of certificate lifetime (0.0–1.0) after which renewal is attempted. + // For example, 0.2 means renew when 20% of the lifetime remains (e.g. ~73 days for a 1-year cert). + // Default: 0.2. + RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"` + // Optionally configure a separate storage module associated with this // issuer, instead of using Caddy's global/default-configured storage. // 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 { if ca.IntermediateLifetime == 0 { ca.IntermediateLifetime = caddy.Duration(defaultIntermediateLifetime) } + if ca.MaintenanceInterval == 0 { + ca.MaintenanceInterval = caddy.Duration(defaultMaintenanceInterval) + } + if ca.RenewalWindowRatio <= 0 || ca.RenewalWindowRatio > 1 { + ca.RenewalWindowRatio = defaultRenewalWindowRatio + } // load the certs and key that will be used for signing var rootCert *x509.Certificate @@ -456,4 +471,6 @@ const ( defaultRootLifetime = 24 * time.Hour * 30 * 12 * 10 defaultIntermediateLifetime = 24 * time.Hour * 7 + defaultMaintenanceInterval = 10 * time.Minute + defaultRenewalWindowRatio = 0.2 ) diff --git a/modules/caddypki/maintain.go b/modules/caddypki/maintain.go index 091e71243..31e4c6a8b 100644 --- a/modules/caddypki/maintain.go +++ b/modules/caddypki/maintain.go @@ -24,20 +24,24 @@ import ( "go.uber.org/zap" ) -func (p *PKI) maintenance() { +func (p *PKI) maintenanceForCA(ca *CA) { defer func() { if err := recover(); err != nil { - log.Printf("[PANIC] PKI maintenance: %v\n%s", err, debug.Stack()) + log.Printf("[PANIC] PKI maintenance for CA %s: %v\n%s", ca.ID, err, debug.Stack()) } }() - ticker := time.NewTicker(10 * time.Minute) // TODO: make configurable + interval := time.Duration(ca.MaintenanceInterval) + if interval <= 0 { + interval = defaultMaintenanceInterval + } + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: - p.renewCerts() + _ = p.renewCertsForCA(ca) case <-p.ctx.Done(): return } @@ -63,7 +67,7 @@ func (p *PKI) renewCertsForCA(ca *CA) error { // only maintain the root if it's not manually provided in the config if ca.Root == nil { - if needsRenewal(ca.root) { + if ca.needsRenewal(ca.root) { // TODO: implement root renewal (use same key) log.Warn("root certificate expiring soon (FIXME: ROOT RENEWAL NOT YET IMPLEMENTED)", zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)), @@ -73,7 +77,7 @@ func (p *PKI) renewCertsForCA(ca *CA) error { // only maintain the intermediate if it's not manually provided in the config if ca.Intermediate == nil { - if needsRenewal(ca.interChain[0]) { + if ca.needsRenewal(ca.interChain[0]) { log.Info("intermediate expires soon; renewing", zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)), ) @@ -97,11 +101,15 @@ func (p *PKI) renewCertsForCA(ca *CA) error { return nil } -func needsRenewal(cert *x509.Certificate) bool { +// needsRenewal reports whether the certificate is within its renewal window +// (i.e. the fraction of lifetime remaining is less than or equal to RenewalWindowRatio). +func (ca *CA) needsRenewal(cert *x509.Certificate) bool { + ratio := ca.RenewalWindowRatio + if ratio <= 0 { + ratio = defaultRenewalWindowRatio + } lifetime := cert.NotAfter.Sub(cert.NotBefore) - renewalWindow := time.Duration(float64(lifetime) * renewalWindowRatio) + renewalWindow := time.Duration(float64(lifetime) * ratio) renewalWindowStart := cert.NotAfter.Add(-renewalWindow) return time.Now().After(renewalWindowStart) } - -const renewalWindowRatio = 0.2 // TODO: make configurable diff --git a/modules/caddypki/maintain_test.go b/modules/caddypki/maintain_test.go new file mode 100644 index 000000000..d20d1d8a5 --- /dev/null +++ b/modules/caddypki/maintain_test.go @@ -0,0 +1,86 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddypki + +import ( + "crypto/x509" + "testing" + "time" +) + +func TestCA_needsRenewal(t *testing.T) { + now := time.Now() + + // cert with 100 days lifetime; last 20% = 20 days before expiry + // So renewal window starts at (NotAfter - 20 days) + makeCert := func(daysUntilExpiry int, lifetimeDays int) *x509.Certificate { + notAfter := now.AddDate(0, 0, daysUntilExpiry) + notBefore := notAfter.AddDate(0, 0, -lifetimeDays) + return &x509.Certificate{NotBefore: notBefore, NotAfter: notAfter} + } + + tests := []struct { + name string + ca *CA + cert *x509.Certificate + expect bool + }{ + { + name: "inside renewal window with ratio 0.2", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(10, 100), + expect: true, + }, + { + name: "outside renewal window with ratio 0.2", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(50, 100), + expect: false, + }, + { + name: "outside renewal window with 21 days left", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(21, 100), + expect: false, + }, + { + name: "just inside renewal window with ratio 0.5", + ca: &CA{RenewalWindowRatio: 0.5}, + cert: makeCert(30, 100), + expect: true, + }, + { + name: "zero ratio uses default", + ca: &CA{RenewalWindowRatio: 0}, + cert: makeCert(10, 100), + expect: true, + }, + { + name: "invalid ratio uses default", + ca: &CA{RenewalWindowRatio: 1.5}, + cert: makeCert(10, 100), + expect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.ca.needsRenewal(tt.cert) + if got != tt.expect { + t.Errorf("needsRenewal() = %v, want %v", got, tt.expect) + } + }) + } +} diff --git a/modules/caddypki/pki.go b/modules/caddypki/pki.go index 9f974a956..557df74fc 100644 --- a/modules/caddypki/pki.go +++ b/modules/caddypki/pki.go @@ -109,8 +109,10 @@ func (p *PKI) Start() error { // see if root/intermediates need renewal... p.renewCerts() - // ...and keep them renewed - go p.maintenance() + // ...and keep them renewed (one goroutine per CA with its own interval) + for _, ca := range p.CAs { + go p.maintenanceForCA(ca) + } return nil }