diff --git a/docs/Configuration.md b/docs/Configuration.md
index c79082fa9..5baf66760 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 3f7b26e59..4860c5b07 100644
--- a/src/StackExchange.Redis/ConfigurationOptions.cs
+++ b/src/StackExchange.Redis/ConfigurationOptions.cs
@@ -106,7 +106,9 @@ internal const string
KeepAlive = "keepAlive",
ClientName = "name",
User = "user",
+ SentinelUser = "sentinelUser",
Password = "password",
+ SentinelPassword = "sentinelPassword",
PreserveAsyncOrder = "preserveAsyncOrder",
Proxy = "proxy",
ResolveDns = "resolveDns",
@@ -141,7 +143,9 @@ internal const string
HighPrioritySocketThreads,
KeepAlive,
User,
+ SentinelUser,
Password,
+ SentinelPassword,
PreserveAsyncOrder,
Proxy,
ResolveDns,
@@ -217,7 +221,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;
@@ -746,6 +750,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.
///
@@ -755,6 +769,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.
///
@@ -941,7 +965,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,
@@ -950,7 +974,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,
@@ -1051,7 +1077,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(',', '|'));
@@ -1190,6 +1218,8 @@ private void Clear()
#if DEBUG
OutputLog = null;
#endif
+ sentinelUser = null;
+ sentinelPassword = null;
}
object ICloneable.Clone() => Clone();
@@ -1274,9 +1304,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 d651df072..9e82d447f 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,7 +144,12 @@ public static Task SentinelConnectAsync(ConfigurationOpti
/// The to log to.
private static ConnectionMultiplexer SentinelPrimaryConnect(ConfigurationOptions configuration, TextWriter? log = null)
{
- var sentinelConnection = SentinelConnect(configuration, log);
+ // Use separate sentinel credentials when provided on the configuration
+ var sentinelConfig = configuration.Clone();
+ sentinelConfig.User = configuration.SentinelUser;
+ sentinelConfig.Password = configuration.SentinelPassword;
+
+ var sentinelConnection = SentinelConnect(sentinelConfig, log);
var muxer = sentinelConnection.GetSentinelMasterConnection(configuration, log);
// Set reference to sentinel connection so that we can dispose it
@@ -161,7 +166,12 @@ private static ConnectionMultiplexer SentinelPrimaryConnect(ConfigurationOptions
/// The to log to.
private static async Task SentinelPrimaryConnectAsync(ConfigurationOptions configuration, TextWriter? writer = null)
{
- var sentinelConnection = await SentinelConnectAsync(configuration, writer).ForAwait();
+ // Use separate sentinel credentials when provided on the configuration
+ var sentinelConfig = configuration.Clone();
+ sentinelConfig.User = configuration.SentinelUser;
+ sentinelConfig.Password = configuration.SentinelPassword;
+
+ var sentinelConnection = await SentinelConnectAsync(sentinelConfig, writer).ForAwait();
var muxer = sentinelConnection.GetSentinelMasterConnection(configuration, writer);
// Set reference to sentinel connection so that we can dispose it
diff --git a/src/StackExchange.Redis/ConnectionMultiplexer.cs b/src/StackExchange.Redis/ConnectionMultiplexer.cs
index 7b8d25154..a585c4437 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; }
@@ -150,6 +152,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.Unshipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
index ddf237e1a..821995702 100644
--- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
+++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Unshipped.txt
@@ -1,2 +1,6 @@
#nullable enable
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelPassword.set -> void
+StackExchange.Redis.ConfigurationOptions.SentinelUser.get -> string?
+StackExchange.Redis.ConfigurationOptions.SentinelUser.set -> void
[SER009]virtual StackExchange.Redis.Configuration.Tunnel.ConnectTransportAsync(System.Net.EndPoint! endpoint, StackExchange.Redis.ConnectionType connectionType, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask
diff --git a/src/StackExchange.Redis/ServerEndPoint.cs b/src/StackExchange.Redis/ServerEndPoint.cs
index 9e483e91e..ebf76262b 100644
--- a/src/StackExchange.Redis/ServerEndPoint.cs
+++ b/src/StackExchange.Redis/ServerEndPoint.cs
@@ -959,13 +959,14 @@ 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 user = config.User;
+ // Note that we need "" (not null) for password in the case of 'nopass' logins
+ var password = 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 00fe6ad1b..098430fa2 100644
--- a/tests/StackExchange.Redis.Tests/ConfigTests.cs
+++ b/tests/StackExchange.Redis.Tests/ConfigTests.cs
@@ -95,6 +95,8 @@ orderby name
"ResponseBufferPool",
"responseTimeout",
"RetryPolicy",
+ "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);
+ }
+}