1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2026-10-29 13:42:30 +00:00

feat: reload TLS certificates when their files change (#625)

This commit is contained in:
Antoine Deschênes
2026-10-05 17:49:35 -04:00
committed by GitHub
parent 9876bb2bd9
commit 8075e8d2a3
6 changed files with 447 additions and 6 deletions
+8
View File
@@ -243,6 +243,14 @@ to the public internet.
Enable the STARTTLS option for opportunistic TLS support Enable the STARTTLS option for opportunistic TLS support
Inbucket checks the certificate and private key files on each full TLS handshake
and reloads them when either file changes, so renewed certificates (for example
from cert-manager) take effect without a restart. Clients that resume an
earlier TLS session keep that session's certificate until their next full
handshake. If a reload fails, Inbucket logs a warning, keeps serving the
previous certificate, and tries again when the files change or after 30 seconds.
The same applies to the POP3 TLS files.
- Default: `false` - Default: `false`
- Values: `true` or `false` - Values: `true` or `false`
+3 -3
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/inbucket/inbucket/v3/pkg/config" "github.com/inbucket/inbucket/v3/pkg/config"
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
"github.com/inbucket/inbucket/v3/pkg/storage" "github.com/inbucket/inbucket/v3/pkg/storage"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -28,14 +29,13 @@ func NewServer(pop3Config config.POP3, store storage.Store) (*Server, error) {
slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger() slog := log.With().Str("module", "pop3").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{} tlsConfig := &tls.Config{}
if pop3Config.TLSEnabled { if pop3Config.TLSEnabled {
var err error reloader, err := tlscert.NewReloader(pop3Config.TLSCert, pop3Config.TLSPrivKey, slog)
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(pop3Config.TLSCert, pop3Config.TLSPrivKey)
if err != nil { if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err) slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
return nil, fmt.Errorf("failed to configure TLS; %v", err) return nil, fmt.Errorf("failed to configure TLS; %v", err)
// Do not silently turn off Security. // Do not silently turn off Security.
} }
tlsConfig.GetCertificate = reloader.GetCertificate
slog.Debug().Msg("TLS config available") slog.Debug().Msg("TLS config available")
} else { } else {
tlsConfig = nil tlsConfig = nil
+63
View File
@@ -3,12 +3,15 @@ package pop3
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/mail" "net/mail"
"net/textproto" "net/textproto"
"os"
"path/filepath"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -765,6 +768,66 @@ func TestNewServerCertificateError(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
// TestForceTLSServesRotatedCertificate verifies that replacing the certificate files on disk
// changes the certificate presented by later full handshakes, without restarting the server,
// while a client resuming an earlier TLS session keeps that session's certificate.
func TestForceTLSServesRotatedCertificate(t *testing.T) {
dir := t.TempDir()
certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writeKeyPair := func(version int) []byte {
cert, privKey, err := generateCertificate(t)
require.NoError(t, err)
mtime := time.Date(2026, 1, 1, 0, 0, version, 0, time.UTC)
require.NoError(t, os.WriteFile(certPath, certToPem(cert), 0o600))
require.NoError(t, os.WriteFile(keyPath, privKeyToPem(privKey), 0o600))
require.NoError(t, os.Chtimes(certPath, mtime, mtime))
require.NoError(t, os.Chtimes(keyPath, mtime, mtime))
return cert
}
first := writeKeyPair(1)
server, err := NewServer(config.POP3{
Addr: "127.0.0.1:0",
Domain: "inbucket.local",
Timeout: 5 * time.Second,
ForceTLS: true,
TLSEnabled: true,
TLSCert: certPath,
TLSPrivKey: keyPath,
}, test.NewStore())
require.NoError(t, err)
t.Cleanup(server.Drain)
// peerCertificate starts a session and returns the certificate the server presents, and
// whether the TLS session was resumed.
peerCertificate := func(tlsConfig *tls.Config) ([]byte, bool) {
tlsConn := tls.Client(setupPOPSession(t, server), tlsConfig)
defer func() { _ = tlsConn.Close() }()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
require.NoError(t, tlsConn.HandshakeContext(ctx))
// Read the greeting so the server is waiting on input when the client closes. Reading
// also processes the session ticket the server sends after the handshake.
readPOP3Greeting(t, textproto.NewConn(tlsConn))
state := tlsConn.ConnectionState()
return state.PeerCertificates[0].Raw, state.DidResume
}
fresh := &tls.Config{InsecureSkipVerify: true}
caching := &tls.Config{InsecureSkipVerify: true, ClientSessionCache: tls.NewLRUClientSessionCache(1)}
cert, _ := peerCertificate(fresh)
assert.Equal(t, first, cert)
cert, _ = peerCertificate(caching)
assert.Equal(t, first, cert)
second := writeKeyPair(2)
cert, _ = peerCertificate(fresh)
assert.Equal(t, second, cert, "full handshake after rotation")
cert, resumed := peerCertificate(caching)
assert.True(t, resumed, "caching client should resume its session")
assert.Equal(t, first, cert, "resumed session after rotation")
}
// TestValidateMsgNum exercises the shared message-number validation helper // TestValidateMsgNum exercises the shared message-number validation helper
// directly. // directly.
func TestValidateMsgNum(t *testing.T) { func TestValidateMsgNum(t *testing.T) {
+3 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/inbucket/inbucket/v3/pkg/message" "github.com/inbucket/inbucket/v3/pkg/message"
"github.com/inbucket/inbucket/v3/pkg/metric" "github.com/inbucket/inbucket/v3/pkg/metric"
"github.com/inbucket/inbucket/v3/pkg/policy" "github.com/inbucket/inbucket/v3/pkg/policy"
"github.com/inbucket/inbucket/v3/pkg/server/tlscert"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -79,14 +80,13 @@ func NewServer(
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger() slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{} tlsConfig := &tls.Config{}
if smtpConfig.TLSEnabled { if smtpConfig.TLSEnabled {
var err error reloader, err := tlscert.NewReloader(smtpConfig.TLSCert, smtpConfig.TLSPrivKey, slog)
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
if err != nil { if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err) slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
slog.Error().Msg("Disabling STARTTLS support") slog.Error().Msg("Disabling STARTTLS support")
smtpConfig.TLSEnabled = false smtpConfig.TLSEnabled = false
} else { } else {
tlsConfig.GetCertificate = reloader.GetCertificate
slog.Debug().Msg("STARTTLS feature available") slog.Debug().Msg("STARTTLS feature available")
} }
} }
+116
View File
@@ -0,0 +1,116 @@
// Package tlscert provides X509 key pairs that follow changes to their files on disk.
package tlscert
import (
"crypto/tls"
"fmt"
"os"
"sync"
"time"
"github.com/rs/zerolog"
)
// retryInterval is the minimum time between attempts to reload a key pair that failed to load
// while its files are unchanged, such as when a chmod or chown fixes its permissions.
const retryInterval = 30 * time.Second
// fileState identifies a version of a file by its modification time and size.
type fileState struct {
modTime time.Time
size int64
}
// Reloader serves a certificate and private key loaded from files, reloading them when either
// file changes. It is safe for concurrent use.
type Reloader struct {
certFile string // X509 public certificate file.
keyFile string // X509 private key file.
logger zerolog.Logger // Logger for reload events.
retryInterval time.Duration // Minimum time between retries of a failed, unchanged pair.
now func() time.Time // Clock used to schedule retries.
mu sync.Mutex // Guards the fields below.
cert *tls.Certificate // Last successfully loaded key pair.
certState fileState // State of certFile at the last load attempt.
keyState fileState // State of keyFile at the last load attempt.
failedAt time.Time // Time of the last failed load attempt, zero after a success.
}
// NewReloader loads the key pair from certFile and keyFile, returning an error if it cannot be
// loaded.
func NewReloader(certFile, keyFile string, logger zerolog.Logger) (*Reloader, error) {
r := &Reloader{
certFile: certFile,
keyFile: keyFile,
logger: logger,
retryInterval: retryInterval,
now: time.Now,
}
certState, keyState, err := r.stat()
if err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
r.cert = &cert
r.certState = certState
r.keyState = keyState
return r, nil
}
// GetCertificate returns the current key pair, reloading it first if either file changed since
// the last load attempt, or if the last attempt failed and retryInterval has elapsed. Failures
// are logged and the previous key pair is served. crypto/tls calls it on full handshakes only.
func (r *Reloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
r.mu.Lock()
defer r.mu.Unlock()
// On error, stat returns zero states, so repeated stat failures count as unchanged files.
certState, keyState, err := r.stat()
changed := certState != r.certState || keyState != r.keyState
retryDue := !r.failedAt.IsZero() && r.now().Sub(r.failedAt) >= r.retryInterval
if !changed && !retryDue {
return r.cert, nil
}
r.certState = certState
r.keyState = keyState
if err == nil {
var cert tls.Certificate
cert, err = tls.LoadX509KeyPair(r.certFile, r.keyFile)
if err == nil {
r.cert = &cert
r.failedAt = time.Time{}
r.logger.Info().Msg("Reloaded X509 KeyPair")
return r.cert, nil
}
}
r.failedAt = r.now()
r.logger.Warn().Err(err).Msg("Failed to reload X509 KeyPair, serving previous certificate")
return r.cert, nil
}
// stat returns the current state of the certificate and key files, or zero states and an error
// if either cannot be read.
func (r *Reloader) stat() (certState, keyState fileState, err error) {
certState, err = statFile(r.certFile)
if err != nil {
return fileState{}, fileState{}, err
}
keyState, err = statFile(r.keyFile)
if err != nil {
return fileState{}, fileState{}, err
}
return certState, keyState, nil
}
func statFile(name string) (fileState, error) {
info, err := os.Stat(name)
if err != nil {
return fileState{}, fmt.Errorf("stat %s: %w", name, err)
}
return fileState{modTime: info.ModTime(), size: info.Size()}, nil
}
+254
View File
@@ -0,0 +1,254 @@
package tlscert
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewReloaderMissingFiles(t *testing.T) {
dir := t.TempDir()
_, err := NewReloader(filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem"), testLogger(t))
require.Error(t, err)
}
func TestNewReloaderMismatchedPair(t *testing.T) {
dir := t.TempDir()
certA, _ := generatePair(t, "a")
_, keyB := generatePair(t, "b")
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writeFile(t, certFile, certA, 1)
writeFile(t, keyFile, keyB, 1)
_, err := NewReloader(certFile, keyFile, testLogger(t))
require.Error(t, err)
}
func TestGetCertificateReloadsOnChange(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, certFile, keyFile, "b", 2)
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateKeepsPreviousOnFailure(t *testing.T) {
tcs := []struct {
name string
mangle func(t *testing.T, certFile, keyFile string)
}{
{
name: "invalid certificate",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
writeFile(t, certFile, []byte("not a certificate"), 2)
},
},
{
name: "certificate rotated before key",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
certB, _ := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
},
},
{
name: "files removed",
mangle: func(t *testing.T, certFile, keyFile string) {
t.Helper()
require.NoError(t, os.Remove(certFile))
require.NoError(t, os.Remove(keyFile))
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
tc.mangle(t, certFile, keyFile)
assert.Equal(t, "a", commonName(t, r))
// A valid pair written afterward is picked up.
writePairFiles(t, certFile, keyFile, "c", 3)
assert.Equal(t, "c", commonName(t, r))
})
}
}
// TestGetCertificateRetriesUnchangedFiles covers a pair that fails to load and is then fixed
// without changing its files' modification time or size, as a chmod or chown would.
func TestGetCertificateRetriesUnchangedFiles(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
// The new key is unreadable on the first attempt.
certB, keyB := generatePair(t, "b")
writeFile(t, certFile, certB, 2)
writeFile(t, keyFile, bytes.Repeat([]byte("x"), len(keyB)), 2)
assert.Equal(t, "a", commonName(t, r))
// Fixing the key leaves its modification time and size unchanged.
writeFile(t, keyFile, keyB, 2)
assert.Equal(t, "a", commonName(t, r), "retried before retryInterval elapsed")
now = now.Add(r.retryInterval)
assert.Equal(t, "b", commonName(t, r))
}
// TestGetCertificateFailureWarnings verifies that a persistent failure is attempted, and logged,
// at most once per retryInterval.
func TestGetCertificateFailureWarnings(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
var logs bytes.Buffer
r, err := NewReloader(certFile, keyFile, zerolog.New(&logs))
require.NoError(t, err)
now := time.Now()
r.now = func() time.Time { return now }
warnings := func() int { return strings.Count(logs.String(), "Failed to reload X509 KeyPair") }
require.NoError(t, os.Remove(certFile))
for range 3 {
assert.Equal(t, "a", commonName(t, r))
}
assert.Equal(t, 1, warnings())
now = now.Add(r.retryInterval)
assert.Equal(t, "a", commonName(t, r))
assert.Equal(t, 2, warnings())
}
// TestGetCertificateSymlinkSwap mirrors how the kubelet updates a mounted Secret: the files are
// symlinks through a ..data symlink, which is atomically repointed at a new directory.
func TestGetCertificateSymlinkSwap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows cannot rename over an existing directory symlink")
}
dir := t.TempDir()
writePairFiles(t, filepath.Join(dir, "v1", "cert.pem"), filepath.Join(dir, "v1", "key.pem"), "a", 1)
require.NoError(t, os.Symlink("v1", filepath.Join(dir, "..data")))
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
require.NoError(t, os.Symlink(filepath.Join("..data", "cert.pem"), certFile))
require.NoError(t, os.Symlink(filepath.Join("..data", "key.pem"), keyFile))
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
assert.Equal(t, "a", commonName(t, r))
writePairFiles(t, filepath.Join(dir, "v2", "cert.pem"), filepath.Join(dir, "v2", "key.pem"), "b", 2)
require.NoError(t, os.Symlink("v2", filepath.Join(dir, "..data_tmp")))
require.NoError(t, os.Rename(filepath.Join(dir, "..data_tmp"), filepath.Join(dir, "..data")))
assert.Equal(t, "b", commonName(t, r))
}
func TestGetCertificateConcurrent(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
writePairFiles(t, certFile, keyFile, "a", 1)
r, err := NewReloader(certFile, keyFile, testLogger(t))
require.NoError(t, err)
done := make(chan struct{})
for range 8 {
go func() {
defer func() { done <- struct{}{} }()
for range 50 {
cert, err := r.GetCertificate(nil)
assert.NoError(t, err)
assert.NotNil(t, cert)
}
}()
}
writePairFiles(t, certFile, keyFile, "b", 2)
for range 8 {
<-done
}
assert.Equal(t, "b", commonName(t, r))
}
func testLogger(t *testing.T) zerolog.Logger {
t.Helper()
return zerolog.New(zerolog.NewTestWriter(t))
}
// commonName returns the subject common name of the certificate r currently serves.
func commonName(t *testing.T, r *Reloader) string {
t.Helper()
cert, err := r.GetCertificate(&tls.ClientHelloInfo{})
require.NoError(t, err)
require.NotNil(t, cert)
leaf, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
return leaf.Subject.CommonName
}
// writePairFiles writes a new key pair for commonName, stamping both files with a modification
// time derived from version so each version is distinguishable regardless of timestamp
// granularity.
func writePairFiles(t *testing.T, certFile, keyFile, commonName string, version int) {
t.Helper()
certPEM, keyPEM := generatePair(t, commonName)
writeFile(t, certFile, certPEM, version)
writeFile(t, keyFile, keyPEM, version)
}
func writeFile(t *testing.T, name string, data []byte, version int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(name), 0o700))
require.NoError(t, os.WriteFile(name, data, 0o600))
mtime := time.Date(2026, 1, 1, 0, 0, version, 0, time.UTC)
require.NoError(t, os.Chtimes(name, mtime, mtime))
}
// generatePair returns PEM encoded certificate and private key for a self-signed certificate.
func generatePair(t *testing.T, commonName string) (certPEM, keyPEM []byte) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: commonName},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM
}