diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs index 879416a..a6f7a89 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs @@ -498,6 +498,12 @@ internal sealed class ClusterReloadOption : AuthReloadOption public override bool IsClusterPoolSizeOrAccountsChange() => _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0; + internal ClusterOpts? ClusterValue => _newValue as ClusterOpts; + internal bool PoolSizeChanged => _poolSizeChanged; + internal bool CompressChanged => _compressChanged; + internal IReadOnlyList AccountsAdded => _accsAdded; + internal IReadOnlyList AccountsRemoved => _accsRemoved; + /// /// Computes pool/account deltas used by reload orchestration. /// Mirrors Go clusterOption.diffPoolAndAccounts. @@ -715,6 +721,8 @@ internal sealed class JetStreamReloadOption : NoopReloadOption private readonly bool _newValue; public JetStreamReloadOption(bool newValue) => _newValue = newValue; + internal bool Value => _newValue; + public override bool IsJetStreamChange() => true; public override bool IsStatszChange() => true; diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs new file mode 100644 index 0000000..b6d5b51 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Reload.cs @@ -0,0 +1,1365 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/reload.go in the NATS server Go source. + +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; + +namespace ZB.MOM.NatsNet.Server; + +public sealed partial class NatsServer +{ + /// + /// Applies supported changes from to the running server. + /// Mirrors Go Server.ReloadOptions(). + /// + public Exception? ReloadOptions(ServerOptions newOptions) + { + ArgumentNullException.ThrowIfNull(newOptions); + + _reloadMu.EnterWriteLock(); + try + { + var currentOptions = GetOpts().Clone(); + + // Match Go behavior: when operators are configured, trusted keys are cleared. + if (currentOptions.TrustedOperators.Count > 0 && currentOptions.TrustedKeys.Count > 0) + currentOptions.TrustedKeys = []; + + var clientOriginalPort = currentOptions.Port; + var clusterOriginalPort = currentOptions.Cluster.Port; + var gatewayOriginalPort = currentOptions.Gateway.Port; + var leafOriginalPort = currentOptions.LeafNode.Port; + var websocketOriginalPort = currentOptions.Websocket.Port; + var mqttOriginalPort = currentOptions.Mqtt.Port; + + if (!string.IsNullOrEmpty(currentOptions.Cluster.ListenStr)) + { + newOptions.Cluster.ListenStr = currentOptions.Cluster.ListenStr; + var overrideError = newOptions.OverrideCluster(); + if (overrideError != null) + return overrideError; + } + + newOptions = ServerOptions.MergeOptions(newOptions, ServerOptions.FlagSnapshot); + + if (ServerOptions.FlagSnapshot != null) + ConfigReloader.ApplyBoolFlags(newOptions, ServerOptions.FlagSnapshot); + + newOptions.SetBaselineOptions(); + + // Preserve already-bound listener ports on reload when RANDOM was used. + if (newOptions.Port == 0) + newOptions.Port = clientOriginalPort; + if (newOptions.Cluster.Port == ServerConstants.RandomPort) + newOptions.Cluster.Port = clusterOriginalPort; + if (newOptions.Gateway.Port == ServerConstants.RandomPort) + newOptions.Gateway.Port = gatewayOriginalPort; + if (newOptions.LeafNode.Port == ServerConstants.RandomPort) + newOptions.LeafNode.Port = leafOriginalPort; + if (newOptions.Websocket.Port == ServerConstants.RandomPort) + newOptions.Websocket.Port = websocketOriginalPort; + if (newOptions.Mqtt.Port == ServerConstants.RandomPort) + newOptions.Mqtt.Port = mqttOriginalPort; + + var reloadError = ReloadOptionsInternal(currentOptions, newOptions); + if (reloadError != null) + return reloadError; + + RecheckPinnedCerts(currentOptions, newOptions); + + _mu.EnterWriteLock(); + try + { + _configTime = DateTime.UtcNow; + } + finally + { + _mu.ExitWriteLock(); + } + + return null; + } + finally + { + _reloadMu.ExitWriteLock(); + } + } + + /// + /// Applies immutable/runtime-only fields and executes diff + apply. + /// Mirrors Go Server.reloadOptions(). + /// + internal Exception? ReloadOptionsInternal(ServerOptions currentOptions, ServerOptions newOptions) + { + ArgumentNullException.ThrowIfNull(currentOptions); + ArgumentNullException.ThrowIfNull(newOptions); + + newOptions.CustomClientAuthentication = currentOptions.CustomClientAuthentication; + newOptions.CustomRouterAuthentication = currentOptions.CustomRouterAuthentication; + + var (changed, diffError) = DiffOptions(newOptions); + if (diffError != null) + return diffError; + + if (changed.Count > 0) + { + var validateError = ValidateOptions(newOptions); + if (validateError != null) + return validateError; + } + + SetOpts(newOptions); + ApplyOptions(new ReloadContext(currentOptions.Cluster.Permissions), changed); + return null; + } + + /// + /// Diffs current and new options and returns ordered reload actions. + /// Mirrors Go Server.diffOptions(). + /// + internal (List Changed, Exception? Error) DiffOptions(ServerOptions newOptions) + { + ArgumentNullException.ThrowIfNull(newOptions); + + var oldOptions = GetOpts().Clone(); + var diffOptions = new List(); + + var jsEnabled = oldOptions.JetStream; + var disableJetStream = false; + var jsMemLimitsChanged = false; + var jsStoreLimitsChanged = false; + var jsStoreDirChanged = false; + + foreach (var property in typeof(ServerOptions).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead) + continue; + + var oldValue = property.GetValue(oldOptions); + var newValue = property.GetValue(newOptions); + + var oldImposeError = ConfigReloader.ImposeOrder(oldValue); + if (oldImposeError != null) + return ([], oldImposeError); + + var newImposeError = ConfigReloader.ImposeOrder(newValue); + if (newImposeError != null) + return ([], newImposeError); + + var optionName = property.Name; + var changed = optionName is not nameof(ServerOptions.Accounts) and not nameof(ServerOptions.Users) + ? !OptionValueEquals(optionName, oldValue, newValue) + : true; + + if (!changed) + { + if (optionName == nameof(ServerOptions.JetStream) && newValue is bool jsWanted && jsWanted && !jsEnabled) + diffOptions.Add(new JetStreamReloadOption(true)); + + continue; + } + + switch (optionName) + { + case nameof(ServerOptions.TraceVerbose): + diffOptions.Add(new TraceVerboseReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.TraceHeaders): + diffOptions.Add(new TraceHeadersReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.Trace): + diffOptions.Add(new TraceReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.Debug): + diffOptions.Add(new DebugReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.Logtime): + diffOptions.Add(new LogtimeReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.LogtimeUtc): + diffOptions.Add(new LogtimeUTCOption((bool)newValue!)); + break; + case nameof(ServerOptions.LogFile): + diffOptions.Add(new LogfileOption((string)newValue!)); + break; + case nameof(ServerOptions.Syslog): + diffOptions.Add(new SyslogReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.RemoteSyslog): + diffOptions.Add(new RemoteSyslogReloadOption((string)newValue!)); + break; + case nameof(ServerOptions.TlsConfig): + diffOptions.Add(new TlsReloadOption(newValue)); + break; + case nameof(ServerOptions.TlsTimeout): + diffOptions.Add(new TlsTimeoutReloadOption((double)newValue!)); + break; + case nameof(ServerOptions.TlsPinnedCerts): + diffOptions.Add(new TlsPinnedCertReloadOption(newValue)); + break; + case nameof(ServerOptions.TlsHandshakeFirst): + diffOptions.Add(new TlsHandshakeFirstReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.TlsHandshakeFirstFallback): + diffOptions.Add(new TlsHandshakeFirstFallbackReloadOption((TimeSpan)newValue!)); + break; + case nameof(ServerOptions.Username): + diffOptions.Add(new UsernameReloadOption()); + break; + case nameof(ServerOptions.Password): + diffOptions.Add(new PasswordReloadOption()); + break; + case nameof(ServerOptions.Tags): + diffOptions.Add(new TagsReloadOption()); + break; + case nameof(ServerOptions.Metadata): + diffOptions.Add(new MetadataReloadOption()); + break; + case nameof(ServerOptions.Authorization): + diffOptions.Add(new AuthorizationReloadOption()); + break; + case nameof(ServerOptions.AuthTimeout): + diffOptions.Add(new AuthTimeoutReloadOption((double)newValue!)); + break; + case nameof(ServerOptions.Users): + diffOptions.Add(new UsersReloadOption()); + break; + case nameof(ServerOptions.Nkeys): + diffOptions.Add(new NkeysReloadOption()); + break; + case nameof(ServerOptions.Cluster): + { + var oldCluster = (ClusterOpts)oldValue!; + var newCluster = (ClusterOpts)newValue!; + + var clusterError = ConfigReloader.ValidateClusterOpts(oldCluster, newCluster); + if (clusterError != null) + return ([], clusterError); + + var clusterOption = new ClusterReloadOption( + newCluster, + permsChanged: !OptionValueEquals(nameof(ClusterOpts.Permissions), oldCluster.Permissions, newCluster.Permissions), + poolSizeChanged: false, + compressChanged: !CompressOptsEqual(oldCluster.Compression, newCluster.Compression), + accsAdded: [], + accsRemoved: []); + + clusterOption.DiffPoolAndAccounts(oldCluster); + + foreach (var accountName in clusterOption.AccountsAdded) + { + var (_, lookupError) = LookupAccount(accountName); + if (lookupError != null) + { + return ([], new InvalidOperationException( + $"unable to add account '{accountName}' to dedicated routes: {lookupError.Message}", + lookupError)); + } + } + + diffOptions.Add(clusterOption); + break; + } + case nameof(ServerOptions.Routes): + { + var oldRoutes = oldValue as IReadOnlyList ?? []; + var newRoutes = newValue as IReadOnlyList ?? []; + var (add, remove) = ConfigReloader.DiffRoutes(oldRoutes, newRoutes); + diffOptions.Add(new RoutesReloadOption([.. add.Cast()], [.. remove.Cast()])); + break; + } + case nameof(ServerOptions.MaxConn): + diffOptions.Add(new MaxConnReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.PidFile): + diffOptions.Add(new PidFileReloadOption((string)newValue!)); + break; + case nameof(ServerOptions.PortsFileDir): + diffOptions.Add(new PortsFileDirReloadOption((string)oldValue!, (string)newValue!)); + break; + case nameof(ServerOptions.MaxControlLine): + diffOptions.Add(new MaxControlLineReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.MaxPayload): + diffOptions.Add(new MaxPayloadReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.PingInterval): + diffOptions.Add(new PingIntervalReloadOption((TimeSpan)newValue!)); + break; + case nameof(ServerOptions.MaxPingsOut): + diffOptions.Add(new MaxPingsOutReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.WriteDeadline): + diffOptions.Add(new WriteDeadlineReloadOption((TimeSpan)newValue!)); + break; + case nameof(ServerOptions.ClientAdvertise): + { + var clientAdvertise = (string)newValue!; + if (!string.IsNullOrEmpty(clientAdvertise)) + { + var (_, _, parseError) = ServerUtilities.ParseHostPort(clientAdvertise, 0); + if (parseError != null) + { + return ([], new InvalidOperationException( + $"invalid ClientAdvertise value '{clientAdvertise}': {parseError.Message}", + parseError)); + } + } + diffOptions.Add(new ClientAdvertiseReloadOption(clientAdvertise)); + break; + } + case nameof(ServerOptions.Accounts): + diffOptions.Add(new AccountsReloadOption()); + break; + case nameof(ServerOptions.AccountResolver): + if ((oldValue is null) != (newValue is null)) + return ([], new InvalidOperationException("config reload does not support moving to or from an account resolver")); + diffOptions.Add(new AccountsReloadOption()); + break; + case nameof(ServerOptions.AccountResolverTlsConfig): + diffOptions.Add(new AccountsReloadOption()); + break; + case nameof(ServerOptions.Gateway): + { + var oldGateway = (GatewayOpts)oldValue!; + var newGateway = (GatewayOpts)newValue!; + if (!GatewayEquivalentForReload(oldGateway, newGateway)) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldGateway}, new={newGateway}")); + } + break; + } + case nameof(ServerOptions.LeafNode): + { + var oldLeaf = (LeafNodeOpts)oldValue!; + var newLeaf = (LeafNodeOpts)newValue!; + var (equivalent, tlsFirstChanged, compressionChanged, disabledChanged) = + LeafNodeReloadCompare(oldLeaf, newLeaf); + if (!equivalent) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldLeaf}, new={newLeaf}")); + } + diffOptions.Add(new LeafNodeReloadOption(tlsFirstChanged, compressionChanged, disabledChanged)); + break; + } + case nameof(ServerOptions.JetStream): + { + var newJsValue = (bool)newValue!; + var oldJsValue = (bool)oldValue!; + if (newJsValue != oldJsValue) + diffOptions.Add(new JetStreamReloadOption(newJsValue)); + disableJetStream = !newJsValue; + break; + } + case nameof(ServerOptions.StoreDir): + { + var oldStoreDir = (string)oldValue!; + var newStoreDir = (string)newValue!; + if (jsEnabled && !string.Equals(oldStoreDir, newStoreDir, StringComparison.Ordinal)) + { + if (string.IsNullOrEmpty(newStoreDir)) + jsStoreDirChanged = true; + else + return ([], new InvalidOperationException("config reload not supported for jetstream storage directory")); + } + break; + } + case nameof(ServerOptions.JetStreamMaxMemory): + case nameof(ServerOptions.JetStreamMaxStore): + { + var oldLimit = (long)oldValue!; + var newLimit = (long)newValue!; + if (jsEnabled && oldLimit != newLimit) + { + var fromUnset = oldLimit == -1; + var toUnset = newLimit == -1; + if (!fromUnset && toUnset) + { + if (optionName == nameof(ServerOptions.JetStreamMaxMemory)) + jsMemLimitsChanged = true; + else + jsStoreLimitsChanged = true; + } + else if (fromUnset && !toUnset) + { + return ([], new InvalidOperationException( + "config reload not supported for jetstream dynamic max memory and store")); + } + else + { + return ([], new InvalidOperationException( + "config reload not supported for jetstream max memory and store")); + } + } + break; + } + case nameof(ServerOptions.Websocket): + { + var oldWebsocket = CloneByJson((WebsocketOpts)oldValue!); + var newWebsocket = CloneByJson((WebsocketOpts)newValue!); + oldWebsocket.TlsConfig = null; + oldWebsocket.TlsConfigOpts = null; + newWebsocket.TlsConfig = null; + newWebsocket.TlsConfigOpts = null; + if (!JsonEquivalent(oldWebsocket, newWebsocket)) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + break; + } + case nameof(ServerOptions.Mqtt): + { + var oldMqtt = CloneByJson((MqttOpts)oldValue!); + var newMqtt = CloneByJson((MqttOpts)newValue!); + + diffOptions.Add(new MqttAckWaitReloadOption(newMqtt.AckWait)); + diffOptions.Add(new MqttMaxAckPendingReloadOption(newMqtt.MaxAckPending)); + diffOptions.Add(new MqttStreamReplicasReloadOption(newMqtt.StreamReplicas)); + diffOptions.Add(new MqttConsumerReplicasReloadOption(newMqtt.ConsumerReplicas)); + diffOptions.Add(new MqttConsumerMemoryStorageReloadOption(newMqtt.ConsumerMemoryStorage)); + diffOptions.Add(new MqttInactiveThresholdReloadOption(newMqtt.ConsumerInactiveThreshold)); + + oldMqtt.TlsConfig = null; + oldMqtt.TlsConfigOpts = null; + oldMqtt.AckWait = default; + oldMqtt.MaxAckPending = default; + oldMqtt.StreamReplicas = default; + oldMqtt.ConsumerReplicas = default; + oldMqtt.ConsumerMemoryStorage = default; + oldMqtt.ConsumerInactiveThreshold = default; + + newMqtt.TlsConfig = null; + newMqtt.TlsConfigOpts = null; + newMqtt.AckWait = default; + newMqtt.MaxAckPending = default; + newMqtt.StreamReplicas = default; + newMqtt.ConsumerReplicas = default; + newMqtt.ConsumerMemoryStorage = default; + newMqtt.ConsumerInactiveThreshold = default; + + if (!JsonEquivalent(oldMqtt, newMqtt)) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + break; + } + case nameof(ServerOptions.ConnectErrorReports): + diffOptions.Add(new ConnectErrorReportsReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.ReconnectErrorReports): + diffOptions.Add(new ReconnectErrorReportsReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.NoLog): + case nameof(ServerOptions.NoSigs): + break; + case nameof(ServerOptions.DisableShortFirstPing): + newOptions.DisableShortFirstPing = (bool)oldValue!; + break; + case nameof(ServerOptions.MaxTracedMsgLen): + diffOptions.Add(new MaxTracedMsgLenReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.Port): + if ((int)newValue! == 0) + break; + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + case nameof(ServerOptions.NoAuthUser): + { + var oldNoAuth = (string)oldValue!; + var newNoAuth = (string)newValue!; + if (!string.IsNullOrEmpty(oldNoAuth) && string.IsNullOrEmpty(newNoAuth)) + { + var matchesUser = newOptions.Users?.Any(u => + string.Equals(u.Username, oldNoAuth, StringComparison.Ordinal)) == true; + if (matchesUser) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + } + else + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + break; + } + case nameof(ServerOptions.DefaultSentinel): + diffOptions.Add(new DefaultSentinelReloadOption((string)newValue!)); + break; + case nameof(ServerOptions.SystemAccount): + { + var oldSystem = (string)oldValue!; + var newSystem = (string)newValue!; + if (!string.Equals(oldSystem, ServerConstants.DefaultSystemAccount, StringComparison.Ordinal) || + !string.IsNullOrEmpty(newSystem)) + { + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + break; + } + case nameof(ServerOptions.OcspConfig): + diffOptions.Add(new OcspReloadOption(newValue)); + break; + case nameof(ServerOptions.OcspCacheConfig): + diffOptions.Add(new OcspResponseCacheReloadOption(newValue)); + break; + case nameof(ServerOptions.ProfBlockRate): + diffOptions.Add(new ProfBlockRateReloadOption((int)newValue!)); + break; + case nameof(ServerOptions.ConfigDigestValue): + break; + case nameof(ServerOptions.NoFastProducerStall): + diffOptions.Add(new NoFastProducerStallReloadOption((bool)newValue!)); + break; + case nameof(ServerOptions.Proxies): + { + var oldProxies = oldValue as ProxiesConfig; + var newProxies = newValue as ProxiesConfig; + var (add, del) = ConfigReloader.DiffProxiesTrustedKeys(oldProxies?.Trusted, newProxies?.Trusted); + if (add.Count > 0 || del.Count > 0) + diffOptions.Add(new ProxiesReloadOption([.. add], [.. del])); + break; + } + default: + return ([], new InvalidOperationException( + $"config reload not supported for {optionName}: old={oldValue}, new={newValue}")); + } + } + + if (!disableJetStream) + { + if (jsMemLimitsChanged || jsStoreLimitsChanged) + return ([], new InvalidOperationException( + "config reload not supported for jetstream max memory and max store")); + + if (jsStoreDirChanged) + return ([], new InvalidOperationException( + "config reload not supported for jetstream storage dir")); + } + + return (diffOptions, null); + } + + /// + /// Applies reload options and executes post-processing hooks. + /// Mirrors Go Server.applyOptions(). + /// + internal void ApplyOptions(ReloadContext context, IReadOnlyList options) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(options); + + var reloadLogging = false; + var reloadAuthorization = false; + var reloadClusterPermissions = false; + var reloadClientTraceLevel = false; + var statsChanged = false; + var jetStreamChanged = false; + var jetStreamEnabled = false; + ClusterReloadOption? clusterOption = null; + + foreach (var option in options) + { + option.Apply(this); + + if (option.IsLoggingChange()) + reloadLogging = true; + if (option.IsTraceLevelChange()) + reloadClientTraceLevel = true; + if (option.IsAuthChange()) + reloadAuthorization = true; + if (option.IsClusterPermsChange()) + reloadClusterPermissions = true; + if (option.IsClusterPoolSizeOrAccountsChange() && option is ClusterReloadOption co) + clusterOption = co; + if (option.IsStatszChange()) + statsChanged = true; + if (option.IsJetStreamChange() && option is JetStreamReloadOption js) + { + jetStreamChanged = true; + jetStreamEnabled = js.Value; + } + } + + if (reloadLogging) + ConfigureLogger(); + + if (reloadClientTraceLevel) + ReloadClientTraceLevel(); + + if (reloadAuthorization) + ReloadAuthorization(); + + if (reloadClusterPermissions) + ReloadClusterPermissions(context.OldClusterPermissions); + + var updatedOptions = GetOpts(); + if (clusterOption != null) + ReloadClusterPoolAndAccounts(clusterOption, updatedOptions); + + if (jetStreamChanged && !jetStreamEnabled) + Noticef("Reloaded: JetStream disabled"); + + if (statsChanged) + Noticef("Reloaded: stats-related settings updated"); + + var digest = updatedOptions.ConfigDigest(); + if (string.IsNullOrEmpty(digest)) + Noticef("Reloaded server configuration"); + else + Noticef("Reloaded server configuration ({0})", digest); + } + + /// + /// Disconnects clients that no longer satisfy pinned-certificate policy. + /// Mirrors Go Server.recheckPinnedCerts(). + /// + internal void RecheckPinnedCerts(ServerOptions currentOptions, ServerOptions newOptions) + { + ArgumentNullException.ThrowIfNull(currentOptions); + ArgumentNullException.ThrowIfNull(newOptions); + + var disconnectClients = new List(); + var protocolPinnedSets = new Dictionary(); + + if (!PinnedCertSetEquals(newOptions.TlsPinnedCerts, currentOptions.TlsPinnedCerts)) + protocolPinnedSets[ClientConnectionType.Nats] = newOptions.TlsPinnedCerts; + if (!PinnedCertSetEquals(newOptions.Mqtt.TlsPinnedCerts, currentOptions.Mqtt.TlsPinnedCerts)) + protocolPinnedSets[ClientConnectionType.Mqtt] = newOptions.Mqtt.TlsPinnedCerts; + if (!PinnedCertSetEquals(newOptions.Websocket.TlsPinnedCerts, currentOptions.Websocket.TlsPinnedCerts)) + protocolPinnedSets[ClientConnectionType.WebSocket] = newOptions.Websocket.TlsPinnedCerts; + + _mu.EnterReadLock(); + try + { + foreach (var client in _clients.Values) + { + if (client.Kind != ClientKind.Client) + continue; + + if (protocolPinnedSets.TryGetValue(client.ClientType(), out var pinned) && + !MatchesPinnedCert(client, pinned)) + { + disconnectClients.Add(client); + } + } + + if (!PinnedCertSetEquals(newOptions.LeafNode.TlsPinnedCerts, currentOptions.LeafNode.TlsPinnedCerts)) + { + foreach (var leaf in _leafs.Values) + { + if (leaf.Kind == ClientKind.Leaf && !MatchesPinnedCert(leaf, newOptions.LeafNode.TlsPinnedCerts)) + disconnectClients.Add(leaf); + } + } + + if (!PinnedCertSetEquals(newOptions.Cluster.TlsPinnedCerts, currentOptions.Cluster.TlsPinnedCerts)) + { + ForEachRoute(route => + { + if (!MatchesPinnedCert(route, newOptions.Cluster.TlsPinnedCerts)) + disconnectClients.Add(route); + }); + } + } + finally + { + _mu.ExitReadLock(); + } + + if (_gateway.Enabled && + !PinnedCertSetEquals(newOptions.Gateway.TlsPinnedCerts, currentOptions.Gateway.TlsPinnedCerts)) + { + _gateway.AcquireReadLock(); + try + { + foreach (var outbound in _gateway.Out.Values) + { + if (!MatchesPinnedCert(outbound, newOptions.Gateway.TlsPinnedCerts)) + disconnectClients.Add(outbound); + } + + foreach (var inbound in _gateway.In.Values) + { + if (inbound.Kind == ClientKind.Gateway && + !MatchesPinnedCert(inbound, newOptions.Gateway.TlsPinnedCerts)) + { + disconnectClients.Add(inbound); + } + } + } + finally + { + _gateway.ReleaseReadLock(); + } + } + + var unique = disconnectClients + .Where(static c => c != null) + .DistinctBy(static c => c.Cid) + .ToList(); + + if (unique.Count == 0) + return; + + Noticef("Disconnect {0} clients due to pinned certs reload", unique.Count); + foreach (var client in unique) + client.CloseConnection(ClosedState.TlsHandshakeError); + } + + /// + /// Signals the internal event-send loop to refresh state. + /// Mirrors Go Server.resetInternalLoopInfo(). + /// + internal void ResetInternalLoopInfo() + { + System.Threading.Channels.Channel? resetChannel; + _mu.EnterReadLock(); + try + { + resetChannel = _sys?.ResetChannel; + } + finally + { + _mu.ExitReadLock(); + } + + if (resetChannel != null) + resetChannel.Writer.TryWrite(true); + } + + /// + /// Recomputes per-client trace flags after logging-level changes. + /// Mirrors Go Server.reloadClientTraceLevel(). + /// + internal void ReloadClientTraceLevel() + { + var opts = GetOpts(); + if (opts.NoLog) + return; + + var clients = new List(); + + _mu.EnterReadLock(); + try + { + clients.AddRange(_clients.Values); + clients.AddRange(_leafs.Values); + ForEachRoute(route => clients.Add(route)); + } + finally + { + _mu.ExitReadLock(); + } + + lock (_grMu) + { + clients.AddRange(_grTmpClients.Values); + } + + _gateway.AcquireReadLock(); + try + { + clients.AddRange(_gateway.In.Values); + clients.AddRange(_gateway.Outo); + } + finally + { + _gateway.ReleaseReadLock(); + } + + foreach (var client in clients.DistinctBy(static c => c.Cid)) + client.SetTraceLevel(); + } + + /// + /// Reloads authorization state and disconnects no-longer-authorized clients. + /// Mirrors Go Server.reloadAuthorization(). + /// + internal void ReloadAuthorization() + { + var opts = GetOpts(); + var deletedAccounts = new List(); + var movedClients = new List(); + var clientsToCheck = new List(); + var routes = new List(); + System.Threading.Channels.Channel? resetChannel = null; + + _mu.EnterWriteLock(); + try + { + if (_trustedKeys == null) + { + var configuredAccounts = new HashSet( + opts.Accounts.Select(static a => a.GetName()), + StringComparer.Ordinal); + + foreach (var kvp in _accounts.ToArray()) + { + var accountName = kvp.Key; + var account = kvp.Value; + if (accountName == ServerConstants.DefaultGlobalAccount || + accountName == ServerConstants.DefaultSystemAccount) + { + continue; + } + + if (!configuredAccounts.Contains(accountName)) + { + deletedAccounts.Add(account); + _accounts.TryRemove(accountName, out _); + } + } + + var (_, configureError) = ConfigureAccounts(true); + if (configureError != null) + Errorf("reloadAuthorization: configureAccounts failed: {0}", configureError.Message); + + ConfigureAuthorization(); + } + else if (opts.AccountResolver != null) + { + var resolverError = ConfigureResolver(); + if (resolverError != null) + Errorf("reloadAuthorization: configureResolver failed: {0}", resolverError.Message); + + if (_accResolver is MemoryAccountResolver) + CheckResolvePreloads(); + } + + foreach (var client in _clients.Values) + { + if (ClientHasMovedToDifferentAccount(client)) + movedClients.Add(client); + else + clientsToCheck.Add(client); + } + + ForEachRoute(route => routes.Add(route)); + + if (_sys?.Account != null && !opts.NoSystemAccount) + _accounts[_sys.Account.Name] = _sys.Account; + + foreach (var account in _accounts.Values) + { + foreach (var client in account.GetClients()) + { + if (client.Kind != ClientKind.Client && client.Kind != ClientKind.Leaf) + clientsToCheck.Add(client); + } + } + + resetChannel = _sys?.ResetChannel; + } + finally + { + _mu.ExitWriteLock(); + } + + foreach (var account in deletedAccounts) + account.ClearEventing(); + + if (resetChannel != null) + resetChannel.Writer.TryWrite(true); + + foreach (var client in movedClients.DistinctBy(static c => c.Cid)) + client.CloseConnection(ClosedState.ClientClosed); + + foreach (var client in clientsToCheck.DistinctBy(static c => c.Cid)) + { + if ((client.Kind == ClientKind.Client || client.Kind == ClientKind.Leaf) && !IsClientAuthorized(client)) + { + client.AuthViolation(); + continue; + } + + if (client.Kind == ClientKind.Router && !IsRouterAuthorized(client)) + { + client.SetNoReconnect(); + client.AuthViolation(); + } + } + + foreach (var route in routes.DistinctBy(static c => c.Cid)) + { + if (!IsRouterAuthorized(route)) + { + route.SetNoReconnect(); + route.AuthViolation(); + } + } + + AccountResolver()?.Reload(); + } + + /// + /// Returns true if a client's configured user/nkey now maps to a different account. + /// Mirrors Go Server.clientHasMovedToDifferentAccount(). + /// + internal bool ClientHasMovedToDifferentAccount(ClientConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + NkeyUser? newNkeyUser = null; + User? newUser = null; + + var nkey = connection.GetNkey(); + if (!string.IsNullOrEmpty(nkey)) + { + if (_nkeys != null) + _nkeys.TryGetValue(nkey, out newNkeyUser); + } + else + { + var username = connection.GetUsername(); + if (!string.IsNullOrEmpty(username) && _users != null) + _users.TryGetValue(username, out newUser); + else if (string.IsNullOrEmpty(username)) + return false; + } + + var currentAccountName = connection.GetAccount()?.Name ?? string.Empty; + + if (newNkeyUser?.Account != null) + return !string.Equals(currentAccountName, newNkeyUser.Account.Name, StringComparison.Ordinal); + if (newUser?.Account != null) + return !string.Equals(currentAccountName, newUser.Account.Name, StringComparison.Ordinal); + + // User or nkey no longer exists in current config. + return true; + } + + /// + /// Reloads cluster route permissions and propagates updated INFO to routes. + /// Mirrors Go Server.reloadClusterPermissions(). + /// + internal void ReloadClusterPermissions(RoutePermissions? oldPermissions) + { + _ = oldPermissions; + + RoutePermissions? newPermissions; + List routes = []; + byte[] infoJson; + + _mu.EnterWriteLock(); + try + { + newPermissions = GetOpts().Cluster.Permissions; + _routeInfo.Import = newPermissions?.Import; + _routeInfo.Export = newPermissions?.Export; + infoJson = GenerateInfoJson(_routeInfo); + ForEachRoute(route => routes.Add(route)); + } + finally + { + _mu.ExitWriteLock(); + } + + foreach (var route in routes.DistinctBy(static c => c.Cid)) + { + var protocol = route.GetOpts().Protocol; + if (protocol < ServerProtocol.RouteProtoInfo) + { + route.CloseConnection(ClosedState.RouteRemoved); + continue; + } + + route.EnqueueProto(infoJson); + } + } + + /// + /// Reloads route-pool size and pinned-account route assignments. + /// Mirrors Go Server.reloadClusterPoolAndAccounts(). + /// + internal void ReloadClusterPoolAndAccounts(ClusterReloadOption clusterOption, ServerOptions options) + { + ArgumentNullException.ThrowIfNull(clusterOption); + ArgumentNullException.ThrowIfNull(options); + + var routesToClose = new HashSet(); + + _mu.EnterWriteLock(); + try + { + _routesReject = true; + + if (clusterOption.AccountsAdded.Count > 0) + { + _accRoutes ??= new Dictionary>(StringComparer.Ordinal); + foreach (var accountName in clusterOption.AccountsAdded) + { + if (!_accRoutes.ContainsKey(accountName)) + _accRoutes[accountName] = new Dictionary(StringComparer.Ordinal); + } + } + + if (clusterOption.AccountsRemoved.Count > 0 && _accRoutes != null) + { + foreach (var accountName in clusterOption.AccountsRemoved) + { + if (_accRoutes.TryGetValue(accountName, out var remoteRoutes)) + { + foreach (var route in remoteRoutes.Values) + { + route.SetNoReconnect(); + routesToClose.Add(route); + } + } + } + } + + if (clusterOption.PoolSizeChanged) + { + ForEachRoute(route => + { + route.SetNoReconnect(); + routesToClose.Add(route); + }); + } + } + finally + { + _mu.ExitWriteLock(); + } + + foreach (var route in routesToClose) + route.CloseConnection(ClosedState.RouteRemoved); + + _mu.EnterWriteLock(); + try + { + _accAddedReqId = string.Empty; + _accAddedCh = null; + + if (_accRoutes != null) + { + foreach (var accountName in clusterOption.AccountsRemoved) + _accRoutes.Remove(accountName); + } + + if (options.Cluster.PinnedAccounts.Count == 0) + _accRoutes = null; + + if (options.Cluster.PoolSize > 0) + { + _routesPoolSize = options.Cluster.PoolSize; + _routeInfo.RoutePoolSize = options.Cluster.PoolSize; + } + else + { + _routesPoolSize = 1; + _routeInfo.RoutePoolSize = 0; + } + + if (clusterOption.PoolSizeChanged || clusterOption.AccountsRemoved.Count > 0) + { + foreach (var account in _accounts.Values) + SetRouteInfo(account); + } + + _routesReject = false; + } + finally + { + _mu.ExitWriteLock(); + } + } + + private static bool OptionValueEquals(string optionName, object? oldValue, object? newValue) + { + if (ReferenceEquals(oldValue, newValue)) + return true; + if (oldValue is null || newValue is null) + return false; + + switch (oldValue) + { + case string oldString when newValue is string newString: + return string.Equals(oldString, newString, StringComparison.Ordinal); + case bool oldBool when newValue is bool newBool: + return oldBool == newBool; + case int oldInt when newValue is int newInt: + return oldInt == newInt; + case long oldLong when newValue is long newLong: + return oldLong == newLong; + case ulong oldUlong when newValue is ulong newUlong: + return oldUlong == newUlong; + case double oldDouble when newValue is double newDouble: + return oldDouble.Equals(newDouble); + case TimeSpan oldTimeSpan when newValue is TimeSpan newTimeSpan: + return oldTimeSpan == newTimeSpan; + case List oldStrings when newValue is List newStrings: + return oldStrings.SequenceEqual(newStrings, StringComparer.Ordinal); + case Dictionary oldMap when newValue is Dictionary newMap: + return oldMap.Count == newMap.Count && + oldMap.All(entry => + newMap.TryGetValue(entry.Key, out var value) && + string.Equals(value, entry.Value, StringComparison.Ordinal)); + case List oldUris when newValue is List newUris: + return oldUris.Count == newUris.Count && + oldUris.Zip(newUris, static (oldUri, newUri) => ServerUtilities.UrlsAreEqual(oldUri, newUri)) + .All(static same => same); + case PinnedCertSet oldPinned when newValue is PinnedCertSet newPinned: + return PinnedCertSetEquals(oldPinned, newPinned); + case CompressionOpts oldCompression when newValue is CompressionOpts newCompression: + return CompressOptsEqual(oldCompression, newCompression); + case GatewayOpts oldGateway when newValue is GatewayOpts newGateway: + return GatewayEquivalentForReload(oldGateway, newGateway); + case LeafNodeOpts oldLeaf when newValue is LeafNodeOpts newLeaf: + return LeafNodeReloadCompare(oldLeaf, newLeaf).Equivalent; + case ClusterOpts oldCluster when newValue is ClusterOpts newCluster: + return JsonEquivalent(oldCluster, newCluster); + case WebsocketOpts oldWebsocket when newValue is WebsocketOpts newWebsocket: + return JsonEquivalent(oldWebsocket, newWebsocket); + case MqttOpts oldMqtt when newValue is MqttOpts newMqtt: + return JsonEquivalent(oldMqtt, newMqtt); + default: + return JsonEquivalent(oldValue, newValue); + } + } + + private static bool JsonEquivalent(object left, object right) + { + try + { + var leftJson = JsonSerializer.Serialize(left); + var rightJson = JsonSerializer.Serialize(right); + return string.Equals(leftJson, rightJson, StringComparison.Ordinal); + } + catch + { + return Equals(left, right); + } + } + + private static T CloneByJson(T value) + { + var json = JsonSerializer.Serialize(value); + var clone = JsonSerializer.Deserialize(json); + return clone ?? throw new InvalidOperationException($"Failed to clone {typeof(T).Name} for reload comparison."); + } + + private static bool GatewayEquivalentForReload(GatewayOpts oldValue, GatewayOpts newValue) + { + var oldGateway = CloneByJson(oldValue); + var newGateway = CloneByJson(newValue); + + oldGateway.TlsConfig = null; + oldGateway.TlsConfigOpts = null; + newGateway.TlsConfig = null; + newGateway.TlsConfigOpts = null; + + oldGateway.Gateways = ConfigReloader.CopyRemoteGWConfigsWithoutTLSConfig(oldGateway.Gateways) ?? []; + newGateway.Gateways = ConfigReloader.CopyRemoteGWConfigsWithoutTLSConfig(newGateway.Gateways) ?? []; + + return JsonEquivalent(oldGateway, newGateway); + } + + private static (bool Equivalent, bool TlsFirstChanged, bool CompressionChanged, bool DisabledChanged) + LeafNodeReloadCompare(LeafNodeOpts oldValue, LeafNodeOpts newValue) + { + var oldLeaf = CloneByJson(oldValue); + var newLeaf = CloneByJson(newValue); + + oldLeaf.TlsConfig = null; + oldLeaf.TlsConfigOpts = null; + newLeaf.TlsConfig = null; + newLeaf.TlsConfigOpts = null; + + var tlsFirstChanged = + oldLeaf.TlsHandshakeFirst != newLeaf.TlsHandshakeFirst || + oldLeaf.TlsHandshakeFirstFallback != newLeaf.TlsHandshakeFirstFallback; + + if (tlsFirstChanged) + { + oldLeaf.TlsHandshakeFirst = false; + newLeaf.TlsHandshakeFirst = false; + oldLeaf.TlsHandshakeFirstFallback = default; + newLeaf.TlsHandshakeFirstFallback = default; + } + else if (oldLeaf.Remotes.Count == newLeaf.Remotes.Count) + { + for (var i = 0; i < oldLeaf.Remotes.Count; i++) + { + if (oldLeaf.Remotes[i].TlsHandshakeFirst != newLeaf.Remotes[i].TlsHandshakeFirst) + { + tlsFirstChanged = true; + break; + } + } + } + + var compressionChanged = !CompressOptsEqual(oldLeaf.Compression, newLeaf.Compression); + if (compressionChanged) + { + oldLeaf.Compression = new CompressionOpts(); + newLeaf.Compression = new CompressionOpts(); + } + else if (oldLeaf.Remotes.Count == newLeaf.Remotes.Count) + { + for (var i = 0; i < oldLeaf.Remotes.Count; i++) + { + if (!CompressOptsEqual(oldLeaf.Remotes[i].Compression, newLeaf.Remotes[i].Compression)) + { + compressionChanged = true; + break; + } + } + } + + var disabledChanged = false; + if (oldLeaf.Remotes.Count == newLeaf.Remotes.Count) + { + for (var i = 0; i < oldLeaf.Remotes.Count; i++) + { + if (oldLeaf.Remotes[i].Disabled != newLeaf.Remotes[i].Disabled) + { + disabledChanged = true; + break; + } + } + } + + oldLeaf.Remotes = ConfigReloader.CopyRemoteLNConfigForReloadCompare(oldLeaf.Remotes) ?? []; + newLeaf.Remotes = ConfigReloader.CopyRemoteLNConfigForReloadCompare(newLeaf.Remotes) ?? []; + + if (LeafRemotesEquivalent(oldLeaf.Remotes, newLeaf.Remotes)) + { + oldLeaf.Remotes = []; + newLeaf.Remotes = []; + } + + if (LeafUsersEquivalent(oldLeaf.Users, newLeaf.Users)) + { + oldLeaf.Users = null; + newLeaf.Users = null; + } + + return (JsonEquivalent(oldLeaf, newLeaf), tlsFirstChanged, compressionChanged, disabledChanged); + } + + private static bool LeafUsersEquivalent(List? oldUsers, List? newUsers) + { + if (oldUsers == null && newUsers == null) + return true; + if (oldUsers == null || newUsers == null) + return false; + if (oldUsers.Count != newUsers.Count) + return false; + + var oldByUsername = oldUsers.ToDictionary(static user => user.Username, StringComparer.Ordinal); + var newByUsername = newUsers.ToDictionary(static user => user.Username, StringComparer.Ordinal); + + foreach (var (username, oldUser) in oldByUsername) + { + if (!newByUsername.TryGetValue(username, out var newUser)) + return false; + + var oldAccountName = oldUser.Account?.Name ?? string.Empty; + var newAccountName = newUser.Account?.Name ?? string.Empty; + + if (!string.Equals(oldUser.Password, newUser.Password, StringComparison.Ordinal) || + !string.Equals(oldAccountName, newAccountName, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static bool LeafRemotesEquivalent(IReadOnlyList oldRemotes, IReadOnlyList newRemotes) + { + if (oldRemotes.Count != newRemotes.Count) + return false; + + var remaining = new List(newRemotes.Count); + foreach (var remote in newRemotes) + remaining.Add(NormalizeLeafRemoteForCompare(remote)); + + foreach (var oldRemote in oldRemotes) + { + var normalizedOld = NormalizeLeafRemoteForCompare(oldRemote); + var matchIndex = remaining.FindIndex(candidate => JsonEquivalent(normalizedOld, candidate)); + if (matchIndex < 0) + return false; + + remaining.RemoveAt(matchIndex); + } + + return remaining.Count == 0; + } + + private static RemoteLeafOpts NormalizeLeafRemoteForCompare(RemoteLeafOpts remote) + { + var normalized = CloneByJson(remote); + if (string.IsNullOrEmpty(normalized.LocalAccount)) + normalized.LocalAccount = ServerConstants.DefaultGlobalAccount; + return normalized; + } + + private static bool PinnedCertSetEquals(PinnedCertSet? left, PinnedCertSet? right) + { + if (ReferenceEquals(left, right)) + return true; + if (left is null || right is null) + return false; + return left.SetEquals(right); + } + + private static bool MatchesPinnedCert(ClientConnection client, PinnedCertSet? pinnedCerts) + { + if (pinnedCerts == null || pinnedCerts.Count == 0) + return true; + + var certificate = client.GetTlsCertificate(); + if (certificate == null) + return false; + + byte[] keyBytes; + try + { + keyBytes = certificate.PublicKey.ExportSubjectPublicKeyInfo(); + } + catch + { + keyBytes = certificate.GetPublicKey(); + } + + var hash = SHA256.HashData(keyBytes); + var hex = Convert.ToHexString(hash).ToLowerInvariant(); + return pinnedCerts.Contains(hex); + } +} + +internal sealed class ReloadContext +{ + public ReloadContext(RoutePermissions? oldClusterPermissions) + { + OldClusterPermissions = oldClusterPermissions; + } + + public RoutePermissions? OldClusterPermissions { get; } +} diff --git a/porting.db b/porting.db index a069405..4d69ce4 100644 Binary files a/porting.db and b/porting.db differ