diff --git a/src/ReverseProxy/Configuration/ClusterValidators/DestinationValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/DestinationValidator.cs new file mode 100644 index 000000000..5f0846cee --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/DestinationValidator.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class DestinationValidator : IClusterValidator +{ + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + if (cluster.Destinations is null) + { + return ValueTask.CompletedTask; + } + + foreach (var (name, destination) in cluster.Destinations) + { + if (string.IsNullOrEmpty(destination.Address)) + { + errors.Add(new ArgumentException($"No address found for destination '{name}' on cluster '{cluster.ClusterId}'.")); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/HealthCheckValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/HealthCheckValidator.cs new file mode 100644 index 000000000..2ff17945f --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/HealthCheckValidator.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Tasks; +using Yarp.ReverseProxy.Health; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class HealthCheckValidator : IClusterValidator +{ + private readonly FrozenDictionary _availableDestinationsPolicies; + private readonly FrozenDictionary _activeHealthCheckPolicies; + private readonly FrozenDictionary _passiveHealthCheckPolicies; + + public HealthCheckValidator(IEnumerable availableDestinationsPolicies, + IEnumerable activeHealthCheckPolicies, + IEnumerable passiveHealthCheckPolicies) + { + _availableDestinationsPolicies = availableDestinationsPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(availableDestinationsPolicies)); + _activeHealthCheckPolicies = activeHealthCheckPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(availableDestinationsPolicies)); + _passiveHealthCheckPolicies = passiveHealthCheckPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(availableDestinationsPolicies)); + } + + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + var availableDestinationsPolicy = cluster.HealthCheck?.AvailableDestinationsPolicy; + if (string.IsNullOrEmpty(availableDestinationsPolicy)) + { + // The default. + availableDestinationsPolicy = HealthCheckConstants.AvailableDestinations.HealthyOrPanic; + } + + if (!_availableDestinationsPolicies.ContainsKey(availableDestinationsPolicy)) + { + errors.Add(new ArgumentException($"No matching {nameof(IAvailableDestinationsPolicy)} found for the available destinations policy '{availableDestinationsPolicy}' set on the cluster.'{cluster.ClusterId}'.")); + } + + ValidateActiveHealthCheck(cluster, errors); + ValidatePassiveHealthCheck(cluster, errors); + + return ValueTask.CompletedTask; + } + + private void ValidateActiveHealthCheck(ClusterConfig cluster, IList errors) + { + if (!(cluster.HealthCheck?.Active?.Enabled ?? false)) + { + // Active health check is disabled + return; + } + + var activeOptions = cluster.HealthCheck.Active; + var policy = activeOptions.Policy; + if (string.IsNullOrEmpty(policy)) + { + // default policy + policy = HealthCheckConstants.ActivePolicy.ConsecutiveFailures; + } + if (!_activeHealthCheckPolicies.ContainsKey(policy)) + { + errors.Add(new ArgumentException($"No matching {nameof(IActiveHealthCheckPolicy)} found for the active health check policy name '{policy}' set on the cluster '{cluster.ClusterId}'.")); + } + + if (activeOptions.Interval is not null && activeOptions.Interval <= TimeSpan.Zero) + { + errors.Add(new ArgumentException($"Destination probing interval set on the cluster '{cluster.ClusterId}' must be positive.")); + } + + if (activeOptions.Timeout is not null && activeOptions.Timeout <= TimeSpan.Zero) + { + errors.Add(new ArgumentException($"Destination probing timeout set on the cluster '{cluster.ClusterId}' must be positive.")); + } + } + + private void ValidatePassiveHealthCheck(ClusterConfig cluster, IList errors) + { + if (!(cluster.HealthCheck?.Passive?.Enabled ?? false)) + { + // Passive health check is disabled + return; + } + + var passiveOptions = cluster.HealthCheck.Passive; + var policy = passiveOptions.Policy; + if (string.IsNullOrEmpty(policy)) + { + // default policy + policy = HealthCheckConstants.PassivePolicy.TransportFailureRate; + } + if (!_passiveHealthCheckPolicies.ContainsKey(policy)) + { + errors.Add(new ArgumentException($"No matching {nameof(IPassiveHealthCheckPolicy)} found for the passive health check policy name '{policy}' set on the cluster '{cluster.ClusterId}'.")); + } + + if (passiveOptions.ReactivationPeriod is not null && passiveOptions.ReactivationPeriod <= TimeSpan.Zero) + { + errors.Add(new ArgumentException($"Unhealthy destination reactivation period set on the cluster '{cluster.ClusterId}' must be positive.")); + } + } +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/IClusterValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/IClusterValidator.cs new file mode 100644 index 000000000..d94f1dda0 --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/IClusterValidator.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +/// +/// Provides method to validate cluster configuration. +/// +public interface IClusterValidator +{ + + /// + /// Perform validation on a cluster configuration by adding exceptions to the provided collection. + /// + /// Cluster configuration to validate + /// Collection of all validation exceptions + /// A ValueTask representing the asynchronous validation operation. + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors); +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/LoadBalancingValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/LoadBalancingValidator.cs new file mode 100644 index 000000000..ab92645a9 --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/LoadBalancingValidator.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Tasks; +using Yarp.ReverseProxy.LoadBalancing; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class LoadBalancingValidator : IClusterValidator +{ + private readonly FrozenDictionary _loadBalancingPolicies; + public LoadBalancingValidator(IEnumerable loadBalancingPolicies) + { + _loadBalancingPolicies = loadBalancingPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(loadBalancingPolicies)); + } + + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + var loadBalancingPolicy = cluster.LoadBalancingPolicy; + if (string.IsNullOrEmpty(loadBalancingPolicy)) + { + // The default. + loadBalancingPolicy = LoadBalancingPolicies.PowerOfTwoChoices; + } + + if (!_loadBalancingPolicies.ContainsKey(loadBalancingPolicy)) + { + errors.Add(new ArgumentException($"No matching {nameof(ILoadBalancingPolicy)} found for the load balancing policy '{loadBalancingPolicy}' set on the cluster '{cluster.ClusterId}'.")); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpClientValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpClientValidator.cs new file mode 100644 index 000000000..1c79901e9 --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpClientValidator.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class ProxyHttpClientValidator : IClusterValidator +{ + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + if (cluster.HttpClient is null) + { + // Proxy http client options are not set. + return ValueTask.CompletedTask; + } + + if (cluster.HttpClient.MaxConnectionsPerServer is not null && cluster.HttpClient.MaxConnectionsPerServer <= 0) + { + errors.Add(new ArgumentException($"Max connections per server limit set on the cluster '{cluster.ClusterId}' must be positive.")); + } + + var requestHeaderEncoding = cluster.HttpClient.RequestHeaderEncoding; + if (requestHeaderEncoding is not null) + { + try + { + Encoding.GetEncoding(requestHeaderEncoding); + } + catch (ArgumentException aex) + { + errors.Add(new ArgumentException($"Invalid request header encoding '{requestHeaderEncoding}'.", aex)); + } + } + + var responseHeaderEncoding = cluster.HttpClient.ResponseHeaderEncoding; + if (responseHeaderEncoding is null) + { + return ValueTask.CompletedTask; + } + + try + { + Encoding.GetEncoding(responseHeaderEncoding); + } + catch (ArgumentException aex) + { + errors.Add(new ArgumentException($"Invalid response header encoding '{responseHeaderEncoding}'.", aex)); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpRequestValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpRequestValidator.cs new file mode 100644 index 000000000..3ea737b41 --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/ProxyHttpRequestValidator.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class ProxyHttpRequestValidator(ILogger logger) : IClusterValidator +{ + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + if (cluster.HttpRequest is null) + { + // Proxy http request options are not set. + return ValueTask.CompletedTask; + } + + if (cluster.HttpRequest.Version is not null && + cluster.HttpRequest.Version != HttpVersion.Version10 && + cluster.HttpRequest.Version != HttpVersion.Version11 && + cluster.HttpRequest.Version != HttpVersion.Version20 && + cluster.HttpRequest.Version != HttpVersion.Version30) + { + errors.Add(new ArgumentException($"Outgoing request version '{cluster.HttpRequest.Version}' is not any of supported HTTP versions (1.0, 1.1, 2 and 3).")); + } + + if (cluster.HttpRequest.Version == HttpVersion.Version10) + { + Log.Http10Version(logger); + } + + return ValueTask.CompletedTask; + } + + private static class Log + { + private static readonly Action _http10RequestVersionDetected = LoggerMessage.Define( + LogLevel.Warning, + EventIds.Http10RequestVersionDetected, + "The HttpRequest version is set to 1.0 which can result in poor performance and port exhaustion. Use 1.1, 2, or 3 instead."); + + public static void Http10Version(ILogger logger) + { + _http10RequestVersionDetected(logger, null); + } + } +} diff --git a/src/ReverseProxy/Configuration/ClusterValidators/SessionAffinityValidator.cs b/src/ReverseProxy/Configuration/ClusterValidators/SessionAffinityValidator.cs new file mode 100644 index 000000000..b74b8109a --- /dev/null +++ b/src/ReverseProxy/Configuration/ClusterValidators/SessionAffinityValidator.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Threading.Tasks; +using Yarp.ReverseProxy.SessionAffinity; +using Yarp.ReverseProxy.Utilities; + +namespace Yarp.ReverseProxy.Configuration.ClusterValidators; + +internal sealed class SessionAffinityValidator : IClusterValidator +{ + private readonly FrozenDictionary _affinityFailurePolicies; + + public SessionAffinityValidator(IEnumerable affinityFailurePolicies) + { + _affinityFailurePolicies = affinityFailurePolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(affinityFailurePolicies)); + } + + public ValueTask ValidateAsync(ClusterConfig cluster, IList errors) + { + if (!(cluster.SessionAffinity?.Enabled ?? false)) + { + // Session affinity is disabled + return ValueTask.CompletedTask; + } + + // Note some affinity validation takes place in AffinitizeTransformProvider.ValidateCluster. + var affinityFailurePolicy = cluster.SessionAffinity.FailurePolicy; + if (string.IsNullOrEmpty(affinityFailurePolicy)) + { + // The default. + affinityFailurePolicy = SessionAffinityConstants.FailurePolicies.Redistribute; + } + + if (!_affinityFailurePolicies.ContainsKey(affinityFailurePolicy)) + { + errors.Add(new ArgumentException($"No matching {nameof(IAffinityFailurePolicy)} found for the affinity failure policy name '{affinityFailurePolicy}' set on the cluster '{cluster.ClusterId}'.")); + } + + if (string.IsNullOrEmpty(cluster.SessionAffinity.AffinityKeyName)) + { + errors.Add(new ArgumentException($"Affinity key name set on the cluster '{cluster.ClusterId}' must not be null.")); + } + + var cookieConfig = cluster.SessionAffinity.Cookie; + + if (cookieConfig is null) + { + return ValueTask.CompletedTask; + } + + if (cookieConfig.Expiration is not null && cookieConfig.Expiration <= TimeSpan.Zero) + { + errors.Add(new ArgumentException($"Session affinity cookie expiration must be positive or null.")); + } + + if (cookieConfig.MaxAge is not null && cookieConfig.MaxAge <= TimeSpan.Zero) + { + errors.Add(new ArgumentException($"Session affinity cookie max-age must be positive or null.")); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/ConfigValidator.cs b/src/ReverseProxy/Configuration/ConfigValidator.cs index 06ac07976..8c35bbac9 100644 --- a/src/ReverseProxy/Configuration/ConfigValidator.cs +++ b/src/ReverseProxy/Configuration/ConfigValidator.cs @@ -2,79 +2,28 @@ // Licensed under the MIT License. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.Linq; -using System.Net; -using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Cors.Infrastructure; -using Microsoft.AspNetCore.Http; -#if NET8_0_OR_GREATER -using Microsoft.AspNetCore.Http.Timeouts; -#endif -using Microsoft.AspNetCore.Routing.Patterns; -using Microsoft.Extensions.Logging; -#if NET8_0_OR_GREATER -using Microsoft.Extensions.Options; -#endif -using Yarp.ReverseProxy.Health; -using Yarp.ReverseProxy.LoadBalancing; -using Yarp.ReverseProxy.SessionAffinity; +using Yarp.ReverseProxy.Configuration.ClusterValidators; +using Yarp.ReverseProxy.Configuration.RouteValidators; using Yarp.ReverseProxy.Transforms.Builder; -using Yarp.ReverseProxy.Utilities; namespace Yarp.ReverseProxy.Configuration; internal sealed class ConfigValidator : IConfigValidator { - private static readonly HashSet _validMethods = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "HEAD", "OPTIONS", "GET", "PUT", "POST", "PATCH", "DELETE", "TRACE", - }; - private readonly ITransformBuilder _transformBuilder; - private readonly IAuthorizationPolicyProvider _authorizationPolicyProvider; - private readonly IYarpRateLimiterPolicyProvider _rateLimiterPolicyProvider; - private readonly ICorsPolicyProvider _corsPolicyProvider; -#if NET8_0_OR_GREATER - private readonly IOptionsMonitor _timeoutOptions; -#endif - private readonly FrozenDictionary _loadBalancingPolicies; - private readonly FrozenDictionary _affinityFailurePolicies; - private readonly FrozenDictionary _availableDestinationsPolicies; - private readonly FrozenDictionary _activeHealthCheckPolicies; - private readonly FrozenDictionary _passiveHealthCheckPolicies; - private readonly ILogger _logger; + private readonly IRouteValidator[] _routeValidators; + private readonly IClusterValidator[] _clusterValidators; public ConfigValidator(ITransformBuilder transformBuilder, - IAuthorizationPolicyProvider authorizationPolicyProvider, - IYarpRateLimiterPolicyProvider rateLimiterPolicyProvider, - ICorsPolicyProvider corsPolicyProvider, -#if NET8_0_OR_GREATER - IOptionsMonitor timeoutOptions, -#endif - IEnumerable loadBalancingPolicies, - IEnumerable affinityFailurePolicies, - IEnumerable availableDestinationsPolicies, - IEnumerable activeHealthCheckPolicies, - IEnumerable passiveHealthCheckPolicies, - ILogger logger) + IEnumerable routeValidators, + IEnumerable clusterValidators) { _transformBuilder = transformBuilder ?? throw new ArgumentNullException(nameof(transformBuilder)); - _authorizationPolicyProvider = authorizationPolicyProvider ?? throw new ArgumentNullException(nameof(authorizationPolicyProvider)); - _rateLimiterPolicyProvider = rateLimiterPolicyProvider ?? throw new ArgumentNullException(nameof(rateLimiterPolicyProvider)); - _corsPolicyProvider = corsPolicyProvider ?? throw new ArgumentNullException(nameof(corsPolicyProvider)); -#if NET8_0_OR_GREATER - _timeoutOptions = timeoutOptions ?? throw new ArgumentNullException(nameof(timeoutOptions)); -#endif - _loadBalancingPolicies = loadBalancingPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(loadBalancingPolicies)); - _affinityFailurePolicies = affinityFailurePolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(affinityFailurePolicies)); - _availableDestinationsPolicies = availableDestinationsPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(availableDestinationsPolicies)); - _activeHealthCheckPolicies = activeHealthCheckPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(activeHealthCheckPolicies)); - _passiveHealthCheckPolicies = passiveHealthCheckPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(passiveHealthCheckPolicies)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _routeValidators = routeValidators?.ToArray() ?? throw new ArgumentNullException(nameof(routeValidators)); + _clusterValidators = clusterValidators?.ToArray() ?? throw new ArgumentNullException(nameof(clusterValidators)); } // Note this performs all validation steps without short circuiting in order to report all possible errors. @@ -89,14 +38,6 @@ public async ValueTask> ValidateRouteAsync(RouteConfig route) } errors.AddRange(_transformBuilder.ValidateRoute(route)); - await ValidateAuthorizationPolicyAsync(errors, route.AuthorizationPolicy, route.RouteId); -#if NET7_0_OR_GREATER - await ValidateRateLimiterPolicyAsync(errors, route.RateLimiterPolicy, route.RouteId); -#endif -#if NET8_0_OR_GREATER - ValidateTimeoutPolicy(errors, route.TimeoutPolicy, route.Timeout, route.RouteId); -#endif - await ValidateCorsPolicyAsync(errors, route.CorsPolicy, route.RouteId); if (route.Match is null) { @@ -104,22 +45,21 @@ public async ValueTask> ValidateRouteAsync(RouteConfig route) return errors; } - if ((route.Match.Hosts is null || !route.Match.Hosts.Any(host => !string.IsNullOrEmpty(host))) && string.IsNullOrEmpty(route.Match.Path)) + if ((route.Match.Hosts is null || route.Match.Hosts.All(string.IsNullOrEmpty)) && string.IsNullOrEmpty(route.Match.Path)) { errors.Add(new ArgumentException($"Route '{route.RouteId}' requires Hosts or Path specified. Set the Path to '/{{**catchall}}' to match all requests.")); } - ValidateHost(errors, route.Match.Hosts, route.RouteId); - ValidatePath(errors, route.Match.Path, route.RouteId); - ValidateMethods(errors, route.Match.Methods, route.RouteId); - ValidateHeaders(errors, route.Match.Headers, route.RouteId); - ValidateQueryParameters(errors, route.Match.QueryParameters, route.RouteId); + foreach (var routeValidator in _routeValidators) + { + await routeValidator.ValidateAsync(route, errors); + } return errors; } // Note this performs all validation steps without short circuiting in order to report all possible errors. - public ValueTask> ValidateClusterAsync(ClusterConfig cluster) + public async ValueTask> ValidateClusterAsync(ClusterConfig cluster) { _ = cluster ?? throw new ArgumentNullException(nameof(cluster)); var errors = new List(); @@ -130,519 +70,12 @@ public ValueTask> ValidateClusterAsync(ClusterConfig cluster) } errors.AddRange(_transformBuilder.ValidateCluster(cluster)); - ValidateDestinations(errors, cluster); - ValidateLoadBalancing(errors, cluster); - ValidateSessionAffinity(errors, cluster); - ValidateProxyHttpClient(errors, cluster); - ValidateProxyHttpRequest(errors, cluster); - ValidateHealthChecks(errors, cluster); - - return new ValueTask>(errors); - } - - private static void ValidateHost(IList errors, IReadOnlyList? hosts, string routeId) - { - // Host is optional when Path is specified - if (hosts is null || hosts.Count == 0) - { - return; - } - - foreach (var host in hosts) - { - if (string.IsNullOrEmpty(host)) - { - errors.Add(new ArgumentException($"Empty host name has been set for route '{routeId}'.")); - } - else if (host.Contains("xn--", StringComparison.OrdinalIgnoreCase)) - { - errors.Add(new ArgumentException($"Punycode host name '{host}' has been set for route '{routeId}'. Use the unicode host name instead.")); - } - } - } - - private static void ValidatePath(IList errors, string? path, string routeId) - { - // Path is optional when Host is specified - if (string.IsNullOrEmpty(path)) - { - return; - } - - try - { - RoutePatternFactory.Parse(path); - } - catch (RoutePatternException ex) - { - errors.Add(new ArgumentException($"Invalid path '{path}' for route '{routeId}'.", ex)); - } - } - - private static void ValidateMethods(IList errors, IReadOnlyList? methods, string routeId) - { - // Methods are optional - if (methods is null) - { - return; - } - - var seenMethods = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var method in methods) - { - if (!seenMethods.Add(method)) - { - errors.Add(new ArgumentException($"Duplicate HTTP method '{method}' for route '{routeId}'.")); - continue; - } - - if (!_validMethods.Contains(method)) - { - errors.Add(new ArgumentException($"Unsupported HTTP method '{method}' has been set for route '{routeId}'.")); - } - } - } - - private static void ValidateHeaders(List errors, IReadOnlyList? headers, string routeId) - { - // Headers are optional - if (headers is null) - { - return; - } - - foreach (var header in headers) - { - if (header is null) - { - errors.Add(new ArgumentException($"A null route header has been set for route '{routeId}'.")); - continue; - } - - if (string.IsNullOrEmpty(header.Name)) - { - errors.Add(new ArgumentException($"A null or empty route header name has been set for route '{routeId}'.")); - } - - if ((header.Mode != HeaderMatchMode.Exists && header.Mode != HeaderMatchMode.NotExists) - && (header.Values is null || header.Values.Count == 0)) - { - errors.Add(new ArgumentException($"No header values were set on route header '{header.Name}' for route '{routeId}'.")); - } - - if ((header.Mode == HeaderMatchMode.Exists || header.Mode == HeaderMatchMode.NotExists) && header.Values?.Count > 0) - { - errors.Add(new ArgumentException($"Header values were set when using mode '{header.Mode}' on route header '{header.Name}' for route '{routeId}'.")); - } - } - } - - private static void ValidateQueryParameters(List errors, IReadOnlyList? queryparams, string routeId) - { - // Query Parameters are optional - if (queryparams is null) - { - return; - } - - foreach (var queryparam in queryparams) - { - if (queryparam is null) - { - errors.Add(new ArgumentException($"A null route query parameter has been set for route '{routeId}'.")); - continue; - } - - if (string.IsNullOrEmpty(queryparam.Name)) - { - errors.Add(new ArgumentException($"A null or empty route query parameter name has been set for route '{routeId}'.")); - } - - if (queryparam.Mode != QueryParameterMatchMode.Exists - && (queryparam.Values is null || queryparam.Values.Count == 0)) - { - errors.Add(new ArgumentException($"No query parameter values were set on route query parameter '{queryparam.Name}' for route '{routeId}'.")); - } - - if (queryparam.Mode == QueryParameterMatchMode.Exists && queryparam.Values?.Count > 0) - { - errors.Add(new ArgumentException($"Query parameter values where set when using mode '{nameof(QueryParameterMatchMode.Exists)}' on route query parameter '{queryparam.Name}' for route '{routeId}'.")); - } - } - } - - private async ValueTask ValidateAuthorizationPolicyAsync(IList errors, string? authorizationPolicyName, string routeId) - { - if (string.IsNullOrEmpty(authorizationPolicyName)) - { - return; - } - - if (string.Equals(AuthorizationConstants.Default, authorizationPolicyName, StringComparison.OrdinalIgnoreCase)) - { - var policy = await _authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); - if (policy is not null) - { - errors.Add(new ArgumentException($"The application has registered an authorization policy named '{authorizationPolicyName}' that conflicts with the reserved authorization policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - return; - } - - if (string.Equals(AuthorizationConstants.Anonymous, authorizationPolicyName, StringComparison.OrdinalIgnoreCase)) - { - var policy = await _authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); - if (policy is not null) - { - errors.Add(new ArgumentException($"The application has registered an authorization policy named '{authorizationPolicyName}' that conflicts with the reserved authorization policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - return; - } - - try - { - var policy = await _authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); - if (policy is null) - { - errors.Add(new ArgumentException($"Authorization policy '{authorizationPolicyName}' not found for route '{routeId}'.")); - } - } - catch (Exception ex) - { - errors.Add(new ArgumentException($"Unable to retrieve the authorization policy '{authorizationPolicyName}' for route '{routeId}'.", ex)); - } - } -#if NET8_0_OR_GREATER - private void ValidateTimeoutPolicy(IList errors, string? timeoutPolicyName, TimeSpan? timeout, string routeId) - { - if (!string.IsNullOrEmpty(timeoutPolicyName)) - { - var policies = _timeoutOptions.CurrentValue.Policies; - - if (string.Equals(TimeoutPolicyConstants.Disable, timeoutPolicyName, StringComparison.OrdinalIgnoreCase)) - { - if (policies.TryGetValue(timeoutPolicyName, out var _)) - { - errors.Add(new ArgumentException($"The application has registered a timeout policy named '{timeoutPolicyName}' that conflicts with the reserved timeout policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - } - else if (!policies.TryGetValue(timeoutPolicyName, out var _)) - { - errors.Add(new ArgumentException($"Timeout policy '{timeoutPolicyName}' not found for route '{routeId}'.")); - } - - if (timeout.HasValue) - { - errors.Add(new ArgumentException($"Route '{routeId}' has both a Timeout '{timeout}' and TimeoutPolicy '{timeoutPolicyName}'.")); - } - } - - if (timeout.HasValue && timeout.Value.TotalMilliseconds <= 0) - { - errors.Add(new ArgumentException($"The Timeout value '{timeout.Value}' is invalid for route '{routeId}'. The Timeout must be greater than zero milliseconds.")); - } - } -#endif - private async ValueTask ValidateRateLimiterPolicyAsync(IList errors, string? rateLimiterPolicyName, string routeId) - { - if (string.IsNullOrEmpty(rateLimiterPolicyName)) - { - return; - } - - if (string.Equals(RateLimitingConstants.Default, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase) - || string.Equals(RateLimitingConstants.Disable, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase)) - { - var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName); - if (policy is not null) - { - // We weren't expecting to find a policy with these names. - errors.Add(new ArgumentException($"The application has registered a RateLimiter policy named '{rateLimiterPolicyName}' that conflicts with the reserved RateLimiter policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - return; - } - - try - { - var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName); - - if (policy is null) - { - errors.Add(new ArgumentException($"RateLimiter policy '{rateLimiterPolicyName}' not found for route '{routeId}'.")); - } - } - catch (Exception ex) - { - errors.Add(new ArgumentException($"Unable to retrieve the RateLimiter policy '{rateLimiterPolicyName}' for route '{routeId}'.", ex)); - } - } - - private async ValueTask ValidateCorsPolicyAsync(IList errors, string? corsPolicyName, string routeId) - { - if (string.IsNullOrEmpty(corsPolicyName)) - { - return; - } - - if (string.Equals(CorsConstants.Default, corsPolicyName, StringComparison.OrdinalIgnoreCase)) - { - var dummyHttpContext = new DefaultHttpContext(); - var policy = await _corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); - if (policy is not null) - { - errors.Add(new ArgumentException($"The application has registered a CORS policy named '{corsPolicyName}' that conflicts with the reserved CORS policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - return; - } - - if (string.Equals(CorsConstants.Disable, corsPolicyName, StringComparison.OrdinalIgnoreCase)) - { - var dummyHttpContext = new DefaultHttpContext(); - var policy = await _corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); - if (policy is not null) - { - errors.Add(new ArgumentException($"The application has registered a CORS policy named '{corsPolicyName}' that conflicts with the reserved CORS policy name used on this route. The registered policy name needs to be changed for this route to function.")); - } - return; - } - - try - { - var dummyHttpContext = new DefaultHttpContext(); - var policy = await _corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); - if (policy is null) - { - errors.Add(new ArgumentException($"CORS policy '{corsPolicyName}' not found for route '{routeId}'.")); - } - } - catch (Exception ex) - { - errors.Add(new ArgumentException($"Unable to retrieve the CORS policy '{corsPolicyName}' for route '{routeId}'.", ex)); - } - } - - private void ValidateDestinations(IList errors, ClusterConfig cluster) - { - if (cluster.Destinations is null) - { - return; - } - foreach (var (name, destination) in cluster.Destinations) - { - if (string.IsNullOrEmpty(destination.Address)) - { - errors.Add(new ArgumentException($"No address found for destination '{name}' on cluster '{cluster.ClusterId}'.")); - } - } - } - - private void ValidateLoadBalancing(IList errors, ClusterConfig cluster) - { - var loadBalancingPolicy = cluster.LoadBalancingPolicy; - if (string.IsNullOrEmpty(loadBalancingPolicy)) - { - // The default. - loadBalancingPolicy = LoadBalancingPolicies.PowerOfTwoChoices; - } - - if (!_loadBalancingPolicies.ContainsKey(loadBalancingPolicy)) - { - errors.Add(new ArgumentException($"No matching {nameof(ILoadBalancingPolicy)} found for the load balancing policy '{loadBalancingPolicy}' set on the cluster '{cluster.ClusterId}'.")); - } - } - - private void ValidateSessionAffinity(IList errors, ClusterConfig cluster) - { - if (!(cluster.SessionAffinity?.Enabled ?? false)) - { - // Session affinity is disabled - return; - } - - // Note some affinity validation takes place in AffinitizeTransformProvider.ValidateCluster. - - var affinityFailurePolicy = cluster.SessionAffinity.FailurePolicy; - if (string.IsNullOrEmpty(affinityFailurePolicy)) - { - // The default. - affinityFailurePolicy = SessionAffinityConstants.FailurePolicies.Redistribute; - } - - if (!_affinityFailurePolicies.ContainsKey(affinityFailurePolicy)) - { - errors.Add(new ArgumentException($"No matching {nameof(IAffinityFailurePolicy)} found for the affinity failure policy name '{affinityFailurePolicy}' set on the cluster '{cluster.ClusterId}'.")); - } - - if (string.IsNullOrEmpty(cluster.SessionAffinity.AffinityKeyName)) - { - errors.Add(new ArgumentException($"Affinity key name set on the cluster '{cluster.ClusterId}' must not be null.")); - } - - var cookieConfig = cluster.SessionAffinity.Cookie; - - if (cookieConfig is null) - { - return; - } - if (cookieConfig.Expiration is not null && cookieConfig.Expiration <= TimeSpan.Zero) + foreach (var clusterValidator in _clusterValidators) { - errors.Add(new ArgumentException($"Session affinity cookie expiration must be positive or null.")); + await clusterValidator.ValidateAsync(cluster, errors); } - if (cookieConfig.MaxAge is not null && cookieConfig.MaxAge <= TimeSpan.Zero) - { - errors.Add(new ArgumentException($"Session affinity cookie max-age must be positive or null.")); - } - } - - private static void ValidateProxyHttpClient(IList errors, ClusterConfig cluster) - { - if (cluster.HttpClient is null) - { - // Proxy http client options are not set. - return; - } - - if (cluster.HttpClient.MaxConnectionsPerServer is not null && cluster.HttpClient.MaxConnectionsPerServer <= 0) - { - errors.Add(new ArgumentException($"Max connections per server limit set on the cluster '{cluster.ClusterId}' must be positive.")); - } - - var requestHeaderEncoding = cluster.HttpClient.RequestHeaderEncoding; - if (requestHeaderEncoding is not null) - { - try - { - Encoding.GetEncoding(requestHeaderEncoding); - } - catch (ArgumentException aex) - { - errors.Add(new ArgumentException($"Invalid request header encoding '{requestHeaderEncoding}'.", aex)); - } - } - - var responseHeaderEncoding = cluster.HttpClient.ResponseHeaderEncoding; - if (responseHeaderEncoding is not null) - { - try - { - Encoding.GetEncoding(responseHeaderEncoding); - } - catch (ArgumentException aex) - { - errors.Add(new ArgumentException($"Invalid response header encoding '{responseHeaderEncoding}'.", aex)); - } - } - } - - private void ValidateProxyHttpRequest(IList errors, ClusterConfig cluster) - { - if (cluster.HttpRequest is null) - { - // Proxy http request options are not set. - return; - } - - if (cluster.HttpRequest.Version is not null && - cluster.HttpRequest.Version != HttpVersion.Version10 && - cluster.HttpRequest.Version != HttpVersion.Version11 && - cluster.HttpRequest.Version != HttpVersion.Version20 && - cluster.HttpRequest.Version != HttpVersion.Version30) - { - errors.Add(new ArgumentException($"Outgoing request version '{cluster.HttpRequest.Version}' is not any of supported HTTP versions (1.0, 1.1, 2 and 3).")); - } - - if (cluster.HttpRequest.Version == HttpVersion.Version10) - { - Log.Http10Version(_logger); - } - } - - private void ValidateHealthChecks(IList errors, ClusterConfig cluster) - { - var availableDestinationsPolicy = cluster.HealthCheck?.AvailableDestinationsPolicy; - if (string.IsNullOrEmpty(availableDestinationsPolicy)) - { - // The default. - availableDestinationsPolicy = HealthCheckConstants.AvailableDestinations.HealthyOrPanic; - } - - if (!_availableDestinationsPolicies.ContainsKey(availableDestinationsPolicy)) - { - errors.Add(new ArgumentException($"No matching {nameof(IAvailableDestinationsPolicy)} found for the available destinations policy '{availableDestinationsPolicy}' set on the cluster.'{cluster.ClusterId}'.")); - } - - ValidateActiveHealthCheck(errors, cluster); - ValidatePassiveHealthCheck(errors, cluster); - } - - private void ValidateActiveHealthCheck(IList errors, ClusterConfig cluster) - { - if (!(cluster.HealthCheck?.Active?.Enabled ?? false)) - { - // Active health check is disabled - return; - } - - var activeOptions = cluster.HealthCheck.Active; - var policy = activeOptions.Policy; - if (string.IsNullOrEmpty(policy)) - { - // default policy - policy = HealthCheckConstants.ActivePolicy.ConsecutiveFailures; - } - if (!_activeHealthCheckPolicies.ContainsKey(policy)) - { - errors.Add(new ArgumentException($"No matching {nameof(IActiveHealthCheckPolicy)} found for the active health check policy name '{policy}' set on the cluster '{cluster.ClusterId}'.")); - } - - if (activeOptions.Interval is not null && activeOptions.Interval <= TimeSpan.Zero) - { - errors.Add(new ArgumentException($"Destination probing interval set on the cluster '{cluster.ClusterId}' must be positive.")); - } - - if (activeOptions.Timeout is not null && activeOptions.Timeout <= TimeSpan.Zero) - { - errors.Add(new ArgumentException($"Destination probing timeout set on the cluster '{cluster.ClusterId}' must be positive.")); - } - } - - private void ValidatePassiveHealthCheck(IList errors, ClusterConfig cluster) - { - if (!(cluster.HealthCheck?.Passive?.Enabled ?? false)) - { - // Passive health check is disabled - return; - } - - var passiveOptions = cluster.HealthCheck.Passive; - var policy = passiveOptions.Policy; - if (string.IsNullOrEmpty(policy)) - { - // default policy - policy = HealthCheckConstants.PassivePolicy.TransportFailureRate; - } - if (!_passiveHealthCheckPolicies.ContainsKey(policy)) - { - errors.Add(new ArgumentException($"No matching {nameof(IPassiveHealthCheckPolicy)} found for the passive health check policy name '{policy}' set on the cluster '{cluster.ClusterId}'.")); - } - - if (passiveOptions.ReactivationPeriod is not null && passiveOptions.ReactivationPeriod <= TimeSpan.Zero) - { - errors.Add(new ArgumentException($"Unhealthy destination reactivation period set on the cluster '{cluster.ClusterId}' must be positive.")); - } - } - - private static class Log - { - private static readonly Action _http10RequestVersionDetected = LoggerMessage.Define( - LogLevel.Warning, - EventIds.Http10RequestVersionDetected, - "The HttpRequest version is set to 1.0 which can result in poor performance and port exhaustion. Use 1.1, 2, or 3 instead."); - - public static void Http10Version(ILogger logger) - { - _http10RequestVersionDetected(logger, null); - } + return errors; } } diff --git a/src/ReverseProxy/Configuration/RouteValidators/AuthorizationPolicyValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/AuthorizationPolicyValidator.cs new file mode 100644 index 000000000..2e34e4cf5 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/AuthorizationPolicyValidator.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class AuthorizationPolicyValidator + (IAuthorizationPolicyProvider authorizationPolicyProvider) : IRouteValidator +{ + public async ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var authorizationPolicyName = routeConfig.AuthorizationPolicy; + if (string.IsNullOrEmpty(authorizationPolicyName)) + { + return; + } + + if (string.Equals(AuthorizationConstants.Default, authorizationPolicyName, StringComparison.OrdinalIgnoreCase)) + { + var policy = await authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); + if (policy is not null) + { + errors.Add(new ArgumentException($"The application has registered an authorization policy named '{authorizationPolicyName}' that conflicts with the reserved authorization policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + + return; + } + + if (string.Equals(AuthorizationConstants.Anonymous, authorizationPolicyName, + StringComparison.OrdinalIgnoreCase)) + { + var policy = await authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); + if (policy is not null) + { + errors.Add(new ArgumentException( + $"The application has registered an authorization policy named '{authorizationPolicyName}' that conflicts with the reserved authorization policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + + return; + } + + try + { + var policy = await authorizationPolicyProvider.GetPolicyAsync(authorizationPolicyName); + if (policy is null) + { + errors.Add(new ArgumentException( + $"Authorization policy '{authorizationPolicyName}' not found for route '{routeConfig.RouteId}'.")); + } + } + catch (Exception ex) + { + errors.Add(new ArgumentException( + $"Unable to retrieve the authorization policy '{authorizationPolicyName}' for route '{routeConfig.RouteId}'.", ex)); + } + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/CorsPolicyValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/CorsPolicyValidator.cs new file mode 100644 index 000000000..288cff809 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/CorsPolicyValidator.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class CorsPolicyValidator(ICorsPolicyProvider corsPolicyProvider) : IRouteValidator +{ + public async ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var corsPolicyName = routeConfig.CorsPolicy; + if (string.IsNullOrEmpty(corsPolicyName)) + { + return; + } + + if (string.Equals(CorsConstants.Default, corsPolicyName, StringComparison.OrdinalIgnoreCase)) + { + var dummyHttpContext = new DefaultHttpContext(); + var policy = await corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); + if (policy is not null) + { + errors.Add(new ArgumentException( + $"The application has registered a CORS policy named '{corsPolicyName}' that conflicts with the reserved CORS policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + + return; + } + + if (string.Equals(CorsConstants.Disable, corsPolicyName, StringComparison.OrdinalIgnoreCase)) + { + var dummyHttpContext = new DefaultHttpContext(); + var policy = await corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); + if (policy is not null) + { + errors.Add(new ArgumentException( + $"The application has registered a CORS policy named '{corsPolicyName}' that conflicts with the reserved CORS policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + + return; + } + + try + { + var dummyHttpContext = new DefaultHttpContext(); + var policy = await corsPolicyProvider.GetPolicyAsync(dummyHttpContext, corsPolicyName); + if (policy is null) + { + errors.Add(new ArgumentException( + $"CORS policy '{corsPolicyName}' not found for route '{routeConfig.RouteId}'.")); + } + } + catch (Exception ex) + { + errors.Add(new ArgumentException( + $"Unable to retrieve the CORS policy '{corsPolicyName}' for route '{routeConfig.RouteId}'.", ex)); + } + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/HeadersValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/HeadersValidator.cs new file mode 100644 index 000000000..a955b0e81 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/HeadersValidator.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class HeadersValidator : IRouteValidator +{ + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var route = routeConfig.Match; + if (route.Headers is null) + { + // Headers are optional + return ValueTask.CompletedTask; + } + + foreach (var header in route.Headers) + { + if (header is null) + { + errors.Add(new ArgumentException($"A null route header has been set for route '{routeConfig.RouteId}'.")); + continue; + } + + if (string.IsNullOrEmpty(header.Name)) + { + errors.Add(new ArgumentException($"A null or empty route header name has been set for route '{routeConfig.RouteId}'.")); + } + + if (header.Mode != HeaderMatchMode.Exists && header.Mode != HeaderMatchMode.NotExists + && (header.Values is null || header.Values.Count == 0)) + { + errors.Add(new ArgumentException($"No header values were set on route header '{header.Name}' for route '{routeConfig.RouteId}'.")); + } + + if ((header.Mode == HeaderMatchMode.Exists || header.Mode == HeaderMatchMode.NotExists) && header.Values?.Count > 0) + { + errors.Add(new ArgumentException($"Header values were set when using mode '{header.Mode}' on route header '{header.Name}' for route '{routeConfig.RouteId}'.")); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/HostValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/HostValidator.cs new file mode 100644 index 000000000..df77551fb --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/HostValidator.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class HostValidator : IRouteValidator +{ + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var route = routeConfig.Match; + if (route.Hosts is null || route.Hosts.Count == 0) + { + // Host is optional when Path is specified + return ValueTask.CompletedTask; + } + + foreach (var host in route.Hosts) + { + if (string.IsNullOrEmpty(host)) + { + errors.Add(new ArgumentException($"Empty host name has been set for route '{routeConfig.RouteId}'.")); + } + else if (host.Contains("xn--", StringComparison.OrdinalIgnoreCase)) + { + errors.Add(new ArgumentException($"Punycode host name '{host}' has been set for route '{routeConfig.RouteId}'. Use the unicode host name instead.")); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/IRouteValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/IRouteValidator.cs new file mode 100644 index 000000000..aac53e356 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/IRouteValidator.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +/// +/// Provides method to validate route configuration. +/// +public interface IRouteValidator +{ + /// + /// Perform validation on a route by adding exceptions to the provided collection. + /// + /// Route configuration to validate + /// Collection of all validation exceptions + /// A ValueTask representing the asynchronous validation operation. + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors); +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/MethodsValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/MethodsValidator.cs new file mode 100644 index 000000000..599b0ac10 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/MethodsValidator.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class MethodsValidator : IRouteValidator +{ + private static readonly HashSet _validMethods = new(StringComparer.OrdinalIgnoreCase) + { + "HEAD", "OPTIONS", "GET", "PUT", "POST", "PATCH", "DELETE", "TRACE", + }; + + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var route = routeConfig.Match; + if (route.Methods is null) + { + // Methods are optional + return ValueTask.CompletedTask; + } + + var seenMethods = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var method in route.Methods) + { + if (!seenMethods.Add(method)) + { + errors.Add(new ArgumentException($"Duplicate HTTP method '{method}' for route '{routeConfig.RouteId}'.")); + continue; + } + + if (!_validMethods.Contains(method)) + { + errors.Add(new ArgumentException($"Unsupported HTTP method '{method}' has been set for route '{routeConfig.RouteId}'.")); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/PathValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/PathValidator.cs new file mode 100644 index 000000000..412d91ec0 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/PathValidator.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing.Patterns; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class PathValidator : IRouteValidator +{ + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var route = routeConfig.Match; + if (string.IsNullOrEmpty(route.Path)) + { + // Path is optional when Host is specified + return ValueTask.CompletedTask; + } + + try + { + RoutePatternFactory.Parse(route.Path); + } + catch (RoutePatternException ex) + { + errors.Add(new ArgumentException($"Invalid path '{route.Path}' for route '{routeConfig.RouteId}'.", ex)); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/QueryParametersValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/QueryParametersValidator.cs new file mode 100644 index 000000000..879bdac6e --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/QueryParametersValidator.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class QueryParametersValidator : IRouteValidator +{ + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var route = routeConfig.Match; + if (route.QueryParameters is null) + { + // Query Parameters are optional + return ValueTask.CompletedTask; + } + + foreach (var queryParameter in route.QueryParameters) + { + if (queryParameter is null) + { + errors.Add(new ArgumentException($"A null route query parameter has been set for route '{routeConfig.RouteId}'.")); + continue; + } + + if (string.IsNullOrEmpty(queryParameter.Name)) + { + errors.Add(new ArgumentException($"A null or empty route query parameter name has been set for route '{routeConfig.RouteId}'.")); + } + + if (queryParameter.Mode != QueryParameterMatchMode.Exists + && (queryParameter.Values is null || queryParameter.Values.Count == 0)) + { + errors.Add(new ArgumentException($"No query parameter values were set on route query parameter '{queryParameter.Name}' for route '{routeConfig.RouteId}'.")); + } + + if (queryParameter.Mode == QueryParameterMatchMode.Exists && queryParameter.Values?.Count > 0) + { + errors.Add(new ArgumentException($"Query parameter values where set when using mode '{nameof(QueryParameterMatchMode.Exists)}' on route query parameter '{queryParameter.Name}' for route '{routeConfig.RouteId}'.")); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/RateLimitPolicyValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/RateLimitPolicyValidator.cs new file mode 100644 index 000000000..fd8306cfa --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/RateLimitPolicyValidator.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class RateLimitPolicyValidator : IRouteValidator +{ +#if NET7_0_OR_GREATER + private readonly IYarpRateLimiterPolicyProvider _rateLimiterPolicyProvider; + public RateLimitPolicyValidator(IYarpRateLimiterPolicyProvider rateLimiterPolicyProvider) + { + _rateLimiterPolicyProvider = rateLimiterPolicyProvider; + } + + public async ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var rateLimiterPolicyName = routeConfig.RateLimiterPolicy; + + if (string.IsNullOrEmpty(rateLimiterPolicyName)) + { + return; + } + + if (string.Equals(RateLimitingConstants.Default, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase) + || string.Equals(RateLimitingConstants.Disable, rateLimiterPolicyName, StringComparison.OrdinalIgnoreCase)) + { + var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName); + if (policy is not null) + { + // We weren't expecting to find a policy with these names. + errors.Add(new ArgumentException( + $"The application has registered a RateLimiter policy named '{rateLimiterPolicyName}' that conflicts with the reserved RateLimiter policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + + return; + } + + try + { + var policy = await _rateLimiterPolicyProvider.GetPolicyAsync(rateLimiterPolicyName); + + if (policy is null) + { + errors.Add(new ArgumentException( + $"RateLimiter policy '{rateLimiterPolicyName}' not found for route '{routeConfig.RouteId}'.")); + } + } + catch (Exception ex) + { + errors.Add(new ArgumentException( + $"Unable to retrieve the RateLimiter policy '{rateLimiterPolicyName}' for route '{routeConfig.RouteId}'.", + ex)); + } + } +#else + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) => ValueTask.CompletedTask; +#endif +} diff --git a/src/ReverseProxy/Configuration/RouteValidators/TimeoutPolicyValidator.cs b/src/ReverseProxy/Configuration/RouteValidators/TimeoutPolicyValidator.cs new file mode 100644 index 000000000..a57131f35 --- /dev/null +++ b/src/ReverseProxy/Configuration/RouteValidators/TimeoutPolicyValidator.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +#if NET8_0_OR_GREATER +using Microsoft.AspNetCore.Http.Timeouts; +#endif +using Microsoft.Extensions.Options; + +namespace Yarp.ReverseProxy.Configuration.RouteValidators; + +internal sealed class TimeoutPolicyValidator : IRouteValidator +{ +#if NET8_0_OR_GREATER + private readonly IOptionsMonitor _timeoutOptions; + + public TimeoutPolicyValidator(IOptionsMonitor timeoutOptions) + { + _timeoutOptions = timeoutOptions; + } + + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) + { + var timeoutPolicyName = routeConfig.TimeoutPolicy; + var timeout = routeConfig.Timeout; + + if (!string.IsNullOrEmpty(timeoutPolicyName)) + { + var policies = _timeoutOptions.CurrentValue.Policies; + + if (string.Equals(TimeoutPolicyConstants.Disable, timeoutPolicyName, StringComparison.OrdinalIgnoreCase)) + { + if (policies.TryGetValue(timeoutPolicyName, out var _)) + { + errors.Add(new ArgumentException($"The application has registered a timeout policy named '{timeoutPolicyName}' that conflicts with the reserved timeout policy name used on this route. The registered policy name needs to be changed for this route to function.")); + } + } + else if (!policies.TryGetValue(timeoutPolicyName, out var _)) + { + errors.Add(new ArgumentException($"Timeout policy '{timeoutPolicyName}' not found for route '{routeConfig.RouteId}'.")); + } + + if (timeout.HasValue) + { + errors.Add(new ArgumentException($"Route '{routeConfig.RouteId}' has both a Timeout '{timeout}' and TimeoutPolicy '{timeoutPolicyName}'.")); + } + } + + if (timeout.HasValue && timeout.Value.TotalMilliseconds <= 0) + { + errors.Add(new ArgumentException($"The Timeout value '{timeout.Value}' is invalid for route '{routeConfig.RouteId}'. The Timeout must be greater than zero milliseconds.")); + } + + return ValueTask.CompletedTask; + } + +#else + public ValueTask ValidateAsync(RouteConfig routeConfig, IList errors) => ValueTask.CompletedTask; +#endif +} diff --git a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs index d91dc0062..22539fcd9 100644 --- a/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs +++ b/src/ReverseProxy/Management/IReverseProxyBuilderExtensions.cs @@ -6,6 +6,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Configuration.ClusterValidators; +using Yarp.ReverseProxy.Configuration.RouteValidators; using Yarp.ReverseProxy.Delegation; using Yarp.ReverseProxy.Forwarder; using Yarp.ReverseProxy.Health; @@ -25,6 +27,21 @@ public static IReverseProxyBuilder AddConfigBuilder(this IReverseProxyBuilder bu { builder.Services.TryAddSingleton(); builder.Services.TryAddSingleton(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton()); builder.Services.TryAddSingleton(); builder.AddTransformFactory(); builder.AddTransformFactory();