diff --git a/docs/docfx/articles/session-affinity.md b/docs/docfx/articles/session-affinity.md index 7e01ba8ed..bee6b9b19 100644 --- a/docs/docfx/articles/session-affinity.md +++ b/docs/docfx/articles/session-affinity.md @@ -16,6 +16,8 @@ endpoints.MapReverseProxy(proxyPipeline => }); ``` +***Note*** Some session affinity implementations depend on Data Protection, which will require additional configuration for scenarios like multiple proxy instances. See [Key Protection](#key-protection) for details. + ### Cluster configuration Session affinity is configured per cluster according to the following configuration scheme. ```JSON @@ -24,7 +26,7 @@ Session affinity is configured per cluster according to the following configurat "": { "SessionAffinity": { "Enabled": "(true|false)", // defaults to 'false' - "Policy": "(Cookie|CustomHeader)", // defaults to 'Cookie' + "Policy": "(HashCookie|ArrCookie|Cookie|CustomHeader)", // defaults to 'HashCookie' "FailurePolicy": "(Redistribute|Return503Error)", // defaults to 'Redistribute' "AffinityKeyName": "Key1", "Cookie": { @@ -42,9 +44,8 @@ Session affinity is configured per cluster according to the following configurat } ``` -### Policy-specific configuration -There is currently one policy-specific strongly-typed configuration section implemented. -- `SessionAffinityCookieConfig` exposes settings to customize cookie properties which will be used by `CookieSessionAffinityPolicy` for creating new affinity cookies. The properties can be JSON config as show above or in code as shown below: +### Cookie configuration +Attributes for configuring the cookie used with the HashCookie, ArrCookie and Cookie policies can be configured using `SessionAffinityCookieConfig`. The properties can be JSON config as show above or in code as shown below: ```C# new ClusterConfig { @@ -53,7 +54,7 @@ new ClusterConfig { Enabled = true, FailurePolicy = "Return503Error", - Policy = "Cookie", + Policy = "HashCookie", AffinityKeyName = "Key1", Cookie = new SessionAffinityCookieConfig { @@ -71,7 +72,7 @@ new ClusterConfig ``` ## Affinity Key -Request to destination affinity is established via the affinity key identifying the target destination. That key can be stored on different request parts depending on the given session affinity implementation, but each request cannot have more than one such key. The exact key semantics is implementation dependent, in example the both of built-in `CookieSessionAffinityPolicy` and `CustomHeaderAffinityPolicy` are currently use `DestinationId` as the affinity key. +Request to destination affinity is established via the affinity key identifying the target destination. That key can be stored on different request parts depending on the given session affinity implementation, but each request cannot have more than one such key. The exact key semantics are implementation dependent, but the built-in policies currently use `DestinationId` as the affinity key. The current design doesn't require a key to uniquely identify the single affinitized destination. It's allowed to establish affinity to a destination group. In this case, the exact destination to handle the given request will be determined by the load balancer. @@ -85,10 +86,21 @@ Once a request arrives and gets routed to a cluster with session affinity enable If a new affinity was established for the request, the affinity key gets attached to a response where exact key representation and location depends on the implementation. Currently, there are two built-in policies storing the key on a cookie or custom header. Once the response gets delivered to the client, it's the client responsibility to attach the key to all following requests in the same session. Further, when the next request carrying the key arrives to the proxy, it resolves the existing affinity, but affinity key does not get again attached to the response. Thus, only the first response carries the affinity key. -There are two built-in affinity policys differing only in how the affinity key is stored on a request. The default policy is `Cookie`. -1. `Cookie` - stores the key as a cookie. It expects the request's key to be delivered as a cookie with configured name and sets the same cookie with `Set-Cookie` header on the first response in an affinitized sequence. This is implemented by `CookieSessionAffinityPolicy`. Cookie name must be explicitly set via `SessionAffinityConfig.AffinityKeyName` which is used by `CookieSessionAffinityPolicy` for this purpose. Other cookie's properties can be configured via `SessionAffinityCookieConfig`. **Important**: `AffinityKeyName` must be unique across all clusters with enabled session affinity to avoid conflicts. +There are four built-in affinity polices that format and store the key differently on requests and responses. The default policy is `HashCookie`. +- `HashCookie`, `ArrCookie`, and `Cookie` policies store the key as a cookie, hashed or encrypted respectively, see [Key Protection](#key-protection) below. The request's key will be delivered as a cookie with the configured name and sets the same cookie with `Set-Cookie` header on the first response in an affinitized sequence. The cookie name must be explicitly set via `SessionAffinityConfig.AffinityKeyName`. Other cookie properties can be configured via `SessionAffinityCookieConfig`. +- `CustomHeader` stores the key as an encrypted header. It expects the affinity key to be delivered in a custom header with the configured name and sets the same header on the first response in an affinitized sequence. The header name must be set via `SessionAffinityConfig.AffinityKeyName`. + +**Important**: `AffinityKeyName` must be unique across all clusters with enabled session affinity to avoid conflicts. + +### Key Protection + +The `HashCookie` policy uses the XxHash64 hash to produce a fast, compact, obscured output format for the cookie value. + +The `ArrCookie` policy uses the SHA-256 hash to produce an obscured output for the cookie value that matches IIS's [ARR](https://www.iis.net/downloads/microsoft/application-request-routing) affinity cookie format. ARR uses the destination host name as the input value so YARP's destination ids would need to be configured to match if used in conjunction with ARR. + +`HashCookie` and `ArrCookie` do not provide strong privacy protection and sensitive data should not be included in destination ids. These policies also don't conceal the total number of unique destinations behind the proxy and should not be used if that's a concern. -2. `CustomHeader` - stores the key on a header. It expects the affinity key to be delivered in a custom header with configured name and sets the same header on the first response in an affinitized sequence. This is implemented by `CustomHeaderSessionAffinityPolicy`. The header name must be set via `SessionAffinityConfig.AffinityKeyName` which is used by `CustomHeaderSessionAffinityPolicy` for this purpose. **Important**: `AffinityKeyName` must be unique across all clusters with enabled session affinity to avoid conflicts. +The `Cookie` and `CustomHeader` policies encrypt the key using Data Protection. This provides strong privacy protections for the key, but requires [additional configuration](https://learn.microsoft.com/aspnet/core/security/data-protection/configuration/overview) when more than once proxy instance is in use. ## Affinity failure policy If the affinity key cannot be decoded or no healthy destination found it's considered as a failure and an affinity failure policy is called to handle it. The policy has the full access to `HttpContext` and can send response to the client by itself. It returns a boolean value indicating whether the request processing can proceed down the pipeline or must be terminated. diff --git a/samples/ReverseProxy.Config.Sample/appsettings.json b/samples/ReverseProxy.Config.Sample/appsettings.json index d47045928..3ba8cc82b 100644 --- a/samples/ReverseProxy.Config.Sample/appsettings.json +++ b/samples/ReverseProxy.Config.Sample/appsettings.json @@ -86,9 +86,9 @@ "LoadBalancingPolicy": "PowerOfTwoChoices", // Alternatively "First", "Random", "RoundRobin", "LeastRequests" "SessionAffinity": { // Ensures subsequent requests from a client go to the same destination server "Enabled": true, // Defaults to 'false' - "Policy": "Cookie", // Default, alternatively "CustomHeader" + "Policy": "HashCookie", // Default, alternatively "Cookie" or "CustomHeader" "FailurePolicy": "Redistribute", // default, alternatively "Return503Error" - "AffinityKeyName": "MySessionCookieName" // defaults to ".Yarp.ReverseProxy.Affinity" + "AffinityKeyName": "MySessionCookieName" // Required, no default }, "HealthCheck": { // Ways to determine which destinations should be filtered out due to unhealthy state "Active": { // Makes API calls to validate the health of each destination diff --git a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs index 158831d1d..a0dd6be4d 100644 --- a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs +++ b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs @@ -86,6 +86,8 @@ public static IReverseProxyBuilder AddSessionAffinityPolicies(this IReverseProxy }); builder.Services.TryAddEnumerable(new[] { ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), + ServiceDescriptor.Singleton(), ServiceDescriptor.Singleton() }); builder.AddTransforms(); diff --git a/src/ReverseProxy/SessionAffinity/AffinitizeTransformProvider.cs b/src/ReverseProxy/SessionAffinity/AffinitizeTransformProvider.cs index 33904e34f..e33f6d22f 100644 --- a/src/ReverseProxy/SessionAffinity/AffinitizeTransformProvider.cs +++ b/src/ReverseProxy/SessionAffinity/AffinitizeTransformProvider.cs @@ -34,7 +34,7 @@ public void ValidateCluster(TransformClusterValidationContext context) if (string.IsNullOrEmpty(policy)) { // The default. - policy = SessionAffinityConstants.Policies.Cookie; + policy = SessionAffinityConstants.Policies.HashCookie; } if (!_sessionAffinityPolicies.ContainsKey(policy)) @@ -49,7 +49,7 @@ public void Apply(TransformBuilderContext context) if (options is not null && options.Enabled.GetValueOrDefault()) { - var policy = _sessionAffinityPolicies.GetRequiredServiceById(options.Policy, SessionAffinityConstants.Policies.Cookie); + var policy = _sessionAffinityPolicies.GetRequiredServiceById(options.Policy, SessionAffinityConstants.Policies.HashCookie); context.ResponseTransforms.Add(new AffinitizeTransform(policy)); } } diff --git a/src/ReverseProxy/SessionAffinity/AffinityHelpers.cs b/src/ReverseProxy/SessionAffinity/AffinityHelpers.cs new file mode 100644 index 000000000..8ba9c70b8 --- /dev/null +++ b/src/ReverseProxy/SessionAffinity/AffinityHelpers.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.AspNetCore.Http; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.SessionAffinity; + +internal static class AffinityHelpers +{ + internal static CookieOptions CreateCookieOptions(SessionAffinityCookieConfig? config, bool isHttps, IClock clock) + { + return new CookieOptions + { + Path = config?.Path ?? "/", + SameSite = config?.SameSite ?? SameSiteMode.Unspecified, + HttpOnly = config?.HttpOnly ?? true, + MaxAge = config?.MaxAge, + Domain = config?.Domain, + IsEssential = config?.IsEssential ?? false, + Secure = config?.SecurePolicy == CookieSecurePolicy.Always || (config?.SecurePolicy == CookieSecurePolicy.SameAsRequest && isHttps), + Expires = config?.Expiration is not null ? clock.GetUtcNow().Add(config.Expiration.Value) : default(DateTimeOffset?), + }; + } +} diff --git a/src/ReverseProxy/SessionAffinity/ArrCookieSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/ArrCookieSessionAffinityPolicy.cs new file mode 100644 index 000000000..4582f9ef4 --- /dev/null +++ b/src/ReverseProxy/SessionAffinity/ArrCookieSessionAffinityPolicy.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using Yarp.ReverseProxy.Model; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.SessionAffinity; + +internal sealed class ArrCookieSessionAffinityPolicy : BaseHashCookieSessionAffinityPolicy +{ + private readonly ConditionalWeakTable _hashes = new(); + + public ArrCookieSessionAffinityPolicy( + IClock clock, + ILogger logger) + : base(clock, logger) { } + + public override string Name => SessionAffinityConstants.Policies.ArrCookie; + + protected override string GetDestinationHash(DestinationState d) + { + return _hashes.GetValue(d, static d => + { + // Matches the format used by ARR + var destinationIdBytes = Encoding.Unicode.GetBytes(d.DestinationId.ToLowerInvariant()); + var hashBytes = SHA256.HashData(destinationIdBytes); + return Convert.ToHexString(hashBytes); + }); + } +} diff --git a/src/ReverseProxy/SessionAffinity/BaseSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/BaseEncryptedSessionAffinityPolicy.cs similarity index 74% rename from src/ReverseProxy/SessionAffinity/BaseSessionAffinityPolicy.cs rename to src/ReverseProxy/SessionAffinity/BaseEncryptedSessionAffinityPolicy.cs index 83497e646..fd2fef861 100644 --- a/src/ReverseProxy/SessionAffinity/BaseSessionAffinityPolicy.cs +++ b/src/ReverseProxy/SessionAffinity/BaseEncryptedSessionAffinityPolicy.cs @@ -12,13 +12,13 @@ namespace Yarp.ReverseProxy.SessionAffinity; -internal abstract class BaseSessionAffinityPolicy : ISessionAffinityPolicy +internal abstract class BaseEncryptedSessionAffinityPolicy : ISessionAffinityPolicy { private readonly IDataProtector _dataProtector; protected static readonly object AffinityKeyId = new object(); protected readonly ILogger Logger; - protected BaseSessionAffinityPolicy(IDataProtectionProvider dataProtectionProvider, ILogger logger) + protected BaseEncryptedSessionAffinityPolicy(IDataProtectionProvider dataProtectionProvider, ILogger logger) { _dataProtector = dataProtectionProvider?.CreateProtector(GetType().FullName!) ?? throw new ArgumentNullException(nameof(dataProtectionProvider)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -145,37 +145,4 @@ private static string Pad(string text) } return text + new string('=', padding); } - - private static class Log - { - private static readonly Action _affinityCannotBeEstablishedBecauseNoDestinationsFound = LoggerMessage.Define( - LogLevel.Warning, - EventIds.AffinityCannotBeEstablishedBecauseNoDestinationsFoundOnCluster, - "The request affinity cannot be established because no destinations are found on cluster `{clusterId}`."); - - private static readonly Action _requestAffinityKeyDecryptionFailed = LoggerMessage.Define( - LogLevel.Error, - EventIds.RequestAffinityKeyDecryptionFailed, - "The request affinity key decryption failed."); - - private static readonly Action _destinationMatchingToAffinityKeyNotFound = LoggerMessage.Define( - LogLevel.Warning, - EventIds.DestinationMatchingToAffinityKeyNotFound, - "Destination matching to the request affinity key is not found on cluster `{backnedId}`. Configured failure policy will be applied."); - - public static void AffinityCannotBeEstablishedBecauseNoDestinationsFound(ILogger logger, string clusterId) - { - _affinityCannotBeEstablishedBecauseNoDestinationsFound(logger, clusterId, null); - } - - public static void RequestAffinityKeyDecryptionFailed(ILogger logger, Exception? ex) - { - _requestAffinityKeyDecryptionFailed(logger, ex); - } - - public static void DestinationMatchingToAffinityKeyNotFound(ILogger logger, string clusterId) - { - _destinationMatchingToAffinityKeyNotFound(logger, clusterId, null); - } - } } diff --git a/src/ReverseProxy/SessionAffinity/BaseHashCookieSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/BaseHashCookieSessionAffinityPolicy.cs new file mode 100644 index 000000000..26369990c --- /dev/null +++ b/src/ReverseProxy/SessionAffinity/BaseHashCookieSessionAffinityPolicy.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Model; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.SessionAffinity; + +internal abstract class BaseHashCookieSessionAffinityPolicy : ISessionAffinityPolicy +{ + private static readonly object AffinityKeyId = new(); + private readonly ILogger _logger; + private readonly IClock _clock; + + public BaseHashCookieSessionAffinityPolicy(IClock clock, ILogger logger) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public abstract string Name { get; } + + public void AffinitizeResponse(HttpContext context, ClusterState cluster, SessionAffinityConfig config, DestinationState destination) + { + if (!config.Enabled.GetValueOrDefault()) + { + throw new InvalidOperationException("Session affinity is disabled for cluster."); + } + + // Affinity key is set on the response only if it's a new affinity. + if (!context.Items.ContainsKey(AffinityKeyId)) + { + var affinityKey = GetDestinationHash(destination); + var affinityCookieOptions = AffinityHelpers.CreateCookieOptions(config.Cookie, context.Request.IsHttps, _clock); + context.Response.Cookies.Append(config.AffinityKeyName, affinityKey, affinityCookieOptions); + } + } + + public AffinityResult FindAffinitizedDestinations(HttpContext context, ClusterState cluster, SessionAffinityConfig config, IReadOnlyList destinations) + { + if (!config.Enabled.GetValueOrDefault()) + { + throw new InvalidOperationException($"Session affinity is disabled for cluster {cluster.ClusterId}."); + } + + var affinityHash = context.Request.Cookies[config.AffinityKeyName]; + if (affinityHash is null) + { + return new(null, AffinityStatus.AffinityKeyNotSet); + } + + foreach (var d in destinations) + { + var hashValue = GetDestinationHash(d); + + if (affinityHash == hashValue) + { + context.Items[AffinityKeyId] = affinityHash; + return new(d, AffinityStatus.OK); + } + } + + if (destinations.Count == 0) + { + Log.AffinityCannotBeEstablishedBecauseNoDestinationsFound(_logger, cluster.ClusterId); + } + else + { + Log.DestinationMatchingToAffinityKeyNotFound(_logger, cluster.ClusterId); + } + + return new(null, AffinityStatus.DestinationNotFound); + } + + protected abstract string GetDestinationHash(DestinationState d); +} diff --git a/src/ReverseProxy/SessionAffinity/CookieSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/CookieSessionAffinityPolicy.cs index 3ec4b1f35..1c06c3da1 100644 --- a/src/ReverseProxy/SessionAffinity/CookieSessionAffinityPolicy.cs +++ b/src/ReverseProxy/SessionAffinity/CookieSessionAffinityPolicy.cs @@ -11,7 +11,7 @@ namespace Yarp.ReverseProxy.SessionAffinity; -internal sealed class CookieSessionAffinityPolicy : BaseSessionAffinityPolicy +internal sealed class CookieSessionAffinityPolicy : BaseEncryptedSessionAffinityPolicy { private readonly IClock _clock; @@ -39,17 +39,7 @@ protected override (string? Key, bool ExtractedSuccessfully) GetRequestAffinityK protected override void SetAffinityKey(HttpContext context, ClusterState cluster, SessionAffinityConfig config, string unencryptedKey) { - var affinityCookieOptions = new CookieOptions - { - Path = config.Cookie?.Path ?? "/", - SameSite = config.Cookie?.SameSite ?? SameSiteMode.Unspecified, - HttpOnly = config.Cookie?.HttpOnly ?? true, - MaxAge = config.Cookie?.MaxAge, - Domain = config.Cookie?.Domain, - IsEssential = config.Cookie?.IsEssential ?? false, - Secure = config.Cookie?.SecurePolicy == CookieSecurePolicy.Always || (config.Cookie?.SecurePolicy == CookieSecurePolicy.SameAsRequest && context.Request.IsHttps), - Expires = config.Cookie?.Expiration is not null ? _clock.GetUtcNow().Add(config.Cookie.Expiration.Value) : default(DateTimeOffset?), - }; + var affinityCookieOptions = AffinityHelpers.CreateCookieOptions(config.Cookie, context.Request.IsHttps, _clock); context.Response.Cookies.Append(config.AffinityKeyName, Protect(unencryptedKey), affinityCookieOptions); } } diff --git a/src/ReverseProxy/SessionAffinity/CustomHeaderSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/CustomHeaderSessionAffinityPolicy.cs index ad39625fe..8a737b334 100644 --- a/src/ReverseProxy/SessionAffinity/CustomHeaderSessionAffinityPolicy.cs +++ b/src/ReverseProxy/SessionAffinity/CustomHeaderSessionAffinityPolicy.cs @@ -11,7 +11,7 @@ namespace Yarp.ReverseProxy.SessionAffinity; -internal sealed class CustomHeaderSessionAffinityPolicy : BaseSessionAffinityPolicy +internal sealed class CustomHeaderSessionAffinityPolicy : BaseEncryptedSessionAffinityPolicy { public CustomHeaderSessionAffinityPolicy( IDataProtectionProvider dataProtectionProvider, diff --git a/src/ReverseProxy/SessionAffinity/HashCookieSessionAffinityPolicy.cs b/src/ReverseProxy/SessionAffinity/HashCookieSessionAffinityPolicy.cs new file mode 100644 index 000000000..c778e7a78 --- /dev/null +++ b/src/ReverseProxy/SessionAffinity/HashCookieSessionAffinityPolicy.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.IO.Hashing; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Extensions.Logging; +using Yarp.ReverseProxy.Model; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.SessionAffinity; + +internal sealed class HashCookieSessionAffinityPolicy : BaseHashCookieSessionAffinityPolicy +{ + private readonly ConditionalWeakTable _hashes = new(); + + public HashCookieSessionAffinityPolicy( + IClock clock, + ILogger logger) + : base(clock, logger) { } + + public override string Name => SessionAffinityConstants.Policies.HashCookie; + + protected override string GetDestinationHash(DestinationState d) + { + return _hashes.GetValue(d, static d => + { + // Stable format across instances + var destinationIdBytes = Encoding.Unicode.GetBytes(d.DestinationId.ToUpperInvariant()); + var hashBytes = XxHash64.Hash(destinationIdBytes); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + }); + } +} diff --git a/src/ReverseProxy/SessionAffinity/Log.cs b/src/ReverseProxy/SessionAffinity/Log.cs new file mode 100644 index 000000000..bfc2a584e --- /dev/null +++ b/src/ReverseProxy/SessionAffinity/Log.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Microsoft.Extensions.Logging; + +namespace Yarp.ReverseProxy.SessionAffinity; + +internal static class Log +{ + private static readonly Action _affinityCannotBeEstablishedBecauseNoDestinationsFound = LoggerMessage.Define( + LogLevel.Warning, + EventIds.AffinityCannotBeEstablishedBecauseNoDestinationsFoundOnCluster, + "The request affinity cannot be established because no destinations are found on cluster `{clusterId}`."); + + private static readonly Action _requestAffinityKeyDecryptionFailed = LoggerMessage.Define( + LogLevel.Error, + EventIds.RequestAffinityKeyDecryptionFailed, + "The request affinity key decryption failed."); + + private static readonly Action _destinationMatchingToAffinityKeyNotFound = LoggerMessage.Define( + LogLevel.Warning, + EventIds.DestinationMatchingToAffinityKeyNotFound, + "Destination matching to the request affinity key is not found on cluster `{clusterId}`. Configured failure policy will be applied."); + + public static void AffinityCannotBeEstablishedBecauseNoDestinationsFound(ILogger logger, string clusterId) + { + _affinityCannotBeEstablishedBecauseNoDestinationsFound(logger, clusterId, null); + } + + public static void RequestAffinityKeyDecryptionFailed(ILogger logger, Exception? ex) + { + _requestAffinityKeyDecryptionFailed(logger, ex); + } + + public static void DestinationMatchingToAffinityKeyNotFound(ILogger logger, string clusterId) + { + _destinationMatchingToAffinityKeyNotFound(logger, clusterId, null); + } +} diff --git a/src/ReverseProxy/SessionAffinity/SessionAffinityConstants.cs b/src/ReverseProxy/SessionAffinity/SessionAffinityConstants.cs index 0ba06e7b5..8295ff1e9 100644 --- a/src/ReverseProxy/SessionAffinity/SessionAffinityConstants.cs +++ b/src/ReverseProxy/SessionAffinity/SessionAffinityConstants.cs @@ -12,6 +12,10 @@ public static class Policies { public static string Cookie => nameof(Cookie); + public static string HashCookie => nameof(HashCookie); + + public static string ArrCookie => nameof(ArrCookie); + public static string CustomHeader => nameof(CustomHeader); } diff --git a/src/ReverseProxy/SessionAffinity/SessionAffinityMiddleware.cs b/src/ReverseProxy/SessionAffinity/SessionAffinityMiddleware.cs index a03f8c509..9cd21005e 100644 --- a/src/ReverseProxy/SessionAffinity/SessionAffinityMiddleware.cs +++ b/src/ReverseProxy/SessionAffinity/SessionAffinityMiddleware.cs @@ -53,7 +53,7 @@ private async Task InvokeInternal(HttpContext context, IReverseProxyFeature prox var destinations = proxyFeature.AvailableDestinations; var cluster = proxyFeature.Route.Cluster!; - var policy = _sessionAffinityPolicies.GetRequiredServiceById(config.Policy, SessionAffinityConstants.Policies.Cookie); + var policy = _sessionAffinityPolicies.GetRequiredServiceById(config.Policy, SessionAffinityConstants.Policies.HashCookie); var affinityResult = policy.FindAffinitizedDestinations(context, cluster, config, destinations); switch (affinityResult.Status) diff --git a/src/ReverseProxy/Yarp.ReverseProxy.csproj b/src/ReverseProxy/Yarp.ReverseProxy.csproj index 6b9ab972e..b920e7ed8 100644 --- a/src/ReverseProxy/Yarp.ReverseProxy.csproj +++ b/src/ReverseProxy/Yarp.ReverseProxy.csproj @@ -10,6 +10,10 @@ README.md + + + + diff --git a/test/ReverseProxy.Tests/SessionAffinity/ArrCookieSessionAffinityPolicyTests.cs b/test/ReverseProxy.Tests/SessionAffinity/ArrCookieSessionAffinityPolicyTests.cs new file mode 100644 index 000000000..6c2811d02 --- /dev/null +++ b/test/ReverseProxy.Tests/SessionAffinity/ArrCookieSessionAffinityPolicyTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Model; +using Yarp.Tests.Common; + +namespace Yarp.ReverseProxy.SessionAffinity.Tests; + +public class ArrCookieSessionAffinityPolicyTests +{ + private readonly SessionAffinityConfig _config = new() + { + Enabled = true, + Policy = "ArrCookie", + FailurePolicy = "Return503Error", + AffinityKeyName = "My.Affinity", + Cookie = new SessionAffinityCookieConfig + { + Domain = "mydomain.my", + HttpOnly = false, + IsEssential = true, + MaxAge = TimeSpan.FromHours(1), + Path = "/some", + SameSite = SameSiteMode.Lax, + SecurePolicy = CookieSecurePolicy.Always, + } + }; + private readonly IReadOnlyList _destinations = new[] { new DestinationState("dest-A"), new DestinationState("dest-B"), new DestinationState("dest-C") }; + + [Fact] + public void FindAffinitizedDestination_AffinityKeyIsNotSetOnRequest_ReturnKeyNotSet() + { + var policy = new ArrCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + + Assert.Equal(SessionAffinityConstants.Policies.ArrCookie, policy.Name); + + var context = new DefaultHttpContext(); + context.Request.Headers["Cookie"] = new[] { $"Some-Cookie=ZZZ" }; + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.AffinityKeyNotSet, affinityResult.Status); + Assert.Null(affinityResult.Destinations); + } + + [Fact] + public void FindAffinitizedDestination_AffinityKeyIsSetOnRequest_Success() + { + var policy = new ArrCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + var affinitizedDestination = _destinations[1]; + context.Request.Headers["Cookie"] = GetCookieWithAffinity(affinitizedDestination); + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.OK, affinityResult.Status); + Assert.Equal(1, affinityResult.Destinations.Count); + Assert.Same(affinitizedDestination, affinityResult.Destinations[0]); + } + + [Fact] + public void AffinitizedRequest_CustomConfigAffinityKeyIsNotExtracted_SetKeyOnResponse() + { + var policy = new ArrCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + + policy.AffinitizeResponse(context, new ClusterState("cluster"), _config, _destinations[1]); + + var affinityCookieHeader = context.Response.Headers["Set-Cookie"]; + Assert.Equal("My.Affinity=920A160FA519353932B655488361A944531650016793761EE7224DE632863B13; max-age=3600; domain=mydomain.my; path=/some; secure; samesite=lax", + affinityCookieHeader); + } + + [Fact] + public void AffinitizeRequest_CookieConfigSpecified_UseIt() + { + var policy = new ArrCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + + policy.AffinitizeResponse(context, new ClusterState("cluster"), _config, _destinations[1]); + + var affinityCookieHeader = context.Response.Headers["Set-Cookie"]; + Assert.Equal("My.Affinity=920A160FA519353932B655488361A944531650016793761EE7224DE632863B13; max-age=3600; domain=mydomain.my; path=/some; secure; samesite=lax", + affinityCookieHeader); + } + + [Fact] + public void AffinitizedRequest_AffinityKeyIsExtracted_DoNothing() + { + var policy = new ArrCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + var affinitizedDestination = _destinations[0]; + context.Request.Headers["Cookie"] = GetCookieWithAffinity(affinitizedDestination); + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.OK, affinityResult.Status); + + policy.AffinitizeResponse(context, cluster, _config, affinitizedDestination); + + Assert.False(context.Response.Headers.ContainsKey("Cookie")); + } + + private string[] GetCookieWithAffinity(DestinationState affinitizedDestination) + { + var destinationIdBytes = Encoding.Unicode.GetBytes(affinitizedDestination.DestinationId.ToLowerInvariant()); + var hashBytes = SHA256.HashData(destinationIdBytes); + var value = Convert.ToHexString(hashBytes); + return new[] { $"Some-Cookie=ZZZ", $"{_config.AffinityKeyName}={value}" }; + } +} diff --git a/test/ReverseProxy.Tests/SessionAffinity/BaseSessionAffinityPolicyTests.cs b/test/ReverseProxy.Tests/SessionAffinity/BaseSessionAffinityPolicyTests.cs index 881082142..06a89a173 100644 --- a/test/ReverseProxy.Tests/SessionAffinity/BaseSessionAffinityPolicyTests.cs +++ b/test/ReverseProxy.Tests/SessionAffinity/BaseSessionAffinityPolicyTests.cs @@ -41,7 +41,7 @@ public void Request_FindAffinitizedDestinations( EventId expectedEventId) { var dataProtector = GetDataProtector(); - var logger = AffinityTestHelper.GetLogger>(); + var logger = AffinityTestHelper.GetLogger>(); var provider = new ProviderStub(dataProtector.Object, logger.Object); var cluster = new ClusterState("cluster"); var affinityResult = provider.FindAffinitizedDestinations(context, cluster, _defaultOptions, allDestinations); @@ -74,7 +74,7 @@ public void Request_FindAffinitizedDestinations( [Fact] public void FindAffinitizedDestination_AffinityDisabledOnCluster_ReturnsAffinityDisabled() { - var provider = new ProviderStub(GetDataProtector().Object, AffinityTestHelper.GetLogger>().Object); + var provider = new ProviderStub(GetDataProtector().Object, AffinityTestHelper.GetLogger>().Object); var options = new SessionAffinityConfig { Enabled = false, @@ -90,7 +90,7 @@ public void FindAffinitizedDestination_AffinityDisabledOnCluster_ReturnsAffinity public void AffinitizeRequest_AffinityDisabled_DoNothing() { var dataProtector = GetDataProtector(); - var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); + var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); Assert.Throws(() => provider.AffinitizeResponse(new DefaultHttpContext(), new ClusterState("cluster"), new SessionAffinityConfig(), new DestinationState("id"))); } @@ -98,7 +98,7 @@ public void AffinitizeRequest_AffinityDisabled_DoNothing() public void AffinitizeRequest_RequestIsAffinitized_DoNothing() { var dataProtector = GetDataProtector(); - var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); + var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); var context = new DefaultHttpContext(); provider.DirectlySetExtractedKeyOnContext(context, "ExtractedKey"); provider.AffinitizeResponse(context, new ClusterState("cluster"), _defaultOptions, new DestinationState("id")); @@ -110,7 +110,7 @@ public void AffinitizeRequest_RequestIsAffinitized_DoNothing() public void AffinitizeRequest_RequestIsNotAffinitized_SetAffinityKey() { var dataProtector = GetDataProtector(); - var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); + var provider = new ProviderStub(dataProtector.Object, AffinityTestHelper.GetLogger>().Object); var destination = new DestinationState("dest-A"); provider.AffinitizeResponse(new DefaultHttpContext(), new ClusterState("cluster"), _defaultOptions, destination); Assert.Equal("ZGVzdC1B", provider.LastSetEncryptedKey); @@ -161,7 +161,7 @@ private Mock GetDataProtector() return result; } - private class ProviderStub : BaseSessionAffinityPolicy + private class ProviderStub : BaseEncryptedSessionAffinityPolicy { public static readonly string KeyNameSetting = "AffinityKeyName"; diff --git a/test/ReverseProxy.Tests/SessionAffinity/HashCookieSessionAffinityPolicyTests.cs b/test/ReverseProxy.Tests/SessionAffinity/HashCookieSessionAffinityPolicyTests.cs new file mode 100644 index 000000000..5e4321b88 --- /dev/null +++ b/test/ReverseProxy.Tests/SessionAffinity/HashCookieSessionAffinityPolicyTests.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.IO.Hashing; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Model; +using Yarp.Tests.Common; + +namespace Yarp.ReverseProxy.SessionAffinity.Tests; + +public class HashCookieSessionAffinityPolicyTests +{ + private readonly SessionAffinityConfig _config = new() + { + Enabled = true, + Policy = "HashCookie", + FailurePolicy = "Return503Error", + AffinityKeyName = "My.Affinity", + Cookie = new SessionAffinityCookieConfig + { + Domain = "mydomain.my", + HttpOnly = false, + IsEssential = true, + MaxAge = TimeSpan.FromHours(1), + Path = "/some", + SameSite = SameSiteMode.Lax, + SecurePolicy = CookieSecurePolicy.Always, + } + }; + private readonly IReadOnlyList _destinations = new[] { new DestinationState("dest-A"), new DestinationState("dest-B"), new DestinationState("dest-C") }; + + [Fact] + public void FindAffinitizedDestination_AffinityKeyIsNotSetOnRequest_ReturnKeyNotSet() + { + var policy = new HashCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + + Assert.Equal(SessionAffinityConstants.Policies.HashCookie, policy.Name); + + var context = new DefaultHttpContext(); + context.Request.Headers["Cookie"] = new[] { $"Some-Cookie=ZZZ" }; + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.AffinityKeyNotSet, affinityResult.Status); + Assert.Null(affinityResult.Destinations); + } + + [Fact] + public void FindAffinitizedDestination_AffinityKeyIsSetOnRequest_Success() + { + var policy = new HashCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + var affinitizedDestination = _destinations[1]; + context.Request.Headers["Cookie"] = GetCookieWithAffinity(affinitizedDestination); + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.OK, affinityResult.Status); + Assert.Equal(1, affinityResult.Destinations.Count); + Assert.Same(affinitizedDestination, affinityResult.Destinations[0]); + } + + [Fact] + public void AffinitizedRequest_CustomConfigAffinityKeyIsNotExtracted_SetKeyOnResponse() + { + var policy = new HashCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + + policy.AffinitizeResponse(context, new ClusterState("cluster"), _config, _destinations[1]); + + var affinityCookieHeader = context.Response.Headers["Set-Cookie"]; + Assert.Equal("My.Affinity=53c079ed4c377b0d; max-age=3600; domain=mydomain.my; path=/some; secure; samesite=lax", + affinityCookieHeader); + } + + [Fact] + public void AffinitizeRequest_CookieConfigSpecified_UseIt() + { + var policy = new HashCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + + policy.AffinitizeResponse(context, new ClusterState("cluster"), _config, _destinations[1]); + + var affinityCookieHeader = context.Response.Headers["Set-Cookie"]; + Assert.Equal("My.Affinity=53c079ed4c377b0d; max-age=3600; domain=mydomain.my; path=/some; secure; samesite=lax", + affinityCookieHeader); + } + + [Fact] + public void AffinitizedRequest_AffinityKeyIsExtracted_DoNothing() + { + var policy = new HashCookieSessionAffinityPolicy( + new ManualClock(), + NullLogger.Instance); + var context = new DefaultHttpContext(); + var affinitizedDestination = _destinations[0]; + context.Request.Headers["Cookie"] = GetCookieWithAffinity(affinitizedDestination); + var cluster = new ClusterState("cluster"); + + var affinityResult = policy.FindAffinitizedDestinations(context, cluster, _config, _destinations); + + Assert.Equal(AffinityStatus.OK, affinityResult.Status); + + policy.AffinitizeResponse(context, cluster, _config, affinitizedDestination); + + Assert.False(context.Response.Headers.ContainsKey("Cookie")); + } + + private string[] GetCookieWithAffinity(DestinationState affinitizedDestination) + { + var destinationIdBytes = Encoding.Unicode.GetBytes(affinitizedDestination.DestinationId.ToUpperInvariant()); + var hashBytes = XxHash64.Hash(destinationIdBytes); + var value = Convert.ToHexString(hashBytes).ToLowerInvariant(); + return new[] { $"Some-Cookie=ZZZ", $"{_config.AffinityKeyName}={value}" }; + } +}