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
This commit is contained in:
@@ -45,8 +45,12 @@ namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
|
||||
/// </remarks>
|
||||
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]+)?://[^@/]+@(?<host>[^:/]+):(?<port>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
new(@"^akka(?:\.[a-z0-9]+)*://[^@/]+@(?<host>[^:/]+):(?<port>\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>The analyzed bootstrap role of this node within its seed list.</summary>
|
||||
/// <param name="Applies">True only when the guard engages: exactly two seeds, one of them this node.</param>
|
||||
|
||||
@@ -135,6 +135,81 @@ public class ClusterOptions
|
||||
/// decision logic and <c>ClusterBootstrapCoordinator</c> for the runtime.
|
||||
/// </summary>
|
||||
public ClusterBootstrapGuardOptions BootstrapGuard { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// TLS for Akka.Remote within a pair (<c>ScadaBridge:Cluster:RemotingTls</c>, ScadaBridge#64).
|
||||
/// Default OFF — a dark switch: with it off <c>AkkaHostedService.BuildHocon</c> emits no SSL
|
||||
/// keys at all, so the remoting document is byte-identical to before. See
|
||||
/// <see cref="ClusterRemotingTlsOptions"/> and
|
||||
/// <c>docs/plans/2026-11-09-tls-production-profile-design.md</c>.
|
||||
/// </summary>
|
||||
public ClusterRemotingTlsOptions RemotingTls { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in TLS for the Akka.Remote DotNetty transport (ScadaBridge#64). Maps onto
|
||||
/// <c>akka.remote.dot-netty.tcp.enable-ssl</c> and its <c>ssl</c> 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Turning it on changes every node address.</b> Akka.Remote's scheme becomes
|
||||
/// <c>akka.ssl.tcp</c>, so every seed node must be written <c>akka.ssl.tcp://scadabridge@host:port</c>
|
||||
/// — <see cref="ClusterOptionsValidator"/> refuses a TLS-enabled config whose seeds still say
|
||||
/// <c>akka.tcp</c>. Both nodes of a pair must flip together: a TLS node and a plaintext node cannot
|
||||
/// talk to each other.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately no <c>suppress-validation</c> switch.</b> Akka's own knob disables chain
|
||||
/// validation entirely; exposing it would make "TLS on" mean nothing. A lab uses a lab CA instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClusterRemotingTlsOptions
|
||||
{
|
||||
/// <summary>Whether Akka.Remote runs over TLS. Default <see langword="false"/>.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path to this node's PKCS#12 (<c>.pfx</c>) certificate with its private key. Set this OR
|
||||
/// <see cref="CertificateThumbprint"/>, never both.
|
||||
/// </summary>
|
||||
public string? CertificatePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password for <see cref="CertificatePath"/>. Write it as a <c>${secret:NAME}</c> reference so the
|
||||
/// pre-host expander resolves it from the Secrets store; never commit a literal.
|
||||
/// </summary>
|
||||
public string? CertificatePassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbprint of this node's certificate in the OS certificate store (the Windows production
|
||||
/// path). Set this OR <see cref="CertificatePath"/>, never both.
|
||||
/// </summary>
|
||||
public string? CertificateThumbprint { get; set; }
|
||||
|
||||
/// <summary>Certificate store name for <see cref="CertificateThumbprint"/>. Default <c>My</c> (Personal).</summary>
|
||||
public string StoreName { get; set; } = "My";
|
||||
|
||||
/// <summary>
|
||||
/// Certificate store location for <see cref="CertificateThumbprint"/>: <c>local-machine</c>
|
||||
/// (default — a Windows service account reads it) or <c>current-user</c>.
|
||||
/// </summary>
|
||||
public string StoreLocation { get; set; } = "local-machine";
|
||||
|
||||
/// <summary>
|
||||
/// Whether both ends must present a certificate (mutual TLS). Default <see langword="true"/>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool RequireMutualAuthentication { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the peer certificate's CN/SAN must match the host being dialled. Default
|
||||
/// <see langword="false"/>, 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.
|
||||
/// </summary>
|
||||
public bool ValidateCertificateHostname { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
||||
protected override void Validate(ValidationBuilder builder, ClusterOptions options)
|
||||
{
|
||||
ValidateBootstrapGuard(builder, options.BootstrapGuard);
|
||||
ValidateRemotingTls(builder, options.RemotingTls, options.SeedNodes);
|
||||
|
||||
// The design doc states "both nodes are seed nodes — each node lists
|
||||
// both itself and its partner" so a properly-configured deployment lists
|
||||
@@ -84,6 +85,47 @@ public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOption
|
||||
+ "younger node forms its own, producing two live clusters.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When Akka.Remote TLS is enabled (ScadaBridge#64): exactly one certificate source, a legal
|
||||
/// store location, and every seed node on the <c>akka.ssl.tcp</c> scheme. Akka.Remote advertises
|
||||
/// <c>akka.ssl.tcp://</c> addresses once SSL is on, so an <c>akka.tcp://</c> 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.
|
||||
/// </summary>
|
||||
private static void ValidateRemotingTls(
|
||||
ValidationBuilder builder, ClusterRemotingTlsOptions? tls, IReadOnlyList<string>? 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<string>())
|
||||
.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)}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the bootstrap guard is enabled, its timing knobs must be positive. A
|
||||
/// zero/negative <see cref="ClusterBootstrapGuardOptions.PartnerProbeSeconds"/> in particular
|
||||
|
||||
@@ -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 {{
|
||||
}}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the <c>enable-ssl</c> + <c>ssl</c> keys for <c>akka.remote.dot-netty.tcp</c>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>suppress-validation</c> is always emitted <c>false</c> on purpose: there is no option to
|
||||
/// turn chain validation off (see <see cref="ClusterRemotingTlsOptions"/>). Exactly one of the
|
||||
/// two certificate sources is rendered; <see cref="ClusterOptionsValidator"/> has already
|
||||
/// refused a config carrying both or neither, and a thumbprint wins here if it ever got through.
|
||||
/// </remarks>
|
||||
/// <param name="tls">The bound TLS options; null or disabled renders nothing.</param>
|
||||
/// <returns>The HOCON fragment, or <see cref="string.Empty"/>.</returns>
|
||||
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")}
|
||||
}}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a <see cref="TimeSpan"/> as a HOCON duration in milliseconds. Akka's
|
||||
/// HOCON parser accepts a <c>ms</c> suffix, so emitting whole milliseconds
|
||||
|
||||
@@ -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 _));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the guard still engages for a TLS pair (ScadaBridge#64): with <c>akka.ssl.tcp</c>
|
||||
/// seeds the old single-segment scheme pattern parsed nothing, so <c>Applies</c> was false and
|
||||
/// the guard silently never ran.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the port decides the tie-break when the hosts are equal.</summary>
|
||||
[Fact]
|
||||
public void Port_participates_in_the_tie_break_when_hosts_are_equal()
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.ClusterInfrastructure.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ClusterOptionsValidator"/> for the opt-in Akka.Remote TLS block
|
||||
/// (<c>ScadaBridge:Cluster:RemotingTls</c>, 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.
|
||||
/// </summary>
|
||||
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<string> { $"{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",
|
||||
};
|
||||
|
||||
/// <summary>Verifies TLS is off by default.</summary>
|
||||
[Fact]
|
||||
public void RemotingTls_is_off_by_default()
|
||||
{
|
||||
Assert.False(new ClusterOptions().RemotingTls.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a file-certificate TLS config with akka.ssl.tcp seeds is valid.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a store-thumbprint TLS config with akka.ssl.tcp seeds is valid.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies exactly one certificate source is required.</summary>
|
||||
/// <param name="path">The certificate path, or null.</param>
|
||||
/// <param name="thumbprint">The thumbprint, or null.</param>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Verifies an unknown store location is refused for a thumbprint certificate.</summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user