From bd248e253090e443b725a35d05f5458aa72ec0e3 Mon Sep 17 00:00:00 2001 From: FZambia Date: Mon, 6 Jan 2025 17:56:00 +0200 Subject: [PATCH] simplified unified TLS config --- internal/config/config.go | 14 +- internal/config/config_test.go | 4 + internal/config/testdata/config.json | 3 +- internal/config/testdata/config.toml | 28 ++++ internal/config/testdata/config.yaml | 27 ++++ internal/configtypes/pem.go | 116 +++++++++++++++++ internal/configtypes/pem_test.go | 108 ++++++++++++++++ internal/configtypes/tls.go | 138 +++++++------------- internal/configtypes/tls_test.go | 186 +++++++++++++++++++++++++++ 9 files changed, 524 insertions(+), 100 deletions(-) create mode 100644 internal/configtypes/pem.go create mode 100644 internal/configtypes/pem_test.go create mode 100644 internal/configtypes/tls_test.go diff --git a/internal/config/config.go b/internal/config/config.go index e44a2d05d0..ba4a62417f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -151,16 +151,18 @@ func GetConfig(cmd *cobra.Command, configFile string) (Config, Meta, error) { v := viper.NewWithOptions(viper.WithDecodeHook(mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), configtypes.StringToDurationHookFunc(), + configtypes.StringToPEMDataHookFunc(), ))) if cmd != nil { bindPFlags := []string{ - "port", "address", "internal_port", "internal_address", "log_level", "log_file", "pid_file", - "engine.type", "broker.enabled", "broker.type", "presence_manager.enabled", "presence_manager.type", - "debug.enabled", "admin.enabled", "admin.external", "admin.insecure", "client.insecure", "http_api.insecure", - "http_api.external", "prometheus.enabled", "health.enabled", "grpc_api.enabled", "grpc_api.port", - "uni_grpc.enabled", "uni_grpc.port", "uni_websocket.enabled", "uni_sse.enabled", "uni_http_stream.enabled", - "sse.enabled", "http_stream.enabled", "swagger.enabled", + "pid_file", "http_server.port", "http_server.address", "http_server.internal_port", + "http_server.internal_address", "log.level", "log.file", "engine.type", "broker.enabled", "broker.type", + "presence_manager.enabled", "presence_manager.type", "debug.enabled", "admin.enabled", "admin.external", + "admin.insecure", "client.insecure", "http_api.insecure", "http_api.external", "prometheus.enabled", + "health.enabled", "grpc_api.enabled", "grpc_api.port", "uni_grpc.enabled", "uni_grpc.port", + "uni_websocket.enabled", "uni_sse.enabled", "uni_http_stream.enabled", "sse.enabled", "http_stream.enabled", + "swagger.enabled", } for _, flag := range bindPFlags { _ = v.BindPFlag(flag, cmd.Flags().Lookup(flag)) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 003a37f3f8..9e2108a12e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -20,6 +20,10 @@ func getConfig(t *testing.T, configFile string) (Config, Meta) { func checkConfig(t *testing.T, conf Config) { t.Helper() require.NotNil(t, conf) + require.True(t, conf.HTTP.TLS.Enabled) + require.NotZero(t, conf.HTTP.TLS.ServerCAPem) + _, err := conf.HTTP.TLS.ToGoTLSConfig("test") + require.NoError(t, err) require.Equal(t, "https://example.com/jwks", conf.Client.Token.JWKSPublicEndpoint) require.Len(t, conf.Client.AllowedOrigins, 1) require.Equal(t, "http://localhost:3000", conf.Client.AllowedOrigins[0]) diff --git a/internal/config/testdata/config.json b/internal/config/testdata/config.json index caa425f6bb..9e683a2c6f 100644 --- a/internal/config/testdata/config.json +++ b/internal/config/testdata/config.json @@ -1,7 +1,8 @@ { "http_server": { "tls": { - "enabled": true + "enabled": true, + "server_ca_pem": "-----BEGIN CERTIFICATE-----\nMIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw\ngZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA\nTWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB\nMD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo\nQWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0\nWjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4\nBgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy\nIEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k\nlfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch\nuKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/\n6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P\n1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu\n90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr\nu44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr\nBgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE\nDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj\nxLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP\nDKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML\nnc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS\nCyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx\nQKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk\nmVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO\nLz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp\nNF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q==\n-----END CERTIFICATE-----" } }, "engine": { diff --git a/internal/config/testdata/config.toml b/internal/config/testdata/config.toml index d610f60532..82e286d97a 100644 --- a/internal/config/testdata/config.toml +++ b/internal/config/testdata/config.toml @@ -2,6 +2,34 @@ [http_server.tls] enabled = true + server_ca_pem = """ +-----BEGIN CERTIFICATE----- +MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw +gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA +TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB +MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo +QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0 +WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4 +BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy +IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k +lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch +uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/ +6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P +1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu +90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr +u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE +DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj +xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP +DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML +nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS +Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx +QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk +mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO +Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp +NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q== +-----END CERTIFICATE----- + """ [engine] type = "redis" diff --git a/internal/config/testdata/config.yaml b/internal/config/testdata/config.yaml index ede6a8efdc..7af21786ea 100644 --- a/internal/config/testdata/config.yaml +++ b/internal/config/testdata/config.yaml @@ -2,6 +2,33 @@ http_server: tls: enabled: true + server_ca_pem: | + -----BEGIN CERTIFICATE----- + MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw + gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA + TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB + MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo + QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0 + WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4 + BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy + IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k + lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch + uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/ + 6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P + 1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu + 90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr + u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr + BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE + DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj + xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP + DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML + nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS + Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx + QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk + mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO + Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp + NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q== + -----END CERTIFICATE----- engine: type: redis redis: diff --git a/internal/configtypes/pem.go b/internal/configtypes/pem.go new file mode 100644 index 0000000000..764432ca27 --- /dev/null +++ b/internal/configtypes/pem.go @@ -0,0 +1,116 @@ +package configtypes + +import ( + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "reflect" + + "github.com/go-viper/mapstructure/v2" +) + +// PEMData represents a flexible PEM-encoded source. +// The order sources checked is the following: +// 1. Raw PEM content +// 2. Base64 encoded PEM content +// 3. Path to file with PEM content +type PEMData string + +// String converts PEMData to a string. +func (p PEMData) String() string { + return string(p) +} + +// MarshalJSON converts PEMData to JSON. +func (p PEMData) MarshalJSON() ([]byte, error) { + return json.Marshal(string(p)) +} + +// UnmarshalJSON parses PEMData from JSON. +func (p *PEMData) UnmarshalJSON(data []byte) error { + var str string + if err := json.Unmarshal(data, &str); err != nil { + return err + } + *p = PEMData(str) + return nil +} + +// MarshalText converts PEMData to text for TOML. +func (p PEMData) MarshalText() ([]byte, error) { + return []byte(p.String()), nil +} + +// UnmarshalText parses PEMData from text (used in TOML). +func (p *PEMData) UnmarshalText(text []byte) error { + *p = PEMData(text) + return nil +} + +// MarshalYAML converts PEMData to a YAML-compatible format. +func (p PEMData) MarshalYAML() (interface{}, error) { + return p.String(), nil +} + +// UnmarshalYAML parses PEMData from YAML. +func (p *PEMData) UnmarshalYAML(unmarshal func(interface{}) error) error { + var str string + if err := unmarshal(&str); err != nil { + return err + } + *p = PEMData(str) + return nil +} + +// StringToPEMDataHookFunc for mapstructure to decode PEMData from strings. +func StringToPEMDataHookFunc() mapstructure.DecodeHookFunc { + return func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error) { + if f.Kind() != reflect.String { + return data, nil + } + if t != reflect.TypeOf(PEMData("")) { + return data, nil + } + + return PEMData(data.(string)), nil + } +} + +// isValidPEM validates if the input string is a valid PEM block. +func isValidPEM(pemData string) bool { + // Decode the PEM data + block, _ := pem.Decode([]byte(pemData)) + return block != nil +} + +// Load detects if PEMData is a file path, base64 string, or raw PEM string and loads the content. +func (p PEMData) Load(statFile StatFileFunc, readFile ReadFileFunc) ([]byte, string, error) { + value := string(p) + if isValidPEM(value) { + return []byte(value), "raw pem", nil + } + // Check if it's base64 encoded. + if decodedValue, err := base64.StdEncoding.DecodeString(value); err == nil { + if isValidPEM(string(decodedValue)) { + return decodedValue, "base64 pem", nil + } + } + // Check if it's a file path by verifying if the file exists. + if _, err := statFile(value); err == nil { + content, err := readFile(value) + if err != nil { + return nil, "", fmt.Errorf("error reading file: %w", err) + } + if !isValidPEM(string(content)) { + return nil, "", fmt.Errorf("file \"%s\" contains invalid PEM data", value) + } + return content, "pem file path", nil + } + return nil, "", errors.New("invalid PEM data: not a valid file path, base64-encoded PEM content, or raw PEM content") +} diff --git a/internal/configtypes/pem_test.go b/internal/configtypes/pem_test.go new file mode 100644 index 0000000000..1809903efd --- /dev/null +++ b/internal/configtypes/pem_test.go @@ -0,0 +1,108 @@ +package configtypes + +import ( + "encoding/base64" + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stretchr/testify/require" +) + +func TestPEMData_Load(t *testing.T) { + mockStatFile := func(name string) (os.FileInfo, error) { + if name != "test-file" { + return nil, os.ErrNotExist + } + return nil, nil + } + mockReadFile := func(name string) ([]byte, error) { + if name != "test-file" { + return nil, os.ErrNotExist + } + return []byte(testPEMCert), nil + } + + tests := []struct { + name string + data PEMData + expected string + }{ + {"File Path", PEMData("test-file"), testPEMCert}, + {"Base64", PEMData(base64.StdEncoding.EncodeToString([]byte(testPEMCert))), testPEMCert}, + {"Raw PEM", PEMData(testPEMCert), testPEMCert}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, _, err := tt.data.Load(mockStatFile, mockReadFile) + require.NoError(t, err) + require.Equal(t, tt.expected, string(data)) + }) + } +} + +func TestStringToPEMDataHookFunc(t *testing.T) { + hook := StringToPEMDataHookFunc().(func( + f reflect.Type, + t reflect.Type, + data interface{}, + ) (interface{}, error)) + result, err := hook(reflect.TypeOf(""), reflect.TypeOf(PEMData("")), "test-data") + require.NoError(t, err) + require.Equal(t, PEMData("test-data"), result) +} + +const testPEMCert = ` +-- GlobalSign Root R2, valid until Dec 15, 2021 +-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----` + +func Test_isValidPEM(t *testing.T) { + tests := []struct { + name string + data string + expected bool + }{ + {"Valid PEM", testPEMCert, true}, + {"Invalid PEM", "/etc/passwd", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidPEM(tt.data) + require.Equal(t, tt.expected, result) + }) + } +} + +const invalidPEM = "invalid PEM content" + +func TestPEMData_Load_Invalid(t *testing.T) { + pem := PEMData(invalidPEM) + + _, _, err := pem.Load(mockStatFileSuccess, mockReadFileInvalid) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid PEM data") +} diff --git a/internal/configtypes/tls.go b/internal/configtypes/tls.go index 45e5aec193..99f07c02b2 100644 --- a/internal/configtypes/tls.go +++ b/internal/configtypes/tls.go @@ -3,59 +3,28 @@ package configtypes import ( "crypto/tls" "crypto/x509" - "encoding/base64" "errors" "fmt" "os" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) // TLSConfig is a common configuration for TLS. -// It allows to configure TLS settings using different sources. The order sources are used is the following: -// 1. File to PEM -// 2. Base64 encoded PEM -// 3. Raw PEM -// It's up to the user to only use a single source of configured values. I.e. if both file and raw PEM are set -// the file will be used and raw PEM will be just ignored. For certificate and key it's required to use the same -// source type - whether set both from file, both from base64 or both from raw string. type TLSConfig struct { // Enabled turns on using TLS. Enabled bool `mapstructure:"enabled" json:"enabled" yaml:"enabled" toml:"enabled" envconfig:"enabled"` - - // CertPemFile is a path to a file with certificate in PEM format. - CertPemFile string `mapstructure:"cert_pem_file" json:"cert_pem_file" envconfig:"cert_pem_file" yaml:"cert_pem_file" toml:"cert_pem_file"` - // KeyPemFile is a path to a file with key in PEM format. - KeyPemFile string `mapstructure:"key_pem_file" json:"key_pem_file" envconfig:"key_pem_file" yaml:"key_pem_file" toml:"key_pem_file"` - - // CertPemB64 is a certificate in base64 encoded PEM format. - CertPemB64 string `mapstructure:"cert_pem_b64" json:"cert_pem_b64" envconfig:"cert_pem_b64" yaml:"cert_pem_b64" toml:"cert_pem_b64"` - // KeyPemB64 is a key in base64 encoded PEM format. - KeyPemB64 string `mapstructure:"key_pem_b64" json:"key_pem_b64" envconfig:"key_pem_b64" yaml:"key_pem_b64" toml:"key_pem_b64"` - - // CertPem is a certificate in PEM format. - CertPem string `mapstructure:"cert_pem" json:"cert_pem" envconfig:"cert_pem" yaml:"cert_pem" toml:"cert_pem"` - // KeyPem is a key in PEM format. - KeyPem string `mapstructure:"key_pem" json:"key_pem" envconfig:"key_pem" yaml:"key_pem" toml:"key_pem"` - - // ServerCAPemFile is a path to a file with server root CA certificate in PEM format. + // CertPem is a PEM certificate. + CertPem PEMData `mapstructure:"cert_pem" json:"cert_pem" envconfig:"cert_pem" yaml:"cert_pem" toml:"cert_pem"` + // KeyPem is a path to a file with key in PEM format. + KeyPem PEMData `mapstructure:"key_pem" json:"key_pem" envconfig:"key_pem" yaml:"key_pem" toml:"key_pem"` + // ServerCAPemFile is a server root CA certificate in PEM format. // The client uses this certificate to verify the server's certificate during the TLS handshake. - ServerCAPemFile string `mapstructure:"server_ca_pem_file" json:"server_ca_pem_file" envconfig:"server_ca_pem_file" yaml:"server_ca_pem_file" toml:"server_ca_pem_file"` - // ServerCAPemB64 is a server root CA certificate in base64 encoded PEM format. - ServerCAPemB64 string `mapstructure:"server_ca_pem_b64" json:"server_ca_pem_b64" envconfig:"server_ca_pem_b64" yaml:"server_ca_pem_b64" toml:"server_ca_pem_b64"` - // ServerCAPem is a server root CA certificate in PEM format. - ServerCAPem string `mapstructure:"server_ca_pem" json:"server_ca_pem" envconfig:"server_ca_pem" yaml:"server_ca_pem" toml:"server_ca_pem"` - - // ClientCAPemFile is a path to a file with client CA certificate in PEM format. - // The server uses this certificate to verify the client's certificate during the TLS handshake. - ClientCAPemFile string `mapstructure:"client_ca_pem_file" json:"client_ca_pem_file" envconfig:"client_ca_pem_file" yaml:"client_ca_pem_file" toml:"client_ca_pem_file"` - // ClientCAPemB64 is a client CA certificate in base64 encoded PEM format. - ClientCAPemB64 string `mapstructure:"client_ca_pem_b64" json:"client_ca_pem_b64" envconfig:"client_ca_pem_b64" yaml:"client_ca_pem_b64" toml:"client_ca_pem_b64"` + ServerCAPem PEMData `mapstructure:"server_ca_pem" json:"server_ca_pem" envconfig:"server_ca_pem" yaml:"server_ca_pem" toml:"server_ca_pem"` // ClientCAPem is a client CA certificate in PEM format. - ClientCAPem string `mapstructure:"client_ca_pem" json:"client_ca_pem" envconfig:"client_ca_pem" yaml:"client_ca_pem" toml:"client_ca_pem"` - + // The server uses this certificate to verify the client's certificate during the TLS handshake. + ClientCAPem PEMData `mapstructure:"client_ca_pem" json:"client_ca_pem" envconfig:"client_ca_pem" yaml:"client_ca_pem" toml:"client_ca_pem"` // InsecureSkipVerify turns off server certificate verification. InsecureSkipVerify bool `mapstructure:"insecure_skip_verify" json:"insecure_skip_verify" envconfig:"insecure_skip_verify" yaml:"insecure_skip_verify" toml:"insecure_skip_verify"` // ServerName is used to verify the hostname on the returned certificates. @@ -68,26 +37,25 @@ func (c TLSConfig) ToGoTLSConfig(logTraceEntity string) (*tls.Config, error) { } logger := log.With().Str("entity", logTraceEntity).Logger() logger.Debug().Msg("TLS enabled") - return makeTLSConfig(c, logger, os.ReadFile) + return makeTLSConfig(c, logger, os.ReadFile, os.Stat) } -// ReadFileFunc is an abstraction for os.ReadFile but also io/fs.ReadFile -// wrapped with an io/fs.FS instance. -// -// Note that os.DirFS has slightly different semantics compared to the native -// filesystem APIs, see https://go.dev/issue/44279 +// ReadFileFunc is like os.ReadFile but helps in testing. type ReadFileFunc func(name string) ([]byte, error) +// StatFileFunc is like os.Stat but helps in testing. +type StatFileFunc func(name string) (os.FileInfo, error) + // makeTLSConfig constructs a tls.Config instance using the given configuration. -func makeTLSConfig(cfg TLSConfig, logger zerolog.Logger, readFile ReadFileFunc) (*tls.Config, error) { +func makeTLSConfig(cfg TLSConfig, logger zerolog.Logger, readFile ReadFileFunc, statFile StatFileFunc) (*tls.Config, error) { tlsConfig := &tls.Config{} - if err := loadCertificate(cfg, logger, tlsConfig, readFile); err != nil { + if err := loadCertificate(cfg, logger, tlsConfig, readFile, statFile); err != nil { return nil, fmt.Errorf("error load certificate: %w", err) } - if err := loadServerCA(cfg, logger, tlsConfig, readFile); err != nil { + if err := loadServerCA(cfg, logger, tlsConfig, readFile, statFile); err != nil { return nil, fmt.Errorf("error load server CA: %w", err) } - if err := loadClientCA(cfg, logger, tlsConfig, readFile); err != nil { + if err := loadClientCA(cfg, logger, tlsConfig, readFile, statFile); err != nil { return nil, fmt.Errorf("error load client CA: %w", err) } tlsConfig.ServerName = cfg.ServerName @@ -98,34 +66,23 @@ func makeTLSConfig(cfg TLSConfig, logger zerolog.Logger, readFile ReadFileFunc) } // loadCertificate loads the TLS certificate from various sources. -func loadCertificate(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc) error { +func loadCertificate(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error { var certPEMBlock, keyPEMBlock []byte var err error switch { - case cfg.CertPemFile != "" && cfg.KeyPemFile != "": - logger.Debug().Str("cert_pem_file", cfg.CertPemFile).Str("key_pem_file", cfg.KeyPemFile).Msg("load TLS certificate and key from files") - certPEMBlock, err = readFile(cfg.CertPemFile) - if err != nil { - return fmt.Errorf("read TLS certificate for %s: %w", cfg.CertPemFile, err) - } - keyPEMBlock, err = readFile(cfg.KeyPemFile) - if err != nil { - return fmt.Errorf("read TLS key for %s: %w", cfg.KeyPemFile, err) - } - case cfg.CertPemB64 != "" && cfg.KeyPemB64 != "": - logger.Debug().Msg("load TLS certificate and key from base64 encoded strings") - certPEMBlock, err = base64.StdEncoding.DecodeString(cfg.CertPemB64) + case cfg.CertPem != "" && cfg.KeyPem != "": + var pemSource string + certPEMBlock, pemSource, err = cfg.CertPem.Load(statFile, readFile) if err != nil { - return fmt.Errorf("error base64 decode certificate PEM: %w", err) + return fmt.Errorf("load TLS certificate: %w", err) } - keyPEMBlock, err = base64.StdEncoding.DecodeString(cfg.KeyPemB64) + logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM certificate") + keyPEMBlock, pemSource, err = cfg.KeyPem.Load(statFile, readFile) if err != nil { - return fmt.Errorf("error base64 decode key PEM: %w", err) + return fmt.Errorf("load TLS key: %w", err) } - case cfg.CertPem != "" && cfg.KeyPem != "": - logger.Debug().Msg("load TLS certificate and key from raw strings") - certPEMBlock, keyPEMBlock = []byte(cfg.CertPem), []byte(cfg.KeyPem) + logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM key") default: } @@ -143,8 +100,12 @@ func loadCertificate(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config } // loadServerCA loads the root CA from various sources. -func loadServerCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc) error { - caCert, err := loadPEMBlock(cfg.ServerCAPemFile, cfg.ServerCAPemB64, cfg.ServerCAPem, logger, "server CA", readFile) +func loadServerCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error { + if cfg.ServerCAPem == "" { + logger.Debug().Msg("no server CA certificate provided") + return nil + } + caCert, err := loadPEMData(cfg.ServerCAPem, logger, "server CA", readFile, statFile) if err != nil { return fmt.Errorf("error load server CA certificate: %w", err) } @@ -162,8 +123,12 @@ func loadServerCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, r } // loadClientCA loads the client CA from various sources. -func loadClientCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc) error { - caCert, err := loadPEMBlock(cfg.ClientCAPemFile, cfg.ClientCAPemB64, cfg.ClientCAPem, logger, "client CA", readFile) +func loadClientCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, readFile ReadFileFunc, statFile StatFileFunc) error { + if cfg.ClientCAPem == "" { + logger.Debug().Msg("no client CA certificate provided") + return nil + } + caCert, err := loadPEMData(cfg.ClientCAPem, logger, "client CA", readFile, statFile) if err != nil { return err } @@ -181,27 +146,14 @@ func loadClientCA(cfg TLSConfig, logger zerolog.Logger, tlsConfig *tls.Config, r return nil } -// loadPEMBlock attempts to load PEM data from a file, base64 string, or raw string. -func loadPEMBlock(file, b64, raw string, logger zerolog.Logger, certType string, readFile ReadFileFunc) ([]byte, error) { - var pemBlock []byte - var err error - if file != "" { - logger.Debug().Str("file", file).Msg("load PEM block of " + certType + " from file") - pemBlock, err = readFile(file) - if err != nil { - return nil, fmt.Errorf("read PEM block for %s: %w", file, err) - } - } else if b64 != "" { - logger.Debug().Msg("load PEM block of " + certType + " from base64 encoded string") - pemBlock, err = base64.StdEncoding.DecodeString(b64) - if err != nil { - return nil, fmt.Errorf("error base64 decode PEM block: %w", err) - } - } else if raw != "" { - logger.Debug().Msg("load PEM block of " + certType + " from raw string") - pemBlock = []byte(raw) +// loadPEMData attempts to load PEM data from a file, base64 string, or raw string. +func loadPEMData(pemData PEMData, logger zerolog.Logger, certType string, readFile ReadFileFunc, statFile StatFileFunc) ([]byte, error) { + pemBytes, pemSource, err := pemData.Load(statFile, readFile) + if err != nil { + return nil, fmt.Errorf("load PEM block for %s: %w", certType, err) } - return pemBlock, nil + logger.Debug().Str("pem_source", pemSource).Msg("loaded PEM data") + return pemBytes, nil } // newCertPoolFromPEM returns certificate pool for the given PEM-encoded diff --git a/internal/configtypes/tls_test.go b/internal/configtypes/tls_test.go new file mode 100644 index 0000000000..944ae7dd12 --- /dev/null +++ b/internal/configtypes/tls_test.go @@ -0,0 +1,186 @@ +package configtypes + +import ( + "crypto/tls" + "errors" + "os" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock file-related functions +func mockReadFileSuccess(name string) ([]byte, error) { + return []byte(`-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAO7rZ8LQQR6cMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +... +-----END CERTIFICATE-----`), nil +} + +func mockReadFileInvalid(name string) ([]byte, error) { + return []byte("invalid content"), nil +} + +func mockStatFileSuccess(name string) (os.FileInfo, error) { + return nil, nil // Mock successful stat +} + +func mockStatFileFailure(name string) (os.FileInfo, error) { + return nil, errors.New("failed to stat file") +} + +// Sample valid PEM strings +const validPEM = `-----BEGIN CERTIFICATE----- +MIIEbjCCAtagAwIBAgIRAN1ZJEYl5ZNIOHsbJizQpucwDQYJKoZIhvcNAQELBQAw +gZ8xHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTE6MDgGA1UECwwxZnpA +TWFjQm9vay1Qcm8tQWxleGFuZGVyLmxvY2FsIChBbGV4YW5kZXIgRW1lbGluKTFB +MD8GA1UEAww4bWtjZXJ0IGZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAo +QWxleGFuZGVyIEVtZWxpbikwHhcNMjIwNjE2MDYxOTM0WhcNMjQwOTE2MDYxOTM0 +WjBlMScwJQYDVQQKEx5ta2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxOjA4 +BgNVBAsMMWZ6QE1hY0Jvb2stUHJvLUFsZXhhbmRlci5sb2NhbCAoQWxleGFuZGVy +IEVtZWxpbikwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDLCNVIle5k +lfRtzjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4ch +uKmNdD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/ +6N408I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P +1Ul417sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu +90nJJBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kr +u44kw7laXghhAgMBAAGjXjBcMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggr +BgEFBQcDATAfBgNVHSMEGDAWgBSMh55IrbevJTB4kiFUXsarEAIjXjAUBgNVHREE +DTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggGBAG9yTMOybS6ike/UoIrj +xLE3a9nPuFdt9anS0XgYicCYNFLc5H6MUXubsqBz30zigFbNP/FR2DKvIP+1cySP +DKqnimTdxZWjzT9d0YHEYcD971yk/whXmKOcla2VmYMuPmUr6M3BmUmYcoWve/ML +nc8qKJ+CsM80zxFSRbqCVqgPfNDzPHqGbJmOn0KbLPWzkUsIbii/O4IjqycJiDMS +Cyuat2Q8TYGiRhDJnouD/semDtqaIGGT77/5QLoEhFRwRKbOfgTT0hjLgTbeKPrx +QKARxjVC/QF59nhdf+je/BgrF7jfR1UuCSxwl0xg2Ub2JB5A77efWEoQh2fuSgZk +mVTZqDnfGvfYcGE9oiAMl21DimEAdYFSAUTtVI6T0S8BagN3jD+FLV7+TJgPiyIO +Lz9gcDP1Zn3jIp4Vy2HawWt+8rta351L70ie9Sk6Cx5fV0slvTFteWYdm26BuKbp +NF7OqlGSRzM2iEVaMFLqnrRwDF4bR7qwGukppEXPrsAq2Q== +-----END CERTIFICATE-----` + +const validKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDLCNVIle5klfRt +zjHe9sEo8zU9pqXfK9fxc2PZqfd6HVDVWyrOHNv9zWV8awEEgwX2kg+sY4chuKmN +dD19UWxLovCMkA92gKhzJoPPBMlVRtSA9QWNw4cXXB25KErPPyBXyyFA13X/6N40 +8I26Aj6ewA0WLISkNgiCddUo31FygTNH4yWXF+F+lol0EJhG+K3E8diYub4P1Ul4 +17sQ/1FxcoGo43fGl8j4y6wCnBQkSNaQCr1vvNEzdmiIYF02a51Efdb3PrSu90nJ +JBbFQxNhpcl98tLRF5t3wZJ+R2Xy4xPUZYwNNWTdICqW7a4bfD4foByp85kru44k +w7laXghhAgMBAAECggEAdK4z3E4FvZqL6RrJgEgwg7cZTr/ZrWKF7EWTCYDrLytv +y91jwSXGq5oBi7n20L/3ilcwWLKt8wwrrJYzzDQh12nhcfZMXJ7dr6dfsnYeujpF +X4LwWSMYHK2ci08DhwzRKoMbLidksdgC80uXN2GY2SSnoKme5LwEsezDvoRwSyu7 +nQCsCqJ0hN41s04vlva8FmgNNOhavvIdR2FNYv3KhnAf/Wv1GfHzq/2kU8j46v6T +/hcZ3m+LC2J982+dyyCp+9+1Grr7rGRHiKXABcpUubAgdnwKS74oyq998/NMEoz3 +OL0j1D6FxpCshLo3RP97nfnbJfguUL8eQ7uB1DR67QKBgQD7hpWGTV4urE8rLDb8 +YANthNMFtvB06ZGKK+R/HEewIt4dGqbwpBcp95Bvmjxa2oA26B8tWcKkIghWQmqx +97icluXdo/x/FZvv5JZLkqZBkRzJFdCpAkbNyWmswo5YI9pzBd370fZqBN+KwhdB +LPa3UML+RXPMD+EDRen3ryXniwKBgQDOpW0gZ26mhDO7/TRuzx5I7DIFfl4lIuMt +1P9T7yY1fwnpgOvV7zl96PaqGMVSh5DvV0IR8PNVup0NAYk0qEMgyLdc+ZAu5r4N +euQEeS9HzIThqoEBSq0u0ds73QirIQ+k8r39q1XABZfNvIauhAToQxdgQgPjQtiH +ytygvR0tQwKBgB8cNF5aL24CbgBfBaYNkh73sMoiKHetdAztBOQb8Vn91g8vfrqA +8USFlF3Za+Go6PbhmwmW8pYuh21z5ZKBm1ny6BeT8uUdHR583YIXb2zor/DHO/nL +iEpnwSRXJBgOxzQ245AEFkBiveuBujKbhyCBYrzkhkAVLrWi7h9ukHelAoGBALOI +UZj3g9Czxuaqg6VJ2LvuST8wnMaS2uD0zqezfHS53Hi8Ayko38AeaD87qiObmDX4 +j3Ra7G4s5UlpbjULgta2y2fBgpzc5316qSOhzYwJieEta0sd//xPYrNNw7w5ywe5 +xYrgEm3z7gFWq4RvOnw33dVJRWtqpgjEHI6h/vlVAoGAG0LyA8WuCp6wUcUUzy2U +n0Ia8Dh5hCy8GvxslMgUy7t0efIkMV1dJ0IuefULUm5ItsGwqRMfLw05RE78ZoE1 +Jr18O4VLqXHDDYbamXO5koEljIeQ0Y8oAKJcB+f0w9bf70RpP/Bsi+fyHrOQHWBE +uNfa+M0tCJMV+XiRsjqDNd8= +-----END PRIVATE KEY-----` + +// 🧠 Test for TLSConfig.ToGoTLSConfig +func TestToGoTLSConfig_Disabled(t *testing.T) { + cfg := TLSConfig{ + Enabled: false, + } + tlsCfg, err := cfg.ToGoTLSConfig("test-entity") + assert.NoError(t, err) + assert.Nil(t, tlsCfg) +} + +func TestToGoTLSConfig_Valid(t *testing.T) { + logger := zerolog.Nop() + + cfg := TLSConfig{ + Enabled: true, + CertPem: PEMData(validPEM), + KeyPem: PEMData(validKey), + ServerCAPem: PEMData(validPEM), + ClientCAPem: PEMData(validPEM), + ServerName: "test.server", + } + + tlsCfg, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess) + require.NoError(t, err) + require.NotNil(t, tlsCfg) + assert.Equal(t, "test.server", tlsCfg.ServerName) + assert.False(t, tlsCfg.InsecureSkipVerify) +} + +func TestMakeTLSConfig_InvalidCertKey(t *testing.T) { + logger := zerolog.Nop() + + cfg := TLSConfig{ + Enabled: true, + CertPem: PEMData(invalidPEM), + KeyPem: PEMData(invalidPEM), + } + + _, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess) + require.Error(t, err) + t.Logf(err.Error()) + assert.Contains(t, err.Error(), "error load certificate") +} + +func TestMakeTLSConfig_InsecureSkipVerify(t *testing.T) { + logger := zerolog.Nop() + + cfg := TLSConfig{ + Enabled: true, + CertPem: PEMData(validPEM), + KeyPem: PEMData(validKey), + InsecureSkipVerify: true, + } + + tlsCfg, err := makeTLSConfig(cfg, logger, mockReadFileSuccess, mockStatFileSuccess) + require.NoError(t, err) + require.NotNil(t, tlsCfg) + assert.True(t, tlsCfg.InsecureSkipVerify) +} + +func TestLoadCertificate_NoPEM(t *testing.T) { + logger := zerolog.Nop() + tlsCfg := &tls.Config{} + + err := loadCertificate(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess) + assert.NoError(t, err) + assert.Empty(t, tlsCfg.Certificates) +} + +func TestLoadServerCA_NoPEM(t *testing.T) { + logger := zerolog.Nop() + tlsCfg := &tls.Config{} + + err := loadServerCA(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess) + assert.NoError(t, err) + assert.Nil(t, tlsCfg.RootCAs) +} + +func TestLoadClientCA_NoPEM(t *testing.T) { + logger := zerolog.Nop() + tlsCfg := &tls.Config{} + + err := loadClientCA(TLSConfig{}, logger, tlsCfg, mockReadFileSuccess, mockStatFileSuccess) + assert.NoError(t, err) + assert.Nil(t, tlsCfg.ClientCAs) +} + +func TestNewCertPoolFromPEM_Invalid(t *testing.T) { + _, err := newCertPoolFromPEM([]byte("invalid")) + assert.Error(t, err) +} + +func TestNewCertPoolFromPEM_Valid(t *testing.T) { + _, err := newCertPoolFromPEM([]byte(validPEM)) + assert.NoError(t, err) +}