Ctrl K
ComunidadGuías…

internal/servertls/servertls_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
		}
	}
}