diff --git a/src/Microsoft.AspNetCore.Server.IISIntegration/IISMiddleware.cs b/src/Microsoft.AspNetCore.Server.IISIntegration/IISMiddleware.cs index 030682807c..d51cb5de57 100644 --- a/src/Microsoft.AspNetCore.Server.IISIntegration/IISMiddleware.cs +++ b/src/Microsoft.AspNetCore.Server.IISIntegration/IISMiddleware.cs @@ -2,12 +2,11 @@ // 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.Diagnostics; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; @@ -20,13 +19,17 @@ namespace Microsoft.AspNetCore.Server.IISIntegration { private const string MSAspNetCoreClientCert = "MS-ASPNETCORE-CLIENTCERT"; private const string MSAspNetCoreToken = "MS-ASPNETCORE-TOKEN"; + private const string MSAspNetCoreEvent = "MS-ASPNETCORE-EVENT"; + private const string ANCMShutdownEventHeaderValue = "shutdown"; + private static readonly PathString ANCMRequestPath = new PathString("/iisintegration"); private readonly RequestDelegate _next; private readonly IISOptions _options; private readonly ILogger _logger; private readonly string _pairingToken; + private readonly IApplicationLifetime _applicationLifetime; - public IISMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions options, string pairingToken, IAuthenticationSchemeProvider authentication) + public IISMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions options, string pairingToken, IAuthenticationSchemeProvider authentication, IApplicationLifetime applicationLifetime) { if (next == null) { @@ -40,6 +43,10 @@ namespace Microsoft.AspNetCore.Server.IISIntegration { throw new ArgumentNullException(nameof(options)); } + if (applicationLifetime == null) + { + throw new ArgumentNullException(nameof(applicationLifetime)); + } if (string.IsNullOrEmpty(pairingToken)) { throw new ArgumentException("Missing or empty pairing token."); @@ -54,6 +61,7 @@ namespace Microsoft.AspNetCore.Server.IISIntegration } _pairingToken = pairingToken; + _applicationLifetime = applicationLifetime; _logger = loggerFactory.CreateLogger(); } @@ -62,7 +70,18 @@ namespace Microsoft.AspNetCore.Server.IISIntegration if (!string.Equals(_pairingToken, httpContext.Request.Headers[MSAspNetCoreToken], StringComparison.Ordinal)) { _logger.LogError($"'{MSAspNetCoreToken}' does not match the expected pairing token '{_pairingToken}', request rejected."); - httpContext.Response.StatusCode = 400; + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + // Handle shutdown from ANCM + if (HttpMethods.IsPost(httpContext.Request.Method) && + httpContext.Request.Path.Equals(ANCMRequestPath) && + string.Equals(ANCMShutdownEventHeaderValue, httpContext.Request.Headers[MSAspNetCoreEvent], StringComparison.OrdinalIgnoreCase)) + { + // Execute shutdown task on background thread without waiting for completion + var shutdownTask = Task.Run(() => _applicationLifetime.StopApplication()); + httpContext.Response.StatusCode = StatusCodes.Status202Accepted; return; } diff --git a/test/Microsoft.AspNetCore.Server.IISIntegration.Tests/IISMiddlewareTests.cs b/test/Microsoft.AspNetCore.Server.IISIntegration.Tests/IISMiddlewareTests.cs index b5136fd65e..d282c07c10 100644 --- a/test/Microsoft.AspNetCore.Server.IISIntegration.Tests/IISMiddlewareTests.cs +++ b/test/Microsoft.AspNetCore.Server.IISIntegration.Tests/IISMiddlewareTests.cs @@ -1,8 +1,10 @@ // 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.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; @@ -72,6 +74,165 @@ namespace Microsoft.AspNetCore.Server.IISIntegration Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Theory] + [InlineData("/", "/iisintegration", "shutdown")] + [InlineData("/", "/iisintegration", "Shutdown")] + [InlineData("/pathBase", "/pathBase/iisintegration", "shutdown")] + [InlineData("/pathBase", "/pathBase/iisintegration", "Shutdown")] + public async Task MiddlewareShutsdownGivenANCMShutdown(string pathBase, string requestPath, string shutdownEvent) + { + var requestExecuted = new ManualResetEvent(false); + var applicationStoppingFired = new ManualResetEvent(false); + var builder = new WebHostBuilder() + .UseSetting("TOKEN", "TestToken") + .UseSetting("PORT", "12345") + .UseSetting("APPL_PATH", pathBase) + .UseIISIntegration() + .Configure(app => + { + var appLifetime = app.ApplicationServices.GetRequiredService(); + appLifetime.ApplicationStopping.Register(() => applicationStoppingFired.Set()); + + app.Run(context => + { + requestExecuted.Set(); + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + var request = new HttpRequestMessage(HttpMethod.Post, requestPath); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-TOKEN", "TestToken"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-EVENT", shutdownEvent); + var response = await server.CreateClient().SendAsync(request); + + Assert.True(applicationStoppingFired.WaitOne(TimeSpan.FromSeconds(5))); + Assert.False(requestExecuted.WaitOne(0)); + Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); + } + + public static TheoryData InvalidShutdownMethods + { + get + { + return new TheoryData + { + HttpMethod.Put, + HttpMethod.Trace, + HttpMethod.Head, + HttpMethod.Get, + HttpMethod.Delete, + HttpMethod.Options + }; + } + } + + [Theory] + [MemberData(nameof(InvalidShutdownMethods))] + public async Task MiddlewareIgnoresShutdownGivenWrongMethod(HttpMethod method) + { + var requestExecuted = new ManualResetEvent(false); + var applicationStoppingFired = new ManualResetEvent(false); + var builder = new WebHostBuilder() + .UseSetting("TOKEN", "TestToken") + .UseSetting("PORT", "12345") + .UseSetting("APPL_PATH", "/") + .UseIISIntegration() + .Configure(app => + { + var appLifetime = app.ApplicationServices.GetRequiredService(); + appLifetime.ApplicationStopping.Register(() => applicationStoppingFired.Set()); + + app.Run(context => + { + requestExecuted.Set(); + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + var request = new HttpRequestMessage(method, "/iisintegration"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-TOKEN", "TestToken"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-EVENT", "shutdown"); + var response = await server.CreateClient().SendAsync(request); + + Assert.False(applicationStoppingFired.WaitOne(TimeSpan.FromSeconds(1))); + Assert.True(requestExecuted.WaitOne(0)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("/")] + [InlineData("/path")] + [InlineData("/path/iisintegration")] + public async Task MiddlewareIgnoresShutdownGivenWrongPath(string path) + { + var requestExecuted = new ManualResetEvent(false); + var applicationStoppingFired = new ManualResetEvent(false); + var builder = new WebHostBuilder() + .UseSetting("TOKEN", "TestToken") + .UseSetting("PORT", "12345") + .UseSetting("APPL_PATH", "/") + .UseIISIntegration() + .Configure(app => + { + var appLifetime = app.ApplicationServices.GetRequiredService(); + appLifetime.ApplicationStopping.Register(() => applicationStoppingFired.Set()); + + app.Run(context => + { + requestExecuted.Set(); + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + var request = new HttpRequestMessage(HttpMethod.Post, path); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-TOKEN", "TestToken"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-EVENT", "shutdown"); + var response = await server.CreateClient().SendAsync(request); + + Assert.False(applicationStoppingFired.WaitOne(TimeSpan.FromSeconds(1))); + Assert.True(requestExecuted.WaitOne(0)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("event")] + [InlineData("")] + [InlineData(null)] + public async Task MiddlewareIgnoresShutdownGivenWrongEvent(string shutdownEvent) + { + var requestExecuted = new ManualResetEvent(false); + var applicationStoppingFired = new ManualResetEvent(false); + var builder = new WebHostBuilder() + .UseSetting("TOKEN", "TestToken") + .UseSetting("PORT", "12345") + .UseSetting("APPL_PATH", "/") + .UseIISIntegration() + .Configure(app => + { + var appLifetime = app.ApplicationServices.GetRequiredService(); + appLifetime.ApplicationStopping.Register(() => applicationStoppingFired.Set()); + + app.Run(context => + { + requestExecuted.Set(); + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + var request = new HttpRequestMessage(HttpMethod.Post, "/iisintegration"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-TOKEN", "TestToken"); + request.Headers.TryAddWithoutValidation("MS-ASPNETCORE-EVENT", shutdownEvent); + var response = await server.CreateClient().SendAsync(request); + + Assert.False(applicationStoppingFired.WaitOne(TimeSpan.FromSeconds(1))); + Assert.True(requestExecuted.WaitOne(0)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + [Fact] public void UrlDelayRegisteredAndPreferHostingUrlsSet() {