Skip to content

Commit

Permalink
Support mTLS in gRPC exporters (open-telemetry#927)
Browse files Browse the repository at this point in the history
* Support mTLS in gRPC exporters

Signed-off-by: Pavol Loffay <ploffay@redhat.com>

* Rename to CaCert

Signed-off-by: Pavol Loffay <ploffay@redhat.com>

* Add tests

Signed-off-by: Pavol Loffay <ploffay@redhat.com>

* Fix lint

Signed-off-by: Pavol Loffay <ploffay@redhat.com>

* rename load func

Signed-off-by: Pavol Loffay <ploffay@redhat.com>
  • Loading branch information
pavolloffay authored and wyTrivail committed Jul 13, 2020
1 parent 2333365 commit 1389ca3
Show file tree
Hide file tree
Showing 12 changed files with 429 additions and 103 deletions.
100 changes: 84 additions & 16 deletions config/configgrpc/configgrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
package configgrpc

import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
"path/filepath"
"time"

"google.golang.org/grpc"
Expand All @@ -42,16 +45,8 @@ type GRPCSettings struct {
// collector. Currently the only supported mode is `gzip`.
Compression string `mapstructure:"compression"`

// Certificate file for TLS credentials of gRPC client. Should
// only be used if `secure` is set to true.
CertPemFile string `mapstructure:"cert_pem_file"`

// Whether to enable client transport security for the exporter's gRPC
// connection. See https://godoc.org/google.golang.org/grpc#WithInsecure.
UseSecure bool `mapstructure:"secure"`

// Authority to check against when doing TLS verification
ServerNameOverride string `mapstructure:"server_name_override"`
// TLSConfig struct exposes TLS client configuration.
TLSConfig TLSConfig `mapstructure:",squash"`

// The keepalive parameters for client gRPC. See grpc.WithKeepaliveParams
// (https://godoc.org/google.golang.org/grpc#WithKeepaliveParams).
Expand All @@ -62,6 +57,26 @@ type GRPCSettings struct {
WaitForReady bool `mapstructure:"wait_for_ready"`
}

// TLSConfig exposes client TLS configuration.
type TLSConfig struct {
// Root CA certificate file for TLS credentials of gRPC client. Should
// only be used if `secure` is set to true.
CaCert string `mapstructure:"cert_pem_file"`

// Client certificate file for TLS credentials of gRPC client.
ClientCert string `mapstructure:"client_cert_pem_file"`

// Client key file for TLS credentials of gRPC client.
ClientKey string `mapstructure:"client_cert_key_file"`

// Whether to enable client transport security for the exporter's gRPC
// connection. See https://godoc.org/google.golang.org/grpc#WithInsecure.
UseSecure bool `mapstructure:"secure"`

// Authority to check against when doing TLS verification
ServerNameOverride string `mapstructure:"server_name_override"`
}

// KeepaliveConfig exposes the keepalive.ClientParameters to be used by the exporter.
// Refer to the original data-structure for the meaning of each parameter.
type KeepaliveConfig struct {
Expand All @@ -82,18 +97,18 @@ func GrpcSettingsToDialOptions(settings GRPCSettings) ([]grpc.DialOption, error)
}
}

if settings.CertPemFile != "" {
creds, err := credentials.NewClientTLSFromFile(settings.CertPemFile, settings.ServerNameOverride)
if settings.TLSConfig.CaCert != "" && !settings.TLSConfig.UseSecure {
creds, err := credentials.NewClientTLSFromFile(settings.TLSConfig.CaCert, settings.TLSConfig.ServerNameOverride)
if err != nil {
return nil, err
}
opts = append(opts, grpc.WithTransportCredentials(creds))
} else if settings.UseSecure {
certPool, err := x509.SystemCertPool()
} else if settings.TLSConfig.UseSecure {
tlsConf, err := settings.TLSConfig.LoadTLSConfig()
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to load TLS config: %w", err)
}
creds := credentials.NewClientTLSFromCert(certPool, settings.ServerNameOverride)
creds := credentials.NewTLS(tlsConf)
opts = append(opts, grpc.WithTransportCredentials(creds))
} else {
opts = append(opts, grpc.WithInsecure())
Expand All @@ -110,3 +125,56 @@ func GrpcSettingsToDialOptions(settings GRPCSettings) ([]grpc.DialOption, error)

return opts, nil
}

// LoadTLSConfig loads TLS certificates and returns a tls.Config.
func (c TLSConfig) LoadTLSConfig() (*tls.Config, error) {
certPool, err := c.loadCertPool()
if err != nil {
return nil, fmt.Errorf("failed to load CA CertPool: %w", err)
}
// #nosec G402
tlsCfg := &tls.Config{
RootCAs: certPool,
ServerName: c.ServerNameOverride,
}

if (c.ClientCert == "" && c.ClientKey != "") || (c.ClientCert != "" && c.ClientKey == "") {
return nil, fmt.Errorf("for client auth via TLS, either both client certificate and key must be supplied, or neither")
}
if c.ClientCert != "" && c.ClientKey != "" {
tlsCert, err := tls.LoadX509KeyPair(filepath.Clean(c.ClientCert), filepath.Clean(c.ClientKey))
if err != nil {
return nil, fmt.Errorf("failed to load server TLS cert and key: %w", err)
}
tlsCfg.Certificates = append(tlsCfg.Certificates, tlsCert)
}

return tlsCfg, nil
}

var systemCertPool = x509.SystemCertPool // to allow overriding in unit test

func (c TLSConfig) loadCertPool() (*x509.CertPool, error) {
if len(c.CaCert) == 0 { // no truststore given, use SystemCertPool
certPool, err := systemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to load SystemCertPool: %w", err)
}
return certPool, nil
}
// setup user specified truststore
return c.loadCert(c.CaCert)
}

func (c TLSConfig) loadCert(caPath string) (*x509.CertPool, error) {
caPEM, err := ioutil.ReadFile(filepath.Clean(caPath))
if err != nil {
return nil, fmt.Errorf("failed to load CA %s: %w", caPath, err)
}

certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("failed to parse CA %s", caPath)
}
return certPool, nil
}
219 changes: 190 additions & 29 deletions config/configgrpc/configgrpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,218 @@
package configgrpc

import (
"os"
"crypto/x509"
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBasicGrpcSettings(t *testing.T) {

_, err := GrpcSettingsToDialOptions(GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
CertPemFile: "",
UseSecure: false,
ServerNameOverride: "",
Headers: nil,
Endpoint: "",
Compression: "",
TLSConfig: TLSConfig{
CaCert: "",
UseSecure: false,
ServerNameOverride: "",
},
KeepaliveParameters: nil,
})

assert.NoError(t, err)
}

func TestInvalidPemFile(t *testing.T) {

_, err := GrpcSettingsToDialOptions(GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
CertPemFile: "/doesnt/exist",
UseSecure: false,
ServerNameOverride: "",
KeepaliveParameters: nil,
})

// don't validate the specific error code as this differs on windows/unix
pathErr := err.(*os.PathError)
assert.Equal(t, pathErr.Op, "open")
assert.Equal(t, pathErr.Path, "/doesnt/exist")
assert.NotNil(t, pathErr.Err)
tests := []struct {
settings GRPCSettings
err string
}{
{
err: "open /doesnt/exist: no such file or directory",
settings: GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
TLSConfig: TLSConfig{
CaCert: "/doesnt/exist",
UseSecure: false,
ServerNameOverride: "",
},
KeepaliveParameters: nil,
},
},
{
err: "failed to load TLS config: failed to load CA CertPool: failed to load CA /doesnt/exist: open /doesnt/exist: no such file or directory",
settings: GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
TLSConfig: TLSConfig{
CaCert: "/doesnt/exist",
UseSecure: true,
ServerNameOverride: "",
},
KeepaliveParameters: nil,
},
},
{
err: "failed to load TLS config: for client auth via TLS, either both client certificate and key must be supplied, or neither",
settings: GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
TLSConfig: TLSConfig{
ClientCert: "/doesnt/exist",
UseSecure: true,
ServerNameOverride: "",
},
KeepaliveParameters: nil,
},
},
}
for _, test := range tests {
t.Run(test.err, func(t *testing.T) {
_, err := GrpcSettingsToDialOptions(test.settings)
assert.EqualError(t, err, test.err)
})
}
}

func TestUseSecure(t *testing.T) {
dialOpts, err := GrpcSettingsToDialOptions(GRPCSettings{
Headers: nil,
Endpoint: "",
Compression: "",
CertPemFile: "",
UseSecure: true,
ServerNameOverride: "",
Headers: nil,
Endpoint: "",
Compression: "",
TLSConfig: TLSConfig{
CaCert: "",
UseSecure: true,
ServerNameOverride: "",
},
KeepaliveParameters: nil,
})

assert.NoError(t, err)
assert.Equal(t, len(dialOpts), 1)
}

func TestOptionsToConfig(t *testing.T) {
tests := []struct {
name string
options TLSConfig
fakeSysPool bool
expectError string
}{
{
name: "should load system CA",
options: TLSConfig{CaCert: ""},
},
{
name: "should fail with fake system CA",
fakeSysPool: true,
options: TLSConfig{CaCert: ""},
expectError: "fake system pool",
},
{
name: "should load custom CA",
options: TLSConfig{CaCert: "testdata/testCA.pem"},
},
{
name: "should fail with invalid CA file path",
options: TLSConfig{CaCert: "testdata/not/valid"},
expectError: "failed to load CA",
},
{
name: "should fail with invalid CA file content",
options: TLSConfig{CaCert: "testdata/testCA-bad.txt"},
expectError: "failed to parse CA",
},
{
name: "should load valid TLS Client settings",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
ClientCert: "testdata/test-cert.pem",
ClientKey: "testdata/test-key.pem",
},
},
{
name: "should fail with missing TLS Client Key",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
ClientCert: "testdata/test-cert.pem",
},
expectError: "both client certificate and key must be supplied",
},
{
name: "should fail with invalid TLS Client Key",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
ClientCert: "testdata/test-cert.pem",
ClientKey: "testdata/not/valid",
},
expectError: "failed to load server TLS cert and key",
},
{
name: "should fail with missing TLS Client Cert",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
ClientKey: "testdata/test-key.pem",
},
expectError: "both client certificate and key must be supplied",
},
{
name: "should fail with invalid TLS Client Cert",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
ClientCert: "testdata/not/valid",
ClientKey: "testdata/test-key.pem",
},
expectError: "failed to load server TLS cert and key",
},
{
name: "should fail with invalid TLS Client CA",
options: TLSConfig{
CaCert: "testdata/not/valid",
},
expectError: "failed to load CA",
},
{
name: "should fail with invalid Client CA pool",
options: TLSConfig{
CaCert: "testdata/testCA-bad.txt",
},
expectError: "failed to parse CA",
},
{
name: "should pass with valid Client CA pool",
options: TLSConfig{
CaCert: "testdata/testCA.pem",
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if test.fakeSysPool {
saveSystemCertPool := systemCertPool
systemCertPool = func() (*x509.CertPool, error) {
return nil, fmt.Errorf("fake system pool")
}
defer func() {
systemCertPool = saveSystemCertPool
}()
}
cfg, err := test.options.LoadTLSConfig()
if test.expectError != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), test.expectError)
} else {
require.NoError(t, err)
assert.NotNil(t, cfg)
}
})
}
}

0 comments on commit 1389ca3

Please sign in to comment.