Skip to content

Commit

Permalink
Support Multiple Hosts for a Route
Browse files Browse the repository at this point in the history
  • Loading branch information
Kahbazi committed Jul 6, 2020
1 parent a3b5e1b commit db63da7
Show file tree
Hide file tree
Showing 13 changed files with 58 additions and 43 deletions.
2 changes: 1 addition & 1 deletion docs/docfx/articles/authn-authz.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Example:
"ClusterId": "cluster1",
"AuthorizationPolicy": "customPolicy",
"Match": {
"Host": "localhost"
"Hosts": "localhost"
},
}
],
Expand Down
4 changes: 2 additions & 2 deletions docs/docfx/articles/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,15 @@ You can find out more about the available configuration options by looking at [P
"ClusterId": "cluster1",
"Match": {
"Methods": [ "GET", "POST" ],
"Host": "localhost",
"Hosts": [ "localhost" ],
"Path": "/app1/"
}
},
{
"RouteId": "route2",
"ClusterId": "cluster2",
"Match": {
"Host": "localhost"
"Hosts": [ "localhost" ]
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion docs/docfx/articles/transforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Here is an example of common transforms:
"RouteId": "route1",
"ClusterId": "cluster1",
"Match": {
"Host": "localhost"
"Hosts": [ "localhost" ]
},
"Transforms": [
{ "PathPrefix": "/apis" },
Expand Down
4 changes: 2 additions & 2 deletions samples/ReverseProxy.Sample/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"ClusterId": "cluster1",
"Match": {
"Methods": [ "GET", "POST" ],
"Host": "localhost",
"Hosts": [ "localhost" ],
"Path": "/api/{plugin}/stuff/{*remainder}"
},
"Transforms": [
Expand All @@ -64,7 +64,7 @@
"RouteId": "route2",
"ClusterId": "cluster2",
"Match": {
"Host": "localhost"
"Hosts": [ "localhost" ]
},
"Transforms": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public class ProxyMatch : IDeepCloneable<ProxyMatch>
/// <summary>
/// Only match requests with the given Host header.
/// </summary>
public string Host { get; set; }
public IReadOnlyList<string> Hosts { get; set; }

/// <summary>
/// Only match requests with the given Path pattern.
Expand All @@ -43,7 +43,7 @@ ProxyMatch IDeepCloneable<ProxyMatch>.DeepClone()
return new ProxyMatch()
{
Methods = Methods?.ToArray(),
Host = Host,
Hosts = Hosts?.ToArray(),
Path = Path,
// Headers = Headers.DeepClone(); // TODO:
};
Expand Down
2 changes: 1 addition & 1 deletion src/ReverseProxy/Service/Config/DynamicConfigBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ private async Task<IList<ParsedRoute>> GetRoutesAsync(IConfigErrorReporter error
{
RouteId = route.RouteId,
Methods = route.Match.Methods,
Host = route.Match.Host,
Hosts = route.Match.Hosts,
Path = route.Match.Path,
Priority = route.Priority,
ClusterId = route.ClusterId,
Expand Down
20 changes: 12 additions & 8 deletions src/ReverseProxy/Service/Config/RuleParsing/RouteValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -68,13 +69,13 @@ public async Task<bool> ValidateRouteAsync(ParsedRoute route, IConfigErrorReport
success = false;
}

if (string.IsNullOrEmpty(route.Host) && string.IsNullOrEmpty(route.Path))
if ((route.Hosts == null || route.Hosts.Count == 0 || route.Hosts.Any(host => string.IsNullOrEmpty(host))) && string.IsNullOrEmpty(route.Path))
{
errorReporter.ReportError(ConfigErrors.ParsedRouteRuleHasNoMatchers, route.RouteId, $"Route requires {nameof(route.Host)} or {nameof(route.Path)} specified. Set the Path to `/{{**catchall}}` to match all requests.");
errorReporter.ReportError(ConfigErrors.ParsedRouteRuleHasNoMatchers, route.RouteId, $"Route requires {nameof(route.Hosts)} or {nameof(route.Path)} specified. Set the Path to `/{{**catchall}}` to match all requests.");
success = false;
}

success &= ValidateHost(route.Host, route.RouteId, errorReporter);
success &= ValidateHost(route.Hosts, route.RouteId, errorReporter);
success &= ValidatePath(route.Path, route.RouteId, errorReporter);
success &= ValidateMethods(route.Methods, route.RouteId, errorReporter);
success &= _transformBuilder.Validate(route.Transforms, route.RouteId, errorReporter);
Expand All @@ -83,18 +84,21 @@ public async Task<bool> ValidateRouteAsync(ParsedRoute route, IConfigErrorReport
return success;
}

private static bool ValidateHost(string host, string routeId, IConfigErrorReporter errorReporter)
private static bool ValidateHost(IReadOnlyList<string> hosts, string routeId, IConfigErrorReporter errorReporter)
{
// Host is optional when Path is specified
if (string.IsNullOrEmpty(host))
if (hosts == null || hosts.Count == 0)
{
return true;
}

if (!_hostNameRegex.IsMatch(host))
for (var i = 0; i < hosts.Count; i++)
{
errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Invalid host name '{host}'");
return false;
if (string.IsNullOrEmpty(hosts[i]) || !_hostNameRegex.IsMatch(hosts[i]))
{
errorReporter.ReportError(ConfigErrors.ParsedRouteRuleInvalidMatcher, routeId, $"Invalid host name '{hosts[i]}'");
return false;
}
}

return true;
Expand Down
8 changes: 5 additions & 3 deletions src/ReverseProxy/Service/ConfigModel/ParsedRoute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ internal class ParsedRoute
/// <summary>
/// Only match requests with the given Host header.
/// </summary>
public string Host { get; set; }
public IReadOnlyList<string> Hosts { get; set; }

/// <summary>
/// Only match requests with the given Path pattern.
Expand Down Expand Up @@ -89,9 +89,11 @@ internal int GetConfigHash()
.Aggregate((total, nextCode) => total ^ nextCode);
}

if (!string.IsNullOrEmpty(Host))
if (Hosts != null && Hosts.Count > 0)
{
hash ^= Host.GetHashCode();
// Assumes un-ordered
hash ^= Hosts.Select(item => item.GetHashCode())
.Aggregate((total, nextCode) => total ^ nextCode);
}

if (!string.IsNullOrEmpty(Path))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -68,9 +69,9 @@ public RouteConfig Build(ParsedRoute source, ClusterInfo cluster, RouteInfo runt
endpointBuilder.DisplayName = source.RouteId;
endpointBuilder.Metadata.Add(newRouteConfig);

if (!string.IsNullOrEmpty(source.Host))
if (source.Hosts != null && source.Hosts.Count != 0)
{
endpointBuilder.Metadata.Add(new AspNetCore.Routing.HostAttribute(source.Host));
endpointBuilder.Metadata.Add(new AspNetCore.Routing.HostAttribute(source.Hosts.ToArray()));
}

if (source.Methods != null && source.Methods.Count > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public void DeepClone_Works()
Match =
{
Methods = new[] { "GET", "POST" },
Host = "example.com",
Hosts = new[] { "example.com" },
Path = "/",
},
Priority = 2,
Expand All @@ -45,7 +45,7 @@ public void DeepClone_Works()
Assert.NotSame(sut.Match, clone.Match);
Assert.NotSame(sut.Match.Methods, clone.Match.Methods);
Assert.Equal(sut.Match.Methods, clone.Match.Methods);
Assert.Equal(sut.Match.Host, clone.Match.Host);
Assert.Equal(sut.Match.Hosts, clone.Match.Hosts);
Assert.Equal(sut.Match.Path, clone.Match.Path);
Assert.Equal(sut.Priority, clone.Priority);
Assert.Equal(sut.ClusterId, clone.ClusterId);
Expand All @@ -68,7 +68,7 @@ public void DeepClone_Nulls_Works()
Assert.NotSame(sut, clone);
Assert.Null(clone.RouteId);
Assert.Null(clone.Match.Methods);
Assert.Null(clone.Match.Host);
Assert.Null(clone.Match.Hosts);
Assert.Null(clone.Match.Path);
Assert.Null(clone.Priority);
Assert.Null(clone.ClusterId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ private class TestClustersRepo : IClustersRepo

public TestClustersRepo(IDictionary<string, Cluster> clusters) { Clusters = clusters; }

public IDictionary<string, Cluster> Clusters { get; set; }
public IDictionary<string, Cluster> Clusters { get; set; }

public Task<IDictionary<string, Cluster>> GetClustersAsync(CancellationToken cancellation) => Task.FromResult(Clusters);

Expand Down Expand Up @@ -143,7 +143,7 @@ public async Task BuildConfigAsync_OneCluster_Works()
public async Task BuildConfigAsync_ValidRoute_Works()
{
var errorReporter = new TestConfigErrorReporter();
var route1 = new ProxyRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, ClusterId = "cluster1" };
var route1 = new ProxyRoute { RouteId = "route1", Match = { Hosts = new[] { "example.com" } }, Priority = 1, ClusterId = "cluster1" };
var configBuilder = CreateConfigBuilder(new TestClustersRepo(), new TestRoutesRepo(new[] { route1 }));

var result = await configBuilder.BuildConfigAsync(errorReporter, CancellationToken.None);
Expand All @@ -159,7 +159,7 @@ public async Task BuildConfigAsync_ValidRoute_Works()
public async Task BuildConfigAsync_RouteValidationError_SkipsRoute()
{
var errorReporter = new TestConfigErrorReporter();
var route1 = new ProxyRoute { RouteId = "route1", Match = { Host = "invalid host name" }, Priority = 1, ClusterId = "cluster1" };
var route1 = new ProxyRoute { RouteId = "route1", Match = { Hosts = new[] { "invalid host name" } }, Priority = 1, ClusterId = "cluster1" };
var configBuilder = CreateConfigBuilder(new TestClustersRepo(), new TestRoutesRepo(new[] { route1 }));

var result = await configBuilder.BuildConfigAsync(errorReporter, CancellationToken.None);
Expand All @@ -173,7 +173,7 @@ public async Task BuildConfigAsync_RouteValidationError_SkipsRoute()
public async Task BuildConfigAsync_ConfigFilterRouteActions_CanFixBrokenRoute()
{
var errorReporter = new TestConfigErrorReporter();
var route1 = new ProxyRoute { RouteId = "route1", Match = { Host = "invalid host name" }, Priority = 1, ClusterId = "cluster1" };
var route1 = new ProxyRoute { RouteId = "route1", Match = { Hosts = new[] { "invalid host name" } }, Priority = 1, ClusterId = "cluster1" };
var configBuilder = CreateConfigBuilder(new TestClustersRepo(), new TestRoutesRepo(new[] { route1 }),
proxyBuilder =>
{
Expand All @@ -188,7 +188,8 @@ public async Task BuildConfigAsync_ConfigFilterRouteActions_CanFixBrokenRoute()
Assert.Single(result.Routes);
var builtRoute = result.Routes[0];
Assert.Same(route1.RouteId, builtRoute.RouteId);
Assert.Equal("example.com", builtRoute.Host);
var host = Assert.Single(builtRoute.Hosts);
Assert.Equal("example.com", host);
}

private class FixRouteHostFilter : IProxyConfigFilter
Expand All @@ -200,7 +201,7 @@ public Task ConfigureClusterAsync(Cluster cluster, CancellationToken cancel)

public Task ConfigureRouteAsync(ProxyRoute route, CancellationToken cancel)
{
route.Match.Host = "example.com";
route.Match.Hosts = new[] { "example.com" };
return Task.CompletedTask;
}
}
Expand Down Expand Up @@ -282,7 +283,7 @@ public async Task BuildConfigAsync_ConfigFilterClusterActionThrows_ClusterSkippe
public async Task BuildConfigAsync_ConfigFilterRouteActions_Works()
{
var errorReporter = new TestConfigErrorReporter();
var route1 = new ProxyRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, ClusterId = "cluster1" };
var route1 = new ProxyRoute { RouteId = "route1", Match = { Hosts = new[] { "example.com" } }, Priority = 1, ClusterId = "cluster1" };
var configBuilder = CreateConfigBuilder(new TestClustersRepo(), new TestRoutesRepo(new[] { route1 }),
proxyBuilder =>
{
Expand All @@ -303,8 +304,8 @@ public async Task BuildConfigAsync_ConfigFilterRouteActions_Works()
public async Task BuildConfigAsync_ConfigFilterRouteActionThrows_SkipsRoute()
{
var errorReporter = new TestConfigErrorReporter();
var route1 = new ProxyRoute { RouteId = "route1", Match = { Host = "example.com" }, Priority = 1, ClusterId = "cluster1" };
var route2 = new ProxyRoute { RouteId = "route2", Match = { Host = "example2.com" }, Priority = 1, ClusterId = "cluster2" };
var route1 = new ProxyRoute { RouteId = "route1", Match = { Hosts = new[] { "example.com" } }, Priority = 1, ClusterId = "cluster1" };
var route2 = new ProxyRoute { RouteId = "route2", Match = { Hosts = new[] { "example2.com" } }, Priority = 1, ClusterId = "cluster2" };
var configBuilder = CreateConfigBuilder(new TestClustersRepo(), new TestRoutesRepo(new[] { route1, route2 }),
proxyBuilder =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
Expand Down Expand Up @@ -31,6 +32,7 @@ public void Constructor_Works()
[InlineData("example.com", null, "get")]
[InlineData("example.com", null, "gEt,put")]
[InlineData("example.com", null, "gEt,put,POST,traCE,PATCH,DELETE,Head")]
[InlineData("example.com,example2.com", null, "get")]
[InlineData("*.example.com", null, null)]
[InlineData("a-b.example.com", null, null)]
[InlineData("a-b.b-c.example.com", null, null)]
Expand All @@ -40,7 +42,7 @@ public async Task Accepts_ValidRules(string host, string path, string methods)
var route = new ParsedRoute
{
RouteId = "route1",
Host = host,
Hosts = host?.Split(",") ?? Array.Empty<string>(),
Path = path,
Methods = methods?.Split(","),
ClusterId = "cluster1",
Expand Down Expand Up @@ -72,14 +74,18 @@ public async Task Rejects_MissingRouteId(string routeId)
Assert.Contains(errorReporter.Errors, err => err.ErrorCode == ConfigErrors.ParsedRouteMissingId);
}

[Fact]
public async Task Rejects_MissingHostAndPath()
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("example.com,")]
public async Task Rejects_MissingHostAndPath(string host)
{
// Arrange
var route = new ParsedRoute
{
RouteId = "route1",
ClusterId = "cluster1",
Hosts = host?.Split(",")
};

// Act
Expand All @@ -102,13 +108,14 @@ public async Task Rejects_MissingHostAndPath()
[InlineData("a.-example.com")]
[InlineData("a.example-.com")]
[InlineData("a.-example-.com")]
[InlineData("example.com,example-.com")]
public async Task Rejects_InvalidHost(string host)
{
// Arrange
var route = new ParsedRoute
{
RouteId = "route1",
Host = host,
Hosts = host.Split(","),
ClusterId = "cluster1",
};

Expand Down Expand Up @@ -175,7 +182,7 @@ public async Task Accepts_ReservedAuthorizationPolicy(string policy)
{
RouteId = "route1",
AuthorizationPolicy = policy,
Host = "localhost",
Hosts = new[] { "localhost" },
ClusterId = "cluster1",
};

Expand All @@ -195,7 +202,7 @@ public async Task Accepts_CustomAuthorizationPolicy()
{
RouteId = "route1",
AuthorizationPolicy = "custom",
Host = "localhost",
Hosts = new[] { "localhost" },
ClusterId = "cluster1",
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void BuildEndpoints_HostAndPath_Works()
var parsedRoute = new ParsedRoute
{
RouteId = "route1",
Host = "example.com",
Hosts = new[] { "example.com" },
Path = "/a",
Priority = 12,
};
Expand Down Expand Up @@ -63,7 +63,7 @@ public void BuildEndpoints_JustHost_Works()
var parsedRoute = new ParsedRoute
{
RouteId = "route1",
Host = "example.com",
Hosts = new[] { "example.com" },
Priority = 12,
};
var cluster = new ClusterInfo("cluster1", new DestinationManager(), new Mock<IProxyHttpClientFactory>().Object);
Expand Down Expand Up @@ -96,7 +96,7 @@ public void BuildEndpoints_JustHostWithWildcard_Works()
var parsedRoute = new ParsedRoute
{
RouteId = "route1",
Host = "*.example.com",
Hosts = new[] { "*.example.com" },
Priority = 12,
};
var cluster = new ClusterInfo("cluster1", new DestinationManager(), new Mock<IProxyHttpClientFactory>().Object);
Expand Down

0 comments on commit db63da7

Please sign in to comment.