From ab1775afeee1d7ee1536a33eaeb4d4b1e94fac59 Mon Sep 17 00:00:00 2001 From: Alexandr Hacicheant Date: Tue, 1 Apr 2025 08:34:13 +0300 Subject: [PATCH] Redis Sentinel SingeFlight: support of Redis master node username and password (#2039) * Add support for Redis Username and Password configuration Introduced Redis master authentication parameters (username and password) to the Redis Sentinel setup. This enhances compatibility with Redis environments that require authentication for both sentinel and master nodes. * Add support for protected Redis Sentinel configuration and related unit tests --- .github/workflows/ci.yml | 14 ++++ cmd/proxy/actions/app_proxy.go | 2 + config.dev.toml | 6 ++ pkg/config/config.go | 2 + pkg/config/config_test.go | 4 +- pkg/config/singleflight.go | 2 + pkg/stash/with_redis_sentinel.go | 6 +- pkg/stash/with_redis_sentinel_test.go | 112 +++++++++++++++++++++++++- 8 files changed, 142 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47f3e4c7..5fe8b02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: REDIS_SENTINEL_TEST_MASTER_NAME: redis-1 REDIS_SENTINEL_TEST_PASSWORD: sekret PROTECTED_REDIS_TEST_ENDPOINT: localhost:6380 + PROTECTED_REDIS_TEST_USERNAME: default + REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT: localhost:26380 + REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME: protectedredis-1 ATHENS_PROTECTED_REDIS_PASSWORD: AthensPass1 GA_PULL_REQUEST: ${{github.event.number}} runs-on: ubuntu-latest @@ -69,6 +72,17 @@ jobs: env: REDIS_PORT_NUMBER: 6380 REDIS_PASSWORD: AthensPass1 + redis-sentinel-protected-redis: + image: bitnami/redis-sentinel + env: + REDIS_MASTER_HOST: protectedredis + REDIS_MASTER_PORT_NUMBER: 6380 + REDIS_MASTER_SET: protectedredis-1 + REDIS_SENTINEL_PASSWORD: sekret + REDIS_SENTINEL_QUORUM: "1" + REDIS_SENTINEL_PORT_NUMBER: 26380 + ports: + - 26380:26380 steps: - uses: actions/checkout@v4 - name: Set up Go diff --git a/cmd/proxy/actions/app_proxy.go b/cmd/proxy/actions/app_proxy.go index 86c2a247..4c2025ee 100644 --- a/cmd/proxy/actions/app_proxy.go +++ b/cmd/proxy/actions/app_proxy.go @@ -166,6 +166,8 @@ func getSingleFlight(l *log.Logger, c *config.Config, s storage.Backend, checker c.SingleFlight.RedisSentinel.Endpoints, c.SingleFlight.RedisSentinel.MasterName, c.SingleFlight.RedisSentinel.SentinelPassword, + c.SingleFlight.RedisSentinel.RedisUsername, + c.SingleFlight.RedisSentinel.RedisPassword, checker, c.SingleFlight.RedisSentinel.LockConfig, ) diff --git a/config.dev.toml b/config.dev.toml index 5049a89f..0784be96 100755 --- a/config.dev.toml +++ b/config.dev.toml @@ -369,6 +369,12 @@ ShutdownTimeout = 60 # Env override: ATHENS_REDIS_SENTINEL_PASSWORD SentinelPassword = "sekret" + # The Redis master authorization parameters + # Env override: ATHENS_REDIS_USERNAME + RedisUsername = "" + # Env override: ATHENS_REDIS_PASSWORD + RedisPassword = "" + [SingleFlight.RedisSentinel.LockConfig] # TTL for the lock in seconds. Defaults to 900 seconds (15 minutes). # Env override: ATHENS_REDIS_LOCK_TTL diff --git a/pkg/config/config.go b/pkg/config/config.go index b591b949..0fb5fa8f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -179,6 +179,8 @@ func defaultConfig() *Config { Endpoints: []string{"127.0.0.1:26379"}, MasterName: "redis-1", SentinelPassword: "sekret", + RedisUsername: "", + RedisPassword: "", LockConfig: DefaultRedisLockConfig(), }, GCP: DefaultGCPConfig(), diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2fad9c5e..4398400b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -375,7 +375,7 @@ func getEnvMap(config *Config) map[string]string { if singleFlight.Redis != nil { envVars["ATHENS_SINGLE_FLIGHT_TYPE"] = "redis" envVars["ATHENS_REDIS_ENDPOINT"] = singleFlight.Redis.Endpoint - envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.Redis.Endpoint + envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.Redis.Password if singleFlight.Redis.LockConfig != nil { envVars["ATHENS_REDIS_LOCK_TTL"] = strconv.Itoa(singleFlight.Redis.LockConfig.TTL) envVars["ATHENS_REDIS_LOCK_TIMEOUT"] = strconv.Itoa(singleFlight.Redis.LockConfig.Timeout) @@ -386,6 +386,8 @@ func getEnvMap(config *Config) map[string]string { envVars["ATHENS_REDIS_SENTINEL_ENDPOINTS"] = strings.Join(singleFlight.RedisSentinel.Endpoints, ",") envVars["ATHENS_REDIS_SENTINEL_MASTER_NAME"] = singleFlight.RedisSentinel.MasterName envVars["ATHENS_REDIS_SENTINEL_PASSWORD"] = singleFlight.RedisSentinel.SentinelPassword + envVars["ATHENS_REDIS_USERNAME"] = singleFlight.RedisSentinel.RedisUsername + envVars["ATHENS_REDIS_PASSWORD"] = singleFlight.RedisSentinel.RedisPassword if singleFlight.RedisSentinel.LockConfig != nil { envVars["ATHENS_REDIS_LOCK_TTL"] = strconv.Itoa(singleFlight.RedisSentinel.LockConfig.TTL) envVars["ATHENS_REDIS_LOCK_TIMEOUT"] = strconv.Itoa(singleFlight.RedisSentinel.LockConfig.Timeout) diff --git a/pkg/config/singleflight.go b/pkg/config/singleflight.go index b3b6fe04..eae580f7 100644 --- a/pkg/config/singleflight.go +++ b/pkg/config/singleflight.go @@ -31,6 +31,8 @@ type RedisSentinel struct { Endpoints []string `envconfig:"ATHENS_REDIS_SENTINEL_ENDPOINTS"` MasterName string `envconfig:"ATHENS_REDIS_SENTINEL_MASTER_NAME"` SentinelPassword string `envconfig:"ATHENS_REDIS_SENTINEL_PASSWORD"` + RedisUsername string `envconfig:"ATHENS_REDIS_USERNAME"` + RedisPassword string `envconfig:"ATHENS_REDIS_PASSWORD"` LockConfig *RedisLockConfig } diff --git a/pkg/stash/with_redis_sentinel.go b/pkg/stash/with_redis_sentinel.go index bf890b20..fbc554c0 100644 --- a/pkg/stash/with_redis_sentinel.go +++ b/pkg/stash/with_redis_sentinel.go @@ -11,7 +11,7 @@ import ( // WithRedisSentinelLock returns a distributed singleflight // with a redis cluster that utilizes sentinel for quorum and failover. -func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, password string, checker storage.Checker, lockConfig *config.RedisLockConfig) (Wrapper, error) { +func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, sentinelPassword, redisUsername, redisPassword string, checker storage.Checker, lockConfig *config.RedisLockConfig) (Wrapper, error) { redis.SetLogger(l) const op errors.Op = "stash.WithRedisSentinelLock" @@ -23,7 +23,9 @@ func WithRedisSentinelLock(l RedisLogger, endpoints []string, master, password s client := redis.NewFailoverClient(&redis.FailoverOptions{ MasterName: master, SentinelAddrs: endpoints, - SentinelPassword: password, + SentinelPassword: sentinelPassword, + Username: redisUsername, + Password: redisPassword, }) _, err := client.Ping(context.Background()).Result() if err != nil { diff --git a/pkg/stash/with_redis_sentinel_test.go b/pkg/stash/with_redis_sentinel_test.go index ec2ee29d..e5af6ca3 100644 --- a/pkg/stash/with_redis_sentinel_test.go +++ b/pkg/stash/with_redis_sentinel_test.go @@ -18,8 +18,8 @@ import ( func TestWithRedisSentinelLock(t *testing.T) { endpoint := os.Getenv("REDIS_SENTINEL_TEST_ENDPOINT") masterName := os.Getenv("REDIS_SENTINEL_TEST_MASTER_NAME") - password := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD") - if len(endpoint) == 0 || len(masterName) == 0 || len(password) == 0 { + sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD") + if len(endpoint) == 0 || len(masterName) == 0 || len(sentinelPassword) == 0 { t.SkipNow() } strg, err := mem.NewStorage() @@ -29,7 +29,7 @@ func TestWithRedisSentinelLock(t *testing.T) { ms := &mockRedisStasher{strg: strg} l := &testingRedisLogger{t: t} - wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, password, storage.WithChecker(strg), config.DefaultRedisLockConfig()) + wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", "", storage.WithChecker(strg), config.DefaultRedisLockConfig()) if err != nil { t.Fatal(err) } @@ -50,3 +50,109 @@ func TestWithRedisSentinelLock(t *testing.T) { t.Fatal(err) } } + +// TestWithRedisSentinelLockWithRedisPassword verifies WithRedisSentinelLock working with +// password protected redis sentinel and redis master nodes +func TestWithRedisSentinelLockWithRedisPassword(t *testing.T) { + endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT") + masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME") + sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD") + redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD") + if len(endpoint) == 0 || len(masterName) == 0 { + t.SkipNow() + } + strg, err := mem.NewStorage() + if err != nil { + t.Fatal(err) + } + ms := &mockRedisStasher{strg: strg} + l := &testingRedisLogger{t: t} + wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig()) + if err != nil { + t.Fatal(err) + } + s := wrapper(ms) + + var eg errgroup.Group + for i := 0; i < 5; i++ { + eg.Go(func() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _, err := s.Stash(ctx, "mod", "ver") + return err + }) + } + + err = eg.Wait() + if err != nil { + t.Fatal(err) + } +} + +// TestWithRedisSentinelLockWithPassword verifies WithRedisSentinelLock working with +// username & password protected master node under the redis sentinel +func TestWithRedisSentinelLockWithUsernameAndPassword(t *testing.T) { + endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT") + masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME") + sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD") + redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD") + redisUsername := os.Getenv("PROTECTED_REDIS_TEST_USERNAME") + if len(endpoint) == 0 || len(masterName) == 0 { + t.SkipNow() + } + strg, err := mem.NewStorage() + if err != nil { + t.Fatal(err) + } + ms := &mockRedisStasher{strg: strg} + l := &testingRedisLogger{t: t} + wrapper, err := WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, redisUsername, redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig()) + if err != nil { + t.Fatal(err) + } + s := wrapper(ms) + + var eg errgroup.Group + for i := 0; i < 5; i++ { + eg.Go(func() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _, err := s.Stash(ctx, "mod", "ver") + return err + }) + } + + err = eg.Wait() + if err != nil { + t.Fatal(err) + } +} + +// TestWithRedisSentinelLockWithWrongPassword verifies the WithRedisSentinelLock fails +// with the correct error when trying to connect with wrong passwords +func TestWithRedisSentinelLockWithWrongRedisPassword(t *testing.T) { + endpoint := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_ENDPOINT") + masterName := os.Getenv("REDIS_SENTINEL_TEST_PROTECTED_MASTER_NAME") + sentinelPassword := os.Getenv("REDIS_SENTINEL_TEST_PASSWORD") + redisPassword := os.Getenv("ATHENS_PROTECTED_REDIS_PASSWORD") + if len(endpoint) == 0 || len(masterName) == 0 { + t.SkipNow() + } + strg, err := mem.NewStorage() + if err != nil { + t.Fatal(err) + } + l := &testingRedisLogger{t: t} + + // Test with wrong sentinel password + _, err = WithRedisSentinelLock(l, []string{endpoint}, masterName, "wrong-sentinel-password", "", redisPassword, storage.WithChecker(strg), config.DefaultRedisLockConfig()) + if err == nil { + t.Fatal("Expected Connection Error for wrong sentinel password") + } + + // Test with wrong redis password + _, err = WithRedisSentinelLock(l, []string{endpoint}, masterName, sentinelPassword, "", "wrong-redis-password", storage.WithChecker(strg), config.DefaultRedisLockConfig()) + if err == nil { + t.Fatal("Expected Connection Error for wrong redis password") + } +}