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