diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87609f4..f2b040e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,10 +9,17 @@ on: workflow_dispatch: jobs: - build: runs-on: windows-latest + strategy: + matrix: + profile: + - publishProfile: "Release.pubxml" + artifactName: "nomad_iis" + - publishProfile: "ReleaseWithMgmtApi.pubxml" + artifactName: "nomad_iis_mgmt_api" + steps: - name: Checkout uses: actions/checkout@v4 @@ -27,7 +34,7 @@ jobs: # Publish the application - name: Publish the application - run: dotnet publish "./src/NomadIIS/NomadIIS.csproj" /p:PublishProfile="./src/NomadIIS/Properties/PublishProfiles/Release.pubxml" + run: dotnet publish "./src/NomadIIS/NomadIIS.csproj" /p:PublishProfile="./src/NomadIIS/Properties/PublishProfiles/${{ matrix.profile.publishProfile }}" # Copy to output - name: Copy to output folder @@ -39,5 +46,5 @@ jobs: - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: nomad_iis + name: ${{ matrix.profile.artifactName }} path: ./dist diff --git a/README.md b/README.md index cab173e..3c3d33d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

-This repository contains a task driver for [HashiCorp Nomad](https://www.nomadproject.io/) to run web-applications in IIS on Windows machines. Unlike most other Nomad task drivers, this one is written in the C# language using ASP.NET 8. +A task driver for [HashiCorp Nomad](https://www.nomadproject.io/) to run web-applications in IIS on Windows machines. Unlike most other Nomad task drivers, this one is written in the C# language using ASP.NET 8. It uses the *Microsoft.Web.Administration*-API to communicate with IIS. Feel free to use it as-is or as a reference implementation for your own C#-based Nomad-plugins. @@ -28,13 +28,14 @@ Feel free to use it as-is or as a reference implementation for your own C#-based | Virtual Directories | ✔ | Support for multiple *virtual directories* below an application. | | HTTP Bindings | ✔ | | | HTTPS Bindings | ✔ | [GH-3](https://github.com/sevensolutions/nomad-iis/issues/3) | -| Environment Variables | ✔ | [Details](#-environment-variables) | +| Environment Variables | ✔ | [Details](https://nomad-iis.sevensolutions.cc/docs/features/environment-variables) | | Resource Statistics | ✔ | | | Logging | ✔ | Experimental UDP logging. See [GH-6](https://github.com/sevensolutions/nomad-iis/issues/6) for details. | -| Signals with `nomad alloc signal` | ✔ | [Details](#-supported-signals) | +| Signals | ✔ | [Details](https://nomad-iis.sevensolutions.cc/docs/features/signals) | | Exec (Shell Access) | ❌ | I'am playing around a little bit but don't want to give you hope :/. See [GH-15](https://github.com/sevensolutions/nomad-iis/issues/15) for status. | -| Filesystem Isolation | 🔶 | [Details](#-filesystem-isolation) | +| Filesystem Isolation | 🔶 | [Details](https://nomad-iis.sevensolutions.cc/docs/features/filesystem-isolation) | | Nomad Networking | ❌ | | +| Management API | ✔ | *Nomad IIS* provides a very powerfull [Management API](https://nomad-iis.sevensolutions.cc/docs/features/management-api) with functionalities like taking a local screenshot or creating a process dump. | ## 📚 Documentation diff --git a/examples/agent.dev.hcl b/examples/agent.dev.hcl index 30cadac..4286b3b 100644 --- a/examples/agent.dev.hcl +++ b/examples/agent.dev.hcl @@ -4,9 +4,15 @@ log_level = "TRACE" plugin "nomad_iis" { + args = ["--management-api-port=5004", "--management-api-key=12345"] config { enabled = true, - fingerprint_interval = "10s" - allowed_target_websites = [ "Default Web Site" ] + fingerprint_interval = "10s" + allowed_target_websites = [ "Default Web Site" ] + + procdump { + binary_path = "C:\\procdump.exe" + accept_eula = true + } } } \ No newline at end of file diff --git a/examples/agent.hcl b/examples/agent.hcl index 464c505..ccaf4af 100644 --- a/examples/agent.hcl +++ b/examples/agent.hcl @@ -18,6 +18,6 @@ client { plugin "nomad_iis" { config { enabled = true, - fingerprint_interval = "10s" + fingerprint_interval = "10s" } } \ No newline at end of file diff --git a/src/NomadIIS.Tests/Data/serverAndClient.hcl b/src/NomadIIS.Tests/Data/configs/default.hcl similarity index 76% rename from src/NomadIIS.Tests/Data/serverAndClient.hcl rename to src/NomadIIS.Tests/Data/configs/default.hcl index d8fb022..6fb8d99 100644 --- a/src/NomadIIS.Tests/Data/serverAndClient.hcl +++ b/src/NomadIIS.Tests/Data/configs/default.hcl @@ -18,6 +18,7 @@ client { plugin "nomad_iis" { config { enabled = true, - fingerprint_interval = "10s" + fingerprint_interval = "10s" + allowed_target_websites = [ "Default Web Site" ] } } diff --git a/src/NomadIIS.Tests/Data/configs/with_api.hcl b/src/NomadIIS.Tests/Data/configs/with_api.hcl new file mode 100644 index 0000000..392f467 --- /dev/null +++ b/src/NomadIIS.Tests/Data/configs/with_api.hcl @@ -0,0 +1,30 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +log_level = "TRACE" + +bind_addr = "0.0.0.0" + +server { + enabled = true + bootstrap_expect = 1 +} + +client { + enabled = true + network_interface = "Ethernet" +} + +plugin "nomad_iis" { + args = ["--management-api-port=5004", "--management-api-key=12345"] + config { + enabled = true, + fingerprint_interval = "10s" + allowed_target_websites = [ "Default Web Site" ] + + procdump { + binary_path = "C:\\procdump.exe" + accept_eula = true + } + } +} diff --git a/src/NomadIIS.Tests/Data/server.hcl b/src/NomadIIS.Tests/Data/server.hcl deleted file mode 100644 index 0b8022a..0000000 --- a/src/NomadIIS.Tests/Data/server.hcl +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) HashiCorp, Inc. -# SPDX-License-Identifier: MPL-2.0 - -log_level = "TRACE" - -bind_addr = "0.0.0.0" - -server { - enabled = true - bootstrap_expect = 1 -} diff --git a/src/NomadIIS.Tests/IntegrationTests.cs b/src/NomadIIS.Tests/IntegrationTests.cs index 1c97df2..d2db1d7 100644 --- a/src/NomadIIS.Tests/IntegrationTests.cs +++ b/src/NomadIIS.Tests/IntegrationTests.cs @@ -221,4 +221,74 @@ public async Task JobWithSettings_PoolShouldHaveSettings () _output.WriteLine( "Job stopped." ); } + +#if MANAGEMENT_API + [Fact] + public async Task ManagementApi_TakeScreenshot () + { + var jobHcl = """ + job "screenshot-job" { + datacenters = ["dc1"] + type = "service" + + group "app" { + count = 1 + + network { + port "httplabel" {} + } + + task "app" { + driver = "iis" + + config { + application { + path = "C:\\inetpub\\wwwroot" + } + + binding { + type = "http" + port = "httplabel" + } + } + } + } + } + """; + + _output.WriteLine( "Submitting job..." ); + + var jobId = await _fixture.ScheduleJobAsync( jobHcl ); + + _output.WriteLine( $"Job Id: {jobId}" ); + + var allocations = await _fixture.ListJobAllocationsAsync( jobId ); + + if ( allocations is null || allocations.Length == 0 ) + Assert.Fail( "No job allocations" ); + + var allocId = allocations[0].Id; + var poolAndWebsiteName = $"nomad-{allocId}-app"; + + _output.WriteLine( $"AppPool and Website Name: {poolAndWebsiteName}" ); + + _fixture.AccessIIS( iis => + { + iis.AppPool( poolAndWebsiteName ).ShouldExist(); + iis.Website( poolAndWebsiteName ).ShouldExist(); + } ); + + var screenshotData = await _fixture.TakeScreenshotAsync( allocId, "app" ); + + _output.WriteLine( $"Returned screenshot with a size of {screenshotData.Length / 1024}kB." ); + + Assert.True( screenshotData.Length > 10_000, "Invalid screenshot received." ); + + _output.WriteLine( "Stopping job..." ); + + await _fixture.StopJobAsync( jobId ); + + _output.WriteLine( "Job stopped." ); + } +#endif } diff --git a/src/NomadIIS.Tests/NomadIIS.Tests.csproj b/src/NomadIIS.Tests/NomadIIS.Tests.csproj index d67fed5..2ecfa1e 100644 --- a/src/NomadIIS.Tests/NomadIIS.Tests.csproj +++ b/src/NomadIIS.Tests/NomadIIS.Tests.csproj @@ -9,6 +9,10 @@ true + + MANAGEMENT_API;$(DefineConstants) + + @@ -29,6 +33,10 @@ + + + + diff --git a/src/NomadIIS.Tests/NomadIISFixture.cs b/src/NomadIIS.Tests/NomadIISFixture.cs index 7fbaf7a..1a31766 100644 --- a/src/NomadIIS.Tests/NomadIISFixture.cs +++ b/src/NomadIIS.Tests/NomadIISFixture.cs @@ -6,6 +6,7 @@ using System.IO; using System.Net.Http; using System.Net.Http.Json; +using System.Text; using System.Threading; namespace NomadIIS.Tests; @@ -13,6 +14,9 @@ namespace NomadIIS.Tests; public sealed class NomadIISFixture : IAsyncLifetime { private readonly HttpClient _httpClient; +#if MANAGEMENT_API + private readonly HttpClient _apiHttpClient; +#endif private CancellationTokenSource _ctsNomad = new CancellationTokenSource(); private Thread? _nomadThread; @@ -23,6 +27,18 @@ public NomadIISFixture () BaseAddress = new Uri( "http://localhost:4646/v1/" ), Timeout = TimeSpan.FromSeconds( 10 ) }; + +#if MANAGEMENT_API + _apiHttpClient = new HttpClient() + { + BaseAddress = new Uri( "http://localhost:5004/api/v1/" ), + Timeout = TimeSpan.FromSeconds( 30 ), + DefaultRequestHeaders = + { + { "X-Api-Key", "12345" } + } + }; +#endif } public HttpClient HttpClient => _httpClient; @@ -37,19 +53,40 @@ public async Task InitializeAsync () pluginDir = @"..\..\..\..\NomadIIS\bin\Debug\net8.0"; var pluginDirectory = Path.GetFullPath( pluginDir ); - var configFile = Path.GetFullPath( @"Data\serverAndClient.hcl" ); + +#if MANAGEMENT_API + var configFile = Path.GetFullPath( @"Data\configs\with_api.hcl" ); +#else + var configFile = Path.GetFullPath( @"Data\configs\default.hcl" ); +#endif _nomadThread = new Thread( async () => { + var stdout = new StringBuilder(); + var stderr = new StringBuilder(); + var nomadCommand = Cli.Wrap( Path.Combine( nomadDirectory, "nomad.exe" ) ) - .WithArguments( $"agent -dev -plugin-dir=\"{pluginDirectory}\"" ) + .WithArguments( $"agent -dev -config=\"{configFile}\" -plugin-dir=\"{pluginDirectory}\"" ) .WithWorkingDirectory( nomadDirectory ) - .WithValidation( CommandResultValidation.None ); - - var result = await nomadCommand.ExecuteBufferedAsync( _ctsNomad.Token ); + .WithStandardOutputPipe( PipeTarget.ToDelegate( line => + { + stdout.AppendLine( line ); + Debug.WriteLine( line ); + } ) ) + .WithStandardOutputPipe( PipeTarget.ToDelegate( line => + { + stderr.AppendLine( line ); + Debug.WriteLine( line ); + } ) ); - Debug.WriteLine( result.StandardOutput ); - Debug.WriteLine( result.StandardError ); + try + { + var result = await nomadCommand.ExecuteBufferedAsync( _ctsNomad.Token ); + } + catch ( Exception ex ) + { + Debug.WriteLine( ex.Message ); + } } ); _nomadThread.Start(); @@ -172,4 +209,17 @@ public void AccessIIS ( Action action ) action( handle ); } + +#if MANAGEMENT_API + public async Task TakeScreenshotAsync ( string allocId, string taskName ) + { + var response = await _apiHttpClient.GetAsync( $"allocs/{allocId}/{taskName}/screenshot" ); + + response.EnsureSuccessStatusCode(); + + using var ms = new MemoryStream(); + await response.Content.CopyToAsync( ms ); + return ms.ToArray(); + } +#endif } diff --git a/src/NomadIIS/ManagementApi/ApiModel.cs b/src/NomadIIS/ManagementApi/ApiModel.cs new file mode 100644 index 0000000..3756436 --- /dev/null +++ b/src/NomadIIS/ManagementApi/ApiModel.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace NomadIIS.ManagementApi.ApiModel; + +public class TaskStatusResponse +{ + [JsonPropertyName( "allocId" )] + public string AllocId { get; set; } = default!; + [JsonPropertyName( "taskName" )] + public string TaskName { get; set; } = default!; + [JsonPropertyName( "applicationPool" )] + public ApplicationPool ApplicationPool { get; set; } = default!; +} +public class ApplicationPool +{ + [JsonPropertyName( "status" )] + public ApplicationPoolStatus Status { get; set; } + [JsonPropertyName( "isWorkerProcessRunning" )] + public bool IsWorkerProcessRunning { get; set; } +} + +[JsonConverter( typeof( JsonStringEnumConverter ) )] +public enum ApplicationPoolStatus +{ + Starting, + Started, + Stopping, + Stopped, + Unknown +} diff --git a/src/NomadIIS/ManagementApi/Authentication.cs b/src/NomadIIS/ManagementApi/Authentication.cs new file mode 100644 index 0000000..926053c --- /dev/null +++ b/src/NomadIIS/ManagementApi/Authentication.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NomadIIS.ManagementApi; +using System; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; + +namespace NomadIIS.ManagementApi +{ + public static class ApiKeyAuthenticationDefaults + { + public const string AuthenticationScheme = "ApiKey"; + } + + public sealed class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions + { + public string HeaderName { get; set; } = "X-Api-Key"; + public string? ApiKey { get; set; } + } + + public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler + { + public ApiKeyAuthenticationHandler ( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder ) + : base( options, logger, encoder ) + { + } + + protected override Task HandleAuthenticateAsync () + { + if ( string.IsNullOrEmpty( Options.ApiKey ) ) + { + return Task.FromResult( + AuthenticateResult.Success( + new AuthenticationTicket( new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ), Scheme.Name ) ) ); + } + + if ( !Request.Headers.ContainsKey( Options.HeaderName ) ) + return Task.FromResult( AuthenticateResult.Fail( $"Missing header {Options.HeaderName}." ) ); + + string headerValue = Request.Headers[Options.HeaderName]!; + + if ( headerValue != Options.ApiKey ) + return Task.FromResult( AuthenticateResult.Fail( "Invalid token." ) ); + + var principal = new ClaimsPrincipal( new ClaimsIdentity( Scheme.Name ) ); + + var ticket = new AuthenticationTicket( principal, Scheme.Name ); + + return Task.FromResult( AuthenticateResult.Success( ticket ) ); + } + } +} + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ApiKeyExtensions + { + public static AuthenticationBuilder AddApiKey ( this AuthenticationBuilder builder ) + => builder.AddScheme( ApiKeyAuthenticationDefaults.AuthenticationScheme, _ => { } ); + public static AuthenticationBuilder AddApiKey ( this AuthenticationBuilder builder, Action? options ) + => builder.AddScheme( ApiKeyAuthenticationDefaults.AuthenticationScheme, options ); + } +} diff --git a/src/NomadIIS/ManagementApi/ManagementApi.cs b/src/NomadIIS/ManagementApi/ManagementApi.cs new file mode 100644 index 0000000..42a0072 --- /dev/null +++ b/src/NomadIIS/ManagementApi/ManagementApi.cs @@ -0,0 +1,180 @@ +#if MANAGEMENT_API +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NomadIIS.Services; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Web; + +namespace NomadIIS.ManagementApi; + +[Route( "/api" )] +public sealed class ManagementApiController : Controller +{ + private readonly ManagementService _managementService; + + public ManagementApiController ( ManagementService managementService ) + { + _managementService = managementService; + } + + [HttpGet( "v1/allocs/{allocId}/{taskName}/status" )] + public async Task GetStatus ( string allocId, string taskName ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + var status = await taskHandle.GetStatusAsync(); + + return Ok( status ); + } + + [HttpPut( "v1/allocs/{allocId}/{taskName}/start" )] + public async Task StartAppPool ( string allocId, string taskName ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + await taskHandle.StartAppPoolAsync(); + + return Ok(); + } + [HttpPut( "v1/allocs/{allocId}/{taskName}/stop" )] + public async Task StopAppPool ( string allocId, string taskName ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + await taskHandle.StopAppPoolAsync(); + + return Ok(); + } + [HttpPut( "v1/allocs/{allocId}/{taskName}/recycle" )] + public async Task RecycleAppPool ( string allocId, string taskName ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + await taskHandle.RecycleAppPoolAsync(); + + return Ok(); + } + + [HttpGet( "v1/allocs/{allocId}/{taskName}/fs/{path}" )] + public async Task GetFileAsync ( string allocId, string taskName, string path ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + path = HttpUtility.UrlDecode( path ); + + await taskHandle.DownloadFileAsync( HttpContext.Response, path ); + + return Ok(); + } + [HttpPut( "v1/allocs/{allocId}/{taskName}/fs/{path}" )] + public async Task PutFileAsync ( string allocId, string taskName, string path, [FromQuery] bool clean = false ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + path = HttpUtility.UrlDecode( path ); + + var isZip = HttpContext.Request.ContentType == "application/zip"; + + await taskHandle.UploadFileAsync( HttpContext.Request.Body, isZip, path, false, clean ); + + return Ok(); + } + [HttpPatch( "v1/allocs/{allocId}/{taskName}/fs/{path}" )] + public async Task PatchFileAsync ( string allocId, string taskName, string path, [FromQuery] bool clean = false ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + path = HttpUtility.UrlDecode( path ); + + var isZip = HttpContext.Request.ContentType == "application/zip"; + + await taskHandle.UploadFileAsync( HttpContext.Request.Body, isZip, path, true, clean ); + + return Ok(); + } + [HttpDelete( "v1/allocs/{allocId}/{taskName}/fs/{path}" )] + public async Task DeleteFileAsync ( string allocId, string taskName, string path ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + path = HttpUtility.UrlDecode( path ); + + await taskHandle.DeleteFileAsync( path ); + + return Ok(); + } + + + [HttpGet( "v1/allocs/{allocId}/{taskName}/screenshot" )] + public async Task GetScreenshotAsync ( string allocId, string taskName, [FromQuery] string path = "/", CancellationToken cancellationToken = default ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + var screenshot = await taskHandle.TakeScreenshotAsync( path, cancellationToken ); + + if ( screenshot is null ) + return NotFound(); + + return File( screenshot, "image/png", $"screenshot_{allocId}_{taskName}_{DateTime.Now:yyyy-MM-dd-HH-mm-ss}.png" ); + } + + [HttpGet( "v1/allocs/{allocId}/{taskName}/procdump" )] + public async Task GetProcdumpAsync ( string allocId, string taskName, CancellationToken cancellationToken = default ) + { + var taskHandle = _managementService.TryGetHandleByAllocIdAndTaskName( allocId, taskName ); + + if ( taskHandle is null ) + return NotFound(); + + var dumpFile = new FileInfo( Path.GetTempFileName() + ".dmp" ); + + try + { + await taskHandle.TakeProcessDump( dumpFile, cancellationToken ); + + // Stream the file to the client + await Results + .File( dumpFile.FullName, "application/octet-stream", $"procdump_{allocId}_{taskName}_{DateTime.Now:yyyy-MM-dd-HH-mm-ss}.dmp" ) + .ExecuteAsync( HttpContext ); + + // Not needed but we need to return something + return Ok(); + } + finally + { + if ( dumpFile.Exists ) + dumpFile.Delete(); + } + } +} +#endif diff --git a/src/NomadIIS/NomadIIS.csproj b/src/NomadIIS/NomadIIS.csproj index 0a0d280..50ff35a 100644 --- a/src/NomadIIS/NomadIIS.csproj +++ b/src/NomadIIS/NomadIIS.csproj @@ -8,7 +8,11 @@ true Windows IIS Driver for HashiCorp Nomad Windows IIS Driver for HashiCorp Nomad - (C) Copyright 2023, Daniel Peinhopf + (C) Copyright 2024, Daniel Peinhopf + + + + MANAGEMENT_API;$(DefineConstants) @@ -22,6 +26,11 @@ + + + + + <_Parameter1>$(ProjectName).Tests diff --git a/src/NomadIIS/Program.cs b/src/NomadIIS/Program.cs index 7c5b3ed..c767735 100644 --- a/src/NomadIIS/Program.cs +++ b/src/NomadIIS/Program.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using NomadIIS.ManagementApi; using NomadIIS.Services; using NomadIIS.Services.Grpc; using Serilog; @@ -42,14 +43,32 @@ //System.Diagnostics.Debugger.Launch(); +var grpcPort = builder.Configuration.GetValue( "port", 5003 ); + +#if MANAGEMENT_API +var managementApiPort = builder.Configuration.GetValue( "management-api-port", 0 ); +#endif + builder.WebHost.ConfigureKestrel( config => { - var port = builder.Configuration.GetValue( "port", 5003 ); - - config.Listen( IPAddress.Loopback, port, listenOptions => + config.Listen( IPAddress.Loopback, grpcPort, listenOptions => { listenOptions.Protocols = HttpProtocols.Http2; } ); + +#if MANAGEMENT_API + if ( managementApiPort > 0 ) + { + // Needed for the /upload API because ZipArchive.Extract() is synchronous. + config.AllowSynchronousIO = true; + + config.Listen( IPAddress.Any, managementApiPort, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http1AndHttp2; + } ); + } +#endif + //config.ListenUnixSocket("/my-socket2.sock", listenOptions => //{ // listenOptions.Protocols = HttpProtocols.Http2; @@ -68,18 +87,64 @@ .AddGrpcHealthChecks() .AddCheck( "plugin", () => HealthCheckResult.Healthy( "SERVING" ) ); +builder.Services.AddProblemDetails(); + +#if MANAGEMENT_API +var mgmtApiKey = builder.Configuration.GetValue( "management-api-key" ); + +if ( managementApiPort > 0 ) +{ + builder.Services.AddAuthentication( config => + { + config.DefaultAuthenticateScheme = ApiKeyAuthenticationDefaults.AuthenticationScheme; + config.DefaultChallengeScheme = ApiKeyAuthenticationDefaults.AuthenticationScheme; + } ) + .AddApiKey( config => + { + config.ApiKey = mgmtApiKey; + } ); + + builder.Services.AddAuthorization(); + + builder.Services.AddControllers(); +} +#endif + var app = builder.Build(); +#if MANAGEMENT_API +if ( managementApiPort > 0 ) + app.UseAuthentication(); +#endif + app.UseRouting(); +#if MANAGEMENT_API +if ( managementApiPort > 0 ) + app.UseAuthorization(); +#endif + if ( app.Environment.IsDevelopment() ) app.MapGrpcReflectionService(); -app.MapGrpcService(); -app.MapGrpcService(); -app.MapGrpcService(); -app.MapGrpcService(); -app.MapGrpcService(); +// TODO: Limit them to the gRPC endpoint +app.MapGrpcService();//.RequireHost( $"*:{grpcPort}" ); +app.MapGrpcService();//.RequireHost( $"*:{grpcPort}" ); +app.MapGrpcService();//.RequireHost( $"*:{grpcPort}" ); +app.MapGrpcService();//.RequireHost( $"*:{grpcPort}" ); +app.MapGrpcService();//.RequireHost( $"*:{grpcPort}" ); + +#if MANAGEMENT_API +if ( managementApiPort > 0 ) +{ + var epApi = app + .MapControllers() + .RequireHost( $"*:{managementApiPort}" ); + + if ( !string.IsNullOrEmpty( mgmtApiKey ) ) + epApi.RequireAuthorization(); +} +#endif app.MapGet( "/", () => "This binary is a plugin. These are not meant to be executed directly. Please execute the program that consumes these plugins, which will load any plugins automatically." ); diff --git a/src/NomadIIS/Properties/PublishProfiles/ReleaseWithMgmtApi.pubxml b/src/NomadIIS/Properties/PublishProfiles/ReleaseWithMgmtApi.pubxml new file mode 100644 index 0000000..318478b --- /dev/null +++ b/src/NomadIIS/Properties/PublishProfiles/ReleaseWithMgmtApi.pubxml @@ -0,0 +1,24 @@ + + + + + false + false + true + Release + Any CPU + FileSystem + bin\Release\net8.0\publish\ + FileSystem + <_TargetId>Folder + + net8.0 + win-x64 + true + 56e61199-83fa-4aea-b695-e3a7d124e991 + true + MANAGEMENT_API;$(DefineConstants) + + \ No newline at end of file diff --git a/src/NomadIIS/Services/Configuration/DriverConfig.cs b/src/NomadIIS/Services/Configuration/DriverConfig.cs index cae0055..b1390f7 100644 --- a/src/NomadIIS/Services/Configuration/DriverConfig.cs +++ b/src/NomadIIS/Services/Configuration/DriverConfig.cs @@ -24,4 +24,22 @@ public sealed class DriverConfig [DefaultValue( 0 )] [ConfigurationField( "udp_logger_port" )] public int? UdpLoggerPort { get; set; } = 0; + + [DefaultValue( "C:\\inetpub\\wwwroot" )] + [ConfigurationField( "placeholder_app_path" )] + public string? PlaceholderAppPath { get; set; } + + [ConfigurationCollectionField( "procdumps", "procdump", 0, 1 )] + public DriverConfigProcdump[] Procdumps { get; set; } = default!; +} + +public sealed class DriverConfigProcdump +{ + [DefaultValue( "C:\\procdump.exe" )] + [ConfigurationField( "binary_path" )] + public string? BinaryPath { get; set; } + + [DefaultValue( false )] + [ConfigurationField( "accept_eula" )] + public bool AcceptEula { get; set; } } diff --git a/src/NomadIIS/Services/Configuration/HclSpecGenerator.cs b/src/NomadIIS/Services/Configuration/HclSpecGenerator.cs index 0f8b871..832fd69 100644 --- a/src/NomadIIS/Services/Configuration/HclSpecGenerator.cs +++ b/src/NomadIIS/Services/Configuration/HclSpecGenerator.cs @@ -103,7 +103,7 @@ private static string GetSchemaType ( Type type ) private static string ConvertDefaultValue ( object? value ) { if ( value is string strValue ) - return $"\"{strValue}\""; + return $"\"{strValue.Replace("\\", "\\\\")}\""; if ( value is bool bValue ) return bValue ? "true" : "false"; if ( value is int intValue ) diff --git a/src/NomadIIS/Services/FileSystemHelper.cs b/src/NomadIIS/Services/FileSystemHelper.cs new file mode 100644 index 0000000..8b043e5 --- /dev/null +++ b/src/NomadIIS/Services/FileSystemHelper.cs @@ -0,0 +1,72 @@ +using System.IO; +using System; +using System.Threading; +using System.Linq; + +namespace NomadIIS.Services +{ + public static class FileSystemHelper + { + public static void CopyDirectory ( string sourcePath, string targetPath ) + => CopyDirectory( new DirectoryInfo( sourcePath ), new DirectoryInfo( targetPath ) ); + public static void CopyDirectory ( DirectoryInfo source, DirectoryInfo target ) + { + Directory.CreateDirectory( target.FullName ); + + // Copy each file into the new directory + foreach ( var fi in source.GetFiles() ) + fi.CopyTo( Path.Combine( target.FullName, fi.Name ), true ); + + // Copy each subdirectory using recursion + foreach ( var diSourceSubDir in source.GetDirectories() ) + { + var nextTargetSubDir = target.CreateSubdirectory( diSourceSubDir.Name ); + + CopyDirectory( diSourceSubDir, nextTargetSubDir ); + } + } + + public static void CleanFolder ( string directoryPath ) + => CleanFolder( new DirectoryInfo( directoryPath ) ); + public static void CleanFolder ( DirectoryInfo directory ) + { + try + { + Try(); + } + catch ( IOException ) + { + // Wait a bit and try again + Thread.Sleep( 100 ); + Try(); + } + + void Try () + { + foreach ( FileInfo file in directory.EnumerateFiles() ) + file.Delete(); + foreach ( DirectoryInfo dir in directory.EnumerateDirectories() ) + dir.Delete( true ); + } + } + + public static string SanitizeRelativePath ( string path ) + { + if ( string.IsNullOrEmpty( path ) ) + throw new ArgumentNullException( nameof( path ) ); + + // Sanitize the path + path = path.Replace( '/', '\\' ); + + if ( Path.IsPathRooted( path ) ) + throw new ArgumentException( "Invalid path. Path must be relative to the task directory and not contain any path traversal.", nameof( path ) ); + + // I don't know if this is enough but better than nothing + var pathParts = path.Split( '\\' ); + if ( pathParts.Any( x => x == ".." ) ) + throw new ArgumentException( "Invalid path. Path must be relative to the task directory and not contain any path traversal.", nameof( path ) ); + + return path; + } + } +} diff --git a/src/NomadIIS/Services/Grpc/DriverService.cs b/src/NomadIIS/Services/Grpc/DriverService.cs index e62e207..37813de 100644 --- a/src/NomadIIS/Services/Grpc/DriverService.cs +++ b/src/NomadIIS/Services/Grpc/DriverService.cs @@ -94,7 +94,7 @@ public override async Task Fingerprint ( FingerprintRequest request, IServerStre { status = FingerprintResponse.Types.HealthState.Undetected; healthDescription = "Driver disabled"; - } + } await responseStream.WriteAsync( new FingerprintResponse() { @@ -125,10 +125,10 @@ public override async Task StartTask ( StartTaskRequest reque var task = request.Task; var handle = _managementService.CreateHandle( task.Id ); - + try { - var driverState = await handle.RunAsync( _logger, task ); + var driverState = await handle.RunAsync( task ); return new StartTaskResponse() { @@ -170,7 +170,7 @@ public override async Task StopTask ( StopTaskRequest request, if ( handle is not null ) { - await handle.StopAsync( _logger ); + await handle.StopAsync(); handle.Dispose(); } @@ -185,7 +185,7 @@ public override async Task SignalTask ( SignalTaskRequest re var handle = _managementService.TryGetHandle( request.TaskId ); if ( handle is not null ) - await handle.SignalAsync( _logger, request.Signal ); + await handle.SignalAsync( request.Signal ); return new SignalTaskResponse(); } @@ -198,7 +198,7 @@ public override async Task DestroyTask ( DestroyTaskRequest if ( handle is not null ) { - await handle.DestroyAsync( _logger ); + await handle.DestroyAsync(); handle.Dispose(); } @@ -262,7 +262,7 @@ public override Task RecoverTask ( RecoverTaskRequest reque // Note: Looks like request.TaskId is always empty here. var handle = _managementService.CreateHandle( request.Handle.Config.Id ); - handle.RecoverState( _logger, request ); + handle.RecoverState( request ); return Task.FromResult( new RecoverTaskResponse() ); } @@ -282,7 +282,7 @@ public override async Task TaskStats ( TaskStatsRequest request, IServerStreamWr if ( handle is null ) break; - var statistics = await handle.GetStatisticsAsync( _logger ); + var statistics = await handle.GetStatisticsAsync(); await responseStream.WriteAsync( new TaskStatsResponse() { @@ -308,7 +308,7 @@ public override async Task WaitTask ( WaitTaskRequest request, var handle = _managementService.TryGetHandle( request.TaskId ); - var exitCode = handle is not null ? await handle.WaitAsync( _logger ) : 0; + var exitCode = handle is not null ? await handle.WaitAsync() : 0; return new WaitTaskResponse() { diff --git a/src/NomadIIS/Services/HandshakeService.cs b/src/NomadIIS/Services/HandshakeService.cs index fb0143a..92345f6 100644 --- a/src/NomadIIS/Services/HandshakeService.cs +++ b/src/NomadIIS/Services/HandshakeService.cs @@ -31,7 +31,7 @@ public Task StartAsync ( CancellationToken cancellationToken ) private void OnServerStarted () { var addressFeature = _server.Features.GetRequiredFeature(); - var address = addressFeature.Addresses.First(); + var address = addressFeature.Addresses.First( x => x.Contains( "127.0.0.1" ) ); string connection = ""; if ( address.StartsWith( "http://" ) ) diff --git a/src/NomadIIS/Services/IisTaskHandle.cs b/src/NomadIIS/Services/IisTaskHandle.cs index 4d930ec..0a21ce7 100644 --- a/src/NomadIIS/Services/IisTaskHandle.cs +++ b/src/NomadIIS/Services/IisTaskHandle.cs @@ -1,18 +1,21 @@ -using Hashicorp.Nomad.Plugins.Drivers.Proto; +#if MANAGEMENT_API +using CliWrap; +using CliWrap.Buffered; +#endif +using Hashicorp.Nomad.Plugins.Drivers.Proto; using MessagePack; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Web.Administration; using NomadIIS.Services.Configuration; -using NomadIIS.Services.Grpc; using System; using System.Collections.Generic; -using System.Diagnostics; using System.IO; +using System.IO.Compression; using System.IO.Pipes; using System.Linq; -using System.Net; using System.Net.NetworkInformation; -using System.Net.Sockets; +using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Cryptography.X509Certificates; using System.Security.Principal; @@ -25,6 +28,7 @@ namespace NomadIIS.Services; public sealed class IisTaskHandle : IDisposable { private readonly ManagementService _owner; + private readonly ILogger _logger; private readonly CancellationTokenSource _ctsDisposed = new(); // Note: These fields need to be recovered by RecoverState()! @@ -35,24 +39,28 @@ public sealed class IisTaskHandle : IDisposable private readonly CpuStats _kernelModeCpuStats = new(); private readonly CpuStats _userModeCpuStats = new(); + private bool _appPoolStoppedIntentionally = false; + private NamedPipeClientStream? _stdoutLogStream; - internal IisTaskHandle ( ManagementService owner, string taskId ) + internal IisTaskHandle ( ManagementService owner, ILogger logger, string taskId ) { if ( string.IsNullOrWhiteSpace( taskId ) ) throw new ArgumentNullException( nameof( taskId ) ); _owner = owner ?? throw new ArgumentNullException( nameof( owner ) ); + _logger = logger; TaskId = taskId; } public string TaskId { get; } + public TaskConfig? TaskConfig => _taskConfig; public int? UdpLoggerPort => _state?.UdpLoggerPort; - public async Task RunAsync ( ILogger logger, TaskConfig task ) + public async Task RunAsync ( TaskConfig task ) { - logger.LogInformation( $"Starting task {task.Id} (Alloc: {task.AllocId})..." ); + _logger.LogInformation( $"Starting task {task.Id} (Alloc: {task.AllocId})..." ); _state = new DriverStateV1(); @@ -99,7 +107,7 @@ await _owner.LockAsync( serverManager => var appPool = FindApplicationPool( serverManager, _state.AppPoolName ); if ( appPool is null ) { - logger.LogInformation( $"Task {task.Id}: Creating AppPool with name {_state.AppPoolName}..." ); + _logger.LogInformation( $"Task {task.Id}: Creating AppPool with name {_state.AppPoolName}..." ); appPool = CreateApplicationPool( serverManager, _state.AppPoolName, _taskConfig, config, _state.UdpLoggerPort, _owner.UdpLoggerPort ); } @@ -117,7 +125,7 @@ await _owner.LockAsync( serverManager => _state.WebsiteName = website.Name; _state.TaskOwnsWebsite = false; - logger.LogInformation( $"Task {task.Id}: Using target website with name {_state.WebsiteName}..." ); + _logger.LogInformation( $"Task {task.Id}: Using target website with name {_state.WebsiteName}..." ); } else { @@ -128,7 +136,7 @@ await _owner.LockAsync( serverManager => if ( website is null ) { - logger.LogInformation( $"Task {task.Id}: Creating Website with name {_state.WebsiteName}..." ); + _logger.LogInformation( $"Task {task.Id}: Creating Website with name {_state.WebsiteName}..." ); website = CreateWebsite( serverManager, _state.WebsiteName, _taskConfig, config, appPool ); } @@ -143,7 +151,7 @@ await _owner.LockAsync( serverManager => throw new InvalidOperationException( $"An application with alias {app.Alias} already exists in website {website.Name}." ); if ( application is null ) - CreateApplication( website, appPool, _taskConfig, app ); + CreateApplication( website, appPool, _taskConfig, app, _owner ); } _state.ApplicationAliases = config.Applications.Select( x => x.Alias ).ToList(); @@ -153,7 +161,7 @@ await _owner.LockAsync( serverManager => } catch ( Exception ex ) { - await SendTaskEventAsync( logger, $"Error: {ex.Message}" ); + await SendTaskEventAsync( $"Error: {ex.Message}" ); throw; } @@ -161,25 +169,25 @@ await _owner.LockAsync( serverManager => try { if ( _owner.DirectorySecurity ) - await SetupDirectoryPermissions( logger, config ); + await SetupDirectoryPermissions( config ); - await SendTaskEventAsync( logger, $"Application started, Name: {_state.AppPoolName}" ); + await SendTaskEventAsync( $"Application started, Name: {_state.AppPoolName}" ); } catch ( Exception ex ) { - await SendTaskEventAsync( logger, $"Error: {ex.Message}" ); + await SendTaskEventAsync( $"Error: {ex.Message}" ); // Note: We do not rethrow here because the website has already been set-up. } return _state; } - public async Task StopAsync ( ILogger logger ) + public async Task StopAsync () { if ( _state is null || _taskConfig is null || string.IsNullOrEmpty( _state.AppPoolName ) || string.IsNullOrEmpty( _state.WebsiteName ) ) throw new InvalidOperationException( "Invalid state." ); - logger.LogInformation( $"Stopping task {_taskConfig.Id} (Alloc: {_taskConfig.AllocId})..." ); + _logger.LogInformation( $"Stopping task {_taskConfig.Id} (Alloc: {_taskConfig.AllocId})..." ); await _owner.LockAsync( serverManager => { @@ -195,7 +203,7 @@ await _owner.LockAsync( serverManager => } catch ( Exception ex ) { - logger.LogWarning( ex, $"Failed to stop AppPool {_state.AppPoolName}." ); + _logger.LogWarning( ex, $"Failed to stop AppPool {_state.AppPoolName}." ); } } @@ -214,7 +222,7 @@ await _owner.LockAsync( serverManager => } } else - logger.LogWarning( "Invalid state. Missing _applicationAliases." ); + _logger.LogWarning( "Invalid state. Missing _applicationAliases." ); } else { @@ -229,19 +237,19 @@ await _owner.LockAsync( serverManager => return Task.CompletedTask; } ); } - public async Task DestroyAsync ( ILogger logger ) + public async Task DestroyAsync () { - await StopAsync( logger ); + await StopAsync(); } - public void RecoverState ( ILogger logger, RecoverTaskRequest request ) + public void RecoverState ( RecoverTaskRequest request ) { // Note: request.TaskId is null/empty here. // Also request.Handle.Config.MsgpackDriverConfig is allways empty. _taskConfig = request.Handle.Config; - logger.LogInformation( $"Recovering task {_taskConfig.Id} (Alloc: {_taskConfig.AllocId})..." ); + _logger.LogInformation( $"Recovering task {_taskConfig.Id} (Alloc: {_taskConfig.AllocId})..." ); if ( request.Handle.DriverState is not null && !request.Handle.DriverState.IsEmpty ) { @@ -253,10 +261,10 @@ public void RecoverState ( ILogger logger, RecoverTaskRequest request ) else throw new InvalidOperationException( "Invalid state." ); - logger.LogInformation( $"Recovered task {_taskConfig.Id} from state: {_state}" ); + _logger.LogInformation( $"Recovered task {_taskConfig.Id} from state: {_state}" ); } - public async Task SignalAsync ( ILogger logger, string signal ) + public async Task SignalAsync ( string signal ) { if ( string.IsNullOrEmpty( signal ) ) return; @@ -266,35 +274,29 @@ public async Task SignalAsync ( ILogger logger, string signal ) switch ( signal.ToUpperInvariant() ) { + case "START": + await StartAppPoolAsync(); + break; + case "STOP": + await StopAppPoolAsync(); + break; + case "SIGHUP": case "RECYCLE": - await _owner.LockAsync( async serverManager => - { - var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); - - if ( appPool is not null ) - { - logger.LogInformation( $"Recycle AppPool {_state.AppPoolName}" ); - - appPool.Recycle(); - - await SendTaskEventAsync( logger, $"ApplicationPool recycled, Name = {_state.AppPoolName}" ); - } - } ); - + await RecycleAppPoolAsync(); break; case "SIGINT": case "SIGKILL": - await StopAsync( logger ); + await StopAsync(); break; default: - logger.LogInformation( $"Unsupported signal {signal} received." ); + _logger.LogInformation( $"Unsupported signal {signal} received." ); break; } } - public async Task WaitAsync ( ILogger logger ) + public async Task WaitAsync () { var exitCode = 0; @@ -314,9 +316,12 @@ public async Task WaitAsync ( ILogger logger ) { var appPool = FindApplicationPool( serverManager, _state.AppPoolName ); + if ( _appPoolStoppedIntentionally ) + return Task.FromResult( 0 ); + if ( appPool is not null ) { - logger.LogDebug( $"AppPool {_state.AppPoolName} is in state {appPool.State}" ); + _logger.LogDebug( $"AppPool {_state.AppPoolName} is in state {appPool.State}" ); if ( appPool.State == ObjectState.Stopped ) return Task.FromResult( -1 ); @@ -338,7 +343,7 @@ public async Task WaitAsync ( ILogger logger ) return exitCode; } - public async Task GetStatisticsAsync ( ILogger logger ) + public async Task GetStatisticsAsync () { if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) throw new InvalidOperationException( "Invalid state." ); @@ -588,15 +593,37 @@ private static Site CreateWebsite ( ServerManager serverManager, string name, Ta private static Application? FindApplicationByPath ( Site website, string path ) => website.Applications.FirstOrDefault( x => x.Path == path ); - private static Application CreateApplication ( Site website, ApplicationPool appPool, TaskConfig taskConfig, DriverTaskConfigApplication appConfig ) + private static Application CreateApplication ( Site website, ApplicationPool appPool, TaskConfig taskConfig, DriverTaskConfigApplication appConfig, ManagementService managementService ) { + if ( string.IsNullOrEmpty( appConfig.Path ) ) + throw new ArgumentNullException( $"Missing application path." ); + var alias = $"/{appConfig.Alias}"; var physicalPath = appConfig.Path.Replace( '/', '\\' ); + // If the path is not an absolute path, make it relative to the task-directory. if ( !Path.IsPathRooted( physicalPath ) ) physicalPath = Path.Combine( taskConfig.AllocDir, taskConfig.Name, physicalPath ); - var application = website.Applications.Add( alias, physicalPath ); + var diPhysicalPath = new DirectoryInfo( physicalPath ); + + // Check if we want to use a placeholder app + if ( Directory.Exists( managementService.PlaceholderAppPath ) ) + { + var directoryIsNew = false; + + if ( !diPhysicalPath.Exists ) + { + diPhysicalPath.Create(); + directoryIsNew = true; + } + + // If the directory is new or empty, copy the placeholder app + if ( directoryIsNew || !diPhysicalPath.EnumerateFileSystemInfos().Any() ) + FileSystemHelper.CopyDirectory( managementService.PlaceholderAppPath, diPhysicalPath.FullName ); + } + + var application = website.Applications.Add( alias, diPhysicalPath.FullName ); application.ApplicationPoolName = appPool.Name; @@ -637,7 +664,7 @@ private bool IsAllowedTargetWebsite ( string websiteName ) return false; } - private async Task SendTaskEventAsync ( ILogger logger, string message ) + private async Task SendTaskEventAsync ( string message ) { if ( _taskConfig is null ) return; @@ -655,29 +682,29 @@ private async Task SendTaskEventAsync ( ILogger logger, string message ) } catch ( Exception ex ) { - logger.LogError( ex, "Failed to send task event." ); + _logger?.LogError( ex, "Failed to send task event." ); } } - private async Task SetupDirectoryPermissions ( ILogger logger, DriverTaskConfig config ) + private async Task SetupDirectoryPermissions ( DriverTaskConfig config ) { try { // GH-43: It may happen that this throws an IdentityNotMappedException sometimes. // I think setting up the AppPoolIdentity takes some time. // So if we're too early, we try again in 2 seconds. - SetupDirectoryPermissionsCore( logger, config ); + SetupDirectoryPermissionsCore( config ); } catch ( IdentityNotMappedException ex ) { - logger.LogDebug( ex, "Failed to setup directory permissions for allocation {allocation}. Retrying in 2 seconds...", _taskConfig?.AllocId ); + _logger.LogDebug( ex, "Failed to setup directory permissions for allocation {allocation}. Retrying in 2 seconds...", _taskConfig?.AllocId ); await Task.Delay( 2000 ); - SetupDirectoryPermissionsCore( logger, config ); + SetupDirectoryPermissionsCore( config ); } } - private void SetupDirectoryPermissionsCore ( ILogger logger, DriverTaskConfig config ) + private void SetupDirectoryPermissionsCore ( DriverTaskConfig config ) { // https://developer.hashicorp.com/nomad/docs/concepts/filesystem // https://learn.microsoft.com/en-us/troubleshoot/developer/webapps/iis/www-authentication-authorization/default-permissions-user-rights @@ -691,19 +718,19 @@ private void SetupDirectoryPermissionsCore ( ILogger logger, DriverTaskConfig co var allocDir = new DirectoryInfo( _taskConfig!.AllocDir ); - var builtinUsersSid = TryGetSid( WellKnownSidType.BuiltinUsersSid, logger ); - var authenticatedUserSid = TryGetSid( WellKnownSidType.AuthenticatedUserSid, logger ); + var builtinUsersSid = TryGetSid( WellKnownSidType.BuiltinUsersSid ); + var authenticatedUserSid = TryGetSid( WellKnownSidType.AuthenticatedUserSid ); - SetupDirectory( [appPoolIdentity], @"alloc", null, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( [appPoolIdentity], @"alloc\data", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( [appPoolIdentity], @"alloc\logs", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( [appPoolIdentity], @"alloc\tmp", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( null, $@"{_taskConfig.Name}\private", null, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( identities, $@"{_taskConfig.Name}\local", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); - SetupDirectory( [appPoolIdentity], $@"{_taskConfig.Name}\secrets", FileSystemRights.Read, InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly, logger ); - SetupDirectory( [appPoolIdentity], $@"{_taskConfig.Name}\tmp", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, logger ); + SetupDirectory( [appPoolIdentity], @"alloc", null, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( [appPoolIdentity], @"alloc\data", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( [appPoolIdentity], @"alloc\logs", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( [appPoolIdentity], @"alloc\tmp", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( null, $@"{_taskConfig.Name}\private", null, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( identities, $@"{_taskConfig.Name}\local", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); + SetupDirectory( [appPoolIdentity], $@"{_taskConfig.Name}\secrets", FileSystemRights.Read, InheritanceFlags.ObjectInherit, PropagationFlags.InheritOnly ); + SetupDirectory( [appPoolIdentity], $@"{_taskConfig.Name}\tmp", FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None ); - void SetupDirectory ( string[]? identities, string subDirectory, FileSystemRights? fileSystemRights, InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags, ILogger logger ) + void SetupDirectory ( string[]? identities, string subDirectory, FileSystemRights? fileSystemRights, InheritanceFlags inheritanceFlags, PropagationFlags propagationFlags ) { var directory = allocDir; @@ -747,7 +774,7 @@ void SetupDirectory ( string[]? identities, string subDirectory, FileSystemRight #pragma warning restore CA1416 // Plattformkompatibilität überprüfen } - private SecurityIdentifier? TryGetSid ( WellKnownSidType sidType, ILogger logger ) + private SecurityIdentifier? TryGetSid ( WellKnownSidType sidType ) { #pragma warning disable CA1416 // Plattformkompatibilität überprüfen try @@ -756,7 +783,7 @@ void SetupDirectory ( string[]? identities, string subDirectory, FileSystemRight } catch ( Exception ex ) { - logger.LogWarning( ex, $"Failed to get SID {sidType}." ); + _logger.LogWarning( ex, $"Failed to get SID {sidType}." ); return null; } @@ -782,7 +809,7 @@ private static long GetNextAvailableWebsiteId ( ServerManager serverManager ) - internal async Task ShipLogsAsync ( ILogger logger, byte[] data ) + internal async Task ShipLogsAsync ( byte[] data ) { if ( _taskConfig?.StdoutPath is null ) return; @@ -833,6 +860,267 @@ private static int GetAvailablePort ( int startingPort ) return 0; } + public async Task StartAppPoolAsync () + { + if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + await _owner.LockAsync( async serverManager => + { + var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); + + try + { + if ( appPool.State == ObjectState.Stopped ) + appPool.Start(); + } + catch ( COMException ) + { + // Sometimes, restarting the pool too fast doesn't work. + // So we wait a bit and try again. + await Task.Delay( 2000 ); + if ( appPool.State == ObjectState.Stopped ) + appPool.Start(); + } + + _appPoolStoppedIntentionally = false; + + return Task.CompletedTask; + } ); + + await SendTaskEventAsync( $"ApplicationPool started, Name = {_state.AppPoolName}" ); + } + public async Task StopAppPoolAsync () + { + if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + await _owner.LockAsync( serverManager => + { + var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); + + _appPoolStoppedIntentionally = true; + if ( appPool.State == ObjectState.Started ) + appPool.Stop(); + + return Task.CompletedTask; + } ); + + await SendTaskEventAsync( $"ApplicationPool stopped, Name = {_state.AppPoolName}" ); + } + public async Task RecycleAppPoolAsync () + { + if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + await _owner.LockAsync( serverManager => + { + var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); + + appPool.Recycle(); + + return Task.CompletedTask; + } ); + + await SendTaskEventAsync( $"ApplicationPool recycled, Name = {_state.AppPoolName}" ); + } + + #region Management API Methods +#if MANAGEMENT_API + public async Task GetStatusAsync () + { + if ( _state is null || _taskConfig is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + return await _owner.LockAsync( serverManager => + { + var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); + + var isWorkerProcessRunning = appPool.WorkerProcesses.Any( + x => x.State == WorkerProcessState.Starting || x.State == WorkerProcessState.Running ); + + return Task.FromResult( new NomadIIS.ManagementApi.ApiModel.TaskStatusResponse() + { + AllocId = _taskConfig.AllocId, + TaskName = _taskConfig.Name, + ApplicationPool = new NomadIIS.ManagementApi.ApiModel.ApplicationPool() + { + Status = (NomadIIS.ManagementApi.ApiModel.ApplicationPoolStatus)(int)appPool.State, + IsWorkerProcessRunning = isWorkerProcessRunning + } + } ); + } ); + } + + public async Task DownloadFileAsync ( HttpResponse response, string path ) + { + if ( _state is null || _taskConfig is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + path = FileSystemHelper.SanitizeRelativePath( path ); + + var physicalPath = Path.Combine( _taskConfig.AllocDir, _taskConfig.Name, path ); + + if ( File.Exists( physicalPath ) ) + { + response.Headers.ContentType = "application/octet-stream"; + response.Headers.ContentDisposition = $"attachment; filename=\"{Path.GetFileName( physicalPath )}\""; + response.StatusCode = StatusCodes.Status200OK; + + await response.StartAsync(); + + using var fs = File.OpenRead( physicalPath ); + await fs.CopyToAsync( response.Body ); + } + else + { + response.Headers.ContentType = "application/zip"; + response.Headers.ContentDisposition = $"attachment; filename=\"{Path.GetFileName( physicalPath )}.zip\""; + response.StatusCode = StatusCodes.Status200OK; + + await response.StartAsync(); + + ZipFile.CreateFromDirectory( physicalPath, response.Body ); + } + } + public async Task UploadFileAsync ( Stream stream, bool isZip, string path, bool hot, bool cleanFolder ) + { + if ( stream is null ) + throw new ArgumentNullException( nameof( stream ) ); + + if ( _state is null || _taskConfig is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + path = FileSystemHelper.SanitizeRelativePath( path ); + + try + { + if ( !hot ) + { + await StopAppPoolAsync(); + await Task.Delay( 500 ); + } + + var physicalPath = Path.Combine( _taskConfig.AllocDir, _taskConfig.Name, path ); + + if ( cleanFolder ) + FileSystemHelper.CleanFolder( physicalPath ); + + if ( isZip ) + { + using var archive = new ZipArchive( stream ); + archive.ExtractToDirectory( physicalPath ); + } + else + { + using var fs = File.OpenWrite( physicalPath ); + await stream.CopyToAsync( fs ); + } + } + finally + { + if ( !hot ) + await StartAppPoolAsync(); + } + } + public Task DeleteFileAsync ( string path ) + { + if ( _state is null || _taskConfig is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + path = FileSystemHelper.SanitizeRelativePath( path ); + + string physicalPath; + + if ( path.EndsWith( "/*" ) || path.EndsWith( "/*.*" ) ) + { + if ( path.EndsWith( "/*" ) ) + path = path[..2]; + else + path = path[..4]; + + physicalPath = Path.Combine( _taskConfig.AllocDir, _taskConfig.Name, path ); + FileSystemHelper.CleanFolder( physicalPath ); + return Task.CompletedTask; + } + + physicalPath = Path.Combine( _taskConfig.AllocDir, _taskConfig.Name, path ); + + if ( File.Exists( physicalPath ) ) + File.Delete( physicalPath ); + else + Directory.Delete( physicalPath, true ); + + return Task.CompletedTask; + } + + public async Task TakeScreenshotAsync ( string path = "/", CancellationToken cancellationToken = default ) + { + if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + if ( string.IsNullOrEmpty( path ) || !path.StartsWith( '/' ) ) + path = $"/{path}"; + + var port = await _owner.LockAsync( serverManager => + { + var site = serverManager.Sites.First( x => x.Name == _state.WebsiteName ); + + var httpBinding = site.Bindings.FirstOrDefault( x => x.Protocol == "http" )?.EndPoint; + + return Task.FromResult( httpBinding?.Port ); + }, cancellationToken ); + + if ( port is null ) + return null; + + return await PlaywrightHelper.TakeScreenshotAsync( $"http://localhost:{port}{path}", cancellationToken ); + } + + public async Task TakeProcessDump ( FileInfo targetFile, CancellationToken cancellationToken = default ) + { + if ( _state is null || string.IsNullOrEmpty( _state.AppPoolName ) ) + throw new InvalidOperationException( "Invalid state." ); + + if ( string.IsNullOrEmpty( _owner.ProcdumpBinaryPath ) ) + throw new NotSupportedException( $"Missing procdump_binary_path in driver configuration." ); + if ( !File.Exists( _owner.ProcdumpBinaryPath ) ) + throw new NotSupportedException( $"{_owner.ProcdumpBinaryPath} is not available." ); + + if ( !_owner.ProcdumpEulaAccepted ) + throw new InvalidOperationException( "Procdump EULA has not been accepted." ); + + var w3wpPids = await _owner.LockAsync( serverManager => + { + var appPool = GetApplicationPool( serverManager, _state.AppPoolName ); + + return Task.FromResult( appPool.WorkerProcesses.Select( x => x.ProcessId ).ToArray() ); + }, cancellationToken ); + + if ( w3wpPids is null || w3wpPids.Length == 0 ) + throw new InvalidOperationException( "No w3wp process running." ); + + var pid = w3wpPids[0]; + + var procdump = Cli.Wrap( _owner.ProcdumpBinaryPath ) + .WithArguments( x => x + .Add( "-accepteula" ) + .Add( "-ma" ) + .Add( pid ) + .Add( targetFile.FullName ) ) + .WithValidation( CommandResultValidation.None ); + + cancellationToken.ThrowIfCancellationRequested(); + + var result = await procdump.ExecuteBufferedAsync( cancellationToken ); + + if ( !targetFile.Exists || targetFile.Length == 0 ) + throw new Exception( result.StandardOutput + Environment.NewLine + result.StandardError ); + } + +#endif + #endregion + private class CpuStats { private double? _previousCpuTime; diff --git a/src/NomadIIS/Services/ManagementService.cs b/src/NomadIIS/Services/ManagementService.cs index 1ab18fa..55160c9 100644 --- a/src/NomadIIS/Services/ManagementService.cs +++ b/src/NomadIIS/Services/ManagementService.cs @@ -25,6 +25,8 @@ public sealed class ManagementService : IHostedService private int? _udpLoggerPort; private UdpClient? _udpLoggerClient; private Task? _udpLoggerTask; + private string? _placeholderAppPath; + private DriverConfigProcdump? _procdumpConfig; private CancellationTokenSource _cts = new CancellationTokenSource(); private readonly ConcurrentDictionary _handles = new(); private readonly SemaphoreSlim _lock = new( 1, 1 ); @@ -45,6 +47,9 @@ public ManagementService ( ILogger logger ) public bool DirectorySecurity => _directorySecurity; public string[] AllowedTargetWebsites => _allowedTargetWebsites; public int? UdpLoggerPort => _udpLoggerPort; + public string? PlaceholderAppPath => _placeholderAppPath; + public string? ProcdumpBinaryPath => _procdumpConfig?.BinaryPath ?? "C:\\procdump.exe"; + public bool ProcdumpEulaAccepted => _procdumpConfig?.AcceptEula ?? false; public async void Configure ( DriverConfig config ) { @@ -56,6 +61,8 @@ public async void Configure ( DriverConfig config ) _fingerprintInterval = config.FingerprintInterval; _directorySecurity = config.DirectorySecurity; _allowedTargetWebsites = config.AllowedTargetWebsites ?? Array.Empty(); + _placeholderAppPath = config.PlaceholderAppPath; + _procdumpConfig = config.Procdumps.Length == 1 ? config.Procdumps[0] : null; // Setup UDP logger endpoint if ( config.UdpLoggerPort is not null && config.UdpLoggerPort.Value > 0 && _udpLoggerClient is null ) @@ -86,7 +93,7 @@ public IisTaskHandle CreateHandle ( string taskId ) if ( string.IsNullOrWhiteSpace( taskId ) ) throw new ArgumentNullException( nameof( taskId ) ); - return _handles.GetOrAdd( taskId, id => new IisTaskHandle( this, id ) ); + return _handles.GetOrAdd( taskId, id => new IisTaskHandle( this, _logger, id ) ); } public IisTaskHandle GetHandle ( string taskId ) @@ -105,18 +112,25 @@ public IisTaskHandle GetHandle ( string taskId ) return handle; return null; } + public IisTaskHandle? TryGetHandleByAllocIdAndTaskName ( string allocId, string taskName ) + { + var handles = _handles.Values.ToArray(); + + return handles.SingleOrDefault( + x => x.TaskConfig != null && x.TaskConfig.AllocId == allocId && x.TaskConfig.Name == taskName ); + } - internal async Task LockAsync ( Func action ) + internal async Task LockAsync ( Func action, CancellationToken cancellationToken = default ) { _ = await LockAsync( async serverManager => { await action( serverManager ); return true; - } ); + }, cancellationToken ); } - internal async Task LockAsync ( Func> action ) + internal async Task LockAsync ( Func> action, CancellationToken cancellationToken = default ) { - await _lock.WaitAsync(); + await _lock.WaitAsync( cancellationToken ); var serverManager = new ServerManager(); @@ -145,7 +159,7 @@ internal async Task LockAsync ( Func> action ) internal void Delete ( IisTaskHandle handle ) => _handles.TryRemove( handle.TaskId, out _ ); - private async Task UdpLoggerReceiverTask() + private async Task UdpLoggerReceiverTask () { if ( _udpLoggerClient is null ) return; @@ -160,7 +174,7 @@ private async Task UdpLoggerReceiverTask() .FirstOrDefault( x => x.UdpLoggerPort == result.RemoteEndPoint.Port ); if ( handle is not null ) - await handle.ShipLogsAsync( _logger, result.Buffer ); + await handle.ShipLogsAsync( result.Buffer ); } catch ( Exception ex ) { diff --git a/src/NomadIIS/Services/PlaywrightHelper.cs b/src/NomadIIS/Services/PlaywrightHelper.cs new file mode 100644 index 0000000..ee13d2f --- /dev/null +++ b/src/NomadIIS/Services/PlaywrightHelper.cs @@ -0,0 +1,97 @@ +#if MANAGEMENT_API +using Microsoft.Playwright; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace NomadIIS.Services +{ + public static class PlaywrightHelper + { + private static SemaphoreSlim _semaphore = new SemaphoreSlim( 1, 1 ); + + public static async Task TakeScreenshotAsync ( string url, CancellationToken cancellationToken = default ) + { + cancellationToken.ThrowIfCancellationRequested(); + + using var playwright = await Playwright.CreateAsync(); + + IBrowser? browser = null; + + cancellationToken.ThrowIfCancellationRequested(); + + try + { + browser = await playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions() + { + Headless = true + } ); + } + catch ( PlaywrightException ) + { + // Install Playwright + await EnsurePlaywrightInstalled( cancellationToken ); + + browser = await playwright.Chromium.LaunchAsync( new BrowserTypeLaunchOptions() + { + Headless = true, + } ); + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + var page = await browser.NewPageAsync( new BrowserNewPageOptions() + { + ViewportSize = new ViewportSize() + { + Width = 1920, + Height = 1080 + }, + ScreenSize = new ScreenSize() + { + Width = 1920, + Height = 1080 + } + } ); + + cancellationToken.ThrowIfCancellationRequested(); + + await page.GotoAsync( url ); + + cancellationToken.ThrowIfCancellationRequested(); + + var screenshot = await page.ScreenshotAsync( new PageScreenshotOptions() + { + FullPage = true, + Type = ScreenshotType.Png + } ); + + return screenshot; + } + finally + { + if ( browser is not null ) + await browser.DisposeAsync(); + } + } + + private static async Task EnsurePlaywrightInstalled ( CancellationToken cancellationToken ) + { + await _semaphore.WaitAsync( 30_000, cancellationToken ); + + try + { + var exitCode = Microsoft.Playwright.Program.Main( ["install", "--with-deps", "chromium"] ); + if ( exitCode != 0 ) + throw new Exception( "Failed to install chromium." ); + } + finally + { + _semaphore.Release(); + } + } + } +} +#endif diff --git a/src/NomadIIS/appsettings.json b/src/NomadIIS/appsettings.json index 1aef507..0c208ae 100644 --- a/src/NomadIIS/appsettings.json +++ b/src/NomadIIS/appsettings.json @@ -4,11 +4,5 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } - }, - "AllowedHosts": "*", - "Kestrel": { - "EndpointDefaults": { - "Protocols": "Http2" - } } } diff --git a/website/docs/features/management-api.md b/website/docs/features/management-api.md new file mode 100644 index 0000000..5aac89c --- /dev/null +++ b/website/docs/features/management-api.md @@ -0,0 +1,161 @@ +--- +sidebar_position: 7 +--- + +# 🛠 Management API + +:::caution +The Management API is only available when using a special binary of *Nomad IIS*. +Please also note, that the API is experimental and may change in the future. +::: + +The Management API is very powerfull and provides additional features, not provided by Nomad directly. +It is meant to be called by some external higher-order service or management tool. + +Most endpoints will need you to provide the allocation id which means, you first need to talk to the [Nomad API](https://developer.hashicorp.com/nomad/api-docs/jobs#list-job-allocations) to find that out. + +## Enabling the API + +You need to enable the Management API by providing a dedicated port as shown below. + +```hcl +plugin "nomad_iis" { + args = ["--management-api-port=5004", "--management-api-key=12345"] + config { + enabled = true + } +} +``` + +### Securing the API + +It is highly recommended to provide an API-Key to secure the API. Specify the key using the `--management-api-key`-argument as shown above. +In this case, every API-call needs to provide this key as `X-Api-Key` header. + +## Filesystem Access + +### Download a File or Folder + +``` +GET /api/v1/allocs/{allocId}/{taskName}/fs/{path} +``` + +This allows you to download a single file or an entire folder from the task directory. +The path needs to be URL-encoded and point to a single file when downloading a file and to a folder, when downloading an entire folder as a ZIP-archive. + +### Upload a File or ZIP-Archive + +``` +PUT /api/v1/allocs/{allocId}/{taskName}/fs/{path}[?clean=true/false] +PATCH /api/v1/allocs/{allocId}/{taskName}/fs/{path}[?clean=true/false] +``` + +With this API you can upload a single file or an entire ZIP-archive into the specified folder of the task directory. +Make sure you send the correct `Content-Type`-header (`application-zip` for ZIP-files and `application/octet-stream` for files). +The path needs to be URL-encoded and point to a single file when sending a file and to a folder, when sending a ZIP-archive. + +Setting the `clean`-parameter to true will delete all files in the target-directory before uploading the new ones. The default is false. + +The difference between the `PUT` and `PATCH` method is, that `PUT` will stop the application while uploading the file, whereas `PATCH` will hot-patch the file, keeping the app running. Keep in mind that hot-patching may fail if some files are currently being locked by the worker process. + +:::tip +Using these methods it is possible to upload an application into a previously deployed allocation. This can be thought as the opposite of Nomad pulling the application from somewhere. This is usefull if you want to run an application shortly, eg. to run UI-tests against. +::: + +:::info +ZIP-archives will be extracted by default. If you want to upload the ZIP-file *as-is*, send it using the `Content-Type` set to `application/octet-stream`. +::: + +:::danger +If Nomad reschedules the allocation, all uploaded application files will be lost. +::: + +### Examples + +**Upload a zipped application to the *local* directory** + +```cmd +curl -X PUT \ + -H "X-Api-Key: 12345" \ + -H "Content-Type: application/zip" \ + --data-binary @"C:\Path\To\static-sample-app.zip" \ + http://localhost:5004/api/v1/allocs/e4c0ee58-2e27-2cd6-7ca5-6ef1ed036aad/app/fs/local?clean=true +``` + +**Download the entire *local* directory as a zip archive** + +```cmd +curl -X GET \ + -H "X-Api-Key: 12345" \ + -o "local.zip" \ + http://localhost:5004/api/v1/allocs/e4c0ee58-2e27-2cd6-7ca5-6ef1ed036aad/app/fs/local +``` + +**Download just a single file from the *local* directory** + +```cmd +curl -X GET \ + -H "X-Api-Key: 12345" \ + -o "index.html" \ + http://localhost:5004/api/v1/allocs/e4c0ee58-2e27-2cd6-7ca5-6ef1ed036aad/app/fs/local%2findex.html +``` + +### Delete a file or folder + +``` +DELETE /api/v1/allocs/{allocId}/{taskName}/fs/{path} +``` + +Deletes a file or folder. If the path ends with `/*` or `/*.*` the folder will only be cleaned. + +## Application Pool Lifecycle Management + +| API | Description | +|---|---| +| `PUT /api/v1/allocs/{allocId}/{taskName}/start` | Start the Application Pool | +| `PUT /api/v1/allocs/{allocId}/{taskName}/stop` | Stop the Application Pool while keeping the Nomad allocation running | +| `PUT /api/v1/allocs/{allocId}/{taskName}/recycle` | Recycle the Application Pool | + +## Taking a local Screenshot + +``` +GET /api/v1/allocs/{allocId}/{taskName}/screenshot[?path=/] +``` + +:::info +Screenshots will be taken by Playwright, which starts a local Chrome browser. This requires downloading some necessary drivers from the Internet, which means the first request will take a few seconds. +::: + +### Examples + +**Take a screenshot** + +```cmd +curl -X GET -O \ + -H "X-Api-Key: 12345" \ + http://localhost:5004/api/v1/allocs/e4c0ee58-2e27-2cd6-7ca5-6ef1ed036aad/app/screenshot +``` + +## Taking a Process Dump + +:::info +To use this feature you need to download and install [procdump.exe](https://learn.microsoft.com/en-us/sysinternals/downloads/procdump) to `C:\procdump.exe` or specify a different location in the [driver configuration](../getting-started/driver-configuration.md). +You also have to agree to the EULA of procdump by setting `accept_eula` to `true`. +::: + +``` +GET /api/v1/allocs/{allocId}/{taskName}/procdump +``` + +Sometimes you need to investigate a performance or memory issue and need a process dump of the *w3wp* worker process. +By calling this API, a process dump will be created using *procdump.exe* which will be streamed to the client. + +### Examples + +**Take a process dump** + +```cmd +curl -X GET -O \ + -H "X-Api-Key: 12345" \ + http://localhost:5004/api/v1/allocs/e4c0ee58-2e27-2cd6-7ca5-6ef1ed036aad/app/procdump +``` \ No newline at end of file diff --git a/website/docs/features/signals.md b/website/docs/features/signals.md index 5615ab8..36a747b 100644 --- a/website/docs/features/signals.md +++ b/website/docs/features/signals.md @@ -9,6 +9,8 @@ The Nomad IIS driver supports the following signals: | Signal | Description | |---|---| | `SIGHUP` or `RECYCLE` | Recycles the Application Pool | +| `STOP` | Stops (pauses) the Application Pool while keeping the Nomad allocation running. | +| `START` | Starts the Application Pool | | `SIGINT` or `SIGKILL` | Stops and removes the Application. Note: When sending this signal manually, the job gets re-scheduled. | To send a *RECYCLE* signal, run: diff --git a/website/docs/getting-started/driver-configuration.md b/website/docs/getting-started/driver-configuration.md index 10b4fb6..343b404 100644 --- a/website/docs/getting-started/driver-configuration.md +++ b/website/docs/getting-started/driver-configuration.md @@ -11,18 +11,33 @@ sidebar_position: 4 | directory_security | bool | no | true | Enables Directory Permission Management for [Filesystem Isolation](../features/filesystem-isolation.md). | | allowed_target_websites | string[] | no | *none* | A list of IIS websites which are allowed to be used as [target_website](../features/existing-website.md). An asterisk (*\**) may be used as a wildcard to allow any website. | | udp_logger_port | number | no | 0 | The local UDP port where the driver is listening for log-events which will be shipped to the Nomad client. The value 0 will disable this feature. Please read the details [here](../features/udp-logging.md). | +| placeholder_app_path | string | no | C:\\inetpub\\wwwroot | Specifies the path to an optional placeholder app. The files of this folder will be copied into the allocation directory when the application path, specified in the job spec, is empty. This may be usefull to show some kind of maintenance-page until the real app is pushed using [the management API](../features/management-api.md#push-app). By default the blue default IIS page will be copied and you can set this to `null` to not copy anything. | +| *procdump* | block list | no | *none* | Defines settings for procdump. See *procdump* schema below for details. Only available when using the nomad_iis.exe including the Management API. | + +## `procdump` Block Configuration + +| Option | Type | Required | Default Value | Description | +|---|---|---|---|---| +| binary_path | string | no | C:\\procdump.exe | Configures the path to procdump.exe. | +| accept_eula | bool | no | false | If you want to use procdump you need to accept it's EULA. | **Example** ```hcl plugin "nomad_iis" { - #args = ["--port 1234"] # Optional. To change the static port. The default is 5003. - #args = ["--port 0"] # Optional. To use a random port + #args = ["--port=1234"] # Optional. To change the static port. The default is 5003. + #args = ["--port=0"] # Optional. To use a random port config { enabled = true, fingerprint_interval = "30s", directory_security = true allowed_target_websites = [ "Default Web Site" ] + + # Only available when using the nomad_iis binary with Management API + procdump { + binary_path = "C:\\procdump.exe" + accept_eula = true + } } } ``` diff --git a/website/docs/getting-started/task-configuration.md b/website/docs/getting-started/task-configuration.md index 0e86e0e..618402b 100644 --- a/website/docs/getting-started/task-configuration.md +++ b/website/docs/getting-started/task-configuration.md @@ -22,7 +22,7 @@ sidebar_position: 5 | Option | Type | Required | Default Value | Description | |---|---|---|---|---| -| path | string | yes | *none* | Defines the path of the web application, containing the application files | +| path | string | yes | *none* | Defines the path of the web application, containing the application files. If this folder is empty, the [Placeholder App](../getting-started/driver-configuration.md) will be copied into. | | alias | string | no | / | Defines an optional alias at which the application should be hosted below the website. If not set, the application will be hosted at the website level. | | enable_preload | bool | no | *IIS default* | Specifies whether the application should be pre-loaded. | | *virtual_directory* | block list | no | *none* | Defines optional virtual directories below this application. See *virtual_directory* schema below for details. |