mirror of
https://github.com/jhillyerd/inbucket.git
synced 2026-10-29 21:52:31 +00:00
255 lines
8.0 KiB
Go
255 lines
8.0 KiB
Go
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
|
|
}
|