Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,26 @@ type RedisConfig struct {
Username string `conf:"REDIS_USERNAME"`
Password string `conf:"REDIS_PASSWORD"`

ClientCertificateFile string `conf:"REDIS_CLIENT_CERT_FILE"`
ClientKeyFile string `conf:"REDIS_CLIENT_KEY_FILE"`
CAFile string `conf:"REDIS_CA_FILE"`

AtomicUpsert bool `conf:"REDIS_ATOMIC_UPSERT"`
}

// TLSEnabled is true if TLS was requested either with the TLS option or with a rediss:// URL.
func (c RedisConfig) TLSEnabled() bool {
if c.TLS {
return true
}
return c.URL.IsDefined() && strings.EqualFold(c.URL.Get().Scheme, "rediss")
}

// hasTLSFiles is true if any certificate, key or CA file option is set.
func (c RedisConfig) hasTLSFiles() bool {
return c.ClientCertificateFile != "" || c.ClientKeyFile != "" || c.CAFile != ""
}

// ConsulConfig configures the optional Consul integration.
//
// Consul is enabled if Host is non-empty.
Expand Down
13 changes: 13 additions & 0 deletions config/config_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ var (
errOTLPNegativeCardinalityLimit = errors.New("metrics cardinality limit must not be negative; use 0 for no limit (OTEL_METRICS_CARDINALITY_LIMIT)")

errRedisURLWithHostAndPort = errors.New("please specify Redis URL or host/port, but not both")
errRedisClientCertWithoutKey = errors.New("REDIS_CLIENT_CERT_FILE and REDIS_CLIENT_KEY_FILE must be specified together")
errRedisBadHostname = errors.New("invalid Redis hostname")
errConsulTokenAndTokenFile = errors.New("Consul token must be specified as either an inline value or a file, but not both") //nolint:staticcheck
errCacheKeyWithoutStore = errors.New("AUTO_CONFIG_CACHE_KEY requires Redis or DynamoDB to be enabled")
Expand All @@ -32,6 +33,9 @@ var (
errInvalidCredentialCleanupInterval = fmt.Errorf("expired credential cleanup interval must be >= %s", minimumCredentialCleanupInterval)
)

const warnRedisTLSFilesWithoutTLS = "Redis client certificate, key or CA file was set, but TLS is not enabled " +
"(use REDIS_TLS or a rediss:// URL); these settings will be ignored"

const warnMetricsCapacityBelowMinimum = "configured usage metrics event capacity of %d is below the minimum of %d; using %[2]d instead"

func warnUnrecognizedSignalExporter(varName, value string) string {
Expand Down Expand Up @@ -236,6 +240,15 @@ func validateConfigDatabases(result *ct.ValidationResult, c *Config, logger *slo
return // no point doing further database config validation if it's in this state
}

if c.Redis.URL.IsDefined() {
if (c.Redis.ClientCertificateFile == "") != (c.Redis.ClientKeyFile == "") {
result.AddError(nil, errRedisClientCertWithoutKey)
}
if c.Redis.hasTLSFiles() && !c.Redis.TLSEnabled() {
logger.Warn(warnRedisTLSFilesWithoutTLS)
}
}

if c.Consul.Host != "" {
if c.Consul.Token != "" && c.Consul.TokenFile != "" {
result.AddError(nil, errConsulTokenAndTokenFile)
Expand Down
15 changes: 15 additions & 0 deletions config/redis_tls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package config

import (
"testing"

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

func TestRedisConfigTLSEnabled(t *testing.T) {
assert.False(t, RedisConfig{}.TLSEnabled())
assert.False(t, RedisConfig{URL: newOptURLAbsoluteMustBeValid("redis://host:6379")}.TLSEnabled())
assert.True(t, RedisConfig{URL: newOptURLAbsoluteMustBeValid("redis://host:6379"), TLS: true}.TLSEnabled())
assert.True(t, RedisConfig{URL: newOptURLAbsoluteMustBeValid("rediss://host:6379")}.TLSEnabled())
assert.True(t, RedisConfig{URL: newOptURLAbsoluteMustBeValid("REDISS://host:6379")}.TLSEnabled())
}
17 changes: 17 additions & 0 deletions config/test_data_configs_invalid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func makeInvalidConfigs() []testDataInvalidConfig {
makeInvalidConfigRedisInvalidDockerPort(),
makeInvalidConfigRedisConflictingParams(),
makeInvalidConfigRedisNoPrefix(),
makeInvalidConfigRedisClientCertWithoutKey(),
makeInvalidConfigRedisAutoConfNoPrefix(),
makeInvalidConfigConsulNoPrefix(),
makeInvalidConfigConsulAutoConfNoPrefix(),
Expand Down Expand Up @@ -322,6 +323,22 @@ Url = "http://redishost:6400"
return c
}

func makeInvalidConfigRedisClientCertWithoutKey() testDataInvalidConfig {
c := testDataInvalidConfig{name: "Redis - client certificate without key"}
c.envVarsError = errRedisClientCertWithoutKey.Error()
c.envVars = map[string]string{
"USE_REDIS": "1",
"REDIS_URL": "rediss://localhost:6379",
"REDIS_CLIENT_CERT_FILE": "/certs/client.pem",
}
c.fileContent = `
[Redis]
URL = rediss://localhost:6379
ClientCertificateFile = /certs/client.pem
`
return c
}

func makeInvalidConfigRedisNoPrefix() testDataInvalidConfig {
c := testDataInvalidConfig{name: "Redis - multiple environments, prefix not defined"}
c.envVarsError = errEnvWithoutDBDisambiguation("env2", false).Error()
Expand Down
28 changes: 28 additions & 0 deletions config/test_data_configs_valid_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func makeValidConfigs() []testDataValidConfig {
makeValidConfigOfflineModeWithMonitoringInterval("5m"),
makeValidConfigRedisMinimal(),
makeValidConfigRedisAll(),
makeValidConfigRedisMTLS(),
makeValidConfigRedisURL(),
makeValidConfigRedisPortOnly(),
makeValidConfigRedisDockerPort(),
Expand Down Expand Up @@ -475,6 +476,33 @@ AtomicUpsert = true
return c
}

func makeValidConfigRedisMTLS() testDataValidConfig {
c := testDataValidConfig{name: "Redis - mTLS files"}
c.makeConfig = func(c *Config) {
c.Redis = RedisConfig{
URL: newOptURLAbsoluteMustBeValid("rediss://redishost:6400"),
ClientCertificateFile: "/certs/client.pem",
ClientKeyFile: "/certs/client.key",
CAFile: "/certs/ca.pem",
}
}
c.envVars = map[string]string{
"USE_REDIS": "1",
"REDIS_URL": "rediss://redishost:6400",
"REDIS_CLIENT_CERT_FILE": "/certs/client.pem",
"REDIS_CLIENT_KEY_FILE": "/certs/client.key",
"REDIS_CA_FILE": "/certs/ca.pem",
}
c.fileContent = `
[Redis]
Url = "rediss://redishost:6400"
ClientCertificateFile = "/certs/client.pem"
ClientKeyFile = "/certs/client.key"
CAFile = "/certs/ca.pem"
`
return c
}

func makeValidConfigRedisURL() testDataValidConfig {
c := testDataValidConfig{name: "Redis - URL instead of host/port"}
c.makeConfig = func(c *Config) {
Expand Down
5 changes: 5 additions & 0 deletions integrationtests/big_segments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ func testBigSegments(t *testing.T, manager *integrationTestManager) {
// of this part is just to make sure connecting with a password also works
doBigSegmentsTestWithPreExistingSegment(t, manager, redisWithPasswordDatabaseTestParams)
})
t.Run("Redis with TLS and client certificate (mTLS)", func(t *testing.T) {
// Big segments use a separate Redis client from the data store, so verify it picks up the
// CA and client certificate too.
doBigSegmentsTestWithPreExistingSegment(t, manager, redisMTLSDatabaseTestParams)
})
t.Run("DynamoDB", func(t *testing.T) {
doAll(t, dynamoDBDatabaseTestParams)
})
Expand Down
65 changes: 61 additions & 4 deletions integrationtests/database_params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ package integrationtests
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"

"github.com/launchdarkly/ld-relay/v9/integrationtests/docker"
"github.com/launchdarkly/ld-relay/v9/internal/api"
"github.com/launchdarkly/ld-relay/v9/internal/sharedtest"

"github.com/stretchr/testify/require"
)
Expand All @@ -22,16 +25,23 @@ const (
)

type databaseTestParams struct {
dbImageName string
dbDockerParams []string
hostnamePrefix string
dbImageName string
dbDockerParams []string
hostnamePrefix string
// mountFn, if set, is called with the database container's hostname before the container is
// created, and returns a host directory to mount into it.
mountFn func(t *testing.T, m *integrationTestManager, hostname string) (hostDir, containerDir string, err error)
setupFn func(*integrationTestManager, *docker.Container) error
envVarsFn func(*docker.Container) map[string]string
expectedStatusFn func(*docker.Container) api.DataStoreStatusRep
}

func (p databaseTestParams) withContainer(t *testing.T, manager *integrationTestManager, action func(*docker.Container)) {
manager.withExtraContainer(t, p.dbImageName, p.dbDockerParams, p.hostnamePrefix, func(dbContainer *docker.Container) {
var mountFn func(string) (string, string, error)
if p.mountFn != nil {
mountFn = func(hostname string) (string, string, error) { return p.mountFn(t, manager, hostname) }
}
manager.withExtraContainer(t, p.dbImageName, p.dbDockerParams, p.hostnamePrefix, mountFn, func(dbContainer *docker.Container) {
containersOnNetwork, err := manager.dockerNetwork.GetContainerIDs()
require.NoError(t, err)
require.Len(t, containersOnNetwork, 1, "database container did not start or did not attach to the test network")
Expand Down Expand Up @@ -155,6 +165,53 @@ var redisWithACLDatabaseTestParams = databaseTestParams{
},
}

// Redis with TLS and required client certificates. The certificates are generated per test run in a
// subdirectory of the Relay shared directory, so Relay sees them under relayContainerSharedDir and the
// Redis container sees them under /tls.
const (
redisMTLSSubdir = "redis-mtls"
redisMTLSContainerTLSDir = "/tls"
redisMTLSRelayContainerDir = relayContainerSharedDir + "/" + redisMTLSSubdir
)

var redisMTLSDatabaseTestParams = databaseTestParams{
dbImageName: "redis",
dbDockerParams: []string{
"--port", "0",
"--tls-port", "6379",
"--tls-cert-file", redisMTLSContainerTLSDir + "/server.pem",
"--tls-key-file", redisMTLSContainerTLSDir + "/server.key",
"--tls-ca-cert-file", redisMTLSContainerTLSDir + "/ca.pem",
"--tls-auth-clients", "yes",
},
hostnamePrefix: "redis",
mountFn: func(t *testing.T, m *integrationTestManager, hostname string) (string, string, error) {
hostDir := filepath.Join(m.relaySharedDir, redisMTLSSubdir)
if err := os.MkdirAll(hostDir, 0o755); err != nil {
return "", "", err
}
// The server certificate must be valid for the container hostname, since Relay verifies it.
sharedtest.NewMTLSFilesInDir(t, hostDir, []string{hostname}, nil)
return hostDir, redisMTLSContainerTLSDir, nil
},
envVarsFn: func(dbContainer *docker.Container) map[string]string {
return map[string]string{
"USE_REDIS": "true",
"REDIS_HOST": dbContainer.GetName(),
"REDIS_TLS": "true",
"REDIS_CA_FILE": redisMTLSRelayContainerDir + "/ca.pem",
"REDIS_CLIENT_CERT_FILE": redisMTLSRelayContainerDir + "/client.pem",
"REDIS_CLIENT_KEY_FILE": redisMTLSRelayContainerDir + "/client.key",
}
},
expectedStatusFn: func(dbContainer *docker.Container) api.DataStoreStatusRep {
return api.DataStoreStatusRep{
Database: "redis",
DBServer: fmt.Sprintf("rediss://%s:6379", dbContainer.GetName()),
}
},
}

var consulDatabaseTestParams = databaseTestParams{
dbImageName: "hashicorp/consul",
hostnamePrefix: "consul",
Expand Down
4 changes: 4 additions & 0 deletions integrationtests/database_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ func testDatabaseIntegrations(t *testing.T, manager *integrationTestManager) {
doDatabaseTest(t, manager, redisWithACLDatabaseTestParams)
})

t.Run("Redis with TLS and client certificate (mTLS)", func(t *testing.T) {
doDatabaseTest(t, manager, redisMTLSDatabaseTestParams)
})

t.Run("Consul", func(t *testing.T) {
doDatabaseTest(t, manager, consulDatabaseTestParams)
})
Expand Down
14 changes: 9 additions & 5 deletions internal/autoconfigcache/redis_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package autoconfigcache

import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
Expand All @@ -13,6 +12,7 @@ import (
"github.com/launchdarkly/ld-relay/v9/config"
"github.com/launchdarkly/ld-relay/v9/internal/autoconfig"
"github.com/launchdarkly/ld-relay/v9/internal/envfactory"
"github.com/launchdarkly/ld-relay/v9/internal/sdks"
)

type redisStore struct {
Expand Down Expand Up @@ -43,11 +43,15 @@ func newRedisStore(redisConfig config.RedisConfig, cacheKey string, encKey []byt
if redisConfig.Username != "" {
uo.Username = redisConfig.Username
}
if redisConfig.TLS && uo.TLSConfig == nil {
uo.TLSConfig = &tls.Config{
ServerName: redisConfig.URL.Get().Hostname(),
MinVersion: tls.VersionTLS12,
// ParseURL sets a default TLSConfig for rediss:// URLs but none for redis://, so apply ours
// whenever TLS is enabled by either the TLS option or the URL scheme.
if redisConfig.TLSEnabled() {
tlsConfig, err := sdks.CreateTLSConfig(redisConfig)
if err != nil {
return nil, err
}

uo.TLSConfig = tlsConfig
}
client := redis.NewUniversalClient(uo)
ctx, cancel := context.WithCancel(context.Background())
Expand Down
75 changes: 75 additions & 0 deletions internal/autoconfigcache/redis_store_mtls_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package autoconfigcache

import (
"context"
"fmt"
"log/slog"
"testing"
"time"

"github.com/launchdarkly/ld-relay/v9/config"
"github.com/launchdarkly/ld-relay/v9/internal/sharedtest"

"github.com/launchdarkly/go-configtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRedisStoreMTLS(t *testing.T) {
files := sharedtest.NewMTLSFiles(t)
port, handshakes := sharedtest.StartMTLSPingServer(t, files)
logger := slog.New(slog.DiscardHandler)

makeStore := func(t *testing.T, scheme string, mutate func(*config.RedisConfig)) Store {
url, err := configtypes.NewOptURLAbsoluteFromString(fmt.Sprintf("%s://127.0.0.1:%d", scheme, port))
require.NoError(t, err)
c := config.RedisConfig{URL: url}
mutate(&c)
store, err := newRedisStore(c, "cache-key", make([]byte, 32), logger)
require.NoError(t, err)
t.Cleanup(func() { _ = store.Close() })
return store
}
withFiles := func(c *config.RedisConfig) {
c.CAFile = files.CAFile
c.ClientCertificateFile = files.ClientCertFile
c.ClientKeyFile = files.ClientKeyFile
}
getAll := func(store Store) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := store.GetAll(ctx)
return err
}
awaitHandshake := func(t *testing.T) error {
select {
case err := <-handshakes:
return err
case <-time.After(5 * time.Second):
t.Fatal("no TLS handshake observed")
return nil
}
}

t.Run("redis URL with TLS option uses CA and client cert", func(t *testing.T) {
store := makeStore(t, "redis", func(c *config.RedisConfig) { c.TLS = true; withFiles(c) })
_ = getAll(store)
assert.NoError(t, awaitHandshake(t))
})

t.Run("rediss URL uses CA and client cert without the TLS option", func(t *testing.T) {
store := makeStore(t, "rediss", withFiles)
_ = getAll(store)
assert.NoError(t, awaitHandshake(t))
})

t.Run("fails without the CA", func(t *testing.T) {
store := makeStore(t, "rediss", func(c *config.RedisConfig) {
c.ClientCertificateFile = files.ClientCertFile
c.ClientKeyFile = files.ClientKeyFile
})
err := getAll(store)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown authority")
})
}
Loading
Loading