From a2c20a6cab8666c5d4dcdb04b6a64a77a55bfc71 Mon Sep 17 00:00:00 2001
From: Justin Sievenpiper <justin@sievenpiper.co>
Date: Wed, 30 Mar 2022 12:12:02 -0700
Subject: [PATCH] Add Redis Sentinel Authentication Support (#19213)

Gitea was not able to supply any authentication parameters to it. So this brings support to do that, along with some light extraction of a couple of bits into some separate functions for easier testing.

I looked at other libraries supporting similar RedisUri-style connection strings (e.g. Lettuce), but it looks like this type of configuration is beyond what would typically be done in a connection string. Since gitea doesn't have configuration options for manually specifying all this redis connection detail, I went ahead and just chose straightforward names for these new parameters.
---
 go.mod                              |   2 +-
 go.sum                              |  14 ++-
 modules/nosql/manager_redis.go      | 163 +++++++++++++++++-----------
 modules/nosql/manager_redis_test.go |  64 +++++++++++
 4 files changed, 172 insertions(+), 71 deletions(-)
 create mode 100644 modules/nosql/manager_redis_test.go

diff --git a/go.mod b/go.mod
index 0edd0dc2dc..e57867ed31 100644
--- a/go.mod
+++ b/go.mod
@@ -32,7 +32,7 @@ require (
 	github.com/go-git/go-billy/v5 v5.3.1
 	github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
 	github.com/go-ldap/ldap/v3 v3.4.2
-	github.com/go-redis/redis/v8 v8.11.4
+	github.com/go-redis/redis/v8 v8.11.5
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/go-swagger/go-swagger v0.29.0
 	github.com/go-testfixtures/testfixtures/v3 v3.6.1
diff --git a/go.sum b/go.sum
index 19ed2271c8..5c052a33e1 100644
--- a/go.sum
+++ b/go.sum
@@ -593,8 +593,8 @@ github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w
 github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg=
 github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA=
 github.com/go-redis/redis/v8 v8.4.0/go.mod h1:A1tbYoHSa1fXwN+//ljcCYYJeLmVrwL9hbQN45Jdy0M=
-github.com/go-redis/redis/v8 v8.11.4 h1:kHoYkfZP6+pe04aFTnhDH6GDROa5yJdHJVNxV3F46Tg=
-github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
+github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
+github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
 github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@@ -758,6 +758,7 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
 github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
@@ -1233,15 +1234,18 @@ github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
 github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
 github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
-github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
 github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
+github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
+github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
+github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
-github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
-github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
+github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
+github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis=
 github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74=
diff --git a/modules/nosql/manager_redis.go b/modules/nosql/manager_redis.go
index b4852cecc8..0ff01dcac2 100644
--- a/modules/nosql/manager_redis.go
+++ b/modules/nosql/manager_redis.go
@@ -6,10 +6,13 @@ package nosql
 
 import (
 	"crypto/tls"
+	"net/url"
 	"path"
 	"strconv"
 	"strings"
 
+	"code.gitea.io/gitea/modules/log"
+
 	"github.com/go-redis/redis/v8"
 )
 
@@ -59,8 +62,59 @@ func (m *Manager) GetRedisClient(connection string) redis.UniversalClient {
 		name: []string{connection, uri.String()},
 	}
 
+	opts := getRedisOptions(uri)
+	tlsConfig := getRedisTLSOptions(uri)
+
+	clientName := uri.Query().Get("clientname")
+
+	if len(clientName) > 0 {
+		client.name = append(client.name, clientName)
+	}
+
+	switch uri.Scheme {
+	case "redis+sentinels":
+		fallthrough
+	case "rediss+sentinel":
+		opts.TLSConfig = tlsConfig
+		fallthrough
+	case "redis+sentinel":
+		client.UniversalClient = redis.NewFailoverClient(opts.Failover())
+	case "redis+clusters":
+		fallthrough
+	case "rediss+cluster":
+		opts.TLSConfig = tlsConfig
+		fallthrough
+	case "redis+cluster":
+		client.UniversalClient = redis.NewClusterClient(opts.Cluster())
+	case "redis+socket":
+		simpleOpts := opts.Simple()
+		simpleOpts.Network = "unix"
+		simpleOpts.Addr = path.Join(uri.Host, uri.Path)
+		client.UniversalClient = redis.NewClient(simpleOpts)
+	case "rediss":
+		opts.TLSConfig = tlsConfig
+		fallthrough
+	case "redis":
+		client.UniversalClient = redis.NewClient(opts.Simple())
+	default:
+		return nil
+	}
+
+	for _, name := range client.name {
+		m.RedisConnections[name] = client
+	}
+
+	client.count++
+
+	return client
+}
+
+// getRedisOptions pulls various configuration options based on the RedisUri format and converts them to go-redis's
+// UniversalOptions fields. This function explicitly excludes fields related to TLS configuration, which is
+// conditionally attached to this options struct before being converted to the specific type for the redis scheme being
+// used, and only in scenarios where TLS is applicable (e.g. rediss://, redis+clusters://).
+func getRedisOptions(uri *url.URL) *redis.UniversalOptions {
 	opts := &redis.UniversalOptions{}
-	tlsConfig := &tls.Config{}
 
 	// Handle username/password
 	if password, ok := uri.User.Password(); ok {
@@ -131,75 +185,54 @@ func (m *Manager) GetRedisClient(connection string) redis.UniversalClient {
 			fallthrough
 		case "mastername":
 			opts.MasterName = v[0]
-		case "skipverify":
-			fallthrough
-		case "insecureskipverify":
-			insecureSkipVerify, _ := strconv.ParseBool(v[0])
-			tlsConfig.InsecureSkipVerify = insecureSkipVerify
-		case "clientname":
-			client.name = append(client.name, v[0])
+		case "sentinelusername":
+			opts.SentinelUsername = v[0]
+		case "sentinelpassword":
+			opts.SentinelPassword = v[0]
 		}
 	}
 
-	switch uri.Scheme {
-	case "redis+sentinels":
-		fallthrough
-	case "rediss+sentinel":
-		opts.TLSConfig = tlsConfig
-		fallthrough
-	case "redis+sentinel":
-		if uri.Host != "" {
-			opts.Addrs = append(opts.Addrs, strings.Split(uri.Host, ",")...)
-		}
-		if uri.Path != "" {
-			if db, err := strconv.Atoi(uri.Path[1:]); err == nil {
-				opts.DB = db
-			}
-		}
-
-		client.UniversalClient = redis.NewFailoverClient(opts.Failover())
-	case "redis+clusters":
-		fallthrough
-	case "rediss+cluster":
-		opts.TLSConfig = tlsConfig
-		fallthrough
-	case "redis+cluster":
-		if uri.Host != "" {
-			opts.Addrs = append(opts.Addrs, strings.Split(uri.Host, ",")...)
-		}
-		if uri.Path != "" {
-			if db, err := strconv.Atoi(uri.Path[1:]); err == nil {
-				opts.DB = db
-			}
-		}
-		client.UniversalClient = redis.NewClusterClient(opts.Cluster())
-	case "redis+socket":
-		simpleOpts := opts.Simple()
-		simpleOpts.Network = "unix"
-		simpleOpts.Addr = path.Join(uri.Host, uri.Path)
-		client.UniversalClient = redis.NewClient(simpleOpts)
-	case "rediss":
-		opts.TLSConfig = tlsConfig
-		fallthrough
-	case "redis":
-		if uri.Host != "" {
-			opts.Addrs = append(opts.Addrs, strings.Split(uri.Host, ",")...)
-		}
-		if uri.Path != "" {
-			if db, err := strconv.Atoi(uri.Path[1:]); err == nil {
-				opts.DB = db
-			}
-		}
-		client.UniversalClient = redis.NewClient(opts.Simple())
-	default:
-		return nil
+	if uri.Host != "" {
+		opts.Addrs = append(opts.Addrs, strings.Split(uri.Host, ",")...)
 	}
 
-	for _, name := range client.name {
-		m.RedisConnections[name] = client
+	// A redis connection string uses the path section of the URI in two different ways. In a TCP-based connection, the
+	// path will be a database index to automatically have the client SELECT. In a Unix socket connection, it will be the
+	// file path. We only want to try to coerce this to the database index when we're not expecting a file path so that
+	// the error log stays clean.
+	if uri.Path != "" && uri.Scheme != "redis+socket" {
+		if db, err := strconv.Atoi(uri.Path[1:]); err == nil {
+			opts.DB = db
+		} else {
+			log.Error("Provided database identifier '%s' is not a valid integer. Gitea will ignore this option.", uri.Path)
+		}
 	}
 
-	client.count++
-
-	return client
+	return opts
+}
+
+// getRedisTlsOptions parses RedisUri TLS configuration parameters and converts them to the go TLS configuration
+// equivalent fields.
+func getRedisTLSOptions(uri *url.URL) *tls.Config {
+	tlsConfig := &tls.Config{}
+
+	skipverify := uri.Query().Get("skipverify")
+
+	if len(skipverify) > 0 {
+		skipverify, err := strconv.ParseBool(skipverify)
+		if err != nil {
+			tlsConfig.InsecureSkipVerify = skipverify
+		}
+	}
+
+	insecureskipverify := uri.Query().Get("insecureskipverify")
+
+	if len(insecureskipverify) > 0 {
+		insecureskipverify, err := strconv.ParseBool(insecureskipverify)
+		if err != nil {
+			tlsConfig.InsecureSkipVerify = insecureskipverify
+		}
+	}
+
+	return tlsConfig
 }
diff --git a/modules/nosql/manager_redis_test.go b/modules/nosql/manager_redis_test.go
new file mode 100644
index 0000000000..3d94532135
--- /dev/null
+++ b/modules/nosql/manager_redis_test.go
@@ -0,0 +1,64 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package nosql
+
+import (
+	"net/url"
+	"testing"
+)
+
+func TestRedisUsernameOpt(t *testing.T) {
+	uri, _ := url.Parse("redis://redis:password@myredis/0")
+	opts := getRedisOptions(uri)
+
+	if opts.Username != "redis" {
+		t.Fail()
+	}
+}
+
+func TestRedisPasswordOpt(t *testing.T) {
+	uri, _ := url.Parse("redis://redis:password@myredis/0")
+	opts := getRedisOptions(uri)
+
+	if opts.Password != "password" {
+		t.Fail()
+	}
+}
+
+func TestRedisSentinelUsernameOpt(t *testing.T) {
+	uri, _ := url.Parse("redis+sentinel://redis:password@myredis/0?sentinelusername=suser&sentinelpassword=spass")
+	opts := getRedisOptions(uri).Failover()
+
+	if opts.SentinelUsername != "suser" {
+		t.Fail()
+	}
+}
+
+func TestRedisSentinelPasswordOpt(t *testing.T) {
+	uri, _ := url.Parse("redis+sentinel://redis:password@myredis/0?sentinelusername=suser&sentinelpassword=spass")
+	opts := getRedisOptions(uri).Failover()
+
+	if opts.SentinelPassword != "spass" {
+		t.Fail()
+	}
+}
+
+func TestRedisDatabaseIndexTcp(t *testing.T) {
+	uri, _ := url.Parse("redis://redis:password@myredis/12")
+	opts := getRedisOptions(uri)
+
+	if opts.DB != 12 {
+		t.Fail()
+	}
+}
+
+func TestRedisDatabaseIndexUnix(t *testing.T) {
+	uri, _ := url.Parse("redis+socket:///var/run/redis.sock?database=12")
+	opts := getRedisOptions(uri)
+
+	if opts.DB != 12 {
+		t.Fail()
+	}
+}