internal/servertls/servertls_test.go
Rama main · réplica de lectura del repositorio original.
package servertls
import (
"crypto/tls"
"crypto/x509"
"io"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/enterpilot/gomodel/internal/tlstest"
)
// newSource loads settings and checks the files on every handshake, so a
// test sees a rotation without waiting out DefaultCheckInterval.
func newSource(t *testing.T, settings Settings) *Source {
t.Helper()
src, err := New(settings)
require.NoError(t, err)
src.interval = 0
src.nextCheck.Store(0)
return src
}
// serve runs an HTTPS server on loopback that answers with the request's
// protocol, and returns its address.
func serve(t *testing.T, src *Source) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, r.Proto)
}),
ReadHeaderTimeout: 5 * time.Second,
}
go func() { _ = srv.Serve(tls.NewListener(ln, src.ServerConfig())) }()
t.Cleanup(func() { _ = srv.Close() })
return ln.Addr().String()
}
// servedLeaf opens a fresh connection and returns the certificate served on it.
func servedLeaf(t *testing.T, addr string, roots *x509.CertPool) *x509.Certificate {
t.Helper()
conn, err := tls.Dial("tcp", addr, &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12})
require.NoError(t, err)
defer func() { _ = conn.Close() }()
return conn.ConnectionState().PeerCertificates[0]
}
// get makes one HTTPS request on a fresh connection and returns the body.
func get(addr string, cfg *tls.Config) (string, error) {
transport := &http.Transport{TLSClientConfig: cfg, ForceAttemptHTTP2: true, DisableKeepAlives: true}
defer transport.CloseIdleConnections()
resp, err := (&http.Client{Transport: transport, Timeout: 5 * time.Second}).Get("https://" + addr + "/")
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
return string(body), err
}
func TestServesHTTPSWithHTTP2(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
certFile, keyFile := ca.Issue(t, "gateway").Write(t, t.TempDir())
addr := serve(t, newSource(t, Settings{CertFile: certFile, KeyFile: keyFile}))
proto, err := get(addr, &tls.Config{RootCAs: ca.Pool, MinVersion: tls.VersionTLS12})
require.NoError(t, err)
assert.Equal(t, "HTTP/2.0", proto)
_, err = get(addr, &tls.Config{RootCAs: ca.Pool, MinVersion: tls.VersionTLS10, MaxVersion: tls.VersionTLS11})
require.Error(t, err, "TLS below 1.2 must be refused")
}
func TestNewRejectsUnusableFiles(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
dir := t.TempDir()
certFile, keyFile := ca.Issue(t, "gateway").Write(t, dir)
_, otherKey := ca.Issue(t, "other").Write(t, t.TempDir())
garbage := filepath.Join(dir, "garbage.pem")
require.NoError(t, os.WriteFile(garbage, []byte("not a certificate"), 0o600))
missing := filepath.Join(dir, "missing.pem")
noDashes := filepath.Join(dir, "no-dashes-ca.pem")
require.NoError(t, os.WriteFile(noDashes, append(append([]byte{}, ca.PEM...), "-----BEGIN CERTIFICATE\nAAAA\n-----END CERTIFICATE-----\n"...), 0o600))
badFraming := filepath.Join(dir, "bad-framing-ca.pem")
require.NoError(t, os.WriteFile(badFraming, append([]byte("-----BEGIN CERTIFICATE-----\n!!!\n-----END CERTIFICATE-----\n"), ca.PEM...), 0o600))
malformedCA := filepath.Join(dir, "malformed-ca.pem")
require.NoError(t, os.WriteFile(malformedCA, append(append([]byte{}, ca.PEM...), "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n"...), 0o600))
tests := []struct {
name string
settings Settings
wantErr string
}{
{"missing certificate", Settings{CertFile: missing, KeyFile: keyFile}, "missing.pem"},
{"missing key", Settings{CertFile: certFile, KeyFile: missing}, "missing.pem"},
{"certificate is not PEM", Settings{CertFile: garbage, KeyFile: keyFile}, "load certificate"},
{"key does not match", Settings{CertFile: certFile, KeyFile: otherKey}, "load certificate"},
{"missing client CA", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: missing}, "missing.pem"},
{"client CA is not PEM", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: garbage}, "no PEM certificates"},
{"client CA bundle drops a CA", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: malformedCA}, "certificate 2"},
{"client CA block with bad base64", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: badFraming}, "1 of 2 PEM blocks are malformed"},
{"client CA BEGIN line without dashes", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: noDashes}, "1 of 2 PEM blocks are malformed"},
{"half configured", Settings{CertFile: certFile}, "both a certificate and a key"},
{"certificate is a directory", Settings{CertFile: dir, KeyFile: keyFile}, "not a regular file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(tt.settings)
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
// PEM contents passed where a path belongs, as with a ${file:...} reference
// that resolves to the key itself, must never be echoed into the error, which
// is logged.
func TestNewRejectsPEMContentsAsPath(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
leaf := ca.Issue(t, "gateway")
certFile, keyFile := leaf.Write(t, t.TempDir())
key := string(leaf.KeyPEM)
tests := []struct {
name string
settings Settings
field string
}{
{"certificate", Settings{CertFile: string(leaf.CertPEM), KeyFile: keyFile}, "server.tls_cert_file"},
{"key", Settings{CertFile: certFile, KeyFile: key}, "server.tls_key_file"},
{"client CA", Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: string(ca.PEM)}, "server.tls_client_ca_file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(tt.settings)
require.EqualError(t, err, tt.field+" expects a file path, not PEM contents")
})
}
}
func TestReloadsRotatedCertificate(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
dir := t.TempDir()
first := ca.Issue(t, "first")
certFile, keyFile := first.Write(t, dir)
addr := serve(t, newSource(t, Settings{CertFile: certFile, KeyFile: keyFile}))
require.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
second := ca.Issue(t, "second")
second.Write(t, dir)
assert.Equal(t, second.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
}
// Kubernetes updates a mounted Secret by pointing the ..data symlink at a new
// directory; the file names the gateway was given never change.
func TestReloadsAfterKubernetesSymlinkSwap(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
mount := t.TempDir()
first, second := ca.Issue(t, "first"), ca.Issue(t, "second")
for name, leaf := range map[string]tlstest.Leaf{"..v1": first, "..v2": second} {
require.NoError(t, os.Mkdir(filepath.Join(mount, name), 0o700))
leaf.Write(t, filepath.Join(mount, name))
}
data := filepath.Join(mount, "..data")
require.NoError(t, os.Symlink("..v1", data))
for _, name := range []string{"tls.crt", "tls.key"} {
require.NoError(t, os.Symlink(filepath.Join("..data", name), filepath.Join(mount, name)))
}
addr := serve(t, newSource(t, Settings{
CertFile: filepath.Join(mount, "tls.crt"),
KeyFile: filepath.Join(mount, "tls.key"),
}))
require.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
swap := filepath.Join(mount, "..data_tmp")
require.NoError(t, os.Symlink("..v2", swap))
require.NoError(t, os.Rename(swap, data))
assert.Equal(t, second.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
}
func TestFailedReloadKeepsLastGoodCertificate(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
dir := t.TempDir()
first := ca.Issue(t, "first")
certFile, keyFile := first.Write(t, dir)
addr := serve(t, newSource(t, Settings{CertFile: certFile, KeyFile: keyFile}))
// The new certificate landed but its key has not yet: a mismatched pair.
second := ca.Issue(t, "second")
tlstest.WriteFile(t, certFile, second.CertPEM)
assert.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
// A file truncated mid-write, then one removed outright.
tlstest.WriteFile(t, certFile, second.CertPEM[:len(second.CertPEM)/2])
assert.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
require.NoError(t, os.Remove(keyFile))
assert.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
// The pair completes, and the next handshake picks it up.
second.Write(t, dir)
assert.Equal(t, second.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
}
// A fix that leaves the files' stat unchanged, such as restored permissions,
// must still be picked up: a failed load is retried on the next check.
func TestRetriesFailedReloadWithoutFileChange(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root reads files regardless of their permissions")
}
ca := tlstest.NewCA(t, "test CA")
dir := t.TempDir()
first := ca.Issue(t, "first")
certFile, keyFile := first.Write(t, dir)
addr := serve(t, newSource(t, Settings{CertFile: certFile, KeyFile: keyFile}))
second := ca.Issue(t, "second")
second.Write(t, dir)
require.NoError(t, os.Chmod(keyFile, 0))
assert.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
require.NoError(t, os.Chmod(keyFile, 0o600))
assert.Equal(t, second.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
}
// Between checks a handshake reuses the loaded certificate without touching
// the file system.
func TestChecksFilesAtMostOncePerInterval(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
dir := t.TempDir()
first := ca.Issue(t, "first")
certFile, keyFile := first.Write(t, dir)
src, err := New(Settings{CertFile: certFile, KeyFile: keyFile})
require.NoError(t, err)
addr := serve(t, src)
ca.Issue(t, "second").Write(t, dir)
assert.Equal(t, first.Cert.SerialNumber, servedLeaf(t, addr, ca.Pool).SerialNumber)
}
func TestMutualTLS(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
clientCA := tlstest.NewCA(t, "client CA")
dir := t.TempDir()
certFile, keyFile := ca.Issue(t, "gateway", x509.ExtKeyUsageServerAuth).Write(t, dir)
caFile := filepath.Join(dir, "client-ca.pem")
tlstest.WriteFile(t, caFile, clientCA.PEM)
src := newSource(t, Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: caFile})
require.True(t, src.MutualTLS())
addr := serve(t, src)
clientCert := func(issuer *tlstest.CA) []tls.Certificate {
leaf := issuer.Issue(t, "client", x509.ExtKeyUsageClientAuth)
pair, err := tls.X509KeyPair(leaf.CertPEM, leaf.KeyPEM)
require.NoError(t, err)
return []tls.Certificate{pair}
}
// GetClientCertificate sends the certificate even when its issuer is not
// among the CAs the server asks for; Go would otherwise withhold it.
request := func(certs []tls.Certificate) error {
cfg := &tls.Config{RootCAs: ca.Pool, MinVersion: tls.VersionTLS12}
if len(certs) > 0 {
cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { return &certs[0], nil }
}
_, err := get(addr, cfg)
return err
}
require.NoError(t, request(clientCert(clientCA)), "a client the CA signed must be accepted")
require.NoError(t, request(nil), "a client without a certificate is left to the HTTP layer")
require.Error(t, request(clientCert(ca)), "a client another CA signed must be rejected")
// The client CA reloads too.
rotated := tlstest.NewCA(t, "rotated client CA")
tlstest.WriteFile(t, caFile, rotated.PEM)
require.Error(t, request(clientCert(clientCA)), "the replaced CA must no longer be trusted")
require.NoError(t, request(clientCert(rotated)))
}
// A marker quoted inside a comment line is not a block, as for pem.Decode.
func TestClientCABundleIgnoresQuotedMarker(t *testing.T) {
ca := tlstest.NewCA(t, "test CA")
clientCA := tlstest.NewCA(t, "client CA")
dir := t.TempDir()
certFile, keyFile := ca.Issue(t, "gateway").Write(t, dir)
caFile := filepath.Join(dir, "client-ca.pem")
tlstest.WriteFile(t, caFile, append([]byte("# paste each CA as -----BEGIN CERTIFICATE----- ... below\n"), clientCA.PEM...))
src, err := New(Settings{CertFile: certFile, KeyFile: keyFile, ClientCAFile: caFile})
require.NoError(t, err)
assert.True(t, src.MutualTLS())
}
// The per-handshake path is one clock read and one atomic load between checks.
func BenchmarkConfigForClient(b *testing.B) {
ca := tlstest.NewCA(b, "test CA")
certFile, keyFile := ca.Issue(b, "gateway").Write(b, b.TempDir())
src, err := New(Settings{CertFile: certFile, KeyFile: keyFile})
if err != nil {
b.Fatal(err)
}
b.ReportAllocs()
for b.Loop() {
if _, err := src.configForClient(nil); err != nil {
b.Fatal(err)
}
}
}