From 3775f6bf3b846b361bf16a579cfea4341be21953 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 07:30:26 -0400 Subject: [PATCH] feat(gateway): supply generated cert as Kestrel HTTPS default --- .../GatewayApplication.cs | 26 +++++++ .../Gateway/GatewayTlsBootstrapTests.cs | 72 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index bd88648..ed43888 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -1,4 +1,6 @@ +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Hosting.StaticWebAssets; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Server.Alarms; using ZB.MOM.WW.MxGateway.Server.Configuration; @@ -55,6 +57,8 @@ public static class GatewayApplication }); StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration); + ConfigureSelfSignedTls(builder); + builder.Services.AddGatewayConfiguration(); builder.Services.AddSqliteAuthStore(); builder.Services.AddGatewayGrpcAuthorization(); @@ -72,6 +76,28 @@ public static class GatewayApplication return builder; } + private static void ConfigureSelfSignedTls(WebApplicationBuilder builder) + { + if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration)) + { + return; + } + + Configuration.TlsOptions tlsOptions = + builder.Configuration.GetSection("MxGateway:Tls").Get() + ?? new Configuration.TlsOptions(); + + using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole()); + Security.Tls.SelfSignedCertificateProvider provider = new( + tlsOptions, + loggerFactory.CreateLogger(), + TimeProvider.System); + + X509Certificate2 certificate = provider.LoadOrCreate(); + builder.WebHost.ConfigureKestrel(options => + options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate)); + } + private static string ResolveContentRootPath() { string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT"); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs new file mode 100644 index 0000000..f34d698 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs @@ -0,0 +1,72 @@ +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.MxGateway.Server; + +namespace ZB.MOM.WW.MxGateway.Tests.Gateway; + +public sealed class GatewayTlsBootstrapTests +{ + /// + /// Verifies that when a Kestrel HTTPS endpoint is configured without its own certificate, + /// the gateway supplies the generated self-signed certificate as the Kestrel HTTPS default. + /// The host must start and bind, and the certificate served on the TLS handshake must be the + /// gateway's generated cert (subject CN=MxAccessGateway Self-Signed) — not an ambient + /// ASP.NET Core development certificate. On a host with no dev cert installed, starting a + /// cert-less HTTPS endpoint throws "No server certificate was specified"; on a host that has a + /// trusted dev cert, Kestrel would otherwise serve that dev cert (CN=localhost), so the + /// subject assertion is what makes this test fail without the wiring on either kind of host. + /// + [Fact] + public async Task Host_ServesGeneratedCertificate_WhenHttpsEndpointHasNoCertificate() + { + string certDir = Directory.CreateTempSubdirectory().FullName; + try + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", "https://127.0.0.1:0"); + Environment.SetEnvironmentVariable( + "MxGateway__Tls__SelfSignedCertPath", Path.Combine(certDir, "gw.pfx")); + + WebApplication app = GatewayApplication.Build([]); + await app.StartAsync(); + try + { + string servedSubject = await ReadServedCertificateSubjectAsync(app); + Assert.Contains("MxAccessGateway Self-Signed", servedSubject, StringComparison.Ordinal); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + finally + { + Environment.SetEnvironmentVariable("Kestrel__Endpoints__Test__Url", null); + Environment.SetEnvironmentVariable("MxGateway__Tls__SelfSignedCertPath", null); + Directory.Delete(certDir, recursive: true); + } + } + + private static async Task ReadServedCertificateSubjectAsync(WebApplication app) + { + IServerAddressesFeature addresses = + app.Services.GetRequiredService().Features.Get() + ?? throw new InvalidOperationException("Server addresses feature was not available."); + Uri endpoint = new(addresses.Addresses.First()); + + using TcpClient client = new(); + await client.ConnectAsync(endpoint.Host, endpoint.Port); + using SslStream ssl = new( + client.GetStream(), + leaveInnerStreamOpen: false, + userCertificateValidationCallback: (_, _, _, _) => true); + await ssl.AuthenticateAsClientAsync("127.0.0.1"); + + return ssl.RemoteCertificate?.Subject ?? "(none)"; + } +}