diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/ListenerPrimaryTests.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/ListenerPrimaryTests.cs new file mode 100644 index 0000000000..471aa42b43 --- /dev/null +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/ListenerPrimaryTests.cs @@ -0,0 +1,229 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel; +using Microsoft.AspNetCore.Server.Kestrel.Internal; +using Microsoft.AspNetCore.Server.Kestrel.Internal.Http; +using Microsoft.AspNetCore.Server.Kestrel.Internal.Networking; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Microsoft.AspNetCore.Server.KestrelTests +{ + public class ListenerPrimaryTests + { + [Fact] + public async Task ConnectionsGetRoundRobinedToSecondaryListeners() + { + var libuv = new Libuv(); + + var serviceContextPrimary = new TestServiceContext + { + FrameFactory = context => + { + return new Frame(new TestApplication(c => + { + return c.Response.WriteAsync("Primary"); + }), context); + } + }; + + var serviceContextSecondary = new ServiceContext + { + Log = serviceContextPrimary.Log, + AppLifetime = serviceContextPrimary.AppLifetime, + DateHeaderValueManager = serviceContextPrimary.DateHeaderValueManager, + ServerOptions = serviceContextPrimary.ServerOptions, + ThreadPool = serviceContextPrimary.ThreadPool, + FrameFactory = context => + { + return new Frame(new TestApplication(c => + { + return c.Response.WriteAsync("Secondary"); ; + }), context); + } + }; + + using (var kestrelEngine = new KestrelEngine(libuv, serviceContextPrimary)) + { + var address = ServerAddress.FromUrl("http://127.0.0.1:0/"); + var pipeName = (libuv.IsWindows ? @"\\.\pipe\kestrel_" : "/tmp/kestrel_") + Guid.NewGuid().ToString("n"); + + // Start primary listener + var kestrelThreadPrimary = new KestrelThread(kestrelEngine); + await kestrelThreadPrimary.StartAsync(); + var listenerPrimary = new TcpListenerPrimary(serviceContextPrimary); + await listenerPrimary.StartAsync(pipeName, address, kestrelThreadPrimary); + + // Until a secondary listener is added, TCP connections get dispatched directly + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + + // Add secondary listener + var kestrelThreadSecondary = new KestrelThread(kestrelEngine); + await kestrelThreadSecondary.StartAsync(); + var listenerSecondary = new TcpListenerSecondary(serviceContextSecondary); + await listenerSecondary.StartAsync(pipeName, address, kestrelThreadSecondary); + + // Once a secondary listener is added, TCP connections start getting dispatched to it + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + + // TCP connections will still get round-robined to the primary listener + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + + await listenerSecondary.DisposeAsync(); + kestrelThreadSecondary.Stop(TimeSpan.FromSeconds(1)); + + await listenerPrimary.DisposeAsync(); + kestrelThreadPrimary.Stop(TimeSpan.FromSeconds(1)); + } + } + + // https://github.com/aspnet/KestrelHttpServer/issues/1182 + [Fact] + public async Task NonListenerPipeConnectionsAreLoggedAndIgnored() + { + var libuv = new Libuv(); + + var primaryTrace = new TestKestrelTrace(); + + var serviceContextPrimary = new TestServiceContext + { + Log = primaryTrace, + FrameFactory = context => + { + return new Frame(new TestApplication(c => + { + return c.Response.WriteAsync("Primary"); + }), context); + } + }; + + var serviceContextSecondary = new ServiceContext + { + Log = new TestKestrelTrace(), + AppLifetime = serviceContextPrimary.AppLifetime, + DateHeaderValueManager = serviceContextPrimary.DateHeaderValueManager, + ServerOptions = serviceContextPrimary.ServerOptions, + ThreadPool = serviceContextPrimary.ThreadPool, + FrameFactory = context => + { + return new Frame(new TestApplication(c => + { + return c.Response.WriteAsync("Secondary"); ; + }), context); + } + }; + + using (var kestrelEngine = new KestrelEngine(libuv, serviceContextPrimary)) + { + var address = ServerAddress.FromUrl("http://127.0.0.1:0/"); + var pipeName = (libuv.IsWindows ? @"\\.\pipe\kestrel_" : "/tmp/kestrel_") + Guid.NewGuid().ToString("n"); + + // Start primary listener + var kestrelThreadPrimary = new KestrelThread(kestrelEngine); + await kestrelThreadPrimary.StartAsync(); + var listenerPrimary = new TcpListenerPrimary(serviceContextPrimary); + await listenerPrimary.StartAsync(pipeName, address, kestrelThreadPrimary); + + // Add secondary listener + var kestrelThreadSecondary = new KestrelThread(kestrelEngine); + await kestrelThreadSecondary.StartAsync(); + var listenerSecondary = new TcpListenerSecondary(serviceContextSecondary); + await listenerSecondary.StartAsync(pipeName, address, kestrelThreadSecondary); + + // TCP Connections get round-robined + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + + // Create a pipe connection and keep it open without sending any data + var connectTcs = new TaskCompletionSource(); + var connectionTrace = new TestKestrelTrace(); + var pipe = new UvPipeHandle(connectionTrace); + + kestrelThreadPrimary.Post(_ => + { + var connectReq = new UvConnectRequest(connectionTrace); + + pipe.Init(kestrelThreadPrimary.Loop, kestrelThreadPrimary.QueueCloseHandle); + connectReq.Init(kestrelThreadPrimary.Loop); + + connectReq.Connect( + pipe, + pipeName, + (req, status, ex, __) => + { + req.Dispose(); + + if (ex != null) + { + connectTcs.SetException(ex); + } + else + { + connectTcs.SetResult(null); + } + }, + null); + }, null); + + await connectTcs.Task; + + // TCP connections will still get round-robined between only the two listeners + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + + await kestrelThreadPrimary.PostAsync(_ => pipe.Dispose(), null); + + // Same for after the non-listener pipe connection is closed + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Secondary", await HttpClientSlim.GetStringAsync(address.ToString())); + Assert.Equal("Primary", await HttpClientSlim.GetStringAsync(address.ToString())); + + await listenerSecondary.DisposeAsync(); + kestrelThreadSecondary.Stop(TimeSpan.FromSeconds(1)); + + await listenerPrimary.DisposeAsync(); + kestrelThreadPrimary.Stop(TimeSpan.FromSeconds(1)); + } + + Assert.Equal(1, primaryTrace.Logger.TotalErrorsLogged); + var errorMessage = primaryTrace.Logger.Messages.First(m => m.LogLevel == LogLevel.Error); + Assert.Contains("EOF", errorMessage.Exception.ToString()); + } + + private class TestApplication : IHttpApplication + { + private readonly Func _app; + + public TestApplication(Func app) + { + _app = app; + } + + public DefaultHttpContext CreateContext(IFeatureCollection contextFeatures) + { + return new DefaultHttpContext(contextFeatures); + } + + public Task ProcessRequestAsync(DefaultHttpContext context) + { + return _app(context); + } + + public void DisposeContext(DefaultHttpContext context, Exception exception) + { + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/HttpClientSlim.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/HttpClientSlim.cs new file mode 100644 index 0000000000..02cf1880fe --- /dev/null +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/HttpClientSlim.cs @@ -0,0 +1,129 @@ +// 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.Net.Http; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Testing +{ + // Lightweight version of HttpClient implemented using Socket and SslStream + public static class HttpClientSlim + { + public static Task GetStringAsync(string requestUri, bool validateCertificate = true) + => GetStringAsync(new Uri(requestUri), validateCertificate); + + public static async Task GetStringAsync(Uri requestUri, bool validateCertificate = true) + { + using (var stream = await GetStream(requestUri, validateCertificate)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"GET {requestUri.PathAndQuery} HTTP/1.0\r\n"); + await writer.WriteAsync($"Host: {requestUri.Authority}\r\n"); + await writer.WriteAsync("\r\n"); + } + + return await ReadResponse(stream); + } + } + + public static Task PostAsync(string requestUri, HttpContent content, bool validateCertificate = true) + => PostAsync(new Uri(requestUri), content, validateCertificate); + + public static async Task PostAsync(Uri requestUri, HttpContent content, bool validateCertificate = true) + { + using (var stream = await GetStream(requestUri, validateCertificate)) + { + using (var writer = new StreamWriter(stream, Encoding.ASCII, bufferSize: 1024, leaveOpen: true)) + { + await writer.WriteAsync($"POST {requestUri.PathAndQuery} HTTP/1.0\r\n"); + await writer.WriteAsync($"Host: {requestUri.Authority}\r\n"); + await writer.WriteAsync($"Content-Type: {content.Headers.ContentType}\r\n"); + await writer.WriteAsync($"Content-Length: {content.Headers.ContentLength}\r\n"); + await writer.WriteAsync("\r\n"); + } + + await content.CopyToAsync(stream); + + return await ReadResponse(stream); + } + } + + private static async Task ReadResponse(Stream stream) + { + using (var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, leaveOpen: true)) + { + var response = await reader.ReadToEndAsync(); + + var status = GetStatus(response); + new HttpResponseMessage(status).EnsureSuccessStatusCode(); + + var body = response.Substring(response.IndexOf("\r\n\r\n") + 4); + return body; + } + + } + + private static HttpStatusCode GetStatus(string response) + { + var statusStart = response.IndexOf(' ') + 1; + var statusEnd = response.IndexOf(' ', statusStart) - 1; + var statusLength = statusEnd - statusStart + 1; + return (HttpStatusCode)int.Parse(response.Substring(statusStart, statusLength)); + } + + private static async Task GetStream(Uri requestUri, bool validateCertificate) + { + var socket = await GetSocket(requestUri); + Stream stream = new NetworkStream(socket, ownsSocket: true); + + if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + var sslStream = new SslStream(stream, leaveInnerStreamOpen: false, userCertificateValidationCallback: + validateCertificate ? null : (RemoteCertificateValidationCallback)((a, b, c, d) => true)); + await sslStream.AuthenticateAsClientAsync(requestUri.Host, clientCertificates: null, + enabledSslProtocols: SslProtocols.Tls11 | SslProtocols.Tls12, + checkCertificateRevocation: validateCertificate); + return sslStream; + } + else + { + return stream; + } + } + + public static async Task GetSocket(Uri requestUri) + { + var tcs = new TaskCompletionSource(); + + var socketArgs = new SocketAsyncEventArgs(); + socketArgs.RemoteEndPoint = new DnsEndPoint(requestUri.DnsSafeHost, requestUri.Port); + socketArgs.Completed += (s, e) => tcs.TrySetResult(e.ConnectSocket); + + // Must use static ConnectAsync(), since instance Connect() does not support DNS names on OSX/Linux. + if (Socket.ConnectAsync(SocketType.Stream, ProtocolType.Tcp, socketArgs)) + { + await tcs.Task; + } + + var socket = socketArgs.ConnectSocket; + + if (socket == null) + { + throw new SocketException((int)socketArgs.SocketError); + } + else + { + return socket; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestApplicationErrorLogger.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestApplicationErrorLogger.cs index 2534de82da..4d97615b2c 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestApplicationErrorLogger.cs +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestApplicationErrorLogger.cs @@ -2,7 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using Microsoft.AspNetCore.Server.Kestrel; +using System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Server.Kestrel.Internal; using Microsoft.Extensions.Logging; @@ -13,9 +14,13 @@ namespace Microsoft.AspNetCore.Server.KestrelTests // Application errors are logged using 13 as the eventId. private const int ApplicationErrorEventId = 13; - public int TotalErrorsLogged { get; set; } + public List Messages { get; } = new List(); - public int ApplicationErrorsLogged { get; set; } + public int TotalErrorsLogged => Messages.Count(message => message.LogLevel == LogLevel.Error); + + public int CriticalErrorsLogged => Messages.Count(message => message.LogLevel == LogLevel.Critical); + + public int ApplicationErrorsLogged => Messages.Count(message => message.EventId.Id == ApplicationErrorEventId); public IDisposable BeginScope(TState state) { @@ -33,15 +38,14 @@ namespace Microsoft.AspNetCore.Server.KestrelTests Console.WriteLine($"Log {logLevel}[{eventId}]: {formatter(state, exception)} {exception?.Message}"); #endif - if (eventId.Id == ApplicationErrorEventId) - { - ApplicationErrorsLogged++; - } + Messages.Add(new LogMessage { LogLevel = logLevel, EventId = eventId, Exception = exception }); + } - if (logLevel == LogLevel.Error) - { - TotalErrorsLogged++; - } + public class LogMessage + { + public LogLevel LogLevel { get; set; } + public EventId EventId { get; set; } + public Exception Exception { get; set; } } } } diff --git a/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestKestrelTrace.cs b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestKestrelTrace.cs index 741ec54a0b..c3f0860064 100644 --- a/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestKestrelTrace.cs +++ b/test/Microsoft.AspNetCore.Server.KestrelTests/TestHelpers/TestKestrelTrace.cs @@ -1,5 +1,7 @@ +// 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 Microsoft.AspNetCore.Server.Kestrel.Internal; -using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.KestrelTests { @@ -9,10 +11,13 @@ namespace Microsoft.AspNetCore.Server.KestrelTests { } - public TestKestrelTrace(ILogger testLogger) : base(testLogger) + public TestKestrelTrace(TestApplicationErrorLogger testLogger) : base(testLogger) { + Logger = testLogger; } + public TestApplicationErrorLogger Logger { get; private set; } + public override void ConnectionRead(string connectionId, int count) { //_logger.LogDebug(1, @"Connection id ""{ConnectionId}"" recv {count} bytes.", connectionId, count);