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); + } +}