From 3908ecdcb1873d51b964bc492f60c189445d056f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 28 Feb 2026 10:53:25 -0500 Subject: [PATCH] feat(batch7): implement f1 config receiver and reload helpers --- .../Config/ReloadOptions.cs | 398 ++++++++++++++++-- .../ServerOptions.Methods.cs | 369 ++++++++++++++++ porting.db | Bin 6492160 -> 6492160 bytes 3 files changed, 731 insertions(+), 36 deletions(-) diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs index 32f16b1..879416a 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs @@ -13,6 +13,11 @@ // // Adapted from server/reload.go in the NATS server Go source. +using System.Reflection; +using System.Net.Security; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; + namespace ZB.MOM.NatsNet.Server; // ============================================================================= @@ -74,7 +79,7 @@ public interface IReloadOption public abstract class NoopReloadOption : IReloadOption { /// - public virtual void Apply(NatsServer server) { } + public virtual void Apply(NatsServer server) => _ = server; /// public virtual bool IsLoggingChange() => false; @@ -196,7 +201,7 @@ internal sealed class DebugReloadOption : LoggingReloadOption public override void Apply(NatsServer server) { server.Noticef("Reloaded: debug = {0}", _newValue); - // TODO: session 13 — call server.ReloadDebugRaftNodes(_newValue) + // DEFERRED: session 13 — call server.ReloadDebugRaftNodes(_newValue) } } @@ -217,10 +222,10 @@ internal sealed class LogtimeReloadOption : LoggingReloadOption /// Reload option for the logtime_utc setting. /// Mirrors Go logtimeUTCOption struct in reload.go. /// -internal sealed class LogtimeUtcReloadOption : LoggingReloadOption +internal sealed class LogtimeUTCOption : LoggingReloadOption { private readonly bool _newValue; - public LogtimeUtcReloadOption(bool newValue) => _newValue = newValue; + public LogtimeUTCOption(bool newValue) => _newValue = newValue; public override void Apply(NatsServer server) => server.Noticef("Reloaded: logtime_utc = {0}", _newValue); @@ -230,10 +235,10 @@ internal sealed class LogtimeUtcReloadOption : LoggingReloadOption /// Reload option for the log_file setting. /// Mirrors Go logfileOption struct in reload.go. /// -internal sealed class LogFileReloadOption : LoggingReloadOption +internal sealed class LogfileOption : LoggingReloadOption { private readonly string _newValue; - public LogFileReloadOption(string newValue) => _newValue = newValue; + public LogfileOption(string newValue) => _newValue = newValue; public override void Apply(NatsServer server) => server.Noticef("Reloaded: log_file = {0}", _newValue); @@ -274,11 +279,11 @@ internal sealed class RemoteSyslogReloadOption : LoggingReloadOption /// Mirrors Go tlsOption struct in reload.go. /// The TLS config is stored as object? because the full /// TlsConfig type is not yet ported. -/// TODO: session 13 — replace object? with the ported TlsConfig type. +/// DEFERRED: session 13 — replace object? with the ported TlsConfig type. /// internal sealed class TlsReloadOption : NoopReloadOption { - // TODO: session 13 — replace object? with ported TlsConfig type + // DEFERRED: session 13 — replace object? with ported TlsConfig type private readonly object? _newValue; public TlsReloadOption(object? newValue) => _newValue = newValue; @@ -288,7 +293,7 @@ internal sealed class TlsReloadOption : NoopReloadOption { var message = _newValue is null ? "disabled" : "enabled"; server.Noticef("Reloaded: tls = {0}", message); - // TODO: session 13 — update server.Info.TLSRequired / TLSVerify + // DEFERRED: session 13 — update server.Info.TLSRequired / TLSVerify } } @@ -310,11 +315,11 @@ internal sealed class TlsTimeoutReloadOption : NoopReloadOption /// Mirrors Go tlsPinnedCertOption struct in reload.go. /// The pinned cert set is stored as object? pending the port /// of the PinnedCertSet type. -/// TODO: session 13 — replace object? with ported PinnedCertSet type. +/// DEFERRED: session 13 — replace object? with ported PinnedCertSet type. /// internal sealed class TlsPinnedCertReloadOption : NoopReloadOption { - // TODO: session 13 — replace object? with ported PinnedCertSet type + // DEFERRED: session 13 — replace object? with ported PinnedCertSet type private readonly object? _newValue; public TlsPinnedCertReloadOption(object? newValue) => _newValue = newValue; @@ -459,17 +464,17 @@ internal sealed class AccountsReloadOption : AuthReloadOption /// Reload option for the cluster setting. /// Stores cluster options as object? pending the port of ClusterOpts. /// Mirrors Go clusterOption struct in reload.go. -/// TODO: session 13 — replace object? with ported ClusterOpts type. +/// DEFERRED: session 13 — replace object? with ported ClusterOpts type. /// internal sealed class ClusterReloadOption : AuthReloadOption { - // TODO: session 13 — replace object? with ported ClusterOpts type + // DEFERRED: session 13 — replace object? with ported ClusterOpts type private readonly object? _newValue; private readonly bool _permsChanged; - private readonly bool _poolSizeChanged; + private bool _poolSizeChanged; private readonly bool _compressChanged; - private readonly string[] _accsAdded; - private readonly string[] _accsRemoved; + private string[] _accsAdded; + private string[] _accsRemoved; public ClusterReloadOption( object? newValue, @@ -493,9 +498,34 @@ internal sealed class ClusterReloadOption : AuthReloadOption public override bool IsClusterPoolSizeOrAccountsChange() => _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0; + /// + /// Computes pool/account deltas used by reload orchestration. + /// Mirrors Go clusterOption.diffPoolAndAccounts. + /// + public void DiffPoolAndAccounts(ClusterOpts oldValue) + { + ArgumentNullException.ThrowIfNull(oldValue); + + if (_newValue is not ClusterOpts newValue) + { + _poolSizeChanged = false; + _accsAdded = []; + _accsRemoved = []; + return; + } + + _poolSizeChanged = newValue.PoolSize != oldValue.PoolSize; + + var oldAccounts = new HashSet(oldValue.PinnedAccounts, StringComparer.Ordinal); + var newAccounts = new HashSet(newValue.PinnedAccounts, StringComparer.Ordinal); + + _accsAdded = newAccounts.Except(oldAccounts).ToArray(); + _accsRemoved = oldAccounts.Except(newAccounts).ToArray(); + } + public override void Apply(NatsServer server) { - // TODO: session 13 — full cluster apply logic (TLS, route info, compression) + // DEFERRED: session 13 — full cluster apply logic (TLS, route info, compression) server.Noticef("Reloaded: cluster"); } } @@ -504,11 +534,11 @@ internal sealed class ClusterReloadOption : AuthReloadOption /// Reload option for the cluster routes setting. /// Routes to add/remove are stored as object[] pending the port of URL handling. /// Mirrors Go routesOption struct in reload.go. -/// TODO: session 13 — replace object[] with Uri[] when route types are ported. +/// DEFERRED: session 13 — replace object[] with Uri[] when route types are ported. /// internal sealed class RoutesReloadOption : NoopReloadOption { - // TODO: session 13 — replace object[] with Uri[] when route URL types are ported + // DEFERRED: session 13 — replace object[] with Uri[] when route URL types are ported private readonly object[] _add; private readonly object[] _remove; @@ -520,7 +550,7 @@ internal sealed class RoutesReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — add/remove routes, update varzUpdateRouteURLs + // DEFERRED: session 13 — add/remove routes, update varzUpdateRouteURLs server.Noticef("Reloaded: cluster routes"); } } @@ -540,7 +570,7 @@ internal sealed class MaxConnReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — close random connections if over limit + // DEFERRED: session 13 — close random connections if over limit server.Noticef("Reloaded: max_connections = {0}", _newValue); } } @@ -558,7 +588,7 @@ internal sealed class PidFileReloadOption : NoopReloadOption { if (string.IsNullOrEmpty(_newValue)) return; - // TODO: session 13 — call server.LogPid() + // DEFERRED: session 13 — call server.LogPid() server.Noticef("Reloaded: pid_file = {0}", _newValue); } } @@ -580,7 +610,7 @@ internal sealed class PortsFileDirReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts() + // DEFERRED: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts() server.Noticef("Reloaded: ports_file_dir = {0}", _newValue); } } @@ -596,7 +626,7 @@ internal sealed class MaxControlLineReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — update mcl on each connected client + // DEFERRED: session 13 — update mcl on each connected client server.Noticef("Reloaded: max_control_line = {0}", _newValue); } } @@ -612,7 +642,7 @@ internal sealed class MaxPayloadReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — update server info and mpay on each client + // DEFERRED: session 13 — update server info and mpay on each client server.Noticef("Reloaded: max_payload = {0}", _newValue); } } @@ -667,7 +697,7 @@ internal sealed class ClientAdvertiseReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — call server.SetInfoHostPort() + // DEFERRED: session 13 — call server.SetInfoHostPort() server.Noticef("Reload: client_advertise = {0}", _newValue); } } @@ -713,11 +743,11 @@ internal sealed class DefaultSentinelReloadOption : NoopReloadOption /// Reload option for the OCSP setting. /// The new value is stored as object? pending the port of OCSPConfig. /// Mirrors Go ocspOption struct in reload.go. -/// TODO: session 13 — replace object? with ported OcspConfig type. +/// DEFERRED: session 13 — replace object? with ported OcspConfig type. /// internal sealed class OcspReloadOption : TlsBaseReloadOption { - // TODO: session 13 — replace object? with ported OcspConfig type + // DEFERRED: session 13 — replace object? with ported OcspConfig type private readonly object? _newValue; public OcspReloadOption(object? newValue) => _newValue = newValue; @@ -730,11 +760,11 @@ internal sealed class OcspReloadOption : TlsBaseReloadOption /// The new value is stored as object? pending the port of /// OCSPResponseCacheConfig. /// Mirrors Go ocspResponseCacheOption struct in reload.go. -/// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type. +/// DEFERRED: session 13 — replace object? with ported OcspResponseCacheConfig type. /// internal sealed class OcspResponseCacheReloadOption : TlsBaseReloadOption { - // TODO: session 13 — replace object? with ported OcspResponseCacheConfig type + // DEFERRED: session 13 — replace object? with ported OcspResponseCacheConfig type private readonly object? _newValue; public OcspResponseCacheReloadOption(object? newValue) => _newValue = newValue; @@ -779,7 +809,7 @@ internal sealed class MaxTracedMsgLenReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — update server.Opts.MaxTracedMsgLen under lock + // DEFERRED: session 13 — update server.Opts.MaxTracedMsgLen under lock server.Noticef("Reloaded: max_traced_msg_len = {0}", _newValue); } } @@ -812,7 +842,7 @@ internal sealed class MqttMaxAckPendingReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — call server.MqttUpdateMaxAckPending(_newValue) + // DEFERRED: session 13 — call server.MqttUpdateMaxAckPending(_newValue) server.Noticef("Reloaded: MQTT max_ack_pending = {0}", _newValue); } } @@ -884,7 +914,7 @@ internal sealed class ProfBlockRateReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — call server.SetBlockProfileRate(_newValue) + // DEFERRED: session 13 — call server.SetBlockProfileRate(_newValue) server.Noticef("Reloaded: prof_block_rate = {0}", _newValue); } } @@ -912,7 +942,7 @@ internal sealed class LeafNodeReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply() + // DEFERRED: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply() if (_tlsFirstChanged) server.Noticef("Reloaded: LeafNode TLS HandshakeFirst settings"); if (_compressionChanged) @@ -963,7 +993,7 @@ internal sealed class ProxiesReloadOption : NoopReloadOption public override void Apply(NatsServer server) { - // TODO: session 13 — disconnect proxied clients for removed keys, + // DEFERRED: session 13 — disconnect proxied clients for removed keys, // call server.ProcessProxiesTrustedKeys() if (_del.Length > 0) server.Noticef("Reloaded: proxies trusted keys {0} were removed", string.Join(", ", _del)); @@ -985,7 +1015,7 @@ internal sealed class ProxiesReloadOption : NoopReloadOption /// internal sealed class ConfigReloader { - // TODO: session 13 — full reload logic + // DEFERRED: session 13 — full reload logic // Mirrors Go server.Reload() / server.ReloadOptions() in server/reload.go /// @@ -993,4 +1023,300 @@ internal sealed class ConfigReloader /// Returns null on success; a non-null Exception describes the failure. /// public Exception? Reload(NatsServer server) => null; + + /// + /// Applies bool-valued config precedence for reload: + /// config-file explicit values first, then explicit command-line flags. + /// Mirrors Go applyBoolFlags. + /// + public static void ApplyBoolFlags(ServerOptions newOptions, ServerOptions flagOptions) + { + ArgumentNullException.ThrowIfNull(newOptions); + ArgumentNullException.ThrowIfNull(flagOptions); + + foreach (var (name, value) in newOptions.InConfig) + SetBooleanMember(newOptions, name, value); + + foreach (var (name, value) in flagOptions.InCmdLine) + SetBooleanMember(newOptions, name, value); + } + + /// + /// Sorts order-insensitive values in place prior to deep comparisons. + /// Mirrors Go imposeOrder. + /// + public static Exception? ImposeOrder(object? value) + { + switch (value) + { + case List accounts: + accounts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + break; + case List users: + users.Sort((a, b) => string.CompareOrdinal(a.Username, b.Username)); + break; + case List nkeys: + nkeys.Sort((a, b) => string.CompareOrdinal(a.Nkey, b.Nkey)); + break; + case List urls: + urls.Sort((a, b) => string.CompareOrdinal(a.ToString(), b.ToString())); + break; + case List strings: + strings.Sort(StringComparer.Ordinal); + break; + case GatewayOpts gateway: + gateway.Gateways.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name)); + break; + case WebsocketOpts websocket: + websocket.AllowedOrigins.Sort(StringComparer.Ordinal); + break; + case null: + case string: + case bool: + case byte: + case ushort: + case uint: + case ulong: + case int: + case long: + case TimeSpan: + case float: + case double: + case LeafNodeOpts: + case ClusterOpts: + case SslServerAuthenticationOptions: + case PinnedCertSet: + case IAccountResolver: + case MqttOpts: + case Dictionary: + case JsLimitOpts: + case StoreCipher: + case OcspResponseCacheConfig: + case ProxiesConfig: + case WriteTimeoutPolicy: + case AuthCalloutOpts: + case JsTpmOpts: + break; + default: + return new InvalidOperationException( + $"OnReload, sort or explicitly skip type: {value.GetType().FullName}"); + } + + return null; + } + + /// + /// Returns remote gateway configs copied for diffing, with TLS runtime fields stripped. + /// Mirrors Go copyRemoteGWConfigsWithoutTLSConfig. + /// + public static List? CopyRemoteGWConfigsWithoutTLSConfig(List? current) + { + if (current is not { Count: > 0 }) + return null; + + var copied = new List(current.Count); + foreach (var config in current) + { + copied.Add(new RemoteGatewayOpts + { + Name = config.Name, + TlsConfig = null, + TlsConfigOpts = null, + TlsTimeout = config.TlsTimeout, + Urls = [.. config.Urls.Select(static u => new Uri(u.ToString(), UriKind.Absolute))], + }); + } + + return copied; + } + + /// + /// Returns remote leaf-node configs copied for diffing, with runtime-mutated + /// fields stripped or normalized. + /// Mirrors Go copyRemoteLNConfigForReloadCompare. + /// + public static List? CopyRemoteLNConfigForReloadCompare(List? current) + { + if (current is not { Count: > 0 }) + return null; + + var copied = new List(current.Count); + foreach (var config in current) + { + copied.Add(new RemoteLeafOpts + { + LocalAccount = config.LocalAccount, + NoRandomize = config.NoRandomize, + Urls = [.. config.Urls.Select(static u => new Uri(u.ToString(), UriKind.Absolute))], + Credentials = config.Credentials, + Nkey = config.Nkey, + SignatureCb = config.SignatureCb, + Tls = false, + TlsConfig = null, + TlsConfigOpts = null, + TlsTimeout = config.TlsTimeout, + TlsHandshakeFirst = false, + Hub = config.Hub, + DenyImports = [], + DenyExports = [], + FirstInfoTimeout = config.FirstInfoTimeout, + Compression = new CompressionOpts(), + Websocket = new RemoteLeafWebsocketOpts + { + Compression = config.Websocket.Compression, + NoMasking = config.Websocket.NoMasking, + }, + Proxy = new RemoteLeafProxyOpts + { + Url = config.Proxy.Url, + Username = config.Proxy.Username, + Password = config.Proxy.Password, + Timeout = config.Proxy.Timeout, + }, + JetStreamClusterMigrate = config.JetStreamClusterMigrate, + JetStreamClusterMigrateDelay = config.JetStreamClusterMigrateDelay, + LocalIsolation = config.LocalIsolation, + RequestIsolation = config.RequestIsolation, + Disabled = false, + }); + } + + return copied; + } + + /// + /// Validates non-reloadable cluster settings and advertise syntax. + /// Mirrors Go validateClusterOpts. + /// + public static Exception? ValidateClusterOpts(ClusterOpts oldValue, ClusterOpts newValue) + { + ArgumentNullException.ThrowIfNull(oldValue); + ArgumentNullException.ThrowIfNull(newValue); + + if (!string.Equals(oldValue.Host, newValue.Host, StringComparison.Ordinal)) + { + return new InvalidOperationException( + $"config reload not supported for cluster host: old={oldValue.Host}, new={newValue.Host}"); + } + + if (oldValue.Port != newValue.Port) + { + return new InvalidOperationException( + $"config reload not supported for cluster port: old={oldValue.Port}, new={newValue.Port}"); + } + + if (!string.IsNullOrEmpty(newValue.Advertise)) + { + var (_, _, err) = ServerUtilities.ParseHostPort(newValue.Advertise, 0); + if (err != null) + return new InvalidOperationException( + $"invalid Cluster.Advertise value of {newValue.Advertise}, err={err.Message}", err); + } + + return null; + } + + /// + /// Diffs old/new route lists and returns routes to add/remove. + /// Mirrors Go diffRoutes. + /// + public static (List Add, List Remove) DiffRoutes(IReadOnlyList oldRoutes, IReadOnlyList newRoutes) + { + ArgumentNullException.ThrowIfNull(oldRoutes); + ArgumentNullException.ThrowIfNull(newRoutes); + + var add = new List(); + var remove = new List(); + + foreach (var oldRoute in oldRoutes) + { + if (!newRoutes.Any(newRoute => ServerUtilities.UrlsAreEqual(oldRoute, newRoute))) + remove.Add(oldRoute); + } + + foreach (var newRoute in newRoutes) + { + if (!oldRoutes.Any(oldRoute => ServerUtilities.UrlsAreEqual(oldRoute, newRoute))) + add.Add(newRoute); + } + + return (add, remove); + } + + /// + /// Diffs proxy trusted keys and returns added/removed key sets. + /// Mirrors Go diffProxiesTrustedKeys. + /// + public static (List Add, List Del) DiffProxiesTrustedKeys( + IReadOnlyList? oldTrusted, + IReadOnlyList? newTrusted) + { + var oldList = oldTrusted ?? []; + var newList = newTrusted ?? []; + + var add = new List(); + var del = new List(); + + foreach (var oldProxy in oldList) + { + if (!newList.Any(np => string.Equals(np.Key, oldProxy.Key, StringComparison.Ordinal))) + del.Add(oldProxy.Key); + } + + foreach (var newProxy in newList) + { + if (!oldList.Any(op => string.Equals(op.Key, newProxy.Key, StringComparison.Ordinal))) + add.Add(newProxy.Key); + } + + return (add, del); + } + + private static void SetBooleanMember(ServerOptions options, string path, bool value) + { + var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + return; + + object? target = options; + for (int i = 0; i < segments.Length - 1; i++) + { + if (target == null) + return; + + var segment = segments[i]; + var targetType = target.GetType(); + var prop = targetType.GetProperty(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (prop != null) + { + target = prop.GetValue(target); + continue; + } + + var field = targetType.GetField(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field != null) + { + target = field.GetValue(target); + continue; + } + + return; + } + + if (target == null) + return; + + var leaf = segments[^1]; + var leafType = target.GetType(); + var leafProperty = leafType.GetProperty(leaf, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (leafProperty?.PropertyType == typeof(bool) && leafProperty.CanWrite) + { + leafProperty.SetValue(target, value); + return; + } + + var leafField = leafType.GetField(leaf, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (leafField?.FieldType == typeof(bool)) + leafField.SetValue(target, value); + } } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs index 776f82d..0731e2e 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs @@ -18,6 +18,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Security.Authentication; +using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Net.Security; using ZB.MOM.NatsNet.Server.Auth; @@ -439,6 +440,368 @@ public sealed partial class ServerOptions public static ServerOptions ProcessConfigFile(string configFile) => ServerOptionsConfiguration.ProcessConfigFile(configFile); + /// + /// Receiver-style config loader that updates this instance with values from + /// . + /// Mirrors Go Options.ProcessConfigFile. + /// + public Exception? ProcessConfigFileOverload2510(string configFile) + { + ConfigFile = configFile; + if (string.IsNullOrEmpty(configFile)) + return null; + + try + { + var data = File.ReadAllText(configFile); + ConfigDigestValue = ComputeConfigDigest(data); + return ProcessConfigString(data); + } + catch (Exception ex) + { + return ex; + } + } + + /// + /// Receiver-style config loader from in-memory content. + /// Mirrors Go Options.ProcessConfigString. + /// + public Exception? ProcessConfigString(string data) + { + try + { + using var doc = JsonDocument.Parse( + data, + new JsonDocumentOptions + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }); + + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return new InvalidOperationException("configuration root must be an object"); + + var normalized = NormalizeConfigValue(doc.RootElement); + var configMap = normalized as IReadOnlyDictionary + ?? normalized as Dictionary; + if (configMap == null) + return new InvalidOperationException("configuration root must be a key/value object"); + + return ProcessConfigFileInternal(string.Empty, configMap); + } + catch (Exception ex) + { + return ex; + } + } + + /// + /// Internal receiver config pipeline that processes each top-level config key. + /// Mirrors Go Options.processConfigFile. + /// + public Exception? ProcessConfigFileInternal(string configFile, IReadOnlyDictionary config) + { + var errors = new List(); + var warnings = new List(); + + if (config.Count == 0) + warnings.Add(new InvalidOperationException($"{configFile}: config has no values or is empty")); + + var sysErr = ConfigureSystemAccount(this, config); + if (sysErr != null) + errors.Add(sysErr); + + foreach (var (key, value) in config) + ProcessConfigFileLine(key, value, errors, warnings); + + if (AuthCallout?.AllowedAccounts is { Count: > 0 }) + { + var configuredAccounts = new HashSet( + Accounts.Select(a => a.Name), + StringComparer.Ordinal); + + foreach (var account in AuthCallout.AllowedAccounts) + { + if (!configuredAccounts.Contains(account)) + { + errors.Add(new InvalidOperationException( + $"auth_callout allowed account \"{account}\" not found in configured accounts")); + } + } + } + + if (errors.Count == 0 && warnings.Count == 0) + return null; + + return new ProcessConfigException(errors, warnings); + } + + /// + /// Processes a single top-level config key. + /// Mirrors Go Options.processConfigFileLine. + /// + public void ProcessConfigFileLine( + string key, + object? value, + ICollection errors, + ICollection warnings) + { + try + { + var normalized = NormalizeConfigValue(value); + switch (key.ToLowerInvariant()) + { + case "listen": + { + var (host, port) = ParseListen(normalized); + Host = host; + Port = port; + break; + } + case "client_advertise": + if (normalized is string ca) + ClientAdvertise = ca; + else + errors.Add(new InvalidOperationException("client_advertise must be a string")); + break; + case "port": + if (TryConvertToLong(normalized, out var p)) + Port = checked((int)p); + else + errors.Add(new InvalidOperationException("port must be an integer")); + break; + case "server_name": + if (normalized is not string sn) + { + errors.Add(new InvalidOperationException("server_name must be a string")); + } + else if (sn.Contains(' ')) + { + errors.Add(ServerErrors.ErrServerNameHasSpaces); + } + else + { + ServerName = sn; + } + break; + case "host": + case "net": + if (normalized is string configuredHost) + Host = configuredHost; + else + errors.Add(new InvalidOperationException($"{key} must be a string")); + break; + case "debug": + if (TryConvertToBool(normalized, out var debug)) + { + Debug = debug; + TrackExplicitVal(InConfig, nameof(Debug), Debug); + } + else + { + errors.Add(new InvalidOperationException("debug must be a boolean")); + } + break; + case "trace": + if (TryConvertToBool(normalized, out var trace)) + { + Trace = trace; + TrackExplicitVal(InConfig, nameof(Trace), Trace); + } + else + { + errors.Add(new InvalidOperationException("trace must be a boolean")); + } + break; + case "trace_verbose": + if (TryConvertToBool(normalized, out var traceVerbose)) + { + TraceVerbose = traceVerbose; + Trace = traceVerbose; + TrackExplicitVal(InConfig, nameof(TraceVerbose), TraceVerbose); + TrackExplicitVal(InConfig, nameof(Trace), Trace); + } + else + { + errors.Add(new InvalidOperationException("trace_verbose must be a boolean")); + } + break; + case "trace_headers": + if (TryConvertToBool(normalized, out var traceHeaders)) + { + TraceHeaders = traceHeaders; + Trace = traceHeaders; + TrackExplicitVal(InConfig, nameof(TraceHeaders), TraceHeaders); + TrackExplicitVal(InConfig, nameof(Trace), Trace); + } + else + { + errors.Add(new InvalidOperationException("trace_headers must be a boolean")); + } + break; + case "logtime": + if (TryConvertToBool(normalized, out var logtime)) + { + Logtime = logtime; + TrackExplicitVal(InConfig, nameof(Logtime), Logtime); + } + else + { + errors.Add(new InvalidOperationException("logtime must be a boolean")); + } + break; + case "logtime_utc": + if (TryConvertToBool(normalized, out var logtimeUtc)) + { + LogtimeUtc = logtimeUtc; + TrackExplicitVal(InConfig, nameof(LogtimeUtc), LogtimeUtc); + } + else + { + errors.Add(new InvalidOperationException("logtime_utc must be a boolean")); + } + break; + case "disable_sublist_cache": + case "no_sublist_cache": + if (TryConvertToBool(normalized, out var noSublistCache)) + NoSublistCache = noSublistCache; + else + errors.Add(new InvalidOperationException($"{key} must be a boolean")); + break; + case "accounts": + { + var err = ParseAccounts(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "default_sentinel": + if (normalized is string sentinel) + DefaultSentinel = sentinel; + else + errors.Add(new InvalidOperationException("default_sentinel must be a string")); + break; + case "authorization": + { + var (auth, err) = ParseAuthorization(normalized, errors, warnings); + if (err != null) + { + errors.Add(err); + break; + } + + if (auth == null) + break; + + AuthBlockDefined = true; + Username = auth.User; + Password = auth.Pass; + ProxyRequired = auth.ProxyRequired; + Authorization = auth.Token; + AuthTimeout = auth.TimeoutSeconds; + AuthCallout = auth.Callout; + + if ((!string.IsNullOrEmpty(auth.User) || !string.IsNullOrEmpty(auth.Pass)) && + !string.IsNullOrEmpty(auth.Token)) + { + errors.Add(new InvalidOperationException("Cannot have a user/pass and token")); + } + break; + } + case "cluster": + { + var err = ParseCluster(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "gateway": + { + var err = ParseGateway(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "leafnodes": + { + var err = ParseLeafNodes(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "routes": + if (normalized is string routesString) + { + RoutesStr = routesString; + Routes = RoutesFromStr(routesString); + break; + } + + if (TryGetArray(normalized, out var routes)) + { + Routes = ParseURLs(routes, "route", warnings, errors); + } + else + { + errors.Add(new InvalidOperationException("routes must be a string or array")); + } + break; + case "jetstream": + { + var err = ParseJetStream(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "websocket": + { + var err = ParseWebsocket(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "mqtt": + { + var err = ParseMQTT(normalized, this, errors, warnings); + if (err != null) + errors.Add(err); + break; + } + case "proxies": + { + var (proxies, err) = ParseProxies(normalized); + if (err != null) + errors.Add(err); + else + Proxies = proxies; + break; + } + case "system_account": + case "system": + { + var err = ConfigureSystemAccount( + this, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [key] = normalized, + }); + if (err != null) + errors.Add(err); + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors.Add(new InvalidOperationException($"unknown field \"{key}\"")); + break; + } + } + catch (Exception ex) + { + errors.Add(ex); + } + } + /// /// Normalizes token-like values to plain CLR values. /// Mirrors unwrapValue intent from opts.go. @@ -5914,6 +6277,12 @@ public sealed partial class ServerOptions } } + private static string ComputeConfigDigest(string configContent) + { + var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(configContent)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + private static void MergeRoutes(ServerOptions opts, ServerOptions flagOpts) { var routeUrls = RoutesFromStr(flagOpts.RoutesStr); diff --git a/porting.db b/porting.db index 87c02517927cc913dff6a62f9968fd3d4b69db2d..a069405baad394df81d00aa62b3fd3829533edb6 100644 GIT binary patch delta 4405 zcmc&#eQXow8NWN<#UJOhosf{g#o&a55Wr1r$C%jU0|Iu~IuaAoj^UQr@tGKclg3H6 zsawf~>P^2?Jt)HOs!dzMCJl8`%Dy;N>o&S+>qIvQ47T;3jda!60HIY(XcN`g>ob^m zT>edT`bp34?)|;*?|q)KL<4nY&Iu#T;As zl@AuM)&ka2zybxVxqy`yFlr$)-CxLzmj!X5xl08fFDmD5&?dUTKOe$>x=9Dg*Fh|W}ZkoA?%`?p3ESA#?ALZj2<`&;>iBQ4x=va&|C($-FqDbV< zMyC5D+K`Qmmr1lnjV_U>RE;i@$b@aPOjpN~amlDFW3sRLPKtUkQHVt9M0}7$>O_1i ziPVYs780ow@c|NW%Bfl1LAjPtD|LmTfx2QXHc7@GSfau=_>|$N21dV~yUPAT z_YQLkhgZ|HR(WftvzVfr1*g!=&~~gevq8hd8sqPuHLt|BNm0Nxe%4p!b^Be76P8S< zgrckDV@{zeD;f5)-_??|2G~uuoMc6&mq>QYYd7KWlz9by;~$llvo3bppriuqOeuXs zv30X^j71=$yt3e|FPFPAUXv=gu#RKkXQhc20GX?R8Al){CC3?u&=H4;Rsy@$n;ET~3E~PVFJOwpWXD>nT4! z!hOIjBDCT6Qk*lFBK#kT1I5?J{Y0m{lHep-`J|tFk;Y?5?#ASu6h}}*peHa87zs=S zJb{@&Ah0NRQr3Tcr2o{YKFaN|?TUW0@iIyC#^Myp86GKjabSAJO5|qefm1@Gwn2X=&u#+ zVJCENVaK@r+qQ`{nV{NQNvEKuQ<@ZKA7$myvGTtxHi@~yMKZ0VaA9%~!=wPd`Y`43 z7_eOytFa)9UM+P}7CRo4{a~hrsC$UICtJRAve>7k2u+APbBAW+W$_I?E3eD6W&Hdq z+%Yb`s=$QUM%(1kOn{hQC+~6!>#~#m^|<)D)?}HN#LcJe;2unECt1Wp7` zAZS3)h`@!wjlhGT34s^E76d*7TM;xP@FNHy*oL45K`Vkb1nmg6BiMnU1Hn!NyAT8s zgb;Kh*p1*b2tJG8Nd%umum?dGf^Gz11bY$mAlQeX7r|2qo<{I_1p5(u0l@(T5d_a5 zcoxAy1bt^A`c}A9A9?)2Kbw!DI3Lv4P_c*I$F~x;FV9*C3+7pXu&sI4Ojy37`EtVY z9nDjOl#)OmRAM;mD|25Sa zyA5B`PZ#}$`zKeyw(ACzs&?BBU4^_4#!Qr*u^+R`y`F>=9UU8y(tIbTpFXhp*j{OH zC>rk?9!(CV(ogiK{Vun+@!@|%dOjJKQvH(O5p+3XW68m|<4|-|a%{@-{=sNEo#Tf_ zBq;#&1_1DA7o)tJ$@Dc1JYqw&MA-F|8JGdA2T} z)8{mtwk+z@ZS9@UaaigaD z+1pB^Ro$K?`~l>gIv%!a1dj08f0ZmOtiJIwe(&$GT?ubf(-IULpSassmd{-T4r@8W z^dAn0_}WQV3AUYxtx9+sm!iC?wFSvaYFWFOO;%9yEkzaoKUV`iR=C&N|GobKN&(S? delta 1958 zcmY*ZYitx%6uvV%v$H$9Gqa`LKA09LwF?WZwUkoY3M~}cVv(}6$a_P{$rdP-~<~l`;4p~k ziEqSf)M5>SeH&RgU!CItl! z>d6;UqxpiLYL}%98jI*Et^}QB zV<0Sy7t-TfWtj^i8=}O#<_0FP3cS=65_Tmr9t#NtW*+V3y*rqvIFcI(n4Y1K@J_{+3PStI0_+UHqFB zFyd!yY!;8(jIPb%o17*Ml1|o!EXA*6#f!SRUYwXn*xxLA>D*>4K10Xl_a^?g=tq8S~}EQ|t|i^GEtT zG$SnfO*55S#NUQ{R?#-Mrn@ zh)+{gxBCzq?;6KesezD4PP8mq;>w`)yY$X6wlikCgOO#?S*(pgHY!2Z#v*^v9F@E_ zeLNl8rWYo*Y0k=yHod~^rguejCX1H)~h%{3X+4gv0I-_?{D%b!roY+ZFIDfr7noP7*Nd9zqC{DwNlS9 zcW4}L*SjP@9WWCx3vdkJSio_B*?{8#a{!+L%mwrTP5_(;=m(qxI2mvXU;yxGz&yZb z0P_K-0u}&H11tob4p;;@1F#sd1h5qFS-_cqvjEEg%K>Kt&H=0doC`P)uoAEeuo`ea z;B$aAfC~Vh2V4kP3%Cd{2)G!q4zM1u0dNW6Qov<^%K={iTmiTea24Qcz%_u4pJV8g zV2b?>dkcS6JE?u89pTT*H|3w@)AYo(5YX2*dRmRou7}