Refactor address binding and handle EAFNOSUPPORT

- Simplify KestrelServer by refactoring address binding into a separate class
 - Use strategy pattern to implement address binding for different sceanrios
 - Add fallback from binding 0.0.0.0 if binding to [::] fails (can happen if UvException with EAFNOSUPPORT is thrown)
This commit is contained in:
Nate McMaster 2017-04-19 14:59:49 -07:00
parent 42d82a507d
commit 7a3a731686
5 changed files with 435 additions and 205 deletions

View File

@ -0,0 +1,307 @@
// 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.Builder;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure;
using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal
{
internal class AddressBinder
{
public static async Task BindAsync(IServerAddressesFeature addresses,
List<ListenOptions> listenOptions,
ILogger logger,
Func<ListenOptions, Task> createBinding)
{
var strategy = CreateStrategy(
listenOptions.ToArray(),
addresses.Addresses.ToArray(),
addresses.PreferHostingUrls);
var context = new AddressBindContext
{
Addresses = addresses.Addresses,
ListenOptions = listenOptions,
Logger = logger,
CreateBinding = createBinding
};
// reset options. The actual used options and addresses will be populated
// by the address binding feature
listenOptions.Clear();
addresses.Addresses.Clear();
await strategy.BindAsync(context).ConfigureAwait(false);
}
private class AddressBindContext
{
public ICollection<string> Addresses { get; set; }
public List<ListenOptions> ListenOptions { get; set; }
public ILogger Logger { get; set; }
public Func<ListenOptions, Task> CreateBinding { get; set; }
}
private static IStrategy CreateStrategy(ListenOptions[] listenOptions, string[] addresses, bool preferAddresses)
{
var hasListenOptions = listenOptions.Length > 0;
var hasAddresses = addresses.Length > 0;
if (preferAddresses && hasAddresses)
{
if (hasListenOptions)
{
return new OverrideWithAddressesStrategy(addresses);
}
return new AddressesStrategy(addresses);
}
else if (hasListenOptions)
{
if (hasAddresses)
{
return new OverrideWithEndpointsStrategy(listenOptions, addresses);
}
return new EndpointsStrategy(listenOptions);
}
else if (hasAddresses)
{
// If no endpoints are configured directly using KestrelServerOptions, use those configured via the IServerAddressesFeature.
return new AddressesStrategy(addresses);
}
else
{
// "localhost" for both IPv4 and IPv6 can't be represented as an IPEndPoint.
return new DefaultAddressStrategy();
}
}
/// <summary>
/// Returns an <see cref="IPEndPoint"/> for the given host an port.
/// If the host parameter isn't "localhost" or an IP address, use IPAddress.Any.
/// </summary>
protected internal static bool TryCreateIPEndPoint(ServerAddress address, out IPEndPoint endpoint)
{
if (!IPAddress.TryParse(address.Host, out var ip))
{
endpoint = null;
return false;
}
endpoint = new IPEndPoint(ip, address.Port);
return true;
}
private static Task BindEndpointAsync(IPEndPoint endpoint, AddressBindContext context)
=> BindEndpointAsync(new ListenOptions(endpoint), context);
private static async Task BindEndpointAsync(ListenOptions endpoint, AddressBindContext context)
{
try
{
await context.CreateBinding(endpoint).ConfigureAwait(false);
}
catch (AddressInUseException ex)
{
throw new IOException($"Failed to bind to address {endpoint}: address already in use.", ex);
}
context.ListenOptions.Add(endpoint);
}
private static async Task BindLocalhostAsync(ServerAddress address, AddressBindContext context)
{
if (address.Port == 0)
{
throw new InvalidOperationException("Dynamic port binding is not supported when binding to localhost. You must either bind to 127.0.0.1:0 or [::1]:0, or both.");
}
var exceptions = new List<Exception>();
try
{
await BindEndpointAsync(new IPEndPoint(IPAddress.Loopback, address.Port), context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, $"Unable to bind to {address} on the IPv4 loopback interface: ({ex.Message})");
exceptions.Add(ex);
}
try
{
await BindEndpointAsync(new IPEndPoint(IPAddress.IPv6Loopback, address.Port), context).ConfigureAwait(false);
}
catch (Exception ex) when (!(ex is IOException))
{
context.Logger.LogWarning(0, $"Unable to bind to {address} on the IPv6 loopback interface: ({ex.Message})");
exceptions.Add(ex);
}
if (exceptions.Count == 2)
{
throw new IOException($"Failed to bind to address {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)
{
var parsedAddress = ServerAddress.FromUrl(address);
if (parsedAddress.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"HTTPS endpoints can only be configured using {nameof(KestrelServerOptions)}.{nameof(KestrelServerOptions.Listen)}().");
}
else if (!parsedAddress.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unrecognized scheme in server address '{address}'. Only 'http://' is supported.");
}
if (!string.IsNullOrEmpty(parsedAddress.PathBase))
{
throw new InvalidOperationException($"A path base can only be configured using {nameof(IApplicationBuilder)}.UsePathBase().");
}
if (parsedAddress.IsUnixPipe)
{
var endPoint = new ListenOptions(parsedAddress.UnixPipePath);
await BindEndpointAsync(endPoint, context).ConfigureAwait(false);
context.Addresses.Add(endPoint.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).ConfigureAwait(false);
}
else
{
ListenOptions options;
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))
{
// 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());
}
}
private interface IStrategy
{
Task BindAsync(AddressBindContext context);
}
private class DefaultAddressStrategy : IStrategy
{
public async Task BindAsync(AddressBindContext context)
{
context.Logger.LogDebug($"No listening endpoints were configured. Binding to {Constants.DefaultServerAddress} by default.");
await BindLocalhostAsync(ServerAddress.FromUrl(Constants.DefaultServerAddress), context).ConfigureAwait(false);
}
}
private class OverrideWithAddressesStrategy : AddressesStrategy
{
public OverrideWithAddressesStrategy(IReadOnlyCollection<string> addresses)
: base(addresses)
{
}
public override Task BindAsync(AddressBindContext context)
{
var joined = string.Join(", ", _addresses);
context.Logger.LogInformation($"Overriding endpoints defined in UseKestrel() since {nameof(IServerAddressesFeature.PreferHostingUrls)} is set to true. Binding to address(es) '{joined}' instead.");
return base.BindAsync(context);
}
}
private class OverrideWithEndpointsStrategy : EndpointsStrategy
{
private readonly string[] _originalAddresses;
public OverrideWithEndpointsStrategy(IReadOnlyCollection<ListenOptions> endpoints, string[] originalAddresses)
: base(endpoints)
{
_originalAddresses = originalAddresses;
}
public override Task BindAsync(AddressBindContext context)
{
var joined = string.Join(", ", _originalAddresses);
context.Logger.LogWarning($"Overriding address(es) {joined}. Binding to endpoints defined in UseKestrel() instead.");
return base.BindAsync(context);
}
}
private class EndpointsStrategy : IStrategy
{
private readonly IReadOnlyCollection<ListenOptions> _endpoints;
public EndpointsStrategy(IReadOnlyCollection<ListenOptions> endpoints)
{
_endpoints = endpoints;
}
public virtual async Task BindAsync(AddressBindContext context)
{
foreach (var endpoint in _endpoints)
{
await BindEndpointAsync(endpoint, context).ConfigureAwait(false);
context.Addresses.Add(endpoint.GetDisplayName());
}
}
}
private class AddressesStrategy : IStrategy
{
protected readonly IReadOnlyCollection<string> _addresses;
public AddressesStrategy(IReadOnlyCollection<string> addresses)
{
_addresses = addresses;
}
public virtual async Task BindAsync(AddressBindContext context)
{
foreach (var address in _addresses)
{
await BindAddressAsync(address, context).ConfigureAwait(false);
}
}
}
}
}

View File

@ -3,12 +3,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http.Features;
@ -111,50 +107,16 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
ServerOptions = Options
};
var listenOptions = Options.ListenOptions;
var hasListenOptions = listenOptions.Any();
var hasServerAddresses = _serverAddresses.Addresses.Any();
if (_serverAddresses.PreferHostingUrls && hasServerAddresses)
async Task OnBind(ListenOptions endpoint)
{
if (hasListenOptions)
{
var joined = string.Join(", ", _serverAddresses.Addresses);
_logger.LogInformation($"Overriding endpoints defined in UseKestrel() since {nameof(IServerAddressesFeature.PreferHostingUrls)} is set to true. Binding to address(es) '{joined}' instead.");
var connectionHandler = new ConnectionHandler<TContext>(endpoint, serviceContext, application);
var transport = _transportFactory.Create(endpoint, connectionHandler);
_transports.Add(transport);
listenOptions.Clear();
}
await BindToServerAddresses(listenOptions, serviceContext, application, cancellationToken).ConfigureAwait(false);
await transport.BindAsync().ConfigureAwait(false);
}
else if (hasListenOptions)
{
if (hasServerAddresses)
{
var joined = string.Join(", ", _serverAddresses.Addresses);
_logger.LogWarning($"Overriding address(es) '{joined}'. Binding to endpoints defined in UseKestrel() instead.");
_serverAddresses.Addresses.Clear();
}
await BindToEndpoints(listenOptions, serviceContext, application).ConfigureAwait(false);
}
else if (hasServerAddresses)
{
// If no endpoints are configured directly using KestrelServerOptions, use those configured via the IServerAddressesFeature.
await BindToServerAddresses(listenOptions, serviceContext, application, cancellationToken).ConfigureAwait(false);
}
else
{
_logger.LogDebug($"No listening endpoints were configured. Binding to {Constants.DefaultServerAddress} by default.");
// "localhost" for both IPv4 and IPv6 can't be represented as an IPEndPoint.
await StartLocalhostAsync(ServerAddress.FromUrl(Constants.DefaultServerAddress), serviceContext, application, cancellationToken).ConfigureAwait(false);
// If StartLocalhost doesn't throw, there is at least one listener.
// The port cannot change for "localhost".
_serverAddresses.Addresses.Add(Constants.DefaultServerAddress);
}
await AddressBinder.BindAsync(_serverAddresses, Options.ListenOptions, _logger, OnBind).ConfigureAwait(false);
}
catch (Exception ex)
{
@ -164,72 +126,6 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
}
}
private async Task BindToServerAddresses<TContext>(List<ListenOptions> listenOptions, ServiceContext serviceContext, IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
var copiedAddresses = _serverAddresses.Addresses.ToArray();
_serverAddresses.Addresses.Clear();
foreach (var address in copiedAddresses)
{
var parsedAddress = ServerAddress.FromUrl(address);
if (parsedAddress.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"HTTPS endpoints can only be configured using {nameof(KestrelServerOptions)}.{nameof(KestrelServerOptions.Listen)}().");
}
else if (!parsedAddress.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Unrecognized scheme in server address '{address}'. Only 'http://' is supported.");
}
if (!string.IsNullOrEmpty(parsedAddress.PathBase))
{
throw new InvalidOperationException($"A path base can only be configured using {nameof(IApplicationBuilder)}.UsePathBase().");
}
if (parsedAddress.IsUnixPipe)
{
listenOptions.Add(new ListenOptions(parsedAddress.UnixPipePath));
}
else if (string.Equals(parsedAddress.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
// "localhost" for both IPv4 and IPv6 can't be represented as an IPEndPoint.
await StartLocalhostAsync(parsedAddress, serviceContext, application, cancellationToken).ConfigureAwait(false);
// If StartLocalhost doesn't throw, there is at least one listener.
// The port cannot change for "localhost".
_serverAddresses.Addresses.Add(parsedAddress.ToString());
}
else
{
// These endPoints will be added later to _serverAddresses.Addresses
listenOptions.Add(new ListenOptions(CreateIPEndPoint(parsedAddress)));
}
}
await BindToEndpoints(listenOptions, serviceContext, application).ConfigureAwait(false);
}
private async Task BindToEndpoints<TContext>(List<ListenOptions> listenOptions, ServiceContext serviceContext, IHttpApplication<TContext> application)
{
foreach (var endPoint in listenOptions)
{
var connectionHandler = new ConnectionHandler<TContext>(endPoint, serviceContext, application);
var transport = _transportFactory.Create(endPoint, connectionHandler);
_transports.Add(transport);
try
{
await transport.BindAsync().ConfigureAwait(false);
}
catch (AddressInUseException ex)
{
throw new IOException($"Failed to bind to address {endPoint}: address already in use.", ex);
}
// If requested port was "0", replace with assigned dynamic port.
_serverAddresses.Addresses.Add(endPoint.GetDisplayName());
}
}
// Graceful shutdown if possible
public async Task StopAsync(CancellationToken cancellationToken)
{
@ -282,74 +178,5 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core
$"Maximum request buffer size ({Options.Limits.MaxRequestBufferSize.Value}) must be greater than or equal to maximum request headers size ({Options.Limits.MaxRequestHeadersTotalSize}).");
}
}
private async Task StartLocalhostAsync<TContext>(ServerAddress parsedAddress, ServiceContext serviceContext, IHttpApplication<TContext> application, CancellationToken cancellationToken)
{
if (parsedAddress.Port == 0)
{
throw new InvalidOperationException("Dynamic port binding is not supported when binding to localhost. You must either bind to 127.0.0.1:0 or [::1]:0, or both.");
}
var exceptions = new List<Exception>();
try
{
var ipv4ListenOptions = new ListenOptions(new IPEndPoint(IPAddress.Loopback, parsedAddress.Port));
var connectionHandler = new ConnectionHandler<TContext>(ipv4ListenOptions, serviceContext, application);
var transport = _transportFactory.Create(ipv4ListenOptions, connectionHandler);
_transports.Add(transport);
await transport.BindAsync().ConfigureAwait(false);
}
catch (AddressInUseException ex)
{
throw new IOException($"Failed to bind to address {parsedAddress} on the IPv4 loopback interface: port already in use.", ex);
}
catch (Exception ex)
{
_logger.LogWarning(0, $"Unable to bind to {parsedAddress} on the IPv4 loopback interface: ({ex.Message})");
exceptions.Add(ex);
}
try
{
var ipv6ListenOptions = new ListenOptions(new IPEndPoint(IPAddress.IPv6Loopback, parsedAddress.Port));
var connectionHandler = new ConnectionHandler<TContext>(ipv6ListenOptions, serviceContext, application);
var transport = _transportFactory.Create(ipv6ListenOptions, connectionHandler);
_transports.Add(transport);
await transport.BindAsync().ConfigureAwait(false);
}
catch (AddressInUseException ex)
{
throw new IOException($"Failed to bind to address {parsedAddress} on the IPv6 loopback interface: port already in use.", ex);
}
catch (Exception ex)
{
_logger.LogWarning(0, $"Unable to bind to {parsedAddress} on the IPv6 loopback interface: ({ex.Message})");
exceptions.Add(ex);
}
if (exceptions.Count == 2)
{
throw new IOException($"Failed to bind to address {parsedAddress}.", new AggregateException(exceptions));
}
}
/// <summary>
/// Returns an <see cref="IPEndPoint"/> for the given host an port.
/// If the host parameter isn't "localhost" or an IP address, use IPAddress.Any.
/// </summary>
internal static IPEndPoint CreateIPEndPoint(ServerAddress address)
{
IPAddress ip;
if (!IPAddress.TryParse(address.Host, out ip))
{
ip = IPAddress.IPv6Any;
}
return new IPEndPoint(ip, address.Port);
}
}
}

View File

@ -0,0 +1,121 @@
// 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.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Transport.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
{
public class AddressBinderTests
{
[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 CorrectIPEndpointsAreCreated(string address, string expectedAddress, int expectedPort)
{
Assert.True(AddressBinder.TryCreateIPEndPoint(
ServerAddress.FromUrl(address), out var endpoint));
Assert.NotNull(endpoint);
Assert.Equal(IPAddress.Parse(expectedAddress), endpoint.Address);
Assert.Equal(expectedPort, endpoint.Port);
}
[Theory]
[InlineData("http://*")]
[InlineData("http://*:5000")]
[InlineData("http://+:80")]
[InlineData("http://+")]
[InlineData("http://randomhost:6000")]
[InlineData("http://randomhost")]
[InlineData("https://randomhost")]
public void DoesNotCreateIPEndPointOnInvalidIPAddress(string address)
{
Assert.False(AddressBinder.TryCreateIPEndPoint(
ServerAddress.FromUrl(address), out var endpoint));
}
[Theory]
[InlineData("*")]
[InlineData("randomhost")]
[InlineData("+")]
[InlineData("contoso.com")]
public async Task DefaultsToIPv6AnyOnInvalidIPAddress(string host)
{
var addresses = new ServerAddressesFeature();
addresses.Addresses.Add($"http://{host}");
var options = new List<ListenOptions>();
var tcs = new TaskCompletionSource<ListenOptions>();
await AddressBinder.BindAsync(addresses,
options,
NullLogger.Instance,
endpoint =>
{
tcs.TrySetResult(endpoint);
return Task.CompletedTask;
});
var result = await tcs.Task;
Assert.Equal(IPAddress.IPv6Any, result.IPEndPoint.Address);
}
[Fact]
public async Task WrapsAddressInUseExceptionAsIOException()
{
var addresses = new ServerAddressesFeature();
addresses.Addresses.Add("http://localhost:5000");
var options = new List<ListenOptions>();
await Assert.ThrowsAsync<IOException>(() =>
AddressBinder.BindAsync(addresses,
options,
NullLogger.Instance,
endpoint => throw new AddressInUseException("already in use")));
}
[Theory]
[InlineData("http://*:80")]
[InlineData("http://+:80")]
[InlineData("http://contoso.com:80")]
public async Task FallbackToIPv4WhenIPv6AnyBindFails(string address)
{
var addresses = new ServerAddressesFeature();
addresses.Addresses.Add(address);
var options = new List<ListenOptions>();
var ipV6Attempt = false;
var ipV4Attempt = false;
await AddressBinder.BindAsync(addresses,
options,
NullLogger.Instance,
endpoint =>
{
if (endpoint.IPEndPoint.Address == IPAddress.IPv6Any)
{
ipV6Attempt = true;
throw new InvalidOperationException("EAFNOSUPPORT");
}
if (endpoint.IPEndPoint.Address == IPAddress.Any)
{
ipV4Attempt = true;
}
return Task.CompletedTask;
});
Assert.True(ipV4Attempt, "Should have attempted to bind to IPAddress.Any");
Assert.True(ipV6Attempt, "Should have attempted to bind to IPAddress.IPv6Any");
}
}
}

View File

@ -1,25 +0,0 @@
// 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.Net;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Xunit;
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests
{
public class CreateIPEndpointTests
{
[Theory]
[InlineData("10.10.10.10", "10.10.10.10")]
[InlineData("[::1]", "::1")]
[InlineData("randomhost", "::")] // "::" is IPAddress.IPv6Any
[InlineData("*", "::")] // "::" is IPAddress.IPv6Any
public void CorrectIPEndpointsAreCreated(string host, string expectedAddress)
{
var endpoint = KestrelServer.CreateIPEndPoint(ServerAddress.FromUrl($"http://{host}:5000/"));
Assert.NotNull(endpoint);
Assert.Equal(IPAddress.Parse(expectedAddress), endpoint.Address);
Assert.Equal(5000, endpoint.Port);
}
}
}

View File

@ -361,7 +361,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.FunctionalTests
{
var exception = Assert.Throws<IOException>(() => host.Start());
Assert.Equal(
$"Failed to bind to address http://localhost:{port} on the {(addressFamily == AddressFamily.InterNetwork ? "IPv4" : "IPv6")} loopback interface: port already in use.",
$"Failed to bind to address http://{(addressFamily == AddressFamily.InterNetwork ? "127.0.0.1" : "[::1]")}:{port}: address already in use.",
exception.Message);
}
}