Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions caddyconfig/httpcaddyfile/pkiapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package httpcaddyfile

import (
"slices"
"strconv"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig"
Expand All @@ -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 [<id>] {
// name <name>
// root_cn <name>
// intermediate_cn <name>
// intermediate_lifetime <duration>
// name <name>
// root_cn <name>
// intermediate_cn <name>
// intermediate_lifetime <duration>
// maintenance_interval <duration>
// renewal_window_ratio <ratio>
// root {
// cert <path>
// key <path>
Expand Down Expand Up @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions caddyconfig/httpcaddyfile/pkiapp_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
17 changes: 17 additions & 0 deletions modules/caddypki/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -456,4 +471,6 @@ const (

defaultRootLifetime = 24 * time.Hour * 30 * 12 * 10
defaultIntermediateLifetime = 24 * time.Hour * 7
defaultMaintenanceInterval = 10 * time.Minute
defaultRenewalWindowRatio = 0.2
)
28 changes: 18 additions & 10 deletions modules/caddypki/maintain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)),
Expand All @@ -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)),
)
Expand All @@ -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
86 changes: 86 additions & 0 deletions modules/caddypki/maintain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
6 changes: 4 additions & 2 deletions modules/caddypki/pki.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading