diff --git a/docs/Configuration.md b/docs/Configuration.md
index ce3ab93f6..ffe2a6bf8 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -86,7 +86,9 @@ The `ConfigurationOptions` object has a wide range of properties, all of which a
| tcpKeepAlive={bool} | `TcpKeepAlive` | `true` | Enables TCP keep-alive when appropriate (endpoint- and platform-dependent) |
| name={string} | `ClientName` | `null` | Identification for the connection within redis |
| password={string} | `Password` | `null` | Password for the redis server |
+| sentinelPassword={string} | `SentinelPassword` | `null` | Optional password to authenticate with Sentinel servers (falls back to `password` if not provided) |
| user={string} | `User` | `null` | User for the redis server (for use with ACLs on redis 6 and above) |
+| sentinelUser={string} | `SentinelUser` | `null` | Optional username to authenticate with Sentinel servers (falls back to `user` if not provided) |
| proxy={proxy type} | `Proxy` | `Proxy.None` | Type of proxy in use (if any); for example "twemproxy/envoyproxy" |
| resolveDns={bool} | `ResolveDns` | `false` | Specifies that DNS resolution should be explicit and eager, rather than implicit |
| serviceName={string} | `ServiceName` | `null` | Used for connecting to a sentinel primary service |
diff --git a/src/StackExchange.Redis/ConfigurationOptions.cs b/src/StackExchange.Redis/ConfigurationOptions.cs
index eef0bde81..37b115eb1 100644
--- a/src/StackExchange.Redis/ConfigurationOptions.cs
+++ b/src/StackExchange.Redis/ConfigurationOptions.cs
@@ -2,7 +2,6 @@
using System.Buffers;
using System.Collections.Generic;
using System.ComponentModel;
-using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Net.Security;
@@ -14,8 +13,6 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
-using RESPite;
-using RESPite.Buffers;
using StackExchange.Redis.Configuration;
namespace StackExchange.Redis
@@ -98,7 +95,9 @@ internal const string
KeepAlive = "keepAlive",
ClientName = "name",
User = "user",
+ SentinelUser = "sentinelUser",
Password = "password",
+ SentinelPassword = "sentinelPassword",
PreserveAsyncOrder = "preserveAsyncOrder",
Proxy = "proxy",
ResolveDns = "resolveDns",
@@ -133,7 +132,9 @@ internal const string
HighPrioritySocketThreads,
KeepAlive,
User,
+ SentinelUser,
Password,
+ SentinelPassword,
PreserveAsyncOrder,
Proxy,
ResolveDns,
@@ -209,7 +210,7 @@ private enum OptionFlags : ulong
private OptionFlags optionFlags;
- private string? tieBreaker, sslHost, configChannel, user, password;
+ private string? tieBreaker, sslHost, configChannel, user, sentinelUser, password, sentinelPassword;
private TimeSpan heartbeatInterval;
@@ -738,6 +739,16 @@ public string? User
set => user = value;
}
+ ///
+ /// The username to use to authenticate with Sentinel servers, only when different from the Redis server password (optional).
+ /// If not specified, is used when communicating with Sentinels.
+ ///
+ public string? SentinelUser
+ {
+ get => sentinelUser ?? user ?? Defaults.User;
+ set => sentinelUser = value;
+ }
+
///
/// The password to use to authenticate with the server.
///
@@ -747,6 +758,16 @@ public string? Password
set => password = value;
}
+ ///
+ /// The password to use to authenticate with Sentinel servers, only when different from the Redis server password (optional).
+ /// If not specified, is used when communicating with Sentinels.
+ ///
+ public string? SentinelPassword
+ {
+ get => sentinelPassword ?? password ?? Defaults.Password;
+ set => sentinelPassword = value;
+ }
+
///
/// Specifies whether asynchronous operations should be invoked in a way that guarantees their original delivery order.
///
@@ -932,7 +953,7 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow
public ConfigurationOptions Clone() => new ConfigurationOptions
{
defaultOptions = defaultOptions,
- optionFlags = this.optionFlags,
+ optionFlags = optionFlags,
ClientName = ClientName,
ServiceName = ServiceName,
keepAlive = keepAlive,
@@ -941,7 +962,9 @@ public static ConfigurationOptions Parse(string configuration, bool ignoreUnknow
defaultVersion = defaultVersion,
connectTimeout = connectTimeout,
user = user,
+ sentinelUser = sentinelUser,
password = password,
+ sentinelPassword = sentinelPassword,
tieBreaker = tieBreaker,
sslHost = sslHost,
configChannel = configChannel,
@@ -1040,7 +1063,9 @@ public string ToString(bool includePassword)
Append(sb, OptionKeys.Version, defaultVersion);
Append(sb, OptionKeys.ConnectTimeout, OptionFlags.ConnectTimeoutHasValue, in connectTimeout);
Append(sb, OptionKeys.User, user);
+ Append(sb, OptionKeys.SentinelUser, sentinelUser);
Append(sb, OptionKeys.Password, (includePassword || string.IsNullOrEmpty(password)) ? password : "*****");
+ Append(sb, OptionKeys.SentinelPassword, (includePassword || string.IsNullOrEmpty(sentinelPassword)) ? sentinelPassword : "*****");
Append(sb, OptionKeys.TieBreaker, tieBreaker);
Append(sb, OptionKeys.Ssl, OptionFlags.SslHasValue, OptionFlags.SslValue);
if (HasValue(OptionFlags.SslProtocolsHasValue)) Append(sb, OptionKeys.SslProtocols, sslProtocols.ToString().Replace(',', '|'));
@@ -1177,6 +1202,8 @@ private void Clear()
#if DEBUG
OutputLog = null;
#endif
+ sentinelUser = null;
+ sentinelPassword = null;
}
object ICloneable.Clone() => Clone();
@@ -1261,9 +1288,15 @@ private ConfigurationOptions DoParse(string configuration, bool ignoreUnknown)
case OptionKeys.User:
user = value;
break;
+ case OptionKeys.SentinelUser:
+ SentinelUser = value;
+ break;
case OptionKeys.Password:
password = value;
break;
+ case OptionKeys.SentinelPassword:
+ SentinelPassword = value;
+ break;
case OptionKeys.TieBreaker:
TieBreaker = value;
break;
diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs
index 52ff4ee1a..3914dca3b 100644
--- a/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs
+++ b/src/StackExchange.Redis/ConnectionMultiplexer.Sentinel.cs
@@ -22,7 +22,7 @@ public partial class ConnectionMultiplexer
/// The to log to, if any.
internal void InitializeSentinel(ILogger? log)
{
- if (ServerSelectionStrategy.ServerType != ServerType.Sentinel)
+ if (!_isSentinel)
{
return;
}
@@ -144,6 +144,7 @@ public static Task SentinelConnectAsync(ConfigurationOpti
/// The to log to.
private static ConnectionMultiplexer SentinelPrimaryConnect(ConfigurationOptions configuration, TextWriter? log = null)
{
+ // Sentinel credentials are selected during handshake based on ServerType
var sentinelConnection = SentinelConnect(configuration, log);
var muxer = sentinelConnection.GetSentinelMasterConnection(configuration, log);
@@ -161,6 +162,7 @@ private static ConnectionMultiplexer SentinelPrimaryConnect(ConfigurationOptions
/// The to log to.
private static async Task SentinelPrimaryConnectAsync(ConfigurationOptions configuration, TextWriter? writer = null)
{
+ // Sentinel credentials are selected during handshake based on ServerType
var sentinelConnection = await SentinelConnectAsync(configuration, writer).ForAwait();
var muxer = sentinelConnection.GetSentinelMasterConnection(configuration, writer);
diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs
index d8e328987..19503c8dd 100644
--- a/src/StackExchange.Redis/ConnectionMultiplexer.cs
+++ b/src/StackExchange.Redis/ConnectionMultiplexer.cs
@@ -44,6 +44,8 @@ public sealed partial class ConnectionMultiplexer : IInternalConnectionMultiplex
internal bool IsDisposed => _isDisposed;
internal ILogger? Logger { get; }
+ private readonly bool _isSentinel;
+
internal CommandMap CommandMap { get; }
internal EndPointCollection EndPoints { get; }
internal ConfigurationOptions RawConfig { get; }
@@ -134,6 +136,8 @@ private ConnectionMultiplexer(ConfigurationOptions configuration, ServerType? se
EndPoints.SetDefaultPorts(serverType, ssl: RawConfig.Ssl);
Logger = configuration.LoggerFactory?.CreateLogger();
+ _isSentinel = serverType == ServerType.Sentinel;
+
var map = CommandMap = configuration.GetCommandMap(serverType);
if (!string.IsNullOrWhiteSpace(configuration.Password) && !configuration.TryResp3()) // RESP3 doesn't need AUTH (can issue as part of HELLO)
{
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.SentinelUnshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.SentinelUnshipped.txt
new file mode 100644
index 000000000..1dcb6da67
--- /dev/null
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.SentinelUnshipped.txt
@@ -0,0 +1,5 @@
+#nullable enable
+StackExchange.Redis.ConfigurationOptions.SentinelUser.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelUser.set -> void
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.set -> void
diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
index a663ada89..7b361fe14 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt
@@ -287,6 +287,10 @@ StackExchange.Redis.ConfigurationOptions.ResolveDns.get -> bool
StackExchange.Redis.ConfigurationOptions.ResolveDns.set -> void
StackExchange.Redis.ConfigurationOptions.ResponseTimeout.get -> int
StackExchange.Redis.ConfigurationOptions.ResponseTimeout.set -> void
+StackExchange.Redis.ConfigurationOptions.SentinelUser.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelUser.set -> void
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.set -> void
StackExchange.Redis.ConfigurationOptions.ServiceName.get -> string?
StackExchange.Redis.ConfigurationOptions.ServiceName.set -> void
StackExchange.Redis.ConfigurationOptions.SetClientLibrary.get -> bool
diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs
index f191c1c3b..d716e188f 100644
--- a/src/StackExchange.Redis/ServerEndPoint.cs
+++ b/src/StackExchange.Redis/ServerEndPoint.cs
@@ -957,13 +957,15 @@ private async Task HandshakeAsync(PhysicalConnection connection, ILogger? log)
Multiplexer.Trace("No connection!?");
return;
}
+
Message msg;
- // Note that we need "" (not null) for password in the case of 'nopass' logins
var config = Multiplexer.RawConfig;
- string? user = config.User;
- string password = config.Password ?? "";
+ var isSentinelConnection = Multiplexer.ServerSelectionStrategy.ServerType == ServerType.Sentinel;
+ var user = isSentinelConnection ? config.SentinelUser : config.User;
+ // Note that we need "" (not null) for password in the case of 'nopass' logins
+ var password = (isSentinelConnection ? config.SentinelPassword : config.Password) ?? "";
+ var clientName = Multiplexer.ClientName;
- string clientName = Multiplexer.ClientName;
if (!string.IsNullOrWhiteSpace(clientName))
{
clientName = nameSanitizer.Replace(clientName, "");
diff --git a/tests/StackExchange.Redis.Tests/ConfigTests.cs b/tests/StackExchange.Redis.Tests/ConfigTests.cs
index 89ffd851e..edde62cb7 100644
--- a/tests/StackExchange.Redis.Tests/ConfigTests.cs
+++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs
@@ -93,6 +93,8 @@ orderby name
"RequestBufferPool",
"ResponseBufferPool",
"responseTimeout",
+ "sentinelPassword",
+ "sentinelUser",
"ServiceName",
"SocketManager",
#if !NETFRAMEWORK
diff --git a/tests/StackExchange.Redis.Tests/SentinelConfigTests.cs b/tests/StackExchange.Redis.Tests/SentinelConfigTests.cs
new file mode 100644
index 000000000..2e659eea6
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/SentinelConfigTests.cs
@@ -0,0 +1,47 @@
+using System;
+using Xunit;
+
+namespace StackExchange.Redis.Tests;
+
+public class SentinelConfigTests
+{
+ [Fact]
+ public void Parse_SentinelCredentials_FromConnectionString()
+ {
+ var cs = "localhost:26379,serviceName=myprimary,sentinelUser=su,sentinelPassword=sp";
+ var options = ConfigurationOptions.Parse(cs);
+
+ Assert.Equal("su", options.SentinelUser);
+ Assert.Equal("sp", options.SentinelPassword);
+ Assert.Equal("myprimary", options.ServiceName);
+ }
+
+ [Fact]
+ public void ToString_Masks_SentinelPassword_WhenExcluded()
+ {
+ var options = new ConfigurationOptions();
+ options.EndPoints.Add("localhost", 26379);
+ options.ServiceName = "myprimary";
+ options.SentinelUser = "su";
+ options.SentinelPassword = "secret";
+
+ var repr = options.ToString(includePassword: false);
+
+ Assert.Contains("sentinelUser=su", repr);
+ Assert.Contains("sentinelPassword=*****", repr);
+ Assert.DoesNotContain("secret", repr);
+ }
+
+ [Fact]
+ public void Clone_Preserves_SentinelCredentials()
+ {
+ var options = new ConfigurationOptions();
+ options.SentinelUser = "su";
+ options.SentinelPassword = "sp";
+
+ var clone = options.Clone();
+
+ Assert.Equal(options.SentinelUser, clone.SentinelUser);
+ Assert.Equal(options.SentinelPassword, clone.SentinelPassword);
+ }
+}