Files
mxaccessgw/docs/plans/2026-06-01-gateway-cert-autogen-implementation.md
T

49 KiB
Raw Blame History

Gateway TLS Auto-Certificate + Lenient Client Trust — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Make the gateway auto-generate and persist a long-lived self-signed certificate for any HTTPS Kestrel endpoint that has no certificate configured, and make all five language clients accept any server certificate by default when using TLS without a pinned CA.

Architecture: Gateway gains a SelfSignedCertificateProvider + KestrelTlsInspector under Security/Tls/, wired in GatewayApplication.CreateBuilder via ConfigureKestrel(o => o.ConfigureHttpsDefaults(...)) so it only fills a missing default cert. A new optional MxGateway:Tls config block tunes path/validity. Each client gets a RequireCertificateValidation opt-out flag; lenient-by-default skip-verify is added at the existing TLS-construction site. Plaintext deployments are untouched.

Tech Stack: .NET 10 (ASP.NET Core Kestrel, System.Security.Cryptography.X509Certificates), Go (crypto/tls, grpc-go), Java (grpc-netty-shaded 1.76, Netty InsecureTrustManagerFactory), Python (grpc.aio, ssl), Rust (tonic 0.13.1 + rustls tls-ring).

Branch: feat/tls-cert-autogen


Environment notes (read first)

  • Where to build/test the gateway: the gateway server is net10.0. If the local macOS .NET 10 SDK can build src/ZB.MOM.WW.MxGateway.Server + src/ZB.MOM.WW.MxGateway.Tests, run there. Otherwise build/test on the Windows dev host (ssh windev / dohertj2@10.100.0.48, passwordless) per the project deploy memory. The cert provider's X509 generation/load tests are cross-platform; the ACL-hardening assertions are Windows-only and must be guarded with OperatingSystem.IsWindows() (skip the ACL assertion off-Windows).
  • Clients build/test on macOS directly: dotnet, go, gradle, pytest, cargo. Toolchain paths are in docs/ToolchainLinks.md.
  • Style: docs/style-guides/CSharpStyleGuide.md — file-scoped namespaces, sealed, Async suffix, nullable enabled, TreatWarningsAsErrors=true (a new analyzer warning breaks the build). Platform-specific code needs [SupportedOSPlatform("windows")] on the helper + an OperatingSystem.IsWindows() guard at the call site, or CA1416 fails the build.
  • Never log secrets: thumbprint/SAN/notAfter are fine to log; PFX bytes, private keys, and any client value/password are not.

Task 1: Add TlsOptions config class + bind into GatewayOptions

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 7, Task 8, Task 9, Task 10, Task 11 (client tasks)

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs
  • Modify: src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs

Step 1: Write the failing test (TlsOptionsBindingTests.cs)

using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using Xunit;

namespace ZB.MOM.WW.MxGateway.Tests.Configuration;

public sealed class TlsOptionsBindingTests
{
    [Fact]
    public void Defaults_AreApplied_WhenSectionAbsent()
    {
        TlsOptions options = new();
        Assert.Equal(10, options.ValidityYears);
        Assert.True(options.RegenerateIfExpired);
        Assert.Empty(options.AdditionalDnsNames);
        Assert.False(string.IsNullOrWhiteSpace(options.SelfSignedCertPath));
    }

    [Fact]
    public void Binds_FromMxGatewayTlsSection()
    {
        IConfiguration config = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["MxGateway:Tls:ValidityYears"] = "5",
                ["MxGateway:Tls:SelfSignedCertPath"] = @"C:\tmp\gw.pfx",
                ["MxGateway:Tls:RegenerateIfExpired"] = "false",
                ["MxGateway:Tls:AdditionalDnsNames:0"] = "gw.internal",
            })
            .Build();

        GatewayOptions options = config.GetSection(GatewayOptions.SectionName).Get<GatewayOptions>()!;

        Assert.Equal(5, options.Tls.ValidityYears);
        Assert.Equal(@"C:\tmp\gw.pfx", options.Tls.SelfSignedCertPath);
        Assert.False(options.Tls.RegenerateIfExpired);
        Assert.Equal("gw.internal", Assert.Single(options.Tls.AdditionalDnsNames));
    }
}

Step 2: Run, expect FAIL (no TlsOptions / no GatewayOptions.Tls)

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~TlsOptionsBindingTests Expected: compile error / FAIL.

Step 3: Create TlsOptions.cs

namespace ZB.MOM.WW.MxGateway.Server.Configuration;

/// <summary>
/// Options controlling the gateway's self-signed certificate auto-generation.
/// Only consulted when a Kestrel HTTPS endpoint is configured without its own
/// certificate; plaintext deployments never trigger generation.
/// </summary>
public sealed class TlsOptions
{
    /// <summary>Path to the persisted self-signed PFX. Reused across restarts.</summary>
    public string SelfSignedCertPath { get; init; } =
        @"C:\ProgramData\MxGateway\certs\gateway-selfsigned.pfx";

    /// <summary>Lifetime in years of a freshly generated certificate.</summary>
    public int ValidityYears { get; init; } = 10;

    /// <summary>Extra DNS SANs to embed (e.g. a load-balancer name).</summary>
    public IReadOnlyList<string> AdditionalDnsNames { get; init; } = [];

    /// <summary>Regenerate the persisted certificate when it has expired.</summary>
    public bool RegenerateIfExpired { get; init; } = true;
}

Add to GatewayOptions.cs (after the Alarms property):

    /// <summary>Gets self-signed TLS certificate auto-generation options.</summary>
    public TlsOptions Tls { get; init; } = new();

Step 4: Run, expect PASS (same command as Step 2).

Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Configuration/TlsOptions.cs \
        src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptions.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Configuration/TlsOptionsBindingTests.cs
git commit -m "feat(gateway): add MxGateway:Tls options block"

Task 2: Validate MxGateway:Tls in GatewayOptionsValidator

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 711

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs (add cases; create file if absent — check first)

Step 1: Write failing tests (add to the validator test class; mirror existing style)

[Fact]
public void Validate_Fails_WhenTlsValidityYearsOutOfRange()
{
    GatewayOptions options = ValidOptions() with { }; // use existing helper; set Tls below
    GatewayOptions withBadTls = CloneWithTls(options, new TlsOptions { ValidityYears = 0 });
    ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, withBadTls);
    Assert.True(result.Failed);
    Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:ValidityYears"));
}

[Fact]
public void Validate_Fails_WhenAdditionalDnsNameBlank()
{
    GatewayOptions options = CloneWithTls(ValidOptions(), new TlsOptions { AdditionalDnsNames = [" "] });
    ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
    Assert.True(result.Failed);
    Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:AdditionalDnsNames"));
}

If the test class has no ValidOptions()/CloneWithTls helper, construct a minimal valid GatewayOptions inline (copy the pattern already used by neighbouring validator tests). The assertions on failure messages are what matter.

Step 2: Run, expect FAIL

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayOptionsValidatorTests

Step 3: Implement — in GatewayOptionsValidator.Validate, add ValidateTls(options.Tls, failures); after ValidateAlarms(...), then add:

    private const int MinimumCertValidityYears = 1;
    private const int MaximumCertValidityYears = 100;

    private static void ValidateTls(TlsOptions options, List<string> failures)
    {
        if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
        {
            failures.Add(
                $"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
        }

        AddIfInvalidPath(
            options.SelfSignedCertPath,
            "MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
            failures);

        foreach (string dns in options.AdditionalDnsNames)
        {
            if (string.IsNullOrWhiteSpace(dns))
            {
                failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
            }
        }
    }

Step 4: Run, expect PASS.

Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs
git commit -m "feat(gateway): validate MxGateway:Tls options"

Task 3: SelfSignedCertificateProvider.GenerateCertificate

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 711 (NOT with Task 4/6 — same file)

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs

Step 1: Write failing tests

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
using Xunit;

namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;

public sealed class SelfSignedCertificateProviderTests
{
    private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time)
        => new(options, NullLogger<SelfSignedCertificateProvider>.Instance, time);

    [Fact]
    public void GenerateCertificate_HasExpectedSansEkuAndValidity()
    {
        FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
        TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] };

        using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate();

        Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date);
        Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime);
        Assert.True(cert.HasPrivateKey);

        string sans = ReadSubjectAltNames(cert);
        Assert.Contains("localhost", sans);
        Assert.Contains("gw.internal", sans);

        X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType<X509EnhancedKeyUsageExtension>().Single();
        Assert.Contains(eku.EnhancedKeyUsages.Cast<System.Security.Cryptography.Oid>(),
            o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
    }

    private static string ReadSubjectAltNames(X509Certificate2 cert)
        => cert.Extensions
            .First(e => e.Oid?.Value == "2.5.29.17")
            .Format(false);
}

Uses Microsoft.Extensions.TimeProvider.Testing (FakeTimeProvider). If the test project lacks the package, add <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" /> (version via the repo's central package management if present — check Directory.Packages.props).

Step 2: Run, expect FAIL.

Run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~SelfSignedCertificateProviderTests

Step 3: Implement (SelfSignedCertificateProvider.cs)

using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Configuration;

namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;

/// <summary>
/// Generates and persists a long-lived self-signed certificate used as the
/// Kestrel HTTPS default when no operator certificate is configured.
/// </summary>
public sealed class SelfSignedCertificateProvider
{
    private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1";

    private readonly TlsOptions _options;
    private readonly ILogger<SelfSignedCertificateProvider> _logger;
    private readonly TimeProvider _timeProvider;

    public SelfSignedCertificateProvider(
        TlsOptions options,
        ILogger<SelfSignedCertificateProvider> logger,
        TimeProvider timeProvider)
    {
        _options = options;
        _logger = logger;
        _timeProvider = timeProvider;
    }

    /// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
    public X509Certificate2 GenerateCertificate()
    {
        using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        CertificateRequest request = new(
            new X500DistinguishedName("CN=MxAccessGateway Self-Signed"),
            key,
            HashAlgorithmName.SHA256);

        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
        request.CertificateExtensions.Add(new X509KeyUsageExtension(
            X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
            critical: true));
        request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
            [new Oid(ServerAuthOid, "Server Authentication")],
            critical: false));

        SubjectAlternativeNameBuilder san = new();
        san.AddDnsName("localhost");
        string machine = Environment.MachineName;
        if (!string.IsNullOrWhiteSpace(machine))
        {
            san.AddDnsName(machine);
        }

        foreach (string extra in _options.AdditionalDnsNames)
        {
            if (!string.IsNullOrWhiteSpace(extra))
            {
                san.AddDnsName(extra);
            }
        }

        san.AddIpAddress(IPAddress.Loopback);
        san.AddIpAddress(IPAddress.IPv6Loopback);
        request.CertificateExtensions.Add(san.Build());

        DateTimeOffset now = _timeProvider.GetUtcNow();
        return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears));
    }
}

Step 4: Run, expect PASS.

Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs
git commit -m "feat(gateway): generate self-signed ECDSA cert with SANs"

Task 4: SelfSignedCertificateProvider.LoadOrCreate (persist, reuse, regenerate, ACL)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 711 (NOT Task 3/6 — same file) Depends on: Task 3

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs

Step 1: Write failing tests (add to existing class; write under a temp dir)

[Fact]
public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
{
    string dir = Directory.CreateTempSubdirectory().FullName;
    try
    {
        string path = Path.Combine(dir, "gw.pfx");
        FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
        TlsOptions options = new() { SelfSignedCertPath = path };

        using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
        Assert.True(File.Exists(path));
        using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();

        Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
    }
    finally { Directory.Delete(dir, recursive: true); }
}

[Fact]
public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
{
    string dir = Directory.CreateTempSubdirectory().FullName;
    try
    {
        string path = Path.Combine(dir, "gw.pfx");
        FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
        TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };

        using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
        time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
        using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();

        Assert.NotEqual(first.Thumbprint, second.Thumbprint);
    }
    finally { Directory.Delete(dir, recursive: true); }
}

[Fact]
public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
{
    string dir = Directory.CreateTempSubdirectory().FullName;
    try
    {
        string path = Path.Combine(dir, "gw.pfx");
        File.WriteAllText(path, "not a pfx");
        TlsOptions options = new() { SelfSignedCertPath = path };
        using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
        Assert.True(cert.HasPrivateKey);
    }
    finally { Directory.Delete(dir, recursive: true); }
}

[Fact]
public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
{
    string dir = Directory.CreateTempSubdirectory().FullName;
    try
    {
        string path = Path.Combine(dir, "gw.pfx");
        FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
        TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
        using (CreateProvider(options, time).LoadOrCreate()) { }
        time.Advance(TimeSpan.FromDays(800));
        Assert.Throws<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
    }
    finally { Directory.Delete(dir, recursive: true); }
}

Step 2: Run, expect FAIL.

Step 3: Implement — add to SelfSignedCertificateProvider:

    /// <summary>Loads the persisted certificate, regenerating when missing,
    /// expired (and allowed), or unreadable.</summary>
    public X509Certificate2 LoadOrCreate()
    {
        string path = _options.SelfSignedCertPath;
        if (string.IsNullOrWhiteSpace(path))
        {
            throw new InvalidOperationException(
                "MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate.");
        }

        if (File.Exists(path))
        {
            try
            {
                X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
                if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime)
                {
                    Log("Loaded", existing);
                    return existing;
                }

                if (!_options.RegenerateIfExpired)
                {
                    string notAfter = existing.NotAfter.ToUniversalTime().ToString("u");
                    existing.Dispose();
                    throw new InvalidOperationException(
                        $"Persisted gateway certificate at '{path}' expired on {notAfter} " +
                        "and MxGateway:Tls:RegenerateIfExpired is false.");
                }

                _logger.LogWarning(
                    "Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.",
                    path, existing.NotAfter.ToUniversalTime());
                existing.Dispose();
            }
            catch (CryptographicException ex)
            {
                _logger.LogWarning(ex,
                    "Persisted gateway certificate at {Path} is unreadable; regenerating.", path);
            }
        }

        return GenerateAndPersist(path);
    }

    private X509Certificate2 GenerateAndPersist(string path)
    {
        using X509Certificate2 generated = GenerateCertificate();
        byte[] pfx = generated.Export(X509ContentType.Pkcs12);

        string? directory = Path.GetDirectoryName(path);
        if (!string.IsNullOrEmpty(directory))
        {
            Directory.CreateDirectory(directory);
        }

        string temp = path + ".tmp";
        File.WriteAllBytes(temp, pfx);
        File.Move(temp, path, overwrite: true);
        HardenPermissions(path);

        X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
        Log("Generated", loaded);
        return loaded;
    }

    private static X509KeyStorageFlags KeyStorageFlags()
        => OperatingSystem.IsWindows()
            ? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable
            : X509KeyStorageFlags.Exportable;

    private void HardenPermissions(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            HardenWindowsAcl(path);
        }
        else
        {
            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
        }
    }

    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    private static void HardenWindowsAcl(string path)
    {
        FileInfo file = new(path);
        System.Security.AccessControl.FileSecurity security = new();
        security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);

        foreach (System.Security.Principal.WellKnownSidType sid in new[]
        {
            System.Security.Principal.WellKnownSidType.LocalSystemSid,
            System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid,
        })
        {
            System.Security.Principal.SecurityIdentifier identifier = new(sid, null);
            security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
                identifier,
                System.Security.AccessControl.FileSystemRights.FullControl,
                System.Security.AccessControl.AccessControlType.Allow));
        }

        file.SetAccessControl(security);
    }

    private void Log(string action, X509Certificate2 cert)
    {
        string sans = cert.Extensions
            .FirstOrDefault(e => e.Oid?.Value == "2.5.29.17")?
            .Format(false) ?? "(none)";
        _logger.LogInformation(
            "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
            action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
    }

Note: persisted PFX uses no password (reuse requires a reproducible secret; a random in-memory password could not be reloaded). The private key is protected by the file ACL (0600 on non-Windows). System.Security.AccessControl is in the framework on net10.0; the HardenWindowsAcl method is platform-guarded so CA1416 passes.

Step 4: Run, expect PASS. (On macOS the ACL branch is skipped; tests assert behavior, not ACL.)

Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/SelfSignedCertificateProvider.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/SelfSignedCertificateProviderTests.cs
git commit -m "feat(gateway): persist/reuse self-signed cert with hardened permissions"

Task 5: KestrelTlsInspector — detect HTTPS-without-cert

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 711

Files:

  • Create: src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs

Step 1: Write failing tests

using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
using Xunit;

namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;

public sealed class KestrelTlsInspectorTests
{
    private static IConfiguration Config(params (string Key, string Value)[] entries)
        => new ConfigurationBuilder()
            .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
            .Build();

    [Fact]
    public void RequiresGeneratedCertificate_True_WhenHttpsEndpointHasNoCertificate()
        => Assert.True(KestrelTlsInspector.RequiresGeneratedCertificate(
            Config(("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"))));

    [Fact]
    public void RequiresGeneratedCertificate_False_WhenAllEndpointsPlaintext()
        => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
            Config(("Kestrel:Endpoints:Http:Url", "http://0.0.0.0:5120"))));

    [Fact]
    public void RequiresGeneratedCertificate_False_WhenHttpsEndpointHasOwnCertificate()
        => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(
            Config(
                ("Kestrel:Endpoints:Http:Url", "https://0.0.0.0:5120"),
                ("Kestrel:Endpoints:Http:Certificate:Path", @"C:\certs\real.pfx"))));

    [Fact]
    public void RequiresGeneratedCertificate_False_WhenNoEndpointsConfigured()
        => Assert.False(KestrelTlsInspector.RequiresGeneratedCertificate(Config()));
}

Step 2: Run, expect FAIL.

Step 3: Implement

using Microsoft.Extensions.Configuration;

namespace ZB.MOM.WW.MxGateway.Server.Security.Tls;

/// <summary>
/// Inspects the Kestrel configuration to decide whether the gateway must supply
/// a generated default certificate (an HTTPS endpoint exists with no certificate
/// of its own).
/// </summary>
public static class KestrelTlsInspector
{
    public static bool RequiresGeneratedCertificate(IConfiguration configuration)
    {
        IConfigurationSection endpoints = configuration.GetSection("Kestrel:Endpoints");
        foreach (IConfigurationSection endpoint in endpoints.GetChildren())
        {
            string? url = endpoint["Url"];
            if (string.IsNullOrWhiteSpace(url) ||
                !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            IConfigurationSection certificate = endpoint.GetSection("Certificate");
            bool hasOwnCertificate =
                !string.IsNullOrWhiteSpace(certificate["Path"]) ||
                !string.IsNullOrWhiteSpace(certificate["Subject"]);

            if (!hasOwnCertificate)
            {
                return true;
            }
        }

        return false;
    }
}

Step 4: Run, expect PASS.

Step 5: Commit

git add src/ZB.MOM.WW.MxGateway.Server/Security/Tls/KestrelTlsInspector.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Security/Tls/KestrelTlsInspectorTests.cs
git commit -m "feat(gateway): detect HTTPS endpoints missing a certificate"

Task 6: Wire auto-cert into GatewayApplication.CreateBuilder

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 711 Depends on: Task 1, Task 4, Task 5

Files:

  • Modify: src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs:49-73
  • Test: src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs

Step 1: Write failing test — build the host with an in-memory HTTPS endpoint on an ephemeral port and assert it starts (today this throws "No server certificate was specified").

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Xunit;

namespace ZB.MOM.WW.MxGateway.Tests.Gateway;

public sealed class GatewayTlsBootstrapTests
{
    [Fact]
    public async Task Host_StartsAndBinds_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();
            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);
        }
    }
}

If GatewayApplication.Build needs more env (auth pepper, worker path) to start, mirror whatever the existing GatewayEndToEndFakeWorkerSmokeTests/host tests set up. The assertion is simply that StartAsync does not throw. Keep this test in the same opt-in tier as other host-level tests if the suite gates them.

Step 2: Run, expect FAIL (Kestrel throws on the cert-less HTTPS endpoint).

Step 3: Implement — in CreateBuilder, after StaticWebAssetsLoader.UseStaticWebAssets(...) and before builder.Services.AddGatewayConfiguration();:

        ConfigureSelfSignedTls(builder);

Add the private method to GatewayApplication:

    private static void ConfigureSelfSignedTls(WebApplicationBuilder builder)
    {
        if (!Security.Tls.KestrelTlsInspector.RequiresGeneratedCertificate(builder.Configuration))
        {
            return;
        }

        Configuration.TlsOptions tlsOptions =
            builder.Configuration.GetSection("MxGateway:Tls").Get<Configuration.TlsOptions>()
            ?? new Configuration.TlsOptions();

        using ILoggerFactory loggerFactory = LoggerFactory.Create(logging => logging.AddConsole());
        Security.Tls.SelfSignedCertificateProvider provider = new(
            tlsOptions,
            loggerFactory.CreateLogger<Security.Tls.SelfSignedCertificateProvider>(),
            TimeProvider.System);

        X509Certificate2 certificate = provider.LoadOrCreate();
        builder.WebHost.ConfigureKestrel(options =>
            options.ConfigureHttpsDefaults(https => https.ServerCertificate = certificate));
    }

Add using System.Security.Cryptography.X509Certificates; and using Microsoft.Extensions.Logging; to the file.

Step 4: Run, expect PASS.

Step 5: Build the full server + run gateway tests

Run:

dotnet build src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj

Expected: build clean (no CA1416 / nullable / warning-as-error breaks), tests PASS.

Step 6: Commit

git add src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs \
        src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayTlsBootstrapTests.cs
git commit -m "feat(gateway): supply generated cert as Kestrel HTTPS default"

Task 7: .NET client — lenient TLS by default

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 16, 8, 9, 10, 11

Files:

  • Modify: clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClientOptions.cs (add option + no new validation failure)
  • Modify: clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewayClient.cs:325-353
  • Test: clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ (add a handler-construction or loopback-TLS test; follow existing test patterns)

Step 1: Write failing test — assert that with UseTls=true and no CaCertificatePath, the built SocketsHttpHandler.SslOptions.RemoteCertificateValidationCallback is non-null (accept-all), and that setting RequireCertificateValidation=true leaves it null (OS default). If CreateHttpHandler is private, expose an internal static test seam (and InternalsVisibleTo the test project — check whether it already exists) or test via a loopback TLS server.

[Fact]
public void Handler_SkipsVerification_WhenTlsAndNoCaPinned()
{
    MxGatewayClientOptions options = new()
    {
        Endpoint = new Uri("https://localhost:5120"),
        ApiKey = "k",
        UseTls = true,
    };
    using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
    Assert.NotNull(handler.SslOptions.RemoteCertificateValidationCallback);
}

[Fact]
public void Handler_KeepsDefaultVerification_WhenRequireCertificateValidation()
{
    MxGatewayClientOptions options = new()
    {
        Endpoint = new Uri("https://localhost:5120"),
        ApiKey = "k",
        UseTls = true,
        RequireCertificateValidation = true,
    };
    using SocketsHttpHandler handler = MxGatewayClient.CreateHttpHandlerForTests(options);
    Assert.Null(handler.SslOptions.RemoteCertificateValidationCallback);
}

Step 2: Run, expect FAIL.

Run: dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj --filter FullyQualifiedName~Handler

Step 3: Implement

In MxGatewayClientOptions.cs, add after CaCertificatePath:

    /// <summary>
    /// When true, TLS connections without a pinned <see cref="CaCertificatePath"/>
    /// use the OS trust store. When false (default), the gateway certificate is
    /// accepted without verification — appropriate for this internal tool's
    /// auto-generated self-signed certificate. Pinning a CA always verifies.
    /// </summary>
    public bool RequireCertificateValidation { get; init; }

In MxGatewayClient.cs, change the else after the CaCertificatePath block (line ~352) so the no-CA path installs an accept-all callback unless strict:

            if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
            {
                // ... existing custom-root validation, unchanged ...
            }
            else if (!options.RequireCertificateValidation)
            {
                handler.SslOptions.RemoteCertificateValidationCallback = (_, _, _, _) => true;
            }

Rename CreateHttpHandler to internal static SocketsHttpHandler CreateHttpHandlerForTests OR add an internal wrapper; ensure the client's .csproj has InternalsVisibleTo for the test assembly (add if missing).

Step 4: Run, expect PASS.

Step 5: Build + test

Run: dotnet build clients/dotnet/MxGateway.Client.sln && dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj

Step 6: Commit

git add clients/dotnet/ZB.MOM.WW.MxGateway.Client/ clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/
git commit -m "feat(client-dotnet): accept gateway cert by default over TLS"

Task 8: Go client — lenient TLS by default

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 17, 9, 10, 11

Files:

  • Modify: clients/go/mxgateway/options.go (add RequireCertificateValidation bool)
  • Modify: clients/go/mxgateway/client.go:214-229 (buildCredentials final TLS branch)
  • Test: clients/go/mxgateway/ (add client_tls_test.go)

Step 1: Write failing test — start an in-process TLS gRPC server with a self-signed cert, dial with default options (TLS, no CA), assert the RPC succeeds; then dial with RequireCertificateValidation: true and assert it fails with a cert error. (If a full server is heavy, at minimum unit-test buildCredentials returns credentials whose tls.Config.InsecureSkipVerify is true by default and false when RequireCertificateValidation — by exposing an internal helper that returns the *tls.Config.)

Step 2: Run, expect FAIL.

Run: cd clients/go && go test ./... -run TLS

Step 3: Implement

options.go — add field with doc:

	// RequireCertificateValidation forces TLS certificate verification even when
	// no CACertFile is pinned. Default false: the gateway's self-signed cert is
	// accepted without verification (internal-tool posture).
	RequireCertificateValidation bool

client.go — final TLS branch:

	return credentials.NewTLS(&tls.Config{
		MinVersion:         tls.VersionTLS12,
		ServerName:         opts.ServerNameOverride,
		InsecureSkipVerify: !opts.RequireCertificateValidation, //nolint:gosec // internal tool; opt-in strict via RequireCertificateValidation
	}), nil

Step 4: Run, expect PASS.

Step 5: Verifycd clients/go && gofmt -l . && go build ./... && go test ./... (gofmt prints nothing, build/tests pass).

Step 6: Commit

git add clients/go/mxgateway/options.go clients/go/mxgateway/client.go clients/go/mxgateway/client_tls_test.go
git commit -m "feat(client-go): accept gateway cert by default over TLS"

Task 9: Java client — lenient TLS by default

Classification: small Estimated implement time: ~4 min Parallelizable with: Task 18, 10, 11

Files:

  • Modify: clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClientOptions.java (add requireCertificateValidation field + builder setter + accessor)
  • Modify: clients/java/zb-mom-ww-mxgateway-client/src/main/java/com/zb/mom/ww/mxgateway/client/MxGatewayClient.java:387-389 (the else TLS branch)
  • Test: clients/java/zb-mom-ww-mxgateway-client/src/test/java/com/zb/mom/ww/mxgateway/client/ (add a TLS fixture test)

Step 1: Write failing test — using grpc-inprocess is not TLS-capable, so spin a Netty server on localhost:0 with a self-signed cert (SelfSignedCertificate from shaded Netty: io.grpc.netty.shaded.io.netty.handler.ssl.util.SelfSignedCertificate). Assert default options (TLS, no CA) connect, and requireCertificateValidation(true) fails. Follow MxGatewayFixtureTests patterns.

Step 2: Run, expect FAIL.

Run: cd clients/java && gradle test --tests '*Tls*'

Step 3: Implement

Options: add private final boolean requireCertificateValidation; (default false), builder requireCertificateValidation(boolean), accessor requireCertificateValidation().

MxGatewayClient.createChannel — replace the final else:

        } else if (!options.requireCertificateValidation()) {
            try {
                builder.sslContext(GrpcSslContexts.forClient()
                        .trustManager(io.grpc.netty.shaded.io.netty.handler.ssl.util
                                .InsecureTrustManagerFactory.INSTANCE)
                        .build());
            } catch (SSLException error) {
                throw new MxGatewayException("failed to configure lenient gateway TLS", error);
            }
        } else {
            builder.useTransportSecurity();
        }

Step 4: Run, expect PASS.

Step 5: Verifycd clients/java && gradle test.

Step 6: Commit

git add clients/java/zb-mom-ww-mxgateway-client/
git commit -m "feat(client-java): accept gateway cert by default over TLS"

Task 10: Python client — lenient TLS via TOFU pre-fetch

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 19, 11

Files:

  • Modify: clients/python/src/zb_mom_ww_mxgateway/options.py (add require_certificate_validation: bool = False; update __repr__ and create_channel)
  • Test: clients/python/tests/test_tls.py

Step 1: Write failing test — start a local TLS gRPC server (grpc.aio with a self-signed cert via server_credentials), connect with default options (TLS, no CA) and assert a healthcheck/OpenSession-style RPC succeeds. Mark @pytest.mark.tls and allow opt-in/skip if loopback timing is flaky on CI (mirror how the suite gates network tests).

Step 2: Run, expect FAIL.

Run: cd clients/python && python -m pytest tests/test_tls.py -q

Step 3: Implement — in options.py:

Add the dataclass field:

    require_certificate_validation: bool = False

Rework the TLS branch of create_channel:

    if options.plaintext:
        return grpc.aio.insecure_channel(options.endpoint, options=channel_options)

    if options.ca_file:
        root_certificates = Path(options.ca_file).read_bytes()
        credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
    elif options.require_certificate_validation:
        credentials = grpc.ssl_channel_credentials()
    else:
        # Lenient default: grpc-python has no per-channel skip-verify, so fetch the
        # server's certificate (unverified) and pin it for this channel (TOFU).
        host, port = _split_authority(options.endpoint)
        presented = ssl.get_server_certificate((host, port))
        credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
        # The gateway self-signed cert always carries a "localhost" SAN, so default
        # the SNI/target-name override to it when none was supplied, tolerating
        # dial-by-IP or hostname mismatch.
        if not options.server_name_override:
            channel_options.append(("grpc.ssl_target_name_override", "localhost"))

    return grpc.aio.secure_channel(options.endpoint, credentials, options=channel_options)

Add at top: import ssl. Add a helper:

def _split_authority(endpoint: str) -> tuple[str, int]:
    """Split a gRPC target (optionally scheme-prefixed) into (host, port)."""
    target = endpoint.split("://", 1)[-1]
    host, _, port = target.rpartition(":")
    return (host or "localhost", int(port) if port else 443)

Wrap the ssl.get_server_certificate call so a connection failure raises the client's existing connect-error type with the endpoint in the message (match how create_channel/connect already surface failures — check the client module).

Step 4: Run, expect PASS.

Step 5: Verifycd clients/python && python -m pytest -q (and ruff/mypy if the repo runs them — check pyproject.toml).

Step 6: Commit

git add clients/python/src/zb_mom_ww_mxgateway/options.py clients/python/tests/test_tls.py
git commit -m "feat(client-python): accept gateway cert by default via TOFU pre-fetch"

Task 11: Rust client — lenient TLS via custom rustls verifier (with spike + fallback)

Classification: high-risk Estimated implement time: ~5 min (spike may push to a second task — split if so) Parallelizable with: Task 110

Files:

  • Modify: clients/rust/src/options.rs (add require_certificate_validation: bool, default false; builder method)
  • Modify: clients/rust/src/client.rs:81-94 (TLS construction)
  • Possibly Modify: clients/rust/Cargo.toml (add rustls if a direct dep is needed for the verifier types)
  • Test: clients/rust/tests/client_behavior.rs or a new tests/tls.rs

Step 0 (SPIKE, ~2 min, no commit): Determine the tonic 0.13.1 wiring for a custom rustls verifier. Check whether tonic::transport::ClientTlsConfig exposes a way to supply a raw rustls::ClientConfig (e.g. a with_* method) in 0.13. Run cargo doc --open -p tonic or inspect ~/.cargo source. Two outcomes:

  • (a) tonic accepts a rustls config / custom verifier: implement below.
  • (b) it does not without a custom connector: implement the documented fallback — when TLS is requested with no CA and !require_certificate_validation, return a clear Error::InvalidEndpoint-style error instructing the user to pass a CA file for Rust TLS, and document Rust as the pin-only exception in the design/README. Do NOT sink hours into a bespoke hyper-rustls connector.

Step 1: Write failing test — start a local tonic TLS server (or rustls-backed) with a self-signed cert; connect with default options (TLS, no CA) and assert success. For the fallback path instead assert that connecting TLS-without-CA returns the documented "CA required" error and that pinning the test CA succeeds.

Step 2: Run, expect FAIL.

Run: cd clients/rust && cargo test --workspace tls

Step 3: Implement

options.rs:

    /// Require TLS certificate verification even without a pinned CA. Default
    /// false: the gateway's self-signed certificate is accepted (internal-tool
    /// posture). Setting a CA file always verifies.
    pub fn with_require_certificate_validation(mut self, require: bool) -> Self {
        self.require_certificate_validation = require;
        self
    }

(add the require_certificate_validation: bool struct field, defaulting to false in the constructor, and a require_certificate_validation(&self) -> bool accessor.)

client.rs TLS block (outcome a):

        if !options.plaintext() {
            let mut tls = ClientTlsConfig::new();
            if let Some(server_name) = options.server_name_override() {
                tls = tls.domain_name(server_name.to_owned());
            }
            if let Some(ca_file) = options.ca_file() {
                let certificate = fs::read(ca_file).map_err(/* unchanged */)?;
                tls = tls.ca_certificate(Certificate::from_pem(certificate));
            } else if !options.require_certificate_validation() {
                // Accept any server certificate (lenient internal-tool default).
                tls = tls /* apply custom rustls verifier per spike outcome (a) */;
            }
            endpoint = endpoint.tls_config(tls)?;
        }

If a custom verifier struct is needed, add to client.rs:

#[derive(Debug)]
struct AcceptAnyServerCert(std::sync::Arc<rustls::crypto::CryptoProvider>);

impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCert {
    fn verify_server_cert(
        &self,
        _end_entity: &rustls::pki_types::CertificateDer<'_>,
        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
        _server_name: &rustls::pki_types::ServerName<'_>,
        _ocsp: &[u8],
        _now: rustls::pki_types::UnixTime,
    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
        Ok(rustls::client::danger::ServerCertVerified::assertion())
    }

    fn verify_tls12_signature(/* delegate to self.0 default verifier */) { /* ... */ }
    fn verify_tls13_signature(/* delegate to self.0 default verifier */) { /* ... */ }
    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
        self.0.signature_verification_algorithms.supported_schemes()
    }
}

(Built into a rustls::ClientConfig via .dangerous().with_custom_certificate_verifier(Arc::new(...)), then handed to tonic per the spike's confirmed API.)

Step 4: Run, expect PASS.

Step 5: Verifycd clients/rust && cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings. (Clippy will warn on the dangerous verifier; scope an #[allow(...)] with a justification comment if needed.)

Step 6: Commit

git add clients/rust/
git commit -m "feat(client-rust): accept gateway cert by default over TLS (or documented pin-only fallback)"

Task 12: Documentation

Classification: small (doc-only, but spans several files) Estimated implement time: ~5 min Parallelizable with: none Depends on: Task 6, 7, 8, 9, 10, 11

Files:

  • Modify: docs/GatewayConfiguration.md (extend the "Host Endpoints and Transport Security (Kestrel)" section)
  • Modify: docs/DesignDecisions.md (record both posture choices + why)
  • Modify: each client README + design doc:
    • clients/dotnet/README.md, clients/rust/README.md + clients/rust/RustClientDesign.md, clients/python/README.md + clients/python/PythonClientDesign.md, clients/java/README.md + clients/java/JavaClientDesign.md, clients/go/README.md + clients/go/GoClientDesign.md
  • Modify: docs/CrossLanguageSmokeMatrix.md (TLS variant note; matrix-over-TLS stays manual/opt-in)

Step 1: In docs/GatewayConfiguration.md, under the existing TLS section, add an "Automatic self-signed certificate" subsection documenting: trigger (HTTPS endpoint with no Certificate), the MxGateway:Tls:* table (path / ValidityYears=10 / AdditionalDnsNames / RegenerateIfExpired), persistence path + empty-password-protected-by-ACL, thumbprint/SAN/notAfter logging, and operator override (drop a real cert into Kestrel:Endpoints:*:Certificate).

Step 2: In each client README + design doc, add a short "TLS is lenient by default" note: TLS without a pinned CA accepts any cert; pin a CA (CaCertificatePath/ca_file/caCertificatePath) to verify; set RequireCertificateValidation/require_certificate_validation/require_certificate_validation to force verification. Note the Python TOFU behavior (pre-fetches + pins the presented cert; defaults SNI override to localhost) and any Rust pin-only fallback if Task 11 took outcome (b).

Step 3: In docs/DesignDecisions.md, add an entry: gateway auto-self-signs for cert-less HTTPS endpoints (internal tool, no PKI), persisted & reused; clients accept by default — and why, so it is not mistaken for an oversight.

Step 4: In docs/CrossLanguageSmokeMatrix.md, note a TLS variant exists and stays a manual/opt-in run (needs the gateway started with an HTTPS endpoint).

Step 5: Commit

git add docs/ clients/*/README.md clients/*/*Design.md
git commit -m "docs: gateway auto-cert and lenient client TLS"

Final verification (after all tasks)

Per CLAUDE.md source-update table, run for each touched component:

  • Gateway: dotnet build src/ZB.MOM.WW.MxGateway.Server/... + dotnet test src/ZB.MOM.WW.MxGateway.Tests/... (on Windows host if local SDK can't).
  • .NET client: dotnet build clients/dotnet/MxGateway.Client.sln + its tests.
  • Go: gofmt -l clients/go && (cd clients/go && go build ./... && go test ./...).
  • Rust: (cd clients/rust && cargo fmt && cargo check --workspace && cargo test --workspace && cargo clippy --workspace --all-targets -- -D warnings).
  • Python: (cd clients/python && python -m pytest).
  • Java: (cd clients/java && gradle test).

Then use superpowers-extended-cc:finishing-a-development-branch to decide merge/PR for feat/tls-cert-autogen.