#2139 Add ListenLocalhost and ListenAnyIP

This commit is contained in:
Chris Ross (ASP.NET) 2017-11-14 15:54:30 -08:00
parent 186e9806cd
commit 89d1862f21
10 changed files with 341 additions and 145 deletions

View File

@ -76,6 +76,13 @@ namespace SampleApp
listenOptions.UseConnectionLogging();
});
options.ListenLocalhost(basePort + 2, listenOptions =>
{
listenOptions.UseHttps("testCert.pfx", "testPassword");
});
options.ListenAnyIP(basePort + 3);
options.UseSystemd();
// The following section should be used to demo sockets

View File

@ -0,0 +1,37 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Server.Kestrel.Core
{
internal class AnyIPListenOptions : ListenOptions
{
internal AnyIPListenOptions(int port)
: base(new IPEndPoint(IPAddress.IPv6Any, port))
{
}
internal override async Task BindAsync(AddressBindContext context)
{
// when address is 'http://hostname:port', 'http://*:port', or 'http://+:port'
try
{
await base.BindAsync(context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogDebug(CoreStrings.FormatFallbackToIPv4Any(IPEndPoint.Port));
// for machines that do not support IPv6
IPEndPoint = new IPEndPoint(IPAddress.Any, IPEndPoint.Port);
await base.BindAsync(context).ConfigureAwait(false);
}
}
}
}

View File

@ -0,0 +1,21 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
{
internal class AddressBindContext
{
public ICollection<string> Addresses { get; set; }
public List<ListenOptions> ListenOptions { get; set; }
public KestrelServerOptions ServerOptions { get; set; }
public ILogger Logger { get; set; }
public IDefaultHttpsProvider DefaultHttpsProvider { get; set; }
public Func<ListenOptions, Task> CreateBinding { get; set; }
}
}

View File

@ -47,17 +47,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
await strategy.BindAsync(context).ConfigureAwait(false);
}
private class AddressBindContext
{
public ICollection<string> Addresses { get; set; }
public List<ListenOptions> ListenOptions { get; set; }
public KestrelServerOptions ServerOptions { get; set; }
public ILogger Logger { get; set; }
public IDefaultHttpsProvider DefaultHttpsProvider { get; set; }
public Func<ListenOptions, Task> CreateBinding { get; set; }
}
private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] addresses, bool preferAddresses)
{
var hasListenOptions = listenOptions.Length > 0;
@ -109,10 +98,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
return true;
}
private static Task BindEndpointAsync(IPEndPoint endpoint, AddressBindContext context)
=> BindEndpointAsync(new ListenOptions(endpoint), context);
private static async Task BindEndpointAsync(ListenOptions endpoint, AddressBindContext context)
internal static async Task BindEndpointAsync(ListenOptions endpoint, AddressBindContext context)
{
try
{
@ -126,60 +112,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
context.ListenOptions.Add(endpoint);
}
private static async Task BindLocalhostAsync(ServerAddress address, AddressBindContext context, bool https)
{
if (address.Port == 0)
{
throw new InvalidOperationException(CoreStrings.DynamicPortOnLocalhostNotSupported);
}
var exceptions = new List<Exception>();
try
{
var options = new ListenOptions(new IPEndPoint(IPAddress.Loopback, address.Port));
await BindEndpointAsync(options, context).ConfigureAwait(false);
if (https)
{
options.KestrelServerOptions = context.ServerOptions;
context.DefaultHttpsProvider.ConfigureHttps(options);
}
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, CoreStrings.NetworkInterfaceBindingFailed, address, "IPv4 loopback", ex.Message);
exceptions.Add(ex);
}
try
{
var options = new ListenOptions(new IPEndPoint(IPAddress.IPv6Loopback, address.Port));
await BindEndpointAsync(options, context).ConfigureAwait(false);
if (https)
{
options.KestrelServerOptions = context.ServerOptions;
context.DefaultHttpsProvider.ConfigureHttps(options);
}
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, CoreStrings.NetworkInterfaceBindingFailed, address, "IPv6 loopback", ex.Message);
exceptions.Add(ex);
}
if (exceptions.Count == 2)
{
throw new IOException(CoreStrings.FormatAddressBindingFailed(address), new AggregateException(exceptions));
}
// If StartLocalhost doesn't throw, there is at least one listener.
// The port cannot change for "localhost".
context.Addresses.Add(address.ToString());
}
private static async Task BindAddressAsync(string address, AddressBindContext context)
internal static ListenOptions ParseAddress(string address, KestrelServerOptions serverOptions, IDefaultHttpsProvider defaultHttpsProvider)
{
var parsedAddress = ServerAddress.FromUrl(address);
var https = false;
@ -202,47 +135,29 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
if (parsedAddress.IsUnixPipe)
{
options = new ListenOptions(parsedAddress.UnixPipePath);
await BindEndpointAsync(options, context).ConfigureAwait(false);
context.Addresses.Add(options.GetDisplayName());
}
else if (string.Equals(parsedAddress.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
// "localhost" for both IPv4 and IPv6 can't be represented as an IPEndPoint.
await BindLocalhostAsync(parsedAddress, context, https).ConfigureAwait(false);
options = new LocalhostListenOptions(parsedAddress.Port);
}
else if (TryCreateIPEndPoint(parsedAddress, out var endpoint))
{
options = new ListenOptions(endpoint);
}
else
{
if (TryCreateIPEndPoint(parsedAddress, out var endpoint))
{
options = new ListenOptions(endpoint);
await BindEndpointAsync(options, context).ConfigureAwait(false);
}
else
{
// when address is 'http://hostname:port', 'http://*:port', or 'http://+:port'
try
{
options = new ListenOptions(new IPEndPoint(IPAddress.IPv6Any, parsedAddress.Port));
await BindEndpointAsync(options, context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogDebug(CoreStrings.FormatFallbackToIPv4Any(parsedAddress.Port));
// for machines that do not support IPv6
options = new ListenOptions(new IPEndPoint(IPAddress.Any, parsedAddress.Port));
await BindEndpointAsync(options, context).ConfigureAwait(false);
}
}
context.Addresses.Add(options.GetDisplayName());
// when address is 'http://hostname:port', 'http://*:port', or 'http://+:port'
options = new AnyIPListenOptions(parsedAddress.Port);
}
if (https && options != null)
if (https)
{
options.KestrelServerOptions = context.ServerOptions;
context.DefaultHttpsProvider.ConfigureHttps(options);
options.KestrelServerOptions = serverOptions;
defaultHttpsProvider.ConfigureHttps(options);
}
return options;
}
private interface IStrategy
@ -256,7 +171,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
{
context.Logger.LogDebug(CoreStrings.BindingToDefaultAddress, Constants.DefaultServerAddress);
await BindLocalhostAsync(ServerAddress.FromUrl(Constants.DefaultServerAddress), context, https: false).ConfigureAwait(false);
await ParseAddress(Constants.DefaultServerAddress, context.ServerOptions, context.DefaultHttpsProvider)
.BindAsync(context).ConfigureAwait(false);
}
}
@ -308,9 +224,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
{
foreach (var endpoint in _endpoints)
{
await BindEndpointAsync(endpoint, context).ConfigureAwait(false);
context.Addresses.Add(endpoint.GetDisplayName());
await endpoint.BindAsync(context).ConfigureAwait(false);
}
}
}
@ -328,7 +242,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
{
foreach (var address in _addresses)
{
await BindAddressAsync(address, context).ConfigureAwait(false);
await ParseAddress(address, context.ServerOptions, context.DefaultHttpsProvider)
.BindAsync(context).ConfigureAwait(false);
}
}
}

View File

@ -105,6 +105,40 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
ListenOptions.Add(listenOptions);
}
public void ListenLocalhost(int port) => ListenLocalhost(port, options => { });
public void ListenLocalhost(int port, Action<ListenOptions> configure)
{
if (configure == null)
{
throw new ArgumentNullException(nameof(configure));
}
var listenOptions = new LocalhostListenOptions(port)
{
KestrelServerOptions = this,
};
configure(listenOptions);
ListenOptions.Add(listenOptions);
}
public void ListenAnyIP(int port) => ListenAnyIP(port, options => { });
public void ListenAnyIP(int port, Action<ListenOptions> configure)
{
if (configure == null)
{
throw new ArgumentNullException(nameof(configure));
}
var listenOptions = new AnyIPListenOptions(port)
{
KestrelServerOptions = this,
};
configure(listenOptions);
ListenOptions.Add(listenOptions);
}
/// <summary>
/// Bind to given Unix domain socket path.
/// </summary>

View File

@ -8,6 +8,7 @@ using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Protocols;
using Microsoft.AspNetCore.Server.Kestrel.Core.Adapter.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal;
namespace Microsoft.AspNetCore.Server.Kestrel.Core
@ -140,7 +141,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
/// <summary>
/// Gets the name of this endpoint to display on command-line when the web server starts.
/// </summary>
internal string GetDisplayName()
internal virtual string GetDisplayName()
{
var scheme = ConnectionAdapters.Any(f => f.IsHttps)
? "https"
@ -182,5 +183,11 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
return app;
}
internal virtual async Task BindAsync(AddressBindContext context)
{
await AddressBinder.BindEndpointAsync(this, context).ConfigureAwait(false);
context.Addresses.Add(GetDisplayName());
}
}
}

View File

@ -0,0 +1,88 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Server.Kestrel.Core
{
internal class LocalhostListenOptions : ListenOptions
{
internal LocalhostListenOptions(int port)
: base(new IPEndPoint(IPAddress.Loopback, port))
{
if (port == 0)
{
throw new InvalidOperationException(CoreStrings.DynamicPortOnLocalhostNotSupported);
}
}
/// <summary>
/// Gets the name of this endpoint to display on command-line when the web server starts.
/// </summary>
internal override string GetDisplayName()
{
var scheme = ConnectionAdapters.Any(f => f.IsHttps)
? "https"
: "http";
return $"{scheme}://localhost:{IPEndPoint.Port}";
}
internal override async Task BindAsync(AddressBindContext context)
{
var exceptions = new List<Exception>();
try
{
var v4Options = Clone(IPAddress.Loopback);
await AddressBinder.BindEndpointAsync(v4Options, context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, CoreStrings.NetworkInterfaceBindingFailed, GetDisplayName(), "IPv4 loopback", ex.Message);
exceptions.Add(ex);
}
try
{
var v6Options = Clone(IPAddress.IPv6Loopback);
await AddressBinder.BindEndpointAsync(v6Options, context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, CoreStrings.NetworkInterfaceBindingFailed, GetDisplayName(), "IPv6 loopback", ex.Message);
exceptions.Add(ex);
}
if (exceptions.Count == 2)
{
throw new IOException(CoreStrings.FormatAddressBindingFailed(GetDisplayName()), new AggregateException(exceptions));
}
// If StartLocalhost doesn't throw, there is at least one listener.
// The port cannot change for "localhost".
context.Addresses.Add(GetDisplayName());
}
// used for cloning to two IPEndpoints
private ListenOptions Clone(IPAddress address)
{
var options = new ListenOptions(new IPEndPoint(address, IPEndPoint.Port))
{
HandleType = HandleType,
KestrelServerOptions = KestrelServerOptions,
NoDelay = NoDelay,
Protocols = Protocols,
};
options.ConnectionAdapters.AddRange(ConnectionAdapters);
return options;
}
}
}

View File

@ -2,12 +2,12 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Protocols;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions.Internal;
using Microsoft.AspNetCore.Testing;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@ -51,24 +51,49 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
[InlineData("randomhost")]
[InlineData("+")]
[InlineData("contoso.com")]
public async Task DefaultsToIPv6AnyOnInvalidIPAddress(string host)
public void ParseAddressDefaultsToAnyIPOnInvalidIPAddress(string host)
{
var addresses = new ServerAddressesFeature();
addresses.Addresses.Add($"http://{host}");
var options = new KestrelServerOptions();
var listenOptions = AddressBinder.ParseAddress($"http://{host}", options, Mock.Of<IDefaultHttpsProvider>());
Assert.IsType<AnyIPListenOptions>(listenOptions);
Assert.Equal(ListenType.IPEndPoint, listenOptions.Type);
Assert.Equal(IPAddress.IPv6Any, listenOptions.IPEndPoint.Address);
Assert.Equal(80, listenOptions.IPEndPoint.Port);
}
var tcs = new TaskCompletionSource<ListenOptions>();
await AddressBinder.BindAsync(addresses,
options,
NullLogger.Instance,
Mock.Of<IDefaultHttpsProvider>(),
endpoint =>
{
tcs.TrySetResult(endpoint);
return Task.CompletedTask;
});
var result = await tcs.Task;
Assert.Equal(IPAddress.IPv6Any, result.IPEndPoint.Address);
[Fact]
public void ParseAddressLocalhost()
{
var options = new KestrelServerOptions();
var listenOptions = AddressBinder.ParseAddress("http://localhost", options, Mock.Of<IDefaultHttpsProvider>());
Assert.IsType<LocalhostListenOptions>(listenOptions);
Assert.Equal(ListenType.IPEndPoint, listenOptions.Type);
Assert.Equal(IPAddress.Loopback, listenOptions.IPEndPoint.Address);
Assert.Equal(80, listenOptions.IPEndPoint.Port);
}
[Fact]
public void ParseAddressUnixPipe()
{
var options = new KestrelServerOptions();
var listenOptions = AddressBinder.ParseAddress("http://unix:/tmp/kestrel-test.sock", options, Mock.Of<IDefaultHttpsProvider>());
Assert.Equal(ListenType.SocketPath, listenOptions.Type);
Assert.Equal("/tmp/kestrel-test.sock", listenOptions.SocketPath);
}
[Theory]
[InlineData("http://10.10.10.10:5000/", "10.10.10.10", 5000)]
[InlineData("http://[::1]:5000", "::1", 5000)]
[InlineData("http://[::1]", "::1", 80)]
[InlineData("http://127.0.0.1", "127.0.0.1", 80)]
[InlineData("https://127.0.0.1", "127.0.0.1", 443)]
public void ParseAddressIP(string address, string ip, int port)
{
var options = new KestrelServerOptions();
var listenOptions = AddressBinder.ParseAddress(address, options, Mock.Of<IDefaultHttpsProvider>());
Assert.Equal(ListenType.IPEndPoint, listenOptions.Type);
Assert.Equal(IPAddress.Parse(ip), listenOptions.IPEndPoint.Address);
Assert.Equal(port, listenOptions.IPEndPoint.Port);
}
[Fact]

View File

@ -187,7 +187,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
private Task RegisterAddresses_Success(string addressInput, string testUrl, int testPort = 0)
=> RegisterAddresses_Success(addressInput, new[] { testUrl }, testPort);
private async Task RegisterAddresses_StaticPort_Success(string addressInput, string[] testUrls)
private Task RegisterAddresses_StaticPort_Success(string addressInput, string[] testUrls) =>
RunTestWithStaticPort(port => RegisterAddresses_Success($"{addressInput}:{port}", testUrls, port));
private async Task RunTestWithStaticPort(Func<int, Task> test)
{
var retryCount = 0;
var errors = new List<Exception>();
@ -197,7 +200,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
try
{
var port = GetNextPort();
await RegisterAddresses_Success($"{addressInput}:{port}", testUrls, port);
await test(port);
return;
}
catch (XunitException)
@ -254,34 +257,93 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
}
}
private async Task RegisterIPEndPoint_StaticPort_Success(IPAddress address, string testUrl)
private Task RegisterIPEndPoint_StaticPort_Success(IPAddress address, string testUrl)
=> RunTestWithStaticPort(port => RegisterIPEndPoint_Success(new IPEndPoint(address, port), testUrl, port));
[ConditionalFact]
public async Task ListenAnyIP_IPv4_Success()
{
var retryCount = 0;
var errors = new List<Exception>();
await ListenAnyIP_Success(new[] { "http://localhost", "http://127.0.0.1" });
}
while (retryCount < MaxRetries)
[ConditionalFact]
[IPv6SupportedCondition]
public async Task ListenAnyIP_IPv6_Success()
{
await ListenAnyIP_Success(new[] { "http://[::1]", "http://localhost", "http://127.0.0.1" });
}
[ConditionalFact]
[NetworkIsReachable]
public async Task ListenAnyIP_HostName_Success()
{
var hostName = Dns.GetHostName();
await ListenAnyIP_Success(new[] { $"http://{hostName}" });
}
private async Task ListenAnyIP_Success(string[] testUrls, int testPort = 0)
{
var hostBuilder = TransportSelector.GetWebHostBuilder()
.UseKestrel(options =>
{
options.ListenAnyIP(testPort);
})
.ConfigureLogging(_configureLoggingDelegate)
.Configure(ConfigureEchoAddress);
using (var host = hostBuilder.Build())
{
try
{
var port = GetNextPort();
await RegisterIPEndPoint_Success(new IPEndPoint(address, port), testUrl, port);
return;
}
catch (XunitException)
{
throw;
}
catch (Exception ex)
{
errors.Add(ex);
}
host.Start();
retryCount++;
foreach (var testUrl in testUrls.Select(testUrl => $"{testUrl}:{(testPort == 0 ? host.GetPort() : testPort)}"))
{
var response = await HttpClientSlim.GetStringAsync(testUrl, validateCertificate: false);
// Compare the response with Uri.ToString(), rather than testUrl directly.
// Required to handle IPv6 addresses with zone index, like "fe80::3%1"
Assert.Equal(new Uri(testUrl).ToString(), response);
}
}
}
if (errors.Any())
[ConditionalFact]
public async Task ListenLocalhost_IPv4LocalhostStaticPort_Success()
{
await ListenLocalhost_StaticPort_Success(new[] { "http://localhost", "http://127.0.0.1" });
}
[ConditionalFact]
[IPv6SupportedCondition]
public async Task ListenLocalhost_IPv6LocalhostStaticPort_Success()
{
await ListenLocalhost_StaticPort_Success(new[] { "http://localhost", "http://127.0.0.1", "http://[::1]" });
}
private Task ListenLocalhost_StaticPort_Success(string[] testUrls) =>
RunTestWithStaticPort(port => ListenLocalhost_Success(testUrls, port));
private async Task ListenLocalhost_Success(string[] testUrls, int testPort = 0)
{
var hostBuilder = TransportSelector.GetWebHostBuilder()
.UseKestrel(options =>
{
options.ListenLocalhost(testPort);
})
.ConfigureLogging(_configureLoggingDelegate)
.Configure(ConfigureEchoAddress);
using (var host = hostBuilder.Build())
{
throw new AggregateException(errors);
host.Start();
foreach (var testUrl in testUrls.Select(testUrl => $"{testUrl}:{(testPort == 0 ? host.GetPort() : testPort)}"))
{
var response = await HttpClientSlim.GetStringAsync(testUrl, validateCertificate: false);
// Compare the response with Uri.ToString(), rather than testUrl directly.
// Required to handle IPv6 addresses with zone index, like "fe80::3%1"
Assert.Equal(new Uri(testUrl).ToString(), response);
}
}
}

View File

@ -41,7 +41,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Transport.Libuv.Tests
_bufferPool = new MemoryPool();
_mockLibuv = new MockLibuv();
var libuvTransport = new LibuvTransport(_mockLibuv, new TestLibuvTransportContext(), new ListenOptions(0));
var libuvTransport = new LibuvTransport(_mockLibuv, new TestLibuvTransportContext(), new ListenOptions((ulong)0));
_libuvThread = new LibuvThread(libuvTransport, maxLoops: 1);
_libuvThread.StartAsync().Wait();
}