diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterBootstrapGuard.cs b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterBootstrapGuard.cs index 7c6eb211..ac6e1e5e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterBootstrapGuard.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterBootstrapGuard.cs @@ -45,8 +45,12 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; /// public static class ClusterBootstrapGuard { + // Any number of dot segments after "akka": akka.tcp AND akka.ssl.tcp (Akka.Remote's scheme + // once dot-netty TLS is on, ScadaBridge#64). The old single-segment form did not match + // akka.ssl.tcp, so a TLS pair would have parsed no seeds and the guard would silently never + // engage. private static readonly Regex SeedPattern = - new(@"^akka(?:\.[a-z0-9]+)?://[^@/]+@(?[^:/]+):(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + new(@"^akka(?:\.[a-z0-9]+)*://[^@/]+@(?[^:/]+):(?\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// The analyzed bootstrap role of this node within its seed list. /// True only when the guard engages: exactly two seeds, one of them this node. diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs index 5d3cefcf..c8c6ee77 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptions.cs @@ -135,6 +135,81 @@ public class ClusterOptions /// decision logic and ClusterBootstrapCoordinator for the runtime. /// public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new(); + + /// + /// TLS for Akka.Remote within a pair (ScadaBridge:Cluster:RemotingTls, ScadaBridge#64). + /// Default OFF — a dark switch: with it off AkkaHostedService.BuildHocon emits no SSL + /// keys at all, so the remoting document is byte-identical to before. See + /// and + /// docs/plans/2026-11-09-tls-production-profile-design.md. + /// + public ClusterRemotingTlsOptions RemotingTls { get; set; } = new(); +} + +/// +/// Opt-in TLS for the Akka.Remote DotNetty transport (ScadaBridge#64). Maps onto +/// akka.remote.dot-netty.tcp.enable-ssl and its ssl block. Each node carries its +/// OWN certificate (a cert per node); trust is the chain to an issuing CA in the node's OS trust +/// store, which is how Akka.Remote validates the peer. +/// +/// +/// +/// Turning it on changes every node address. Akka.Remote's scheme becomes +/// akka.ssl.tcp, so every seed node must be written akka.ssl.tcp://scadabridge@host:port +/// — refuses a TLS-enabled config whose seeds still say +/// akka.tcp. Both nodes of a pair must flip together: a TLS node and a plaintext node cannot +/// talk to each other. +/// +/// +/// Deliberately no suppress-validation switch. Akka's own knob disables chain +/// validation entirely; exposing it would make "TLS on" mean nothing. A lab uses a lab CA instead. +/// +/// +public sealed class ClusterRemotingTlsOptions +{ + /// Whether Akka.Remote runs over TLS. Default . + public bool Enabled { get; set; } + + /// + /// Path to this node's PKCS#12 (.pfx) certificate with its private key. Set this OR + /// , never both. + /// + public string? CertificatePath { get; set; } + + /// + /// Password for . Write it as a ${secret:NAME} reference so the + /// pre-host expander resolves it from the Secrets store; never commit a literal. + /// + public string? CertificatePassword { get; set; } + + /// + /// Thumbprint of this node's certificate in the OS certificate store (the Windows production + /// path). Set this OR , never both. + /// + public string? CertificateThumbprint { get; set; } + + /// Certificate store name for . Default My (Personal). + public string StoreName { get; set; } = "My"; + + /// + /// Certificate store location for : local-machine + /// (default — a Windows service account reads it) or current-user. + /// + public string StoreLocation { get; set; } = "local-machine"; + + /// + /// Whether both ends must present a certificate (mutual TLS). Default , + /// matching Akka's own default. With it on, an unauthenticated host can no longer join or + /// send to the pair — which is the point, since Akka.Remote has no other authentication. + /// + public bool RequireMutualAuthentication { get; set; } = true; + + /// + /// Whether the peer certificate's CN/SAN must match the host being dialled. Default + /// , matching Akka's default; the trust anchor is the issuing CA. + /// Turn on only when every seed host name is a SAN on the peer's certificate. + /// + public bool ValidateCertificateHostname { get; set; } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs index 28a39816..5a653561 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure/ClusterOptionsValidator.cs @@ -34,6 +34,7 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase + /// When Akka.Remote TLS is enabled (ScadaBridge#64): exactly one certificate source, a legal + /// store location, and every seed node on the akka.ssl.tcp scheme. Akka.Remote advertises + /// akka.ssl.tcp:// addresses once SSL is on, so an akka.tcp:// seed names an + /// address no node listens as — the join would retry forever with no startup error. Nothing is + /// checked when TLS is off, so the default configuration can never be blocked by this. + /// + private static void ValidateRemotingTls( + ValidationBuilder builder, ClusterRemotingTlsOptions? tls, IReadOnlyList? seedNodes) + { + if (tls is null || !tls.Enabled) + { + return; + } + + var hasPath = !string.IsNullOrWhiteSpace(tls.CertificatePath); + var hasThumbprint = !string.IsNullOrWhiteSpace(tls.CertificateThumbprint); + builder.RequireThat(hasPath ^ hasThumbprint, + "ClusterOptions.RemotingTls requires exactly one certificate source when Enabled: " + + "CertificatePath (a .pfx) or CertificateThumbprint (OS certificate store), not both and not neither."); + + if (hasThumbprint) + { + builder.RequireThat(!string.IsNullOrWhiteSpace(tls.StoreName), + "ClusterOptions.RemotingTls.StoreName must be set when CertificateThumbprint is used (e.g. 'My')."); + builder.RequireThat( + string.Equals(tls.StoreLocation, "local-machine", StringComparison.Ordinal) + || string.Equals(tls.StoreLocation, "current-user", StringComparison.Ordinal), + $"ClusterOptions.RemotingTls.StoreLocation must be 'local-machine' or 'current-user' (was '{tls.StoreLocation}')."); + } + + var nonTlsSeeds = (seedNodes ?? Array.Empty()) + .Where(seed => !(seed ?? string.Empty).TrimStart() + .StartsWith("akka.ssl.tcp://", StringComparison.OrdinalIgnoreCase)) + .ToList(); + builder.RequireThat(nonTlsSeeds.Count == 0, + "ClusterOptions.SeedNodes must use the 'akka.ssl.tcp://' scheme when RemotingTls is enabled " + + "(Akka.Remote advertises akka.ssl.tcp addresses over TLS, so an akka.tcp seed is never answered); " + + $"offending: {string.Join(", ", nonTlsSeeds)}."); + } + /// /// When the bootstrap guard is enabled, its timing knobs must be positive. A /// zero/negative in particular diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index df58c455..7600de81 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -311,6 +311,10 @@ public class AkkaHostedService : IHostedService ? $"{LeaderAutoDowningProvider.UnreachableAfterPath} = {DurationHocon(clusterOptions.StableAfter)}\n" : string.Empty; + // Akka.Remote TLS (ScadaBridge#64, opt-in). Emitted inside dot-netty.tcp only when + // ScadaBridge:Cluster:RemotingTls:Enabled, so a TLS-off document is byte-identical to before. + var remotingTlsBlock = BuildRemotingTlsBlock(clusterOptions.RemotingTls); + return $@" {leaderAutoDownBlock}audit-telemetry-dispatcher {{ type = ForkJoinDispatcher @@ -329,7 +333,7 @@ akka {{ remote {{ dot-netty.tcp {{ hostname = {QuoteHocon(nodeOptions.NodeHostname)} - port = {nodeOptions.RemotingPort} + port = {nodeOptions.RemotingPort}{remotingTlsBlock} }} transport-failure-detector {{ heartbeat-interval = {DurationHocon(transportHeartbeat)} @@ -354,6 +358,47 @@ akka {{ }}"; } + /// + /// Renders the enable-ssl + ssl keys for akka.remote.dot-netty.tcp + /// (ScadaBridge#64), or an empty string when TLS is off. The leading newline is part of the + /// block so the disabled case adds nothing — not even whitespace — to the document. + /// + /// + /// suppress-validation is always emitted false on purpose: there is no option to + /// turn chain validation off (see ). Exactly one of the + /// two certificate sources is rendered; has already + /// refused a config carrying both or neither, and a thumbprint wins here if it ever got through. + /// + /// The bound TLS options; null or disabled renders nothing. + /// The HOCON fragment, or . + internal static string BuildRemotingTlsBlock(ClusterRemotingTlsOptions? tls) + { + if (tls is null || !tls.Enabled) + { + return string.Empty; + } + + var certificate = !string.IsNullOrWhiteSpace(tls.CertificateThumbprint) + ? $@"use-thumbprint-over-file = true + thumbprint = {QuoteHocon(tls.CertificateThumbprint)} + store-name = {QuoteHocon(tls.StoreName)} + store-location = {QuoteHocon(tls.StoreLocation)}" + : $@"use-thumbprint-over-file = false + path = {QuoteHocon(tls.CertificatePath)} + password = {QuoteHocon(tls.CertificatePassword)}"; + + return $@" + enable-ssl = true + ssl {{ + certificate {{ + {certificate} + }} + suppress-validation = false + require-mutual-authentication = {(tls.RequireMutualAuthentication ? "on" : "off")} + validate-certificate-hostname = {(tls.ValidateCertificateHostname ? "on" : "off")} + }}"; + } + /// /// Renders a as a HOCON duration in milliseconds. Akka's /// HOCON parser accepts a ms suffix, so emitting whole milliseconds diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs index 4efecb07..cdd6609d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterBootstrapGuardTests.cs @@ -132,6 +132,8 @@ public class ClusterBootstrapGuardTests [InlineData("akka.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)] [InlineData("akka.tcp://scadabridge@10.0.0.5:8082/", "10.0.0.5", 8082)] [InlineData(" akka.tcp://scadabridge@host.lan:1234 ", "host.lan", 1234)] + // Akka.Remote's scheme once dot-netty TLS is on (ScadaBridge#64) has TWO dot segments. + [InlineData("akka.ssl.tcp://scadabridge@scadabridge-site-a-a:8082", "scadabridge-site-a-a", 8082)] public void TryParse_extracts_host_and_port(string seed, string expectedHost, int expectedPort) { Assert.True(ClusterBootstrapGuard.TryParse(seed, out var host, out var port)); @@ -150,6 +152,25 @@ public class ClusterBootstrapGuardTests Assert.False(ClusterBootstrapGuard.TryParse(seed, out _, out _)); } + /// + /// Verifies the guard still engages for a TLS pair (ScadaBridge#64): with akka.ssl.tcp + /// seeds the old single-segment scheme pattern parsed nothing, so Applies was false and + /// the guard silently never ran. + /// + [Fact] + public void Analyze_engages_for_a_tls_pair() + { + const string tlsA = "akka.ssl.tcp://scadabridge@scadabridge-site-a-a:8082"; + const string tlsB = "akka.ssl.tcp://scadabridge@scadabridge-site-a-b:8082"; + + var role = ClusterBootstrapGuard.Analyze(new[] { tlsB, tlsA }, "scadabridge-site-a-b", 8082); + + Assert.True(role.Applies); + Assert.False(role.IsFounder); + Assert.Equal("scadabridge-site-a-a", role.PartnerHost); + Assert.Equal(new[] { tlsB, tlsA }, role.SelfFirstOrder); + } + /// Verifies that the port decides the tie-break when the hosts are equal. [Fact] public void Port_participates_in_the_tie_break_when_hosts_are_equal() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterRemotingTlsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterRemotingTlsValidatorTests.cs new file mode 100644 index 00000000..4f605432 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests/ClusterRemotingTlsValidatorTests.cs @@ -0,0 +1,117 @@ +namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests; + +/// +/// for the opt-in Akka.Remote TLS block +/// (ScadaBridge:Cluster:RemotingTls, ScadaBridge#64). Disabled must check nothing — so the +/// default configuration can never be blocked by it — and enabled must refuse the shapes that would +/// boot into a pair that silently never joins. +/// +public class ClusterRemotingTlsValidatorTests +{ + private static ClusterOptions Options(bool tlsSeeds, ClusterRemotingTlsOptions tls) + { + var scheme = tlsSeeds ? "akka.ssl.tcp" : "akka.tcp"; + return new ClusterOptions + { + SeedNodes = new List { $"{scheme}://scadabridge@node1:8081", $"{scheme}://scadabridge@node2:8081" }, + SplitBrainResolverStrategy = "auto-down", + RemotingTls = tls, + }; + } + + private static ClusterRemotingTlsOptions PathTls() => new() + { + Enabled = true, + CertificatePath = "/certs/node1.pfx", + CertificatePassword = "pw", + }; + + /// Verifies TLS is off by default. + [Fact] + public void RemotingTls_is_off_by_default() + { + Assert.False(new ClusterOptions().RemotingTls.Enabled); + } + + /// + /// Verifies a disabled block carrying garbage (both sources, a bad store location) is not checked: + /// with TLS off the fields are inert, and validating them would let an unused block break a boot. + /// + [Fact] + public void Disabled_block_is_not_validated() + { + var tls = new ClusterRemotingTlsOptions + { + Enabled = false, + CertificatePath = "/x.pfx", + CertificateThumbprint = "ABC", + StoreLocation = "nowhere", + }; + + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: false, tls)); + + Assert.True(result.Succeeded, result.FailureMessage); + } + + /// Verifies a file-certificate TLS config with akka.ssl.tcp seeds is valid. + [Fact] + public void Enabled_with_path_and_tls_seeds_is_valid() + { + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, PathTls())); + + Assert.True(result.Succeeded, result.FailureMessage); + } + + /// Verifies a store-thumbprint TLS config with akka.ssl.tcp seeds is valid. + [Fact] + public void Enabled_with_thumbprint_and_tls_seeds_is_valid() + { + var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificateThumbprint = "45df32e258c92a7abf6c112e54912ab15bbb9eb0" }; + + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls)); + + Assert.True(result.Succeeded, result.FailureMessage); + } + + /// + /// Verifies TLS with akka.tcp seeds is refused: Akka.Remote advertises akka.ssl.tcp once SSL is on, + /// so those seeds name an address nobody answers and the join retries forever. + /// + [Fact] + public void Enabled_with_plain_tcp_seeds_fails() + { + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: false, PathTls())); + + Assert.True(result.Failed); + Assert.Contains("akka.ssl.tcp://", result.FailureMessage); + Assert.Contains("akka.tcp://scadabridge@node1:8081", result.FailureMessage); + } + + /// Verifies exactly one certificate source is required. + /// The certificate path, or null. + /// The thumbprint, or null. + [Theory] + [InlineData(null, null)] + [InlineData("/certs/node1.pfx", "45df32e258c92a7abf6c112e54912ab15bbb9eb0")] + public void Enabled_requires_exactly_one_certificate_source(string? path, string? thumbprint) + { + var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificatePath = path, CertificateThumbprint = thumbprint }; + + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls)); + + Assert.True(result.Failed); + Assert.Contains("exactly one certificate source", result.FailureMessage); + } + + /// Verifies an unknown store location is refused for a thumbprint certificate. + [Fact] + public void Enabled_thumbprint_rejects_unknown_store_location() + { + var tls = new ClusterRemotingTlsOptions { Enabled = true, CertificateThumbprint = "ABC", StoreLocation = "LocalMachine" }; + + var result = new ClusterOptionsValidator().Validate(null, Options(tlsSeeds: true, tls)); + + Assert.True(result.Failed); + Assert.Contains("StoreLocation", result.FailureMessage); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconRemotingTlsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconRemotingTlsTests.cs new file mode 100644 index 00000000..ebc78044 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconRemotingTlsTests.cs @@ -0,0 +1,163 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Akka.Actor; +using Akka.Configuration; +using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; +using ZB.MOM.WW.ScadaBridge.Host.Actors; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// The opt-in Akka.Remote TLS block that emits from +/// ScadaBridge:Cluster:RemotingTls (ScadaBridge#64). Off must add nothing to the document; +/// on must render keys Akka actually reads — proven by booting a real transport on it. +/// +public class HoconRemotingTlsTests +{ + private const string TlsPrefix = "akka.remote.dot-netty.tcp."; + + private static NodeOptions Node(int port = 8081) => new() { Role = "Central", NodeHostname = "localhost", RemotingPort = port }; + + private static ClusterOptions Cluster(ClusterRemotingTlsOptions tls, bool emptySeeds = false) => new() + { + SeedNodes = new List { "akka.ssl.tcp://scadabridge@localhost:8081", "akka.ssl.tcp://scadabridge@peer:8081" }, + SplitBrainResolverStrategy = "auto-down", + // The bootstrap guard renders an EMPTY seed list, which lets the boot test below start an + // unjoined node that does not try to dial a peer that is not there. + BootstrapGuard = new ClusterBootstrapGuardOptions { Enabled = emptySeeds }, + RemotingTls = tls, + }; + + private static string Build(ClusterRemotingTlsOptions tls, NodeOptions? node = null, bool emptySeeds = false) => + AkkaHostedService.BuildHocon(node ?? Node(), Cluster(tls, emptySeeds), new[] { "Central" }, + TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)); + + /// Verifies the default (off) document carries no SSL key at all. + [Fact] + public void Default_emits_no_ssl_keys() + { + var hocon = Build(new ClusterRemotingTlsOptions()); + + // (The fixture's seeds say akka.ssl.tcp, so assert on the keys, not the bare word.) + Assert.DoesNotContain("enable-ssl", hocon, StringComparison.Ordinal); + Assert.DoesNotContain("ssl {", hocon, StringComparison.Ordinal); + var config = ConfigurationFactory.ParseString(hocon); + Assert.False(config.HasPath(TlsPrefix + "enable-ssl")); + Assert.False(config.HasPath(TlsPrefix + "ssl.certificate.path")); + } + + /// + /// Verifies a DISABLED block with every field populated renders exactly the default document — + /// the fields are inert until Enabled, byte for byte. + /// + [Fact] + public void Disabled_block_with_fields_is_byte_identical_to_default() + { + var populated = new ClusterRemotingTlsOptions + { + Enabled = false, + CertificatePath = "/certs/x.pfx", + CertificatePassword = "pw", + CertificateThumbprint = "ABC", + StoreName = "Root", + StoreLocation = "current-user", + RequireMutualAuthentication = false, + ValidateCertificateHostname = true, + }; + + Assert.Equal(Build(new ClusterRemotingTlsOptions()), Build(populated)); + } + + /// Verifies a file certificate renders the path/password keys Akka reads. + [Fact] + public void Enabled_with_path_renders_file_certificate_keys() + { + var config = ConfigurationFactory.ParseString(Build(new ClusterRemotingTlsOptions + { + Enabled = true, + CertificatePath = "/certs/node1.pfx", + CertificatePassword = "p\"w\\d", + })); + + Assert.True(config.GetBoolean(TlsPrefix + "enable-ssl")); + Assert.False(config.GetBoolean(TlsPrefix + "ssl.certificate.use-thumbprint-over-file")); + Assert.Equal("/certs/node1.pfx", config.GetString(TlsPrefix + "ssl.certificate.path")); + // Quote and backslash survive the round trip: the password cannot break out of its literal. + Assert.Equal("p\"w\\d", config.GetString(TlsPrefix + "ssl.certificate.password")); + Assert.False(config.GetBoolean(TlsPrefix + "ssl.suppress-validation")); + Assert.True(config.GetBoolean(TlsPrefix + "ssl.require-mutual-authentication")); + Assert.False(config.GetBoolean(TlsPrefix + "ssl.validate-certificate-hostname")); + // The block sits inside dot-netty.tcp without disturbing its siblings. + Assert.Equal("localhost", config.GetString(TlsPrefix + "hostname")); + Assert.Equal(8081, config.GetInt(TlsPrefix + "port")); + Assert.Equal(1, config.GetInt("akka.cluster.min-nr-of-members")); + } + + /// Verifies a store certificate renders the thumbprint keys and the policy switches. + [Fact] + public void Enabled_with_thumbprint_renders_store_keys() + { + var config = ConfigurationFactory.ParseString(Build(new ClusterRemotingTlsOptions + { + Enabled = true, + CertificateThumbprint = "45df32e258c92a7abf6c112e54912ab15bbb9eb0", + RequireMutualAuthentication = false, + ValidateCertificateHostname = true, + })); + + Assert.True(config.GetBoolean(TlsPrefix + "ssl.certificate.use-thumbprint-over-file")); + Assert.Equal("45df32e258c92a7abf6c112e54912ab15bbb9eb0", config.GetString(TlsPrefix + "ssl.certificate.thumbprint")); + Assert.Equal("My", config.GetString(TlsPrefix + "ssl.certificate.store-name")); + Assert.Equal("local-machine", config.GetString(TlsPrefix + "ssl.certificate.store-location")); + Assert.False(config.HasPath(TlsPrefix + "ssl.certificate.path")); + Assert.False(config.GetBoolean(TlsPrefix + "ssl.require-mutual-authentication")); + Assert.True(config.GetBoolean(TlsPrefix + "ssl.validate-certificate-hostname")); + } + + /// + /// Boots a real ActorSystem on the emitted TLS document with a generated certificate and checks + /// Akka.Remote came up as a TLS transport: its address scheme is akka.ssl.tcp. This is the + /// fact the seed-scheme rule in ClusterOptionsValidator relies on, and it fails if the + /// rendered keys are ones Akka does not read (the transport would come up plain akka.tcp). + /// + [Fact] + public async Task Enabled_document_boots_a_tls_transport_with_akka_ssl_tcp_addresses() + { + var dir = Directory.CreateTempSubdirectory("sb-akka-tls-"); + try + { + const string password = "test-only-pfx-password"; + var pfxPath = Path.Combine(dir.FullName, "node.pfx"); + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + request.CertificateExtensions.Add(san.Build()); + using var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); + await File.WriteAllBytesAsync(pfxPath, cert.Export(X509ContentType.Pfx, password)); + } + + var hocon = Build( + new ClusterRemotingTlsOptions { Enabled = true, CertificatePath = pfxPath, CertificatePassword = password }, + Node(port: 0), + emptySeeds: true); + + var system = ActorSystem.Create("scadabridge", ConfigurationFactory.ParseString(hocon)); + try + { + var address = ((ExtendedActorSystem)system).Provider.DefaultAddress; + Assert.Equal("akka.ssl.tcp", address.Protocol); + Assert.True(address.Port > 0); + } + finally + { + await system.Terminate(); + } + } + finally + { + dir.Delete(recursive: true); + } + } +}