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