diff --git a/src/NATS.Server/LeafNodes/LeafNodeManager.cs b/src/NATS.Server/LeafNodes/LeafNodeManager.cs index 6aa300a..681a2d0 100644 --- a/src/NATS.Server/LeafNodes/LeafNodeManager.cs +++ b/src/NATS.Server/LeafNodes/LeafNodeManager.cs @@ -42,6 +42,60 @@ public sealed class LeafNodeManager : IAsyncDisposable public string ListenEndpoint => $"{_options.Host}:{_options.Port}"; + /// + /// Current TLS certificate path, or null if TLS is not configured. + /// Updated by . + /// Go reference: leafnode.go — TLSConfig / tlsCertFile on server options. + /// + public string? CurrentCertPath { get; private set; } + + /// + /// Current TLS private key path, or null if TLS is not configured. + /// Updated by . + /// + public string? CurrentKeyPath { get; private set; } + + /// + /// True when a TLS certificate is currently configured for leaf node connections. + /// Go reference: leafnode.go — tls_required / tlsTimeout on LeafNodeOpts. + /// + public bool IsTlsEnabled => CurrentCertPath is not null; + + /// + /// Incremented each time detects a change and applies it. + /// Useful for testing and observability. + /// + public int TlsReloadCount { get; private set; } + + /// + /// Compares and against the + /// currently active values. When either differs the paths are updated, the reload counter + /// is incremented, and a with Changed = true is + /// returned. When both are identical a result with Changed = false is returned and + /// no state is mutated. + /// Go reference: leafnode.go — reloadTLSConfig hot-reload path. + /// + public LeafTlsReloadResult UpdateTlsConfig(string? newCertPath, string? newKeyPath) + { + var previousCert = CurrentCertPath; + + if (string.Equals(CurrentCertPath, newCertPath, StringComparison.Ordinal) + && string.Equals(CurrentKeyPath, newKeyPath, StringComparison.Ordinal)) + { + return new LeafTlsReloadResult(Changed: false, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null); + } + + CurrentCertPath = newCertPath; + CurrentKeyPath = newKeyPath; + TlsReloadCount++; + + _logger.LogInformation( + "Leaf node TLS config updated (cert={CertPath}, key={KeyPath}, reloads={Count})", + newCertPath, newKeyPath, TlsReloadCount); + + return new LeafTlsReloadResult(Changed: true, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null); + } + public LeafNodeManager( LeafNodeOptions options, ServerStats stats, @@ -326,3 +380,16 @@ public sealed class LeafNodeManager : IAsyncDisposable return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1])); } } + +/// +/// Describes the outcome of a call. +/// +/// True when the cert or key path differed from the previously active values. +/// The cert path that was active before this call. +/// The cert path supplied to this call. +/// Non-null when an error prevented the reload (reserved for future use). +public sealed record LeafTlsReloadResult( + bool Changed, + string? PreviousCertPath, + string? NewCertPath, + string? Error); diff --git a/tests/NATS.Server.Tests/LeafNodes/LeafTlsReloadTests.cs b/tests/NATS.Server.Tests/LeafNodes/LeafTlsReloadTests.cs new file mode 100644 index 0000000..a793d21 --- /dev/null +++ b/tests/NATS.Server.Tests/LeafNodes/LeafTlsReloadTests.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.Logging.Abstractions; +using NATS.Server.Configuration; +using NATS.Server.LeafNodes; + +namespace NATS.Server.Tests.LeafNodes; + +/// +/// Unit tests for leaf node TLS certificate hot-reload (Gap 12.1). +/// Verifies that correctly tracks cert/key +/// paths, increments the reload counter only on genuine changes, and returns accurate +/// values. +/// Go reference: leafnode.go — reloadTLSConfig, TestLeafNodeTLSCertReload. +/// +public class LeafTlsReloadTests +{ + private static LeafNodeManager CreateManager() => + new( + options: new LeafNodeOptions { Host = "127.0.0.1", Port = 0 }, + stats: new ServerStats(), + serverId: "test-server", + remoteSubSink: _ => { }, + messageSink: _ => { }, + logger: NullLogger.Instance); + + // Go: TestLeafNodeTLSCertReload — leafnode_test.go, first reload call + [Fact] + public void UpdateTlsConfig_NewCert_ReturnsChanged() + { + var manager = CreateManager(); + + var result = manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + result.Changed.ShouldBeTrue(); + result.NewCertPath.ShouldBe("/certs/leaf.pem"); + } + + // Go: TestLeafNodeTLSCertReload — no-op reload when cert unchanged + [Fact] + public void UpdateTlsConfig_SameCert_ReturnsUnchanged() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + var result = manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + result.Changed.ShouldBeFalse(); + } + + // Go: TestLeafNodeTLSCertReload — cert rotation path + [Fact] + public void UpdateTlsConfig_ChangedCert_UpdatesPath() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/old.pem", "/certs/old-key.pem"); + + manager.UpdateTlsConfig("/certs/new.pem", "/certs/new-key.pem"); + + manager.CurrentCertPath.ShouldBe("/certs/new.pem"); + } + + // Go: TestLeafNodeTLSCertReload — disabling TLS by passing null cert + [Fact] + public void UpdateTlsConfig_ClearCert_ReturnsChanged() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + var result = manager.UpdateTlsConfig(null, null); + + result.Changed.ShouldBeTrue(); + result.NewCertPath.ShouldBeNull(); + } + + // Go: TestLeafNodeTLS — IsTlsEnabled when no cert configured + [Fact] + public void IsTlsEnabled_NoCert_ReturnsFalse() + { + var manager = CreateManager(); + + manager.IsTlsEnabled.ShouldBeFalse(); + } + + // Go: TestLeafNodeTLS — IsTlsEnabled when cert is configured + [Fact] + public void IsTlsEnabled_WithCert_ReturnsTrue() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + manager.IsTlsEnabled.ShouldBeTrue(); + } + + // Go: TestLeafNodeTLSCertReload — reload counter increments on each genuine change + [Fact] + public void TlsReloadCount_IncrementedOnChange() + { + var manager = CreateManager(); + manager.TlsReloadCount.ShouldBe(0); + + manager.UpdateTlsConfig("/certs/a.pem", "/certs/a-key.pem"); + manager.TlsReloadCount.ShouldBe(1); + + manager.UpdateTlsConfig("/certs/b.pem", "/certs/b-key.pem"); + manager.TlsReloadCount.ShouldBe(2); + } + + // Go: TestLeafNodeTLSCertReload — reload counter unchanged when config identical + [Fact] + public void TlsReloadCount_NotIncrementedOnNoChange() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + manager.TlsReloadCount.ShouldBe(1); + } + + // Go: TestLeafNodeTLSCertReload — result carries previous cert path + [Fact] + public void UpdateTlsConfig_ReportsPreviousPath() + { + var manager = CreateManager(); + manager.UpdateTlsConfig("/certs/first.pem", "/certs/first-key.pem"); + + var result = manager.UpdateTlsConfig("/certs/second.pem", "/certs/second-key.pem"); + + result.PreviousCertPath.ShouldBe("/certs/first.pem"); + result.NewCertPath.ShouldBe("/certs/second.pem"); + } + + // Go: TestLeafNodeTLSCertReload — key path tracked alongside cert path + [Fact] + public void UpdateTlsConfig_UpdatesKeyPath() + { + var manager = CreateManager(); + + manager.UpdateTlsConfig("/certs/leaf.pem", "/certs/leaf-key.pem"); + + manager.CurrentKeyPath.ShouldBe("/certs/leaf-key.pem"); + } +}