From 5366482648c3bb81dd8843e224f37e1746eaf392 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Mon, 14 Sep 2026 18:35:48 +0200 Subject: [PATCH] Load the HTTPS certificate while validating settings instead of at Kestrel bind time AddServiceControlHttps loaded the certificate inside the ConfigureHttpsDefaults callback, which Kestrel invokes when binding endpoints. An unusable certificate therefore failed only after RavenDB, the transport and every hosted service had started and had to be torn down again. ValidateCertificateConfiguration now loads the certificate too, and the failure names the file, its size and last-modified time, and whether a password was configured, but never the password itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../Https/HostApplicationBuilderExtensions.cs | 18 +++---- .../HttpsSettings.cs | 30 +++++++++++ .../Settings/HttpsSettingsTests.cs | 54 +++++++++++++++++-- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs index 1f8df12831..f5152869ed 100644 --- a/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs +++ b/src/ServiceControl.Hosting/Https/HostApplicationBuilderExtensions.cs @@ -1,7 +1,6 @@ namespace ServiceControl.Hosting.Https; using System; -using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpsPolicy; @@ -35,23 +34,18 @@ public static void AddServiceControlHttps(this WebApplicationBuilder hostBuilder // Kestrel HTTPS is disabled by default if (settings.Enabled) { + // The certificate was loaded and validated when HttpsSettings was constructed. Doing it + // here instead would defer the failure to endpoint binding, which happens after every + // hosted service has already started and has to be torn down again. + var certificate = settings.Certificate ?? throw new InvalidOperationException("HTTPS is enabled but no certificate was loaded."); + hostBuilder.WebHost.ConfigureKestrel(kestrel => { kestrel.ConfigureHttpsDefaults(httpsOptions => { - httpsOptions.ServerCertificate = LoadCertificate(settings); + httpsOptions.ServerCertificate = certificate; }); }); } } - - static X509Certificate2 LoadCertificate(HttpsSettings settings) - { - if (string.IsNullOrEmpty(settings.CertificatePassword)) - { - return X509CertificateLoader.LoadPkcs12FromFile(settings.CertificatePath, null); - } - - return X509CertificateLoader.LoadPkcs12FromFile(settings.CertificatePath, settings.CertificatePassword); - } } diff --git a/src/ServiceControl.Infrastructure/HttpsSettings.cs b/src/ServiceControl.Infrastructure/HttpsSettings.cs index 1db700c3c8..9d98256f70 100644 --- a/src/ServiceControl.Infrastructure/HttpsSettings.cs +++ b/src/ServiceControl.Infrastructure/HttpsSettings.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Infrastructure; using System; using System.IO; +using System.Security.Cryptography.X509Certificates; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using ServiceControl.Configuration; @@ -57,6 +58,12 @@ public HttpsSettings(SettingsRootNamespace rootNamespace) [JsonIgnore] public string CertificatePassword { get; } + /// + /// The certificate loaded from , or null when HTTPS is disabled. + /// + [JsonIgnore] + public X509Certificate2 Certificate { get; private set; } + /// /// When true, HTTP requests will be redirected to HTTPS. /// Requires HTTPS to be properly configured. Default is false. @@ -103,6 +110,29 @@ void ValidateCertificateConfiguration() logger.LogCritical(message); throw new InvalidOperationException(message); } + + // Loaded here rather than when Kestrel binds its endpoints: an unusable certificate is a + // configuration error, and binding happens only after every hosted service has started. + try + { + Certificate = string.IsNullOrEmpty(CertificatePassword) + ? X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, null) + : X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, CertificatePassword); + } + catch (Exception ex) + { + // .NET reports several unrelated causes as "the password may be incorrect", so describe + // the file itself too. Never the password, only whether one was configured. + var file = new FileInfo(CertificatePath); + var message = $"The HTTPS certificate could not be loaded, so this instance cannot start. " + + $"Https.CertificatePath: '{CertificatePath}' ({file.Length} bytes, last modified {file.LastWriteTimeUtc:u}). " + + $"Https.CertificatePassword configured: {!string.IsNullOrEmpty(CertificatePassword)}. " + + $"{ex.GetType().Name}: {ex.Message} " + + $"Check that the file is a PKCS#12/PFX holding both the certificate and its private key, and that Https.CertificatePassword matches it. " + + $"To start without HTTPS while investigating, set Https.Enabled to false."; + logger.LogCritical(message); + throw new InvalidOperationException(message, ex); + } } void LogConfiguration() diff --git a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs index 8a0730e49b..0caba4ba81 100644 --- a/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs +++ b/src/ServiceControl.UnitTests/Infrastructure/Settings/HttpsSettingsTests.cs @@ -2,6 +2,8 @@ namespace ServiceControl.UnitTests.Infrastructure.Settings; using System; using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using NUnit.Framework; using ServiceControl.Configuration; using ServiceControl.Infrastructure; @@ -22,9 +24,21 @@ public class HttpsSettingsTests string tempCertPath; [SetUp] - public void SetUp() => - // Create a temporary file to simulate a certificate file - tempCertPath = Path.GetTempFileName(); + public void SetUp() + { + // The certificate is loaded as part of validation, so tests that get that far need a real PFX + tempCertPath = Path.Combine(Path.GetTempPath(), $"sc-test-{Guid.NewGuid():n}.pfx"); + WritePfx(tempCertPath); + } + + static void WritePfx(string path, string password = null) + { + using var key = RSA.Create(2048); + var request = new CertificateRequest("CN=ServiceControl.Tests", 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, password)); + } [TearDown] public void TearDown() @@ -89,6 +103,8 @@ public void Should_read_certificate_path() [Test] public void Should_read_certificate_password() { + WritePfx(tempCertPath, "my-secret-password"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", "my-secret-password"); @@ -119,6 +135,38 @@ public void Should_throw_when_certificate_path_does_not_exist() Assert.That(ex.Message, Does.Contain("does not exist")); } + [Test] + public void Should_load_certificate_when_https_enabled() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); + + var settings = new HttpsSettings(TestNamespace); + + Assert.That(settings.Certificate, Is.Not.Null); + } + + [Test] + public void Should_throw_when_certificate_cannot_be_loaded() + { + WritePfx(tempCertPath, "correct-password"); + + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_ENABLED", "true"); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPATH", tempCertPath); + Environment.SetEnvironmentVariable("SERVICECONTROL_HTTPS_CERTIFICATEPASSWORD", "wrong-password"); + + var ex = Assert.Throws(() => new HttpsSettings(TestNamespace)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.Message, Does.Contain("could not be loaded")); + Assert.That(ex.Message, Does.Contain(tempCertPath)); + Assert.That(ex.Message, Does.Contain("Https.CertificatePassword configured: True")); + Assert.That(ex.Message, Does.Not.Contain("correct-password")); + Assert.That(ex.Message, Does.Not.Contain("wrong-password")); + } + } + [Test] public void Should_enable_redirect_when_configured() {