diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs
index 5815a20..3379997 100644
--- a/src/NATS.Server/Configuration/ConfigReloader.cs
+++ b/src/NATS.Server/Configuration/ConfigReloader.cs
@@ -498,6 +498,37 @@ public static class ConfigReloader
// ─── Comparison helpers ─────────────────────────────────────────
+ ///
+ /// Derives the effective log-level string from Debug/Trace flags.
+ /// Trace takes precedence: Trace=true → "Trace", Debug=true → "Debug", else "Information".
+ ///
+ private static string EffectiveLogLevel(NatsOptions opts)
+ {
+ if (opts.Trace) return "Trace";
+ if (opts.Debug) return "Debug";
+ return "Information";
+ }
+
+ /// Flattens all gateway remote URLs into a single list.
+ private static List FlattenGatewayUrls(GatewayOptions? gw)
+ {
+ if (gw is null) return [];
+ var urls = new List(gw.Remotes);
+ foreach (var remote in gw.RemoteGateways)
+ urls.AddRange(remote.Urls);
+ return urls;
+ }
+
+ /// Flattens all leaf-node remote URLs into a single list.
+ private static List FlattenLeafUrls(LeafNodeOptions? leaf)
+ {
+ if (leaf is null) return [];
+ var urls = new List(leaf.Remotes);
+ foreach (var remote in leaf.RemoteLeaves)
+ urls.AddRange(remote.Urls);
+ return urls;
+ }
+
private static void CompareAndAdd(List changes, string name, T oldVal, T newVal)
{
if (!Equals(oldVal, newVal))
@@ -601,6 +632,41 @@ public static class ConfigReloader
}
///
+ /// Compares TLS certificate paths between old and new config.
+ /// If changed, validates the new cert is loadable.
+ /// Go reference: server/reload.go — tlsConfigReload.
+ ///
+ public static TlsReloadResult ReloadTlsCertificates(NatsOptions oldOpts, NatsOptions newOpts)
+ {
+ var result = new TlsReloadResult();
+
+ var oldCert = oldOpts.TlsCert ?? string.Empty;
+ var newCert = newOpts.TlsCert ?? string.Empty;
+
+ if (string.Equals(oldCert, newCert, StringComparison.Ordinal))
+ return result; // No change
+
+ result.CertificateChanged = true;
+ result.CertificatePath = newCert;
+
+ if (string.IsNullOrWhiteSpace(newCert))
+ {
+ result.CertificateLoaded = true; // TLS being disabled
+ return result;
+ }
+
+ // Validate the file exists (actual X509 loading would require the key file too)
+ if (!File.Exists(newCert))
+ {
+ result.Error = $"TLS certificate file not found: {newCert}";
+ return result;
+ }
+
+ result.CertificateLoaded = true;
+ return result;
+ }
+
+ ///
/// Reloads TLS certificates from the current options and atomically swaps them
/// into the certificate provider. New connections will use the new certificate;
/// existing connections keep their original certificate.
@@ -740,3 +806,25 @@ public sealed class AuthChangeResult
/// True when the Authorization token string changed.
public bool TokenChanged { get; set; }
}
+///
+/// Result of a TLS certificate path comparison and validation during config hot-reload.
+/// Returned by .
+/// Go reference: server/reload.go — tlsConfigReload.
+///
+public sealed class TlsReloadResult
+{
+ /// True when the TLS certificate path changed between old and new config.
+ public bool CertificateChanged { get; set; }
+
+ /// True when the new certificate path was successfully validated (or TLS is being disabled).
+ public bool CertificateLoaded { get; set; }
+
+ /// Set when validation fails (e.g., file not found). Null on success.
+ public string? Error { get; set; }
+
+ /// The new certificate file path (empty string when TLS is being disabled).
+ public string? CertificatePath { get; set; }
+
+ /// Reserved for future use: expiry time of the loaded certificate.
+ public DateTime? ExpiresUtc { get; set; }
+}
diff --git a/tests/NATS.Server.Tests/Configuration/ClusterConfigReloadTests.cs b/tests/NATS.Server.Tests/Configuration/ClusterConfigReloadTests.cs
new file mode 100644
index 0000000..85fac95
--- /dev/null
+++ b/tests/NATS.Server.Tests/Configuration/ClusterConfigReloadTests.cs
@@ -0,0 +1,186 @@
+// Tests for ConfigReloader.ApplyClusterConfigChanges.
+// Go reference: golang/nats-server/server/reload.go — routesOption.Apply, gatewayOption.Apply.
+
+using NATS.Server;
+using NATS.Server.Configuration;
+using Shouldly;
+
+namespace NATS.Server.Tests.Configuration;
+
+public class ClusterConfigReloadTests
+{
+ // ─── helpers ────────────────────────────────────────────────────
+
+ private static NatsOptions WithRoutes(params string[] routes)
+ {
+ var opts = new NatsOptions();
+ opts.Cluster = new ClusterOptions { Routes = [..routes] };
+ return opts;
+ }
+
+ private static NatsOptions WithGatewayRemotes(params string[] urls)
+ {
+ var opts = new NatsOptions();
+ opts.Gateway = new GatewayOptions
+ {
+ RemoteGateways = [new RemoteGatewayOptions { Urls = [..urls] }]
+ };
+ return opts;
+ }
+
+ private static NatsOptions WithLeafRemotes(params string[] urls)
+ {
+ var opts = new NatsOptions();
+ opts.LeafNode = new LeafNodeOptions { Remotes = [..urls] };
+ return opts;
+ }
+
+ // ─── tests ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void No_changes_returns_no_changes()
+ {
+ // Go reference: reload.go routesOption.Apply — no-op when sets are equal
+ var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
+ var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeFalse();
+ result.RouteUrlsChanged.ShouldBeFalse();
+ result.GatewayUrlsChanged.ShouldBeFalse();
+ result.LeafUrlsChanged.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Route_url_added_detected()
+ {
+ // Go reference: reload.go routesOption.Apply — new route triggers reconnect
+ var old = WithRoutes("nats://server1:6222");
+ var updated = WithRoutes("nats://server1:6222", "nats://server2:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.RouteUrlsChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Route_url_removed_detected()
+ {
+ // Go reference: reload.go routesOption.Apply — removed route triggers disconnect
+ var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
+ var updated = WithRoutes("nats://server1:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.RouteUrlsChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Gateway_url_changed_detected()
+ {
+ // Go reference: reload.go gatewayOption.Apply — gateway remotes reconciled on reload
+ var old = WithGatewayRemotes("nats://gw1:7222");
+ var updated = WithGatewayRemotes("nats://gw2:7222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.GatewayUrlsChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Leaf_url_changed_detected()
+ {
+ // Go reference: reload.go leafNodeOption.Apply — leaf remotes reconciled on reload
+ var old = WithLeafRemotes("nats://hub:5222");
+ var updated = WithLeafRemotes("nats://hub2:5222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.LeafUrlsChanged.ShouldBeTrue();
+ }
+
+ [Fact]
+ public void Multiple_changes_detected()
+ {
+ // Go reference: reload.go — multiple topology changes in a single reload
+ var old = new NatsOptions
+ {
+ Cluster = new ClusterOptions { Routes = ["nats://r1:6222"] },
+ Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw1:7222"] }] }
+ };
+ var updated = new NatsOptions
+ {
+ Cluster = new ClusterOptions { Routes = ["nats://r1:6222", "nats://r2:6222"] },
+ Gateway = new GatewayOptions { RemoteGateways = [new RemoteGatewayOptions { Urls = ["nats://gw2:7222"] }] }
+ };
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.RouteUrlsChanged.ShouldBeTrue();
+ result.GatewayUrlsChanged.ShouldBeTrue();
+ result.LeafUrlsChanged.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Same_urls_different_order_no_change()
+ {
+ // Go reference: reload.go — order-independent URL comparison
+ var old = WithRoutes("nats://server1:6222", "nats://server2:6222");
+ var updated = WithRoutes("nats://server2:6222", "nats://server1:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeFalse();
+ result.RouteUrlsChanged.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void AddedRouteUrls_lists_new_routes()
+ {
+ // Go reference: reload.go routesOption.Apply — identifies routes to dial
+ var old = WithRoutes("nats://server1:6222");
+ var updated = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.AddedRouteUrls.Count.ShouldBe(2);
+ result.AddedRouteUrls.ShouldContain("nats://server2:6222");
+ result.AddedRouteUrls.ShouldContain("nats://server3:6222");
+ result.RemovedRouteUrls.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void RemovedRouteUrls_lists_removed_routes()
+ {
+ // Go reference: reload.go routesOption.Apply — identifies routes to close
+ var old = WithRoutes("nats://server1:6222", "nats://server2:6222", "nats://server3:6222");
+ var updated = WithRoutes("nats://server1:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.RemovedRouteUrls.Count.ShouldBe(2);
+ result.RemovedRouteUrls.ShouldContain("nats://server2:6222");
+ result.RemovedRouteUrls.ShouldContain("nats://server3:6222");
+ result.AddedRouteUrls.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public void Empty_to_non_empty_detected()
+ {
+ // Go reference: reload.go routesOption.Apply — nil→populated triggers dial
+ var old = new NatsOptions(); // no Cluster configured
+ var updated = WithRoutes("nats://server1:6222");
+
+ var result = ConfigReloader.ApplyClusterConfigChanges(old, updated);
+
+ result.HasChanges.ShouldBeTrue();
+ result.RouteUrlsChanged.ShouldBeTrue();
+ result.AddedRouteUrls.ShouldContain("nats://server1:6222");
+ }
+}