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); } } }