feat: support multiple certificates in coder server and helm (#4150)

This commit is contained in:
Dean Sheather
2022-10-04 21:45:21 +10:00
committed by GitHub
parent a1056bfa2a
commit 6325a9ea91
6 changed files with 294 additions and 78 deletions
+55 -36
View File
@@ -5,7 +5,6 @@ import (
"crypto/tls"
"crypto/x509"
"database/sql"
"encoding/pem"
"errors"
"fmt"
"io"
@@ -106,11 +105,11 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
telemetryEnable bool
telemetryTraceEnable bool
telemetryURL string
tlsCertFile string
tlsCertFiles []string
tlsClientCAFile string
tlsClientAuth string
tlsEnable bool
tlsKeyFile string
tlsKeyFiles []string
tlsMinVersion string
tunnel bool
traceEnable bool
@@ -221,7 +220,7 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
defer listener.Close()
if tlsEnable {
listener, err = configureTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile)
listener, err = configureServerTLS(listener, tlsMinVersion, tlsClientAuth, tlsCertFiles, tlsKeyFiles, tlsClientCAFile)
if err != nil {
return xerrors.Errorf("configure tls: %w", err)
}
@@ -842,8 +841,8 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
_ = root.Flags().MarkHidden("telemetry-url")
cliflag.BoolVarP(root.Flags(), &tlsEnable, "tls-enable", "", "CODER_TLS_ENABLE", false,
"Whether TLS will be enabled.")
cliflag.StringVarP(root.Flags(), &tlsCertFile, "tls-cert-file", "", "CODER_TLS_CERT_FILE", "",
"Path to the certificate for TLS. It requires a PEM-encoded file. "+
cliflag.StringArrayVarP(root.Flags(), &tlsCertFiles, "tls-cert-file", "", "CODER_TLS_CERT_FILE", []string{},
"Path to each certificate for TLS. It requires a PEM-encoded file. "+
"To configure the listener to use a CA certificate, concatenate the primary certificate "+
"and the CA certificate together. The primary certificate should appear first in the combined file.")
cliflag.StringVarP(root.Flags(), &tlsClientCAFile, "tls-client-ca-file", "", "CODER_TLS_CLIENT_CA_FILE", "",
@@ -851,8 +850,8 @@ func Server(newAPI func(context.Context, *coderd.Options) (*coderd.API, error))
cliflag.StringVarP(root.Flags(), &tlsClientAuth, "tls-client-auth", "", "CODER_TLS_CLIENT_AUTH", "request",
`Policy the server will follow for TLS Client Authentication. `+
`Accepted values are "none", "request", "require-any", "verify-if-given", or "require-and-verify"`)
cliflag.StringVarP(root.Flags(), &tlsKeyFile, "tls-key-file", "", "CODER_TLS_KEY_FILE", "",
"Path to the private key for the certificate. It requires a PEM-encoded file")
cliflag.StringArrayVarP(root.Flags(), &tlsKeyFiles, "tls-key-file", "", "CODER_TLS_KEY_FILE", []string{},
"Paths to the private keys for each of the certificates. It requires a PEM-encoded file")
cliflag.StringVarP(root.Flags(), &tlsMinVersion, "tls-min-version", "", "CODER_TLS_MIN_VERSION", "tls12",
`Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13"`)
cliflag.BoolVarP(root.Flags(), &tunnel, "tunnel", "", "CODER_TUNNEL", false,
@@ -1040,7 +1039,32 @@ func printLogo(cmd *cobra.Command, spooky bool) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s - Remote development on your infrastucture\n", cliui.Styles.Bold.Render("Coder "+buildinfo.Version()))
}
func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFile, tlsKeyFile, tlsClientCAFile string) (net.Listener, error) {
func loadCertificates(tlsCertFiles, tlsKeyFiles []string) ([]tls.Certificate, error) {
if len(tlsCertFiles) != len(tlsKeyFiles) {
return nil, xerrors.New("--tls-cert-file and --tls-key-file must be used the same amount of times")
}
if len(tlsCertFiles) == 0 {
return nil, xerrors.New("--tls-cert-file is required when tls is enabled")
}
if len(tlsKeyFiles) == 0 {
return nil, xerrors.New("--tls-key-file is required when tls is enabled")
}
certs := make([]tls.Certificate, len(tlsCertFiles))
for i := range tlsCertFiles {
certFile, keyFile := tlsCertFiles[i], tlsKeyFiles[i]
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, xerrors.Errorf("load TLS key pair %d (%q, %q): %w", i, certFile, keyFile, err)
}
certs[i] = cert
}
return certs, nil
}
func configureServerTLS(listener net.Listener, tlsMinVersion, tlsClientAuth string, tlsCertFiles, tlsKeyFiles []string, tlsClientCAFile string) (net.Listener, error) {
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
@@ -1072,36 +1096,31 @@ func configureTLS(listener net.Listener, tlsMinVersion, tlsClientAuth, tlsCertFi
return nil, xerrors.Errorf("unrecognized tls client auth: %q", tlsClientAuth)
}
if tlsCertFile == "" {
return nil, xerrors.New("tls-cert-file is required when tls is enabled")
}
if tlsKeyFile == "" {
return nil, xerrors.New("tls-key-file is required when tls is enabled")
certs, err := loadCertificates(tlsCertFiles, tlsKeyFiles)
if err != nil {
return nil, xerrors.Errorf("load certificates: %w", err)
}
tlsConfig.GetCertificate = func(hi *tls.ClientHelloInfo) (*tls.Certificate, error) {
// If there's only one certificate, return it.
if len(certs) == 1 {
return &certs[0], nil
}
certPEMBlock, err := os.ReadFile(tlsCertFile)
if err != nil {
return nil, xerrors.Errorf("read file %q: %w", tlsCertFile, err)
}
keyPEMBlock, err := os.ReadFile(tlsKeyFile)
if err != nil {
return nil, xerrors.Errorf("read file %q: %w", tlsKeyFile, err)
}
keyBlock, _ := pem.Decode(keyPEMBlock)
if keyBlock == nil {
return nil, xerrors.New("decoded pem is blank")
}
cert, err := tls.X509KeyPair(certPEMBlock, keyPEMBlock)
if err != nil {
return nil, xerrors.Errorf("create key pair: %w", err)
}
tlsConfig.GetCertificate = func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
return &cert, nil
}
// Expensively check which certificate matches the client hello.
for _, cert := range certs {
cert := cert
if err := hi.SupportsCertificate(&cert); err == nil {
return &cert, nil
}
}
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(certPEMBlock)
tlsConfig.RootCAs = certPool
// Return the first certificate if we have one, or return nil so the
// server doesn't fail.
if len(certs) > 0 {
return &certs[0], nil
}
return nil, nil //nolint:nilnil
}
if tlsClientCAFile != "" {
caPool := x509.NewCertPool()
+145 -13
View File
@@ -21,6 +21,7 @@ import (
"runtime"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
@@ -240,20 +241,64 @@ func TestServer(t *testing.T) {
err := root.ExecuteContext(ctx)
require.Error(t, err)
})
t.Run("TLSNoCertFile", func(t *testing.T) {
t.Run("TLSInvalid", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
root, _ := clitest.New(t,
"server",
"--in-memory",
"--address", ":0",
"--tls-enable",
"--cache-dir", t.TempDir(),
)
err := root.ExecuteContext(ctx)
require.Error(t, err)
cert1Path, key1Path := generateTLSCertificate(t)
cert2Path, key2Path := generateTLSCertificate(t)
cases := []struct {
name string
args []string
errContains string
}{
{
name: "NoCertAndKey",
args: []string{"--tls-enable"},
errContains: "--tls-cert-file is required when tls is enabled",
},
{
name: "NoCert",
args: []string{"--tls-enable", "--tls-key-file", key1Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "NoKey",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "MismatchedCount",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key1Path, "--tls-cert-file", cert2Path},
errContains: "--tls-cert-file and --tls-key-file must be used the same amount of times",
},
{
name: "MismatchedCertAndKey",
args: []string{"--tls-enable", "--tls-cert-file", cert1Path, "--tls-key-file", key2Path},
errContains: "load TLS key pair",
},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
args := []string{
"server",
"--in-memory",
"--address", ":0",
"--cache-dir", t.TempDir(),
}
args = append(args, c.args...)
root, _ := clitest.New(t, args...)
err := root.ExecuteContext(ctx)
require.Error(t, err)
require.ErrorContains(t, err, c.errContains)
})
}
})
t.Run("TLSValid", func(t *testing.T) {
t.Parallel()
@@ -293,6 +338,86 @@ func TestServer(t *testing.T) {
cancelFunc()
require.NoError(t, <-errC)
})
t.Run("TLSValidMultiple", func(t *testing.T) {
t.Parallel()
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
cert1Path, key1Path := generateTLSCertificate(t, "alpaca.com")
cert2Path, key2Path := generateTLSCertificate(t, "*.llama.com")
root, cfg := clitest.New(t,
"server",
"--in-memory",
"--address", ":0",
"--tls-enable",
"--tls-cert-file", cert1Path,
"--tls-key-file", key1Path,
"--tls-cert-file", cert2Path,
"--tls-key-file", key2Path,
"--cache-dir", t.TempDir(),
)
errC := make(chan error, 1)
go func() {
errC <- root.ExecuteContext(ctx)
}()
accessURL := waitAccessURL(t, cfg)
require.Equal(t, "https", accessURL.Scheme)
originalHost := accessURL.Host
var (
expectAddr string
dials int64
)
client := codersdk.New(accessURL)
client.HTTPClient = &http.Client{
Transport: &http.Transport{
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
atomic.AddInt64(&dials, 1)
assert.Equal(t, expectAddr, addr)
host, _, err := net.SplitHostPort(addr)
require.NoError(t, err)
// Always connect to the accessURL ip:port regardless of
// hostname.
conn, err := tls.Dial(network, originalHost, &tls.Config{
MinVersion: tls.VersionTLS12,
//nolint:gosec
InsecureSkipVerify: true,
ServerName: host,
})
if err != nil {
return nil, err
}
// We can't call conn.VerifyHostname because it requires
// that the certificates are valid, so we call
// VerifyHostname on the first certificate instead.
require.Len(t, conn.ConnectionState().PeerCertificates, 1)
err = conn.ConnectionState().PeerCertificates[0].VerifyHostname(host)
assert.NoError(t, err, "invalid cert common name")
return conn, nil
},
},
}
// Use the first certificate and hostname.
client.URL.Host = "alpaca.com:443"
expectAddr = "alpaca.com:443"
_, err := client.HasFirstUser(ctx)
require.NoError(t, err)
require.EqualValues(t, 1, atomic.LoadInt64(&dials))
// Use the second certificate (wildcard) and hostname.
client.URL.Host = "hi.llama.com:443"
expectAddr = "hi.llama.com:443"
_, err = client.HasFirstUser(ctx)
require.NoError(t, err)
require.EqualValues(t, 2, atomic.LoadInt64(&dials))
cancelFunc()
require.NoError(t, <-errC)
})
// This cannot be ran in parallel because it uses a signal.
//nolint:paralleltest
t.Run("Shutdown", func(t *testing.T) {
@@ -480,16 +605,22 @@ func TestServer(t *testing.T) {
})
}
func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
func generateTLSCertificate(t testing.TB, commonName ...string) (certPath, keyPath string) {
dir := t.TempDir()
commonNameStr := "localhost"
if len(commonName) > 0 {
commonNameStr = commonName[0]
}
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
Organization: []string{"Acme Co"},
CommonName: commonNameStr,
},
DNSNames: []string{commonNameStr},
NotBefore: time.Now(),
NotAfter: time.Now().Add(time.Hour * 24 * 180),
@@ -498,6 +629,7 @@ func generateTLSCertificate(t testing.TB) (certPath, keyPath string) {
BasicConstraintsValid: true,
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
require.NoError(t, err)
certFile, err := os.CreateTemp(dir, "")