Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change default cookie session affinity format #1989

Merged
merged 6 commits into from
Jan 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions docs/docfx/articles/session-affinity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,7 +26,7 @@ Session affinity is configured per cluster according to the following configurat
"<cluster-name>": {
"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": {
Expand All @@ -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
{
Expand All @@ -53,7 +54,7 @@ new ClusterConfig
{
Enabled = true,
FailurePolicy = "Return503Error",
Policy = "Cookie",
Policy = "HashCookie",
AffinityKeyName = "Key1",
Cookie = new SessionAffinityCookieConfig
{
Expand All @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I misunderstanding this or is this just plain wrong? If the key is shared by more than 1 destination, we'll pick whichever one we happen to find first.

Copy link
Member Author

@Tratcher Tratcher Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current policy implementations would pick the first one since we do a linear search, but the abstraction allows us to create keys that are not specific to a single destination. E.g. if you had sets of destinations a-1, a-2, b-1, b-2... you could make a policy that used the key a and returned both a-1 and a-2, then load balancing would choose between them.


Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions samples/ReverseProxy.Config.Sample/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ public static IReverseProxyBuilder AddSessionAffinityPolicies(this IReverseProxy
});
builder.Services.TryAddEnumerable(new[] {
ServiceDescriptor.Singleton<ISessionAffinityPolicy, CookieSessionAffinityPolicy>(),
ServiceDescriptor.Singleton<ISessionAffinityPolicy, HashCookieSessionAffinityPolicy>(),
ServiceDescriptor.Singleton<ISessionAffinityPolicy, ArrCookieSessionAffinityPolicy>(),
ServiceDescriptor.Singleton<ISessionAffinityPolicy, CustomHeaderSessionAffinityPolicy>()
});
builder.AddTransforms<AffinitizeTransformProvider>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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));
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/ReverseProxy/SessionAffinity/AffinityHelpers.cs
Original file line number Diff line number Diff line change
@@ -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?),
};
}
}
Original file line number Diff line number Diff line change
@@ -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<DestinationState, string> _hashes = new();

public ArrCookieSessionAffinityPolicy(
IClock clock,
ILogger<ArrCookieSessionAffinityPolicy> 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);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@

namespace Yarp.ReverseProxy.SessionAffinity;

internal abstract class BaseSessionAffinityPolicy<T> : ISessionAffinityPolicy
internal abstract class BaseEncryptedSessionAffinityPolicy<T> : 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));
Expand Down Expand Up @@ -145,37 +145,4 @@ private static string Pad(string text)
}
return text + new string('=', padding);
}

private static class Log
{
private static readonly Action<ILogger, string, Exception?> _affinityCannotBeEstablishedBecauseNoDestinationsFound = LoggerMessage.Define<string>(
LogLevel.Warning,
EventIds.AffinityCannotBeEstablishedBecauseNoDestinationsFoundOnCluster,
"The request affinity cannot be established because no destinations are found on cluster `{clusterId}`.");

private static readonly Action<ILogger, Exception?> _requestAffinityKeyDecryptionFailed = LoggerMessage.Define(
LogLevel.Error,
EventIds.RequestAffinityKeyDecryptionFailed,
"The request affinity key decryption failed.");

private static readonly Action<ILogger, string, Exception?> _destinationMatchingToAffinityKeyNotFound = LoggerMessage.Define<string>(
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<DestinationState> 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;
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
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);
}
14 changes: 2 additions & 12 deletions src/ReverseProxy/SessionAffinity/CookieSessionAffinityPolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Yarp.ReverseProxy.SessionAffinity;

internal sealed class CookieSessionAffinityPolicy : BaseSessionAffinityPolicy<string>
internal sealed class CookieSessionAffinityPolicy : BaseEncryptedSessionAffinityPolicy<string>
{
private readonly IClock _clock;

Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Yarp.ReverseProxy.SessionAffinity;

internal sealed class CustomHeaderSessionAffinityPolicy : BaseSessionAffinityPolicy<string>
internal sealed class CustomHeaderSessionAffinityPolicy : BaseEncryptedSessionAffinityPolicy<string>
{
public CustomHeaderSessionAffinityPolicy(
IDataProtectionProvider dataProtectionProvider,
Expand Down
Loading