diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs index 20052f9..5815a20 100644 --- a/src/NATS.Server/Configuration/ConfigReloader.cs +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -422,6 +422,80 @@ public static class ConfigReloader RejectedChanges: rejectedChanges)); } + /// + /// Compares cluster-topology settings between two instances + /// and returns a describing what changed. + /// Callers use this to reconcile route/gateway/leaf connections after a hot reload. + /// Reference: golang/nats-server/server/reload.go — routesOption.Apply / gatewayOption.Apply. + /// + public static ClusterConfigChangeResult ApplyClusterConfigChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new ClusterConfigChangeResult(); + + // Compare cluster route URLs (ClusterOptions.Routes) + var oldRoutes = oldOpts.Cluster?.Routes ?? []; + var newRoutes = newOpts.Cluster?.Routes ?? []; + + var oldSet = new HashSet(oldRoutes, StringComparer.OrdinalIgnoreCase); + var newSet = new HashSet(newRoutes, StringComparer.OrdinalIgnoreCase); + + if (!oldSet.SetEquals(newSet)) + { + result.RouteUrlsChanged = true; + result.AddedRouteUrls.AddRange(newSet.Except(oldSet, StringComparer.OrdinalIgnoreCase)); + result.RemovedRouteUrls.AddRange(oldSet.Except(newSet, StringComparer.OrdinalIgnoreCase)); + } + + // Compare gateway remote URLs — flatten all URLs across all RemoteGateway entries + var oldGwUrls = FlattenGatewayUrls(oldOpts.Gateway); + var newGwUrls = FlattenGatewayUrls(newOpts.Gateway); + if (!new HashSet(oldGwUrls, StringComparer.OrdinalIgnoreCase) + .SetEquals(new HashSet(newGwUrls, StringComparer.OrdinalIgnoreCase))) + result.GatewayUrlsChanged = true; + + // Compare leaf-node remote URLs — flatten Remotes + all RemoteLeaves entries + var oldLeafUrls = FlattenLeafUrls(oldOpts.LeafNode); + var newLeafUrls = FlattenLeafUrls(newOpts.LeafNode); + if (!new HashSet(oldLeafUrls, StringComparer.OrdinalIgnoreCase) + .SetEquals(new HashSet(newLeafUrls, StringComparer.OrdinalIgnoreCase))) + result.LeafUrlsChanged = true; + + result.HasChanges = result.RouteUrlsChanged || result.GatewayUrlsChanged || result.LeafUrlsChanged; + return result; + } + + /// + /// Compares logging settings between two instances and + /// returns a describing what changed. + /// The effective log level is derived from the Debug/Trace flags: Trace → "Trace", + /// Debug → "Debug", otherwise "Information" — matching Go's precedence. + /// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply. + /// + public static LoggingChangeResult ApplyLoggingChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new LoggingChangeResult(); + + // Derive effective level from Debug/Trace flags (Trace takes precedence over Debug) + var oldLevel = EffectiveLogLevel(oldOpts); + var newLevel = EffectiveLogLevel(newOpts); + + if (!string.Equals(oldLevel, newLevel, StringComparison.OrdinalIgnoreCase)) + { + result.LevelChanged = true; + result.OldLevel = oldLevel; + result.NewLevel = newLevel; + } + + // Check individual trace/debug flags + if (oldOpts.Trace != newOpts.Trace) + result.TraceChanged = true; + if (oldOpts.Debug != newOpts.Debug) + result.DebugChanged = true; + + result.HasChanges = result.LevelChanged || result.TraceChanged || result.DebugChanged; + return result; + } + // ─── Comparison helpers ───────────────────────────────────────── private static void CompareAndAdd(List changes, string name, T oldVal, T newVal) @@ -487,6 +561,45 @@ public static class ConfigReloader return !string.Equals(oldJetStream.StoreDir, newJetStream.StoreDir, StringComparison.Ordinal); } + /// + /// Compares auth-related fields between two instances and + /// returns an describing what changed. This drives + /// re-evaluation of existing connections after a config reload. + /// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply. + /// + public static AuthChangeResult PropagateAuthChanges(NatsOptions oldOpts, NatsOptions newOpts) + { + var result = new AuthChangeResult(); + + // Check if users changed + var oldUsers = oldOpts.Users; + var newUsers = newOpts.Users; + if ((oldUsers?.Count ?? 0) != (newUsers?.Count ?? 0)) + { + result.UsersChanged = true; + } + else if (oldUsers is not null && newUsers is not null) + { + var oldUserSet = new HashSet(oldUsers.Select(u => u.Username), StringComparer.Ordinal); + var newUserSet = new HashSet(newUsers.Select(u => u.Username), StringComparer.Ordinal); + if (!oldUserSet.SetEquals(newUserSet)) + result.UsersChanged = true; + } + + // Check if accounts changed + var oldAccountCount = oldOpts.Accounts?.Count ?? 0; + var newAccountCount = newOpts.Accounts?.Count ?? 0; + if (oldAccountCount != newAccountCount) + result.AccountsChanged = true; + + // Check auth token (Authorization is the token string directly on NatsOptions) + if (!string.Equals(oldOpts.Authorization, newOpts.Authorization, StringComparison.Ordinal)) + result.TokenChanged = true; + + result.HasChanges = result.UsersChanged || result.AccountsChanged || result.TokenChanged; + return result; + } + /// /// Reloads TLS certificates from the current options and atomically swaps them /// into the certificate provider. New connections will use the new certificate; @@ -553,3 +666,77 @@ public sealed class ConfigReloadResult /// Result of an in-memory options comparison for reload validation. /// public sealed record ReloadFromOptionsResult(bool Success, List RejectedChanges); + +/// +/// Describes what cluster-topology configuration changed between two +/// instances. Returned by and used to +/// determine which routes, gateways, or leaf connections need to be reconciled after a reload. +/// Reference: golang/nats-server/server/reload.go — clusterOption.Apply / routesOption.Apply. +/// +public sealed class ClusterConfigChangeResult +{ + /// True when any cluster-topology field changed. + public bool HasChanges { get; set; } + + /// True when the set of cluster route URLs changed. + public bool RouteUrlsChanged { get; set; } + + /// True when any gateway remote URL set changed. + public bool GatewayUrlsChanged { get; set; } + + /// True when the leaf-node remote URL set changed. + public bool LeafUrlsChanged { get; set; } + + /// Route URLs present in new config but absent in old config. + public List AddedRouteUrls { get; init; } = []; + + /// Route URLs present in old config but absent in new config. + public List RemovedRouteUrls { get; init; } = []; +} + +/// +/// Describes what logging configuration changed between two +/// instances. Returned by . +/// Reference: golang/nats-server/server/reload.go — traceOption.Apply / debugOption.Apply. +/// +public sealed class LoggingChangeResult +{ + /// True when any logging field changed. + public bool HasChanges { get; set; } + + /// True when the effective log level string changed. + public bool LevelChanged { get; set; } + + /// The effective log level before the reload. + public string? OldLevel { get; set; } + + /// The effective log level after the reload. + public string? NewLevel { get; set; } + + /// True when the Trace flag changed. + public bool TraceChanged { get; set; } + + /// True when the Debug flag changed. + public bool DebugChanged { get; set; } +} + +/// +/// Describes what auth-related configuration changed between two +/// instances. Returned by and used to +/// determine which existing connections need to be re-evaluated after a reload. +/// Reference: golang/nats-server/server/reload.go — authOption.Apply / usersOption.Apply. +/// +public sealed class AuthChangeResult +{ + /// True when any auth field changed. + public bool HasChanges { get; set; } + + /// True when the set of configured users changed. + public bool UsersChanged { get; set; } + + /// True when the set of configured accounts changed. + public bool AccountsChanged { get; set; } + + /// True when the Authorization token string changed. + public bool TokenChanged { get; set; } +} diff --git a/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs b/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs new file mode 100644 index 0000000..6e5aba6 --- /dev/null +++ b/tests/NATS.Server.Tests/Configuration/AuthChangePropagationTests.cs @@ -0,0 +1,196 @@ +// Port of Go server/reload.go — auth change propagation tests. +// Reference: golang/nats-server/server/reload.go — authOption.Apply, usersOption.Apply. + +using NATS.Server.Auth; +using NATS.Server.Configuration; +using Shouldly; + +namespace NATS.Server.Tests.Configuration; + +public sealed class AuthChangePropagationTests +{ + // ─── helpers ──────────────────────────────────────────────────── + + private static User MakeUser(string username, string password = "pw") => + new() { Username = username, Password = password }; + + private static NatsOptions BaseOpts() => new(); + + // ─── tests ────────────────────────────────────────────────────── + + [Fact] + public void No_changes_returns_no_changes() + { + // Same empty options → nothing changed. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.HasChanges.ShouldBeFalse(); + result.UsersChanged.ShouldBeFalse(); + result.AccountsChanged.ShouldBeFalse(); + result.TokenChanged.ShouldBeFalse(); + } + + [Fact] + public void User_added_detected() + { + // Adding a user must set UsersChanged. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("alice")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void User_removed_detected() + { + // Removing a user must set UsersChanged. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice")]; + var newOpts = BaseOpts(); + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Account_added_detected() + { + // Adding an account must set AccountsChanged. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Accounts = new Dictionary + { + ["engineering"] = new AccountConfig() + }; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.AccountsChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Token_changed_detected() + { + // Changing the Authorization token must set TokenChanged. + var oldOpts = BaseOpts(); + oldOpts.Authorization = "old-secret-token"; + var newOpts = BaseOpts(); + newOpts.Authorization = "new-secret-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Multiple_changes_all_flagged() + { + // Changing both users and accounts must set both flags. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice")]; + oldOpts.Accounts = new Dictionary + { + ["acct-a"] = new AccountConfig() + }; + + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("alice"), MakeUser("bob")]; + newOpts.Accounts = new Dictionary + { + ["acct-a"] = new AccountConfig(), + ["acct-b"] = new AccountConfig() + }; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.AccountsChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Same_users_different_order_no_change() + { + // Users in a different order with the same names must NOT trigger UsersChanged + // because the comparison is set-based. + var oldOpts = BaseOpts(); + oldOpts.Users = [MakeUser("alice"), MakeUser("bob")]; + + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("bob"), MakeUser("alice")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeFalse(); + result.HasChanges.ShouldBeFalse(); + } + + [Fact] + public void HasChanges_true_when_any_change() + { + // A single changed field (token only) is enough to set HasChanges. + var oldOpts = BaseOpts(); + var newOpts = BaseOpts(); + newOpts.Authorization = "token-xyz"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Empty_to_non_empty_users_detected() + { + // Going from zero users to one user must be detected. + var oldOpts = BaseOpts(); + // No Users assigned — null list. + var newOpts = BaseOpts(); + newOpts.Users = [MakeUser("charlie")]; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.UsersChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void No_auth_to_auth_detected() + { + // Going from null Authorization to a token string must be detected. + var oldOpts = BaseOpts(); + // Authorization is null by default. + var newOpts = BaseOpts(); + newOpts.Authorization = "brand-new-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeTrue(); + result.HasChanges.ShouldBeTrue(); + } + + [Fact] + public void Same_token_no_change() + { + // The same token value on both sides must NOT flag TokenChanged. + var oldOpts = BaseOpts(); + oldOpts.Authorization = "stable-token"; + var newOpts = BaseOpts(); + newOpts.Authorization = "stable-token"; + + var result = ConfigReloader.PropagateAuthChanges(oldOpts, newOpts); + + result.TokenChanged.ShouldBeFalse(); + result.HasChanges.ShouldBeFalse(); + } +}