Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/HoconRemotingTlsTests.cs
T
Joseph Doherty 2e6102d48f feat(cluster): opt-in Akka.Remote TLS block, default off (ScadaBridge#64)
ScadaBridge:Cluster:RemotingTls (ClusterRemotingTlsOptions) renders
akka.remote.dot-netty.tcp enable-ssl + ssl { certificate, mutual auth,
hostname validation } only when Enabled. Chain validation is always on
(no suppress-validation switch). Certificate is a .pfx path + password
(${secret:} reference) or an OS-store thumbprint.

ClusterOptionsValidator, TLS on only: exactly one certificate source, a
legal store location, and akka.ssl.tcp:// on every seed - Akka.Remote
advertises akka.ssl.tcp addresses once SSL is on, so an akka.tcp seed is
never answered and the join retries forever with no startup error.

ClusterBootstrapGuard.SeedPattern accepted only one scheme segment, so
akka.ssl.tcp seeds parsed as nothing and the guard would silently never
engage on a TLS pair; it now accepts any number.

Default HOCON is byte-identical: 12 rendered documents (3 downing
strategies x guard on/off x Central/Site) captured before the change and
diffed after, all identical; a permanent test pins that a disabled block
with every field set renders the default document. A real ActorSystem
booted on the emitted TLS document comes up as akka.ssl.tcp (mutation
check: forcing enable-ssl=false turns it red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:06:01 -05:00

164 lines
7.7 KiB
C#

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;
/// <summary>
/// The opt-in Akka.Remote TLS block that <see cref="AkkaHostedService.BuildHocon"/> emits from
/// <c>ScadaBridge:Cluster:RemotingTls</c> (ScadaBridge#64). Off must add nothing to the document;
/// on must render keys Akka actually reads — proven by booting a real transport on it.
/// </summary>
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<string> { "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));
/// <summary>Verifies the default (off) document carries no SSL key at all.</summary>
[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"));
}
/// <summary>
/// Verifies a DISABLED block with every field populated renders exactly the default document —
/// the fields are inert until <c>Enabled</c>, byte for byte.
/// </summary>
[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));
}
/// <summary>Verifies a file certificate renders the path/password keys Akka reads.</summary>
[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"));
}
/// <summary>Verifies a store certificate renders the thumbprint keys and the policy switches.</summary>
[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"));
}
/// <summary>
/// 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 <c>akka.ssl.tcp</c>. This is the
/// fact the seed-scheme rule in <c>ClusterOptionsValidator</c> relies on, and it fails if the
/// rendered keys are ones Akka does not read (the transport would come up plain <c>akka.tcp</c>).
/// </summary>
[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);
}
}
}