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 87c0251..a069405 100644 Binary files a/porting.db and b/porting.db differ