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

Add IDestinationResolver for resolving cluster destination addresses #2210

Merged
merged 19 commits into from
Aug 17, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ private static DestinationConfig CreateDestination(IConfigurationSection section
Address = section[nameof(DestinationConfig.Address)]!,
Health = section[nameof(DestinationConfig.Health)],
Metadata = section.GetSection(nameof(DestinationConfig.Metadata)).ReadStringDictionary(),
Host = section[nameof(DestinationConfig.Host)]
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/ReverseProxy/Configuration/DestinationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public sealed record DestinationConfig
/// </summary>
public IReadOnlyDictionary<string, string>? Metadata { get; init; }

/// <summary>
/// Host value to pass to this destination.
ReubenBond marked this conversation as resolved.
Show resolved Hide resolved
/// </summary>
public string? Host { get; init; }

public bool Equals(DestinationConfig? other)
{
if (other is null)
Expand Down
26 changes: 26 additions & 0 deletions src/ReverseProxy/Configuration/IDestinationResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Yarp.ReverseProxy.Configuration;

/// <summary>
/// Resolves destination addresses.
/// </summary>
public interface IDestinationResolver
{
/// <summary>
/// Resolves the provided destinations and returns resolved destinations.
/// </summary>
/// <param name="destinations">The destinations to resolve.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>
/// The resolved destinations and a change token used to indicate when resolution should be performed again.
/// </returns>
ValueTask<ResolvedDestinationCollection> ResolveDestinationsAsync(
IReadOnlyDictionary<string, DestinationConfig> destinations,
CancellationToken cancellationToken);
}
17 changes: 17 additions & 0 deletions src/ReverseProxy/Configuration/NoOpDestinationResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Yarp.ReverseProxy.Configuration;

/// <summary>
/// An <see cref="IDestinationResolver"/> which performs no action.
/// </summary>
internal sealed class NoOpDestinationResolver : IDestinationResolver
{
public ValueTask<ResolvedDestinationCollection> ResolveDestinationsAsync(IReadOnlyDictionary<string, DestinationConfig> destinations, CancellationToken cancellationToken)
=> new(new ResolvedDestinationCollection(destinations, ChangeToken: null));
davidfowl marked this conversation as resolved.
Show resolved Hide resolved
}
14 changes: 14 additions & 0 deletions src/ReverseProxy/Configuration/ResolvedDestinationCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Generic;
using Microsoft.Extensions.Primitives;

namespace Yarp.ReverseProxy.Configuration;

/// <summary>
/// Represents a collection of resolved destinations.
/// </summary>
/// <param name="Destinations">The resolved destinations.</param>
/// <param name="ChangeToken">An optional change token which indicates when the destination collection should be refreshed.</param>
public record class ResolvedDestinationCollection(IReadOnlyDictionary<string, DestinationConfig> Destinations, IChangeToken? ChangeToken);
5 changes: 5 additions & 0 deletions src/ReverseProxy/Health/DefaultProbingRequestFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public HttpRequestMessage CreateRequest(ClusterModel cluster, DestinationModel d
VersionPolicy = cluster.Config.HttpRequest?.VersionPolicy ?? HttpVersionPolicy.RequestVersionOrLower,
};

if (!string.IsNullOrWhiteSpace(destination.Config.Host))
ReubenBond marked this conversation as resolved.
Show resolved Hide resolved
{
request.Headers.Add(HeaderNames.Host, destination.Config.Host);
}

request.Headers.Add(HeaderNames.UserAgent, _defaultUserAgent);

return request;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,10 @@ public static IReverseProxyBuilder AddHttpSysDelegation(this IReverseProxyBuilde

return builder;
}

public static IReverseProxyBuilder AddDestinationResolver(this IReverseProxyBuilder builder)
{
builder.Services.TryAddSingleton<IDestinationResolver, NoOpDestinationResolver>();
return builder;
}
}
158 changes: 138 additions & 20 deletions src/ReverseProxy/Management/ProxyConfigManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ internal sealed class ProxyConfigManager : EndpointDataSource, IProxyStateLookup
private readonly List<Action<EndpointBuilder>> _conventions;
private readonly IActiveHealthCheckMonitor _activeHealthCheckMonitor;
private readonly IClusterDestinationsUpdater _clusterDestinationsUpdater;
private readonly IDestinationResolver _destinationResolver;
private readonly IConfigChangeListener[] _configChangeListeners;
private List<Endpoint>? _endpoints;
private CancellationTokenSource _endpointsChangeSource = new();
Expand All @@ -67,7 +68,8 @@ public ProxyConfigManager(
IForwarderHttpClientFactory httpClientFactory,
IActiveHealthCheckMonitor activeHealthCheckMonitor,
IClusterDestinationsUpdater clusterDestinationsUpdater,
IEnumerable<IConfigChangeListener> configChangeListeners)
IEnumerable<IConfigChangeListener> configChangeListeners,
IDestinationResolver destinationResolver)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_providers = providers?.ToArray() ?? throw new ArgumentNullException(nameof(providers));
Expand All @@ -80,7 +82,7 @@ public ProxyConfigManager(
_httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
_activeHealthCheckMonitor = activeHealthCheckMonitor ?? throw new ArgumentNullException(nameof(activeHealthCheckMonitor));
_clusterDestinationsUpdater = clusterDestinationsUpdater ?? throw new ArgumentNullException(nameof(clusterDestinationsUpdater));

_destinationResolver = destinationResolver;
ReubenBond marked this conversation as resolved.
Show resolved Hide resolved
_configChangeListeners = configChangeListeners?.ToArray() ?? Array.Empty<IConfigChangeListener>();

if (_providers.Length == 0)
Expand Down Expand Up @@ -158,11 +160,19 @@ internal async Task<EndpointDataSource> InitialLoadAsync()
var routes = new List<RouteConfig>();
var clusters = new List<ClusterConfig>();

// Begin resolving config providers concurrently.
var resolvedConfigs = new List<(int Index, IProxyConfigProvider Provider, ValueTask<IProxyConfig> Config)>();
ReubenBond marked this conversation as resolved.
Show resolved Hide resolved
for (var i = 0; i < _providers.Length; i++)
{
var provider = _providers[i];
var config = provider.GetConfig();
ValidateConfigProperties(config);
var configLoadTask = LoadConfigAsync(provider, cancellationToken: default);
resolvedConfigs.Add((i, provider, configLoadTask));
}

// Wait for all configs to be resolved.
foreach (var (i, provider, configLoadTask) in resolvedConfigs)
{
var config = await configLoadTask;
_configs[i] = new ConfigState(provider, config);
routes.AddRange(config.Routes ?? Array.Empty<RouteConfig>());
clusters.AddRange(config.Clusters ?? Array.Empty<ClusterConfig>());
Expand Down Expand Up @@ -202,33 +212,52 @@ private async Task ReloadConfigAsync()
var sourcesChanged = false;
var routes = new List<RouteConfig>();
var clusters = new List<ClusterConfig>();
var reloadedConfigs = new List<(ConfigState Config, ValueTask<IProxyConfig> ResolveTask)>();

// Start reloading changed configurations.
foreach (var instance in _configs)
{
try
if (instance.LatestConfig.ChangeToken.HasChanged)
{
if (instance.LatestConfig.ChangeToken.HasChanged)
try
{
var reloadTask = LoadConfigAsync(instance.Provider, cancellationToken: default);
reloadedConfigs.Add((instance, reloadTask));
}
catch (Exception ex)
{
var config = instance.Provider.GetConfig();
ValidateConfigProperties(config);
instance.LatestConfig = config;
instance.LoadFailed = false;
sourcesChanged = true;
OnConfigLoadError(instance, ex);
}
}
}

// Wait for all changed config providers to be reloaded.
foreach (var (instance, loadTask) in reloadedConfigs)
{
try
{
instance.LatestConfig = await loadTask.ConfigureAwait(false);
instance.LoadFailed = false;
sourcesChanged = true;
}
catch (Exception ex)
{
instance.LoadFailed = true;
Log.ErrorReloadingConfig(_logger, ex);
OnConfigLoadError(instance, ex);
}
}

foreach (var configChangeListener in _configChangeListeners)
{
configChangeListener.ConfigurationLoadingFailed(instance.Provider, ex);
}
// Extract the routes and clusters from the configs, regardless of whether they were reloaded.
foreach (var instance in _configs)
{
if (instance.LatestConfig.Routes is { Count: > 0 } updatedRoutes)
{
routes.AddRange(updatedRoutes);
}

// If we didn't/couldn't get a new config then re-use the last one.
routes.AddRange(instance.LatestConfig.Routes ?? Array.Empty<RouteConfig>());
clusters.AddRange(instance.LatestConfig.Clusters ?? Array.Empty<ClusterConfig>());
if (instance.LatestConfig.Clusters is { Count: > 0 } updatedClusters)
{
clusters.AddRange(updatedClusters);
}
}

var proxyConfigs = ExtractListOfProxyConfigs(_configs);
Expand Down Expand Up @@ -270,6 +299,17 @@ private async Task ReloadConfigAsync()
}

ListenForConfigChanges();

void OnConfigLoadError(ConfigState instance, Exception ex)
{
instance.LoadFailed = true;
Log.ErrorReloadingConfig(_logger, ex);

foreach (var configChangeListener in _configChangeListeners)
{
configChangeListener.ConfigurationLoadingFailed(instance.Provider, ex);
}
}
}

private static void ValidateConfigProperties(IProxyConfig config)
Expand All @@ -278,12 +318,90 @@ private static void ValidateConfigProperties(IProxyConfig config)
{
throw new InvalidOperationException($"{nameof(IProxyConfigProvider.GetConfig)} returned a null value.");
}

if (config.ChangeToken is null)
{
throw new InvalidOperationException($"{nameof(IProxyConfig.ChangeToken)} has a null value.");
}
}

private ValueTask<IProxyConfig> LoadConfigAsync(IProxyConfigProvider provider, CancellationToken cancellationToken)
{
var config = provider.GetConfig();
ValidateConfigProperties(config);

if (_destinationResolver.GetType() == typeof(NoOpDestinationResolver))
{
return new(config);
}

return LoadConfigAsyncCore(config, cancellationToken);
}

private async ValueTask<IProxyConfig> LoadConfigAsyncCore(IProxyConfig config, CancellationToken cancellationToken)
{
List<(int Index, ValueTask<ResolvedDestinationCollection> Task)> resolverTasks = new();
List<ClusterConfig> clusters = new(config.Clusters);
List<IChangeToken>? changeTokens = null;
for (var i = 0; i < clusters.Count; i++)
{
var cluster = clusters[i];
if (cluster.Destinations is { Count: > 0 } destinations)
{
// Resolve destinations if there are any.
var task = _destinationResolver.ResolveDestinationsAsync(destinations, cancellationToken);
resolverTasks.Add((i, task));
}
}

if (resolverTasks.Count > 0)
{
foreach (var (i, task) in resolverTasks)
{
var resolvedDestinations = await task;
clusters[i] = clusters[i] with { Destinations = resolvedDestinations.Destinations };
if (resolvedDestinations.ChangeToken is { } token)
{
changeTokens ??= new();
changeTokens.Add(token);
}
}

IChangeToken changeToken;
if (changeTokens is not null)
{
// Combine change tokens from the resolver with the configuration's existing change token.
changeTokens.Add(config.ChangeToken);
changeToken = new CompositeChangeToken(changeTokens);
}
else
{
changeToken = config.ChangeToken;
}

// Return updated config
return new ResolvedProxyConfig(config, clusters, changeToken);
}

return config;
}

private sealed class ResolvedProxyConfig : IProxyConfig
{
private readonly IProxyConfig _innerConfig;

public ResolvedProxyConfig(IProxyConfig innerConfig, IReadOnlyList<ClusterConfig> clusters, IChangeToken changeToken)
{
_innerConfig = innerConfig;
Clusters = clusters;
ChangeToken = changeToken;
}

public IReadOnlyList<RouteConfig> Routes => _innerConfig.Routes;
public IReadOnlyList<ClusterConfig> Clusters { get; }
public IChangeToken ChangeToken { get; }
}

private void ListenForConfigChanges()
{
// Use a central change token to avoid overlap between different sources.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public static IReverseProxyBuilder AddReverseProxy(this IServiceCollection servi
.AddPassiveHealthCheck()
.AddLoadBalancingPolicies()
.AddHttpSysDelegation()
.AddDestinationResolver()
.AddProxy();

services.TryAddSingleton<ProxyEndpointFactory>();
Expand Down
6 changes: 2 additions & 4 deletions src/ReverseProxy/Transforms/Builder/TransformBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,8 @@ internal static StructuredTransformer CreateTransformer(TransformBuilderContext
{
// RequestHeaderOriginalHostKey defaults to false, and CopyRequestHeaders defaults to true.
// If RequestHeaderOriginalHostKey was not specified then we need to make sure the transform gets
// added anyways to remove the original host. If CopyRequestHeaders is false then we can omit the
// transform.
if (context.CopyRequestHeaders.GetValueOrDefault(true)
&& !context.RequestTransforms.Any(item => item is RequestHeaderOriginalHostTransform))
// added anyways to remove the original host and to observe hosts specified in DestinationConfig.
if (!context.RequestTransforms.Any(item => item is RequestHeaderOriginalHostTransform))
{
context.AddOriginalHost(false);
}
Expand Down
27 changes: 15 additions & 12 deletions src/ReverseProxy/Transforms/RequestHeaderOriginalHostTransform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading.Tasks;
using Microsoft.Net.Http.Headers;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Model;

namespace Yarp.ReverseProxy.Transforms;

Expand All @@ -31,24 +32,26 @@ private RequestHeaderOriginalHostTransform(bool useOriginalHost)

public override ValueTask ApplyAsync(RequestTransformContext context)
{
var destinationConfigHost = context.HttpContext.Features.Get<IReverseProxyFeature>()?.ProxiedDestination?.Model.Config?.Host;
var originalHost = context.HttpContext.Request.Host.Value is { Length: > 0 } host ? host : null;
var existingHost = RequestUtilities.TryGetValues(context.ProxyRequest.Headers, HeaderNames.Host, out var currentHost) ? currentHost.ToString() : null;

if (UseOriginalHost)
{
if (!context.HeadersCopied)
if (!context.HeadersCopied && existingHost is null)
{
// Don't override a custom host
if (!context.ProxyRequest.Headers.NonValidated.Contains(HeaderNames.Host))
{
context.ProxyRequest.Headers.TryAddWithoutValidation(HeaderNames.Host, context.HttpContext.Request.Host.Value);
}
// Propagate the host if the transform pipeline didn't already override it.
// If there was no original host specified, allow the destination config host to flow through.
context.ProxyRequest.Headers.TryAddWithoutValidation(HeaderNames.Host, originalHost ?? destinationConfigHost);
}
}
else if (context.HeadersCopied
// Don't remove a custom host, only the original
&& RequestUtilities.TryGetValues(context.ProxyRequest.Headers, HeaderNames.Host, out var existingHost)
&& string.Equals(context.HttpContext.Request.Host.Value, existingHost.ToString(), StringComparison.Ordinal))
else if (((context.HeadersCopied && existingHost is not null && string.Equals(originalHost, existingHost, StringComparison.Ordinal))
|| (!context.HeadersCopied && existingHost is null)))
ReubenBond marked this conversation as resolved.
Show resolved Hide resolved
{
// Remove it after the copy, use the destination host instead.
context.ProxyRequest.Headers.Host = null;
// Either headers were copied, there is a host, and it's equal to the original host (i.e, unchanged),
// Or, headers weren't copied and there is no existing host.
// Suppress the original host, setting the host to the destination host (which may be null).
context.ProxyRequest.Headers.Host = destinationConfigHost;
}

return default;
Expand Down
Loading
Loading