diff --git a/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs b/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs index 7c98392949..9e385618bf 100644 --- a/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs +++ b/src/ServiceControl.AcceptanceTesting/Https/HttpsTestConfiguration.cs @@ -15,6 +15,18 @@ public class HttpsTestConfiguration(ServiceControlInstanceType instanceType) : I readonly string envVarPrefix = EnvironmentVariablePrefixes.GetPrefix(instanceType); bool disposed; + /// + /// Configures Kestrel to serve HTTPS with the PKCS#12 certificate at the given path. + /// The file must exist when the settings are constructed, because they load it. + /// + /// Path to a PFX file holding the certificate and its private key, with no password + public HttpsTestConfiguration WithKestrelHttps(string certificatePath) + { + SetEnvironmentVariable("HTTPS_ENABLED", "true"); + SetEnvironmentVariable("HTTPS_CERTIFICATEPATH", certificatePath); + return this; + } + /// /// Configures HTTPS redirect to be enabled. /// When enabled, HTTP requests will be redirected to HTTPS. diff --git a/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs b/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs index c9472b6b94..51add4610e 100644 --- a/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs +++ b/src/ServiceControl.AcceptanceTests/Recoverability/When_hosting_error_ingestion_only.cs @@ -5,8 +5,12 @@ namespace ServiceControl.AcceptanceTests.Recoverability using System.Diagnostics; using System.IO; using System.Linq; + using System.Net.Http; using System.Runtime.Loader; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; using System.Threading.Tasks; + using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +22,8 @@ namespace ServiceControl.AcceptanceTests.Recoverability using NUnit.Framework; using Particular.ServiceControl.Hosting; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.AcceptanceTesting; + using ServiceControl.AcceptanceTesting.Https; using ServiceControl.ExternalIntegrations; using ServiceControl.Hosting.Commands; using ServiceControl.Infrastructure; @@ -163,6 +169,70 @@ await WaitFor(host, async dbContext => await dbContext.EventLogItems.AsNoTrackin } } + [Test] + [NonParallelizable] + public async Task Should_serve_health_over_https_with_the_configured_certificate() + { + var certificatePath = Path.Combine(Path.GetTempPath(), $"sc-test-{Guid.NewGuid():n}.pfx"); + using var https = new HttpsTestConfiguration(ServiceControlInstanceType.Primary).WithKestrelHttps(certificatePath); + WebApplication host = null; + + try + { + var configuredThumbprint = WriteSelfSignedPfx(certificatePath); + + var settings = await CreateSettings(); + await new SetupCommand().Execute(new HostArguments([]), settings); + + host = ErrorIngestionOnlyCommand.BuildHost(settings); + host.Urls.Add("https://127.0.0.1:0"); + + await host.StartAsync(); + + var url = host.Urls.Single(); + string servedThumbprint = null; + using var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, certificate, _, _) => + { + servedThumbprint = certificate?.Thumbprint; + return true; + } + }; + using var client = new HttpClient(handler); + + using var response = await client.GetAsync($"{url}/health"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(response.IsSuccessStatusCode, Is.True); + // A machine with an ASP.NET Core development certificate binds without the configured one, so only the thumbprint tells them apart. + Assert.That(servedThumbprint, Is.EqualTo(configuredThumbprint)); + } + } + finally + { + File.Delete(certificatePath); + + if (host != null) + { + await host.StopAsync(); + await host.DisposeAsync(); + } + } + } + + static string WriteSelfSignedPfx(string path) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); + + File.WriteAllBytes(path, certificate.Export(X509ContentType.Pkcs12)); + + return certificate.Thumbprint; + } + static async Task DispatchFailedMessage(Settings settings, string messageId) { var dispatchSettings = new TransportSettings diff --git a/src/ServiceControl/Hosting/Commands/ErrorIngestionOnlyCommand.cs b/src/ServiceControl/Hosting/Commands/ErrorIngestionOnlyCommand.cs index 1fc1c68e4e..00d38bb0b3 100644 --- a/src/ServiceControl/Hosting/Commands/ErrorIngestionOnlyCommand.cs +++ b/src/ServiceControl/Hosting/Commands/ErrorIngestionOnlyCommand.cs @@ -11,6 +11,7 @@ namespace ServiceControl.Hosting.Commands using ServiceBus.Management.Infrastructure.Settings; using ServiceControl.EventLog; using ServiceControl.ExternalIntegrations; + using ServiceControl.Hosting.Https; using ServiceControl.Infrastructure.Health; using ServiceControl.Monitoring; using ServiceControl.Persistence; @@ -43,6 +44,7 @@ internal static WebApplication BuildHost(Settings settings, Action