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