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