Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support mTLS in gRPC exporters #927

Merged
merged 5 commits into from
May 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ: should this be moved in a package configtls in order to be shared with other protocols that support tls?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I feel that we are inconsistent between calling the structs settings vs config. May be done in a separate PR

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to this request - I'm working on adding mTLS for receivers/exporters that use HTTP.

// 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"`
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config property name hasn't been changed.

Can we make a breaking change and change it to include ca in name? e.g ca_cert_pem_file? Or just ca_cert?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably do that in a separate PR.

This is a breaking change when it comes to the code (not configuration) so you will need to followup with a PR in the collector-contrib where you update otel version (using make, see the rule there) and update all the code. Also need to make a quick announcement on the gitter, so if there are others who depend on this they know about this change.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmitryax updated OTEL collector in the contrib in PR https://github.com/open-telemetry/opentelemetry-collector-contrib/pull/220/files#diff-993db3ecf423549b9fcb5b7e4d195efeR10.

That corresponds to 80ee563 that landed to master after this PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bogdandrutu ping me if there is anything I can do more. The refactoring/unifying of the TLS spec will be done in #933


// 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.Nil(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.Nil(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)
}
})
}
}