// Copyright 2012-2025 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/opts.go in the NATS server Go source. using System.Runtime.InteropServices; using System.Text.Json; using System.Threading; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; using System.Net.Security; using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Config; namespace ZB.MOM.NatsNet.Server; // Global flag for unknown field handling. internal static class ConfigFlags { private static int _allowUnknownTopLevelField; /// /// Sets whether unknown top-level config fields should be allowed. /// Mirrors NoErrOnUnknownFields in opts.go. /// public static void NoErrOnUnknownFields(bool noError) => Interlocked.Exchange(ref _allowUnknownTopLevelField, noError ? 1 : 0); public static bool AllowUnknownTopLevelField => Interlocked.CompareExchange(ref _allowUnknownTopLevelField, 0, 0) != 0; } public sealed partial class ServerOptions { /// /// Toggles unknown top-level field handling for config parsing. /// Mirrors NoErrOnUnknownFields in opts.go. /// public static void NoErrOnUnknownFields(bool noError) => ConfigFlags.NoErrOnUnknownFields(noError); /// /// Snapshot of command-line flags, populated during . /// Mirrors FlagSnapshot in opts.go. /// public static ServerOptions? FlagSnapshot { get; internal set; } /// /// Deep-copies this instance. /// Mirrors Options.Clone() in opts.go. /// public ServerOptions Clone() { // Start with a shallow memberwise clone. var clone = (ServerOptions)MemberwiseClone(); // Deep-copy reference types that need isolation. if (Routes.Count > 0) clone.Routes = Routes.Select(u => new Uri(u.ToString())).ToList(); clone.Cluster = CloneClusterOpts(Cluster); clone.Gateway = CloneGatewayOpts(Gateway); clone.LeafNode = CloneLeafNodeOpts(LeafNode); clone.Websocket = CloneWebsocketOpts(Websocket); clone.Mqtt = CloneMqttOpts(Mqtt); clone.Tags = [.. Tags]; clone.Metadata = new Dictionary(Metadata); clone.TrustedKeys = [.. TrustedKeys]; clone.JsAccDefaultDomain = new Dictionary(JsAccDefaultDomain); clone.InConfig = new Dictionary(InConfig); clone.InCmdLine = new Dictionary(InCmdLine); clone.OperatorJwt = [.. OperatorJwt]; clone.ResolverPreloads = new Dictionary(ResolverPreloads); clone.ResolverPinnedAccounts = [.. ResolverPinnedAccounts]; return clone; } /// /// Returns the SHA-256 digest of the configuration. /// Mirrors Options.ConfigDigest() in opts.go. /// public string ConfigDigest() => ConfigDigestValue; // ------------------------------------------------------------------------- // Merge / Baseline // ------------------------------------------------------------------------- /// /// Merges file-based options with command-line flag options. /// Flag options override file options where set. /// Mirrors MergeOptions in opts.go. /// public static ServerOptions MergeOptions(ServerOptions? fileOpts, ServerOptions? flagOpts) { if (fileOpts == null) return flagOpts ?? new ServerOptions(); if (flagOpts == null) return fileOpts; var opts = fileOpts.Clone(); if (flagOpts.Port != 0) opts.Port = flagOpts.Port; if (!string.IsNullOrEmpty(flagOpts.Host)) opts.Host = flagOpts.Host; if (flagOpts.DontListen) opts.DontListen = true; if (!string.IsNullOrEmpty(flagOpts.ClientAdvertise)) opts.ClientAdvertise = flagOpts.ClientAdvertise; if (!string.IsNullOrEmpty(flagOpts.Username)) opts.Username = flagOpts.Username; if (!string.IsNullOrEmpty(flagOpts.Password)) opts.Password = flagOpts.Password; if (!string.IsNullOrEmpty(flagOpts.Authorization)) opts.Authorization = flagOpts.Authorization; if (flagOpts.HttpPort != 0) opts.HttpPort = flagOpts.HttpPort; if (!string.IsNullOrEmpty(flagOpts.HttpBasePath)) opts.HttpBasePath = flagOpts.HttpBasePath; if (flagOpts.Debug) opts.Debug = true; if (flagOpts.Trace) opts.Trace = true; if (flagOpts.Logtime) opts.Logtime = true; if (!string.IsNullOrEmpty(flagOpts.LogFile)) opts.LogFile = flagOpts.LogFile; if (!string.IsNullOrEmpty(flagOpts.PidFile)) opts.PidFile = flagOpts.PidFile; if (!string.IsNullOrEmpty(flagOpts.PortsFileDir)) opts.PortsFileDir = flagOpts.PortsFileDir; if (flagOpts.ProfPort != 0) opts.ProfPort = flagOpts.ProfPort; if (!string.IsNullOrEmpty(flagOpts.Cluster.ListenStr)) opts.Cluster.ListenStr = flagOpts.Cluster.ListenStr; if (flagOpts.Cluster.NoAdvertise) opts.Cluster.NoAdvertise = true; if (flagOpts.Cluster.ConnectRetries != 0) opts.Cluster.ConnectRetries = flagOpts.Cluster.ConnectRetries; if (!string.IsNullOrEmpty(flagOpts.Cluster.Advertise)) opts.Cluster.Advertise = flagOpts.Cluster.Advertise; if (!string.IsNullOrEmpty(flagOpts.RoutesStr)) MergeRoutes(opts, flagOpts); if (flagOpts.JetStream) opts.JetStream = true; if (!string.IsNullOrEmpty(flagOpts.StoreDir)) opts.StoreDir = flagOpts.StoreDir; return opts; } /// /// Parses route URLs from a comma-separated string. /// Mirrors RoutesFromStr in opts.go. /// public static List RoutesFromStr(string routesStr) { var parts = routesStr.Split(','); if (parts.Length == 0) return []; var urls = new List(); foreach (var r in parts) { var trimmed = r.Trim(); if (Uri.TryCreate(trimmed, UriKind.Absolute, out var u)) urls.Add(u); } return urls; } /// /// Applies system-wide defaults to any unset options. /// Mirrors setBaselineOptions in opts.go. /// public void SetBaselineOptions() { if (string.IsNullOrEmpty(Host)) Host = ServerConstants.DefaultHost; if (string.IsNullOrEmpty(HttpHost)) HttpHost = Host; if (Port == 0) Port = ServerConstants.DefaultPort; else if (Port == ServerConstants.RandomPort) Port = 0; if (MaxConn == 0) MaxConn = ServerConstants.DefaultMaxConnections; if (PingInterval == TimeSpan.Zero) PingInterval = ServerConstants.DefaultPingInterval; if (MaxPingsOut == 0) MaxPingsOut = ServerConstants.DefaultPingMaxOut; if (TlsTimeout == 0) TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds; if (AuthTimeout == 0) AuthTimeout = GetDefaultAuthTimeout(TlsConfig, TlsTimeout); // Cluster defaults if (Cluster.Port != 0 || !string.IsNullOrEmpty(Cluster.ListenStr)) { if (string.IsNullOrEmpty(Cluster.Host)) Cluster.Host = ServerConstants.DefaultHost; if (Cluster.TlsTimeout == 0) Cluster.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds; if (Cluster.AuthTimeout == 0) Cluster.AuthTimeout = GetDefaultAuthTimeout(Cluster.TlsConfig, Cluster.TlsTimeout); if (Cluster.PoolSize == 0) Cluster.PoolSize = ServerConstants.DefaultRoutePoolSize; // Add system account to pinned accounts if pool is enabled. if (Cluster.PoolSize > 0) { var sysAccName = SystemAccount; if (string.IsNullOrEmpty(sysAccName) && !NoSystemAccount) sysAccName = ServerConstants.DefaultSystemAccount; if (!string.IsNullOrEmpty(sysAccName) && !Cluster.PinnedAccounts.Contains(sysAccName)) Cluster.PinnedAccounts.Add(sysAccName); } // Default compression to "accept". if (string.IsNullOrEmpty(Cluster.Compression.Mode)) Cluster.Compression.Mode = CompressionModes.Accept; } // LeafNode defaults if (LeafNode.Port != 0) { if (string.IsNullOrEmpty(LeafNode.Host)) LeafNode.Host = ServerConstants.DefaultHost; if (LeafNode.TlsTimeout == 0) LeafNode.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds; if (LeafNode.AuthTimeout == 0) LeafNode.AuthTimeout = GetDefaultAuthTimeout(LeafNode.TlsConfig, LeafNode.TlsTimeout); if (string.IsNullOrEmpty(LeafNode.Compression.Mode)) LeafNode.Compression.Mode = CompressionModes.S2Auto; } // Remote leafnode defaults foreach (var r in LeafNode.Remotes) { foreach (var u in r.Urls) { if (u.IsDefaultPort || string.IsNullOrEmpty(u.GetComponents(UriComponents.Port, UriFormat.Unescaped))) { var builder = new UriBuilder(u) { Port = ServerConstants.DefaultLeafNodePort }; r.Urls[r.Urls.IndexOf(u)] = builder.Uri; } } if (string.IsNullOrEmpty(r.Compression.Mode)) r.Compression.Mode = CompressionModes.S2Auto; if (r.FirstInfoTimeout <= TimeSpan.Zero) r.FirstInfoTimeout = ServerConstants.DefaultLeafNodeInfoWait; } if (LeafNode.ReconnectInterval == TimeSpan.Zero) LeafNode.ReconnectInterval = ServerConstants.DefaultLeafNodeReconnect; // Protocol limits if (MaxControlLine == 0) MaxControlLine = ServerConstants.MaxControlLineSize; if (MaxPayload == 0) MaxPayload = ServerConstants.MaxPayloadSize; if (MaxPending == 0) MaxPending = ServerConstants.MaxPendingSize; if (WriteDeadline == TimeSpan.Zero) WriteDeadline = ServerConstants.DefaultFlushDeadline; if (MaxClosedClients == 0) MaxClosedClients = ServerConstants.DefaultMaxClosedClients; if (LameDuckDuration == TimeSpan.Zero) LameDuckDuration = ServerConstants.DefaultLameDuckDuration; if (LameDuckGracePeriod == TimeSpan.Zero) LameDuckGracePeriod = ServerConstants.DefaultLameDuckGracePeriod; // Gateway defaults if (Gateway.Port != 0) { if (string.IsNullOrEmpty(Gateway.Host)) Gateway.Host = ServerConstants.DefaultHost; if (Gateway.TlsTimeout == 0) Gateway.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds; if (Gateway.AuthTimeout == 0) Gateway.AuthTimeout = GetDefaultAuthTimeout(Gateway.TlsConfig, Gateway.TlsTimeout); } // Error reporting if (ConnectErrorReports == 0) ConnectErrorReports = ServerConstants.DefaultConnectErrorReports; if (ReconnectErrorReports == 0) ReconnectErrorReports = ServerConstants.DefaultReconnectErrorReports; // WebSocket defaults if (Websocket.Port != 0) { if (string.IsNullOrEmpty(Websocket.Host)) Websocket.Host = ServerConstants.DefaultHost; } // MQTT defaults if (Mqtt.Port != 0) { if (string.IsNullOrEmpty(Mqtt.Host)) Mqtt.Host = ServerConstants.DefaultHost; if (Mqtt.TlsTimeout == 0) Mqtt.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds; } // JetStream defaults if (JetStreamMaxMemory == 0 && !MaxMemSet) JetStreamMaxMemory = -1; if (JetStreamMaxStore == 0 && !MaxStoreSet) JetStreamMaxStore = -1; if (SyncInterval == TimeSpan.Zero && !SyncSet) SyncInterval = TimeSpan.FromMinutes(2); // defaultSyncInterval if (JetStreamRequestQueueLimit <= 0) JetStreamRequestQueueLimit = 4096; // JSDefaultRequestQueueLimit } /// /// Normalizes an HTTP base path (ensure leading slash, clean redundant separators). /// Mirrors normalizeBasePath in opts.go. /// public static string NormalizeBasePath(string p) { if (string.IsNullOrEmpty(p)) return "/"; if (p[0] != '/') p = "/" + p; // Simple path clean: collapse repeated slashes and remove trailing slash. while (p.Contains("//")) p = p.Replace("//", "/"); return p.Length > 1 && p.EndsWith('/') ? p[..^1] : p; } /// /// Computes the default auth timeout based on TLS config presence. /// Mirrors getDefaultAuthTimeout in opts.go. /// public static double GetDefaultAuthTimeout(object? tlsConfig, double tlsTimeout) { if (tlsConfig != null) return tlsTimeout + 1.0; return ServerConstants.AuthTimeout.TotalSeconds; } /// /// Returns the user's home directory. /// Mirrors homeDir in opts.go. /// public static string HomeDir() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); /// /// Expands environment variables and ~/ prefix in a path. /// Mirrors expandPath in opts.go. /// public static string ExpandPath(string p) { p = Environment.ExpandEnvironmentVariables(p); if (!p.StartsWith('~')) return p; return Path.Combine(HomeDir(), p[1..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); } /// /// Reads a PID from a file path if possible, otherwise returns the string as-is. /// Mirrors maybeReadPidFile in opts.go. /// public static string MaybeReadPidFile(string pidStr) { try { return File.ReadAllText(pidStr).Trim(); } catch { return pidStr; } } /// /// Applies TLS overrides from command-line options. /// Mirrors overrideTLS in opts.go. /// public Exception? OverrideTls() { if (string.IsNullOrEmpty(TlsCert)) return new InvalidOperationException("TLS Server certificate must be present and valid"); if (string.IsNullOrEmpty(TlsKey)) return new InvalidOperationException("TLS Server private key must be present and valid"); // TLS config generation is deferred to GenTlsConfig (session 06+). // For now, mark that TLS is enabled. Tls = true; return null; } /// /// Overrides cluster options from the --cluster flag. /// Mirrors overrideCluster in opts.go. /// public Exception? OverrideCluster() { if (string.IsNullOrEmpty(Cluster.ListenStr)) { Cluster.Port = 0; return null; } var listenStr = Cluster.ListenStr; var wantsRandom = false; if (listenStr.EndsWith(":-1")) { wantsRandom = true; listenStr = listenStr[..^3] + ":0"; } if (!Uri.TryCreate(listenStr, UriKind.Absolute, out var clusterUri)) return new InvalidOperationException($"could not parse cluster URL: {Cluster.ListenStr}"); Cluster.Host = clusterUri.Host; Cluster.Port = wantsRandom ? -1 : clusterUri.Port; var userInfo = clusterUri.UserInfo; if (!string.IsNullOrEmpty(userInfo)) { var parts = userInfo.Split(':', 2); if (parts.Length != 2) return new InvalidOperationException("expected cluster password to be set"); Cluster.Username = parts[0]; Cluster.Password = parts[1]; } else { Cluster.Username = string.Empty; Cluster.Password = string.Empty; } return null; } // ------------------------------------------------------------------------- // Batch 6: opts.go package-level parse/config helpers (F1) // ------------------------------------------------------------------------- /// /// Deep copies route/gateway URL lists. /// Mirrors deepCopyURLs in opts.go. /// public static List? DeepCopyURLs(IReadOnlyList? urls) { if (urls == null) return null; var copied = new List(urls.Count); foreach (var u in urls) copied.Add(new Uri(u.ToString(), UriKind.Absolute)); return copied; } /// /// Loads server options from a config file. /// Mirrors package-level ProcessConfigFile in opts.go. /// public static ServerOptions ProcessConfigFile(string configFile) => ServerOptionsConfiguration.ProcessConfigFile(configFile); /// /// Normalizes token-like values to plain CLR values. /// Mirrors unwrapValue intent from opts.go. /// public static object? UnwrapValue(object? value) => NormalizeConfigValue(value); /// /// Converts a recovered panic/exception to an error list entry. /// Mirrors convertPanicToErrorList in opts.go. /// public static void ConvertPanicToErrorList(Exception? panic, ICollection? errors, string? context = null) { if (panic == null || errors == null) return; var message = string.IsNullOrWhiteSpace(context) ? "encountered panic while processing config" : $"encountered panic while processing {context}"; errors.Add(new InvalidOperationException(message, panic)); } /// /// Converts a recovered panic/exception to a single error output. /// Mirrors convertPanicToError in opts.go. /// public static void ConvertPanicToError(Exception? panic, ref Exception? error, string? context = null) { if (panic == null || error != null) return; var message = string.IsNullOrWhiteSpace(context) ? "encountered panic while processing config" : $"encountered panic while processing {context}"; error = new InvalidOperationException(message, panic); } /// /// Applies system_account/system config values. /// Mirrors configureSystemAccount in opts.go. /// public static Exception? ConfigureSystemAccount(ServerOptions options, IReadOnlyDictionary config) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(config); if (!TryGetFirst(config, ["system_account", "system"], out var value)) return null; if (value is not string systemAccount) return new InvalidOperationException("system account name must be a string"); options.SystemAccount = systemAccount; return null; } /// /// Builds a username/nkey identity map for duplicate detection. /// Mirrors setupUsersAndNKeysDuplicateCheckMap in opts.go. /// public static HashSet SetupUsersAndNKeysDuplicateCheckMap(ServerOptions options) { ArgumentNullException.ThrowIfNull(options); var identities = new HashSet(StringComparer.Ordinal); if (options.Users != null) { foreach (var user in options.Users) { if (!string.IsNullOrWhiteSpace(user.Username)) identities.Add(user.Username); } } if (options.Nkeys != null) { foreach (var user in options.Nkeys) { if (!string.IsNullOrWhiteSpace(user.Nkey)) identities.Add(user.Nkey); } } return identities; } /// /// Parses a duration from config value. /// Mirrors parseDuration in opts.go. /// public static TimeSpan ParseDuration( string field, object? value, ICollection? errors = null, ICollection? warnings = null) { if (value is string s) { try { return NatsDurationJsonConverter.Parse(s); } catch (Exception ex) { errors?.Add(new InvalidOperationException($"error parsing {field}: {ex.Message}", ex)); return TimeSpan.Zero; } } if (TryConvertToLong(value, out var legacySeconds)) { warnings?.Add(new InvalidOperationException($"{field} should be converted to a duration")); return TimeSpan.FromSeconds(legacySeconds); } errors?.Add(new InvalidOperationException($"{field} should be a duration string or number of seconds")); return TimeSpan.Zero; } /// /// Parses write timeout policy value. /// Mirrors parseWriteDeadlinePolicy in opts.go. /// public static WriteTimeoutPolicy ParseWriteDeadlinePolicy(string value, ICollection? errors = null) => value.ToLowerInvariant() switch { "default" => WriteTimeoutPolicy.Default, "close" => WriteTimeoutPolicy.Close, "retry" => WriteTimeoutPolicy.Retry, _ => ParseWriteDeadlinePolicyFallback(value, errors), }; /// /// Parses listen values (port or host:port). /// Mirrors parseListen in opts.go. /// public static (string Host, int Port) ParseListen(object? value) { if (TryConvertToLong(value, out var portOnly)) return (string.Empty, checked((int)portOnly)); if (value is not string address) throw new InvalidOperationException($"expected port or host:port, got {value?.GetType().Name ?? "null"}"); if (!TrySplitHostPort(address, out var host, out var port)) throw new InvalidOperationException($"could not parse address string \"{address}\""); return (host, port); } /// /// Parses cluster block config. /// Mirrors parseCluster in opts.go. /// public static Exception? ParseCluster( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var clusterMap)) return new InvalidOperationException($"Expected map to define cluster, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in clusterMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "name": { var name = entry as string ?? string.Empty; if (name.Contains(' ')) { errors?.Add(new InvalidOperationException(ServerErrors.ErrClusterNameHasSpaces.Message)); break; } options.Cluster.Name = name; break; } case "listen": { try { var (host, port) = ParseListen(entry); options.Cluster.Host = host; options.Cluster.Port = port; } catch (Exception ex) { errors?.Add(ex); } break; } case "port": if (TryConvertToLong(entry, out var clusterPort)) options.Cluster.Port = checked((int)clusterPort); break; case "host": case "net": options.Cluster.Host = entry as string ?? string.Empty; break; case "authorization": { var auth = ParseSimpleAuthorization(entry, errors, warnings); if (auth == null) break; if (auth.HasUsers) { errors?.Add(new InvalidOperationException("Cluster authorization does not allow multiple users")); break; } if (!string.IsNullOrEmpty(auth.Token)) { errors?.Add(new InvalidOperationException("Cluster authorization does not support tokens")); break; } if (auth.HasCallout) { errors?.Add(new InvalidOperationException("Cluster authorization does not support callouts")); break; } options.Cluster.Username = auth.Username; options.Cluster.Password = auth.Password; if (auth.TimeoutSeconds > 0) options.Cluster.AuthTimeout = auth.TimeoutSeconds; break; } case "routes": if (TryGetArray(entry, out var routes)) options.Routes = ParseURLs(routes, "route", warnings, errors); break; case "cluster_advertise": case "advertise": options.Cluster.Advertise = entry as string ?? string.Empty; break; case "no_advertise": if (TryConvertToBool(entry, out var noAdvertise)) { options.Cluster.NoAdvertise = noAdvertise; TrackExplicitVal(options.InConfig, "Cluster.NoAdvertise", noAdvertise); } break; case "connect_retries": if (TryConvertToLong(entry, out var retries)) options.Cluster.ConnectRetries = checked((int)retries); break; case "connect_backoff": if (TryConvertToBool(entry, out var connectBackoff)) options.Cluster.ConnectBackoff = connectBackoff; break; case "compression": { var parseError = ParseCompression( options.Cluster.Compression, CompressionModes.S2Fast, "compression", entry); if (parseError != null) errors?.Add(parseError); break; } case "ping_interval": options.Cluster.PingInterval = ParseDuration("ping_interval", entry, errors, warnings); break; case "ping_max": if (TryConvertToLong(entry, out var pingMax)) options.Cluster.MaxPingsOut = checked((int)pingMax); break; case "write_deadline": options.Cluster.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings); break; case "write_timeout": options.Cluster.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses compression option values from bool/string/map forms. /// Mirrors parseCompression in opts.go. /// public static Exception? ParseCompression( CompressionOpts compression, string chosenModeForOn, string fieldName, object? value) { ArgumentNullException.ThrowIfNull(compression); switch (NormalizeConfigValue(value)) { case string mode: compression.Mode = mode; return null; case bool enabled: compression.Mode = enabled ? chosenModeForOn : CompressionModes.Off; return null; default: if (!TryGetMap(value, out var map)) return new InvalidOperationException( $"field \"{fieldName}\" should be a boolean or a structure, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "mode": compression.Mode = entry as string ?? string.Empty; break; case "rtt_thresholds": case "thresholds": case "rtts": case "rtt": if (!TryGetArray(entry, out var thresholds)) return new InvalidOperationException("rtt_thresholds should be an array"); foreach (var threshold in thresholds) { if (threshold is not string thresholdValue) return new InvalidOperationException("rtt_thresholds entries should be duration strings"); compression.RttThresholds.Add(NatsDurationJsonConverter.Parse(thresholdValue)); } break; default: return new InvalidOperationException($"unknown field \"{rawKey}\""); } } return null; } } /// /// Parses URL arrays with duplicate detection. /// Mirrors parseURLs in opts.go. /// public static List ParseURLs( IEnumerable values, string type, ICollection? warnings = null, ICollection? errors = null) { var urls = new List(); var dedupe = new HashSet(StringComparer.Ordinal); foreach (var rawValue in values) { if (NormalizeConfigValue(rawValue) is not string urlValue) { errors?.Add(new InvalidOperationException($"{type} url must be a string")); continue; } if (!dedupe.Add(urlValue)) { warnings?.Add(new InvalidOperationException($"Duplicate {type} entry detected: {urlValue}")); continue; } try { urls.Add(ParseURL(urlValue, type)); } catch (Exception ex) { errors?.Add(ex); } } return urls; } /// /// Parses a single URL entry. /// Mirrors parseURL in opts.go. /// public static Uri ParseURL(string value, string type) { var trimmed = value.Trim(); if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed)) throw new InvalidOperationException($"error parsing {type} url [\"{trimmed}\"]"); return parsed; } /// /// Parses gateway block config. /// Mirrors parseGateway in opts.go. /// public static Exception? ParseGateway( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var gatewayMap)) return new InvalidOperationException($"Expected gateway to be a map, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in gatewayMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "name": { var name = entry as string ?? string.Empty; if (name.Contains(' ')) { errors?.Add(new InvalidOperationException(ServerErrors.ErrGatewayNameHasSpaces.Message)); break; } options.Gateway.Name = name; break; } case "listen": { try { var (host, port) = ParseListen(entry); options.Gateway.Host = host; options.Gateway.Port = port; } catch (Exception ex) { errors?.Add(ex); } break; } case "port": if (TryConvertToLong(entry, out var gatewayPort)) options.Gateway.Port = checked((int)gatewayPort); break; case "host": case "net": options.Gateway.Host = entry as string ?? string.Empty; break; case "authorization": { var auth = ParseSimpleAuthorization(entry, errors, warnings); if (auth == null) break; if (auth.HasUsers) { errors?.Add(new InvalidOperationException("Gateway authorization does not allow multiple users")); break; } if (!string.IsNullOrEmpty(auth.Token)) { errors?.Add(new InvalidOperationException("Gateway authorization does not support tokens")); break; } if (auth.HasCallout) { errors?.Add(new InvalidOperationException("Gateway authorization does not support callouts")); break; } options.Gateway.Username = auth.Username; options.Gateway.Password = auth.Password; if (auth.TimeoutSeconds > 0) options.Gateway.AuthTimeout = auth.TimeoutSeconds; break; } case "advertise": options.Gateway.Advertise = entry as string ?? string.Empty; break; case "connect_retries": if (TryConvertToLong(entry, out var retries)) options.Gateway.ConnectRetries = checked((int)retries); break; case "connect_backoff": if (TryConvertToBool(entry, out var connectBackoff)) options.Gateway.ConnectBackoff = connectBackoff; break; case "reject_unknown": case "reject_unknown_cluster": if (TryConvertToBool(entry, out var rejectUnknown)) options.Gateway.RejectUnknown = rejectUnknown; break; case "write_deadline": options.Gateway.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings); break; case "write_timeout": options.Gateway.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } // ------------------------------------------------------------------------- // Batch 6: opts.go package-level parse/config helpers (F2) // ------------------------------------------------------------------------- /// /// Parses JetStream account enablement/limits for an account-level block. /// Mirrors parseJetStreamForAccount in opts.go. /// public static Exception? ParseJetStreamForAccount( object? value, Account account, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(account); var normalized = NormalizeConfigValue(value); switch (normalized) { case bool enabled: account.JetStreamLimits = enabled ? CreateDefaultJetStreamAccountTiers() : null; return null; case string mode: switch (mode.Trim().ToLowerInvariant()) { case "enabled": case "enable": account.JetStreamLimits = CreateDefaultJetStreamAccountTiers(); return null; case "disabled": case "disable": account.JetStreamLimits = null; return null; default: return new InvalidOperationException( $"Expected 'enabled' or 'disabled' for string value, got '{mode}'"); } default: if (!TryGetMap(normalized, out var map)) { return new InvalidOperationException( $"Expected map, bool or string to define JetStream, got {normalized?.GetType().Name ?? "null"}"); } var limits = CreateUnlimitedJetStreamAccountLimits(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "max_memory": case "max_mem": case "mem": case "memory": if (!TryConvertToLong(entry, out var maxMemory)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MaxMemory = maxMemory; break; case "max_store": case "max_file": case "max_disk": case "store": case "disk": if (!TryConvertToLong(entry, out var maxStore)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MaxStore = maxStore; break; case "max_streams": case "streams": if (!TryConvertToLong(entry, out var maxStreams)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MaxStreams = checked((int)maxStreams); break; case "max_consumers": case "consumers": if (!TryConvertToLong(entry, out var maxConsumers)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MaxConsumers = checked((int)maxConsumers); break; case "max_bytes_required": case "max_stream_bytes": case "max_bytes": if (!TryConvertToBool(entry, out var maxBytesRequired)) return new InvalidOperationException($"Expected a parseable bool for \"{rawKey}\", got {entry}"); limits.MaxBytesRequired = maxBytesRequired; break; case "mem_max_stream_bytes": case "memory_max_stream_bytes": if (!TryConvertToLong(entry, out var memoryMaxStreamBytes)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MemoryMaxStreamBytes = memoryMaxStreamBytes; break; case "disk_max_stream_bytes": case "store_max_stream_bytes": if (!TryConvertToLong(entry, out var storeMaxStreamBytes)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.StoreMaxStreamBytes = storeMaxStreamBytes; break; case "max_ack_pending": if (!TryConvertToLong(entry, out var maxAckPending)) return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); limits.MaxAckPending = checked((int)maxAckPending); break; case "cluster_traffic": { var traffic = entry as string ?? string.Empty; switch (traffic) { case "system": case "": account.NrgAccount = string.Empty; break; case "owner": account.NrgAccount = account.Name; break; default: return new InvalidOperationException( $"Expected 'system' or 'owner' string value for \"{rawKey}\", got {entry}"); } break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } account.JetStreamLimits = new Dictionary(StringComparer.Ordinal) { [string.Empty] = limits, }; return null; } } /// /// Parses storage sizes from integer or suffixed-string values. /// Mirrors getStorageSize in opts.go. /// public static long GetStorageSize(object? value) { if (TryConvertToLong(value, out var asLong)) return asLong; if (NormalizeConfigValue(value) is not string raw) throw new InvalidOperationException("must be int64 or string"); var s = raw.Trim(); if (s.Length == 0) return 0; var suffix = char.ToUpperInvariant(s[^1]); var prefix = s[..^1]; if (!long.TryParse(prefix, out var parsed)) throw new InvalidOperationException($"invalid size value: {raw}"); return suffix switch { 'K' => parsed << 10, 'M' => parsed << 20, 'G' => parsed << 30, 'T' => parsed << 40, _ => throw new InvalidOperationException("sizes defined as strings must end in K, M, G, T"), }; } /// /// Parses server-level JetStream limits. /// Mirrors parseJetStreamLimits in opts.go. /// public static Exception? ParseJetStreamLimits( object? value, ServerOptions options, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected a map to define JetStreamLimits, got {value?.GetType().Name ?? "null"}"); options.JetStreamLimits = new JsLimitOpts(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "max_ack_pending": if (TryConvertToLong(entry, out var maxAckPending)) options.JetStreamLimits.MaxAckPending = checked((int)maxAckPending); break; case "max_ha_assets": if (TryConvertToLong(entry, out var maxHaAssets)) options.JetStreamLimits.MaxHaAssets = checked((int)maxHaAssets); break; case "max_request_batch": if (TryConvertToLong(entry, out var maxRequestBatch)) options.JetStreamLimits.MaxRequestBatch = checked((int)maxRequestBatch); break; case "duplicate_window": options.JetStreamLimits.Duplicates = ParseDuration("duplicate_window", entry, errors, warnings: null); break; case "batch": { var parseError = ParseJetStreamLimitsBatch(entry, options, errors); if (parseError != null) return parseError; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses nested batch limits inside JetStream limits block. /// Mirrors parseJetStreamLimitsBatch in opts.go. /// public static Exception? ParseJetStreamLimitsBatch( object? value, ServerOptions options, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected a map to define batch limits, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "max_inflight_per_stream": if (TryConvertToLong(entry, out var maxInflightPerStream)) options.JetStreamLimits.MaxBatchInflightPerStream = checked((int)maxInflightPerStream); break; case "max_inflight_total": if (TryConvertToLong(entry, out var maxInflightTotal)) options.JetStreamLimits.MaxBatchInflightTotal = checked((int)maxInflightTotal); break; case "max_msgs": if (TryConvertToLong(entry, out var maxBatchSize)) options.JetStreamLimits.MaxBatchSize = checked((int)maxBatchSize); break; case "timeout": options.JetStreamLimits.MaxBatchTimeout = ParseDuration("timeout", entry, errors, warnings: null); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses JetStream TPM options. /// Mirrors parseJetStreamTPM in opts.go. /// public static Exception? ParseJetStreamTPM( object? value, ServerOptions options, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected a map to define JetStream TPM, got {value?.GetType().Name ?? "null"}"); options.JetStreamTpm = new JsTpmOpts(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "keys_file": options.JetStreamTpm.KeysFile = entry as string ?? string.Empty; break; case "encryption_password": options.JetStreamTpm.KeyPassword = entry as string ?? string.Empty; break; case "srk_password": options.JetStreamTpm.SrkPassword = entry as string ?? string.Empty; break; case "pcr": if (TryConvertToLong(entry, out var pcr)) options.JetStreamTpm.Pcr = checked((int)pcr); break; case "cipher": { var parseError = SetJetStreamEkCipher(options, entry); if (parseError != null) return parseError; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses JetStream encryption cipher selection. /// Mirrors setJetStreamEkCipher in opts.go. /// public static Exception? SetJetStreamEkCipher(ServerOptions options, object? value) { ArgumentNullException.ThrowIfNull(options); var cipher = (NormalizeConfigValue(value) as string ?? string.Empty).ToLowerInvariant(); switch (cipher) { case "chacha": case "chachapoly": options.JetStreamCipher = StoreCipher.ChaCha; return null; case "aes": options.JetStreamCipher = StoreCipher.Aes; return null; default: return new InvalidOperationException($"Unknown cipher type: \"{value}\""); } } /// /// Parses top-level JetStream enablement/configuration. /// Mirrors parseJetStream in opts.go. /// public static Exception? ParseJetStream( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); var normalized = NormalizeConfigValue(value); switch (normalized) { case bool enabled: options.JetStream = enabled; return null; case string mode: switch (mode.Trim().ToLowerInvariant()) { case "enabled": case "enable": options.JetStream = true; return null; case "disabled": case "disable": options.JetStream = false; return null; default: return new InvalidOperationException( $"Expected 'enabled' or 'disabled' for string value, got '{mode}'"); } default: if (!TryGetMap(normalized, out var map)) { return new InvalidOperationException( $"Expected map, bool or string to define JetStream, got {normalized?.GetType().Name ?? "null"}"); } var enable = true; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "strict": if (!TryConvertToBool(entry, out var strictMode)) return new InvalidOperationException($"Expected bool for \"{rawKey}\", got {entry}"); options.NoJetStreamStrict = !strictMode; break; case "store": case "store_dir": case "storedir": if (!string.IsNullOrEmpty(options.StoreDir)) return new InvalidOperationException("Duplicate 'store_dir' configuration"); options.StoreDir = entry as string ?? string.Empty; break; case "sync": case "sync_interval": if ((entry as string)?.Equals("always", StringComparison.OrdinalIgnoreCase) == true) { options.SyncInterval = TimeSpan.FromMinutes(2); options.SyncAlways = true; } else { options.SyncInterval = ParseDuration(rawKey, entry, errors, warnings); } options.SyncSet = true; break; case "max_memory_store": case "max_mem_store": case "max_mem": options.JetStreamMaxMemory = GetStorageSize(entry); options.MaxMemSet = true; break; case "max_file_store": case "max_file": options.JetStreamMaxStore = GetStorageSize(entry); options.MaxStoreSet = true; break; case "domain": options.JetStreamDomain = entry as string ?? string.Empty; break; case "enable": case "enabled": if (TryConvertToBool(entry, out var toggle)) enable = toggle; break; case "key": case "ek": case "encryption_key": options.JetStreamKey = entry as string ?? string.Empty; break; case "prev_key": case "prev_ek": case "prev_encryption_key": options.JetStreamOldKey = entry as string ?? string.Empty; break; case "cipher": { var parseError = SetJetStreamEkCipher(options, entry); if (parseError != null) return parseError; break; } case "extension_hint": options.JetStreamExtHint = entry as string ?? string.Empty; break; case "limits": { var parseError = ParseJetStreamLimits(entry, options, errors); if (parseError != null) return parseError; break; } case "tpm": { var parseError = ParseJetStreamTPM(entry, options, errors); if (parseError != null) return parseError; break; } case "unique_tag": options.JetStreamUniqueTag = (entry as string ?? string.Empty).Trim().ToLowerInvariant(); break; case "max_outstanding_catchup": options.JetStreamMaxCatchup = GetStorageSize(entry); break; case "max_buffered_size": options.StreamMaxBufferedSize = GetStorageSize(entry); break; case "max_buffered_msgs": if (TryConvertToLong(entry, out var bufferedMsgs)) options.StreamMaxBufferedMsgs = checked((int)bufferedMsgs); break; case "request_queue_limit": if (TryConvertToLong(entry, out var requestQueueLimit)) options.JetStreamRequestQueueLimit = requestQueueLimit; break; case "meta_compact": if (TryConvertToLong(entry, out var compact)) { if (compact < 0) return new InvalidOperationException($"Expected an absolute size for \"{rawKey}\", got {entry}"); options.JetStreamMetaCompact = checked((ulong)compact); } break; case "meta_compact_size": { var compactSize = GetStorageSize(entry); if (compactSize < 0) return new InvalidOperationException($"Expected an absolute size for \"{rawKey}\", got {entry}"); options.JetStreamMetaCompactSize = checked((ulong)compactSize); break; } case "meta_compact_sync": if (TryConvertToBool(entry, out var compactSync)) options.JetStreamMetaCompactSync = compactSync; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } options.JetStream = enable; return null; } } /// /// Parses leaf-node configuration. /// Mirrors parseLeafNodes in opts.go. /// public static Exception? ParseLeafNodes( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected map to define a leafnode, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "listen": { try { var (host, port) = ParseListen(entry); options.LeafNode.Host = host; options.LeafNode.Port = port; } catch (Exception ex) { errors?.Add(ex); } break; } case "port": if (TryConvertToLong(entry, out var leafPort)) options.LeafNode.Port = checked((int)leafPort); break; case "host": case "net": options.LeafNode.Host = entry as string ?? string.Empty; break; case "authorization": { var auth = ParseLeafAuthorization(entry, errors, warnings); if (auth == null) break; options.LeafNode.Username = auth.Username; options.LeafNode.Password = auth.Password; options.LeafNode.ProxyRequired = auth.ProxyRequired; options.LeafNode.AuthTimeout = auth.TimeoutSeconds; options.LeafNode.Account = auth.AccountName; options.LeafNode.Users = auth.Users; options.LeafNode.Nkey = auth.Nkey; break; } case "remotes": options.LeafNode.Remotes = ParseRemoteLeafNodes(entry, errors, warnings); break; case "reconnect": case "reconnect_delay": case "reconnect_interval": options.LeafNode.ReconnectInterval = ParseDuration("reconnect", entry, errors, warnings); break; case "tls": { var (config, tlsOpts, parseError) = GetTLSConfig(entry, requireClientCertificate: false); if (parseError != null) { errors?.Add(parseError); break; } options.LeafNode.TlsConfig = config; options.LeafNode.TlsTimeout = tlsOpts?.Timeout > 0 ? tlsOpts.Timeout : ServerConstants.DefaultLeafTlsTimeout.TotalSeconds; options.LeafNode.TlsMap = tlsOpts?.Map ?? false; options.LeafNode.TlsPinnedCerts = tlsOpts?.PinnedCerts; options.LeafNode.TlsHandshakeFirst = tlsOpts?.HandshakeFirst ?? false; options.LeafNode.TlsHandshakeFirstFallback = tlsOpts?.FallbackDelay ?? TimeSpan.Zero; options.LeafNode.TlsConfigOpts = tlsOpts; break; } case "leafnode_advertise": case "advertise": options.LeafNode.Advertise = entry as string ?? string.Empty; break; case "no_advertise": if (TryConvertToBool(entry, out var noAdvertise)) { options.LeafNode.NoAdvertise = noAdvertise; TrackExplicitVal(options.InConfig, "LeafNode.NoAdvertise", noAdvertise); } break; case "min_version": case "minimum_version": options.LeafNode.MinVersion = entry as string ?? string.Empty; break; case "compression": { var parseError = ParseCompression( options.LeafNode.Compression, CompressionModes.S2Auto, "compression", entry); if (parseError != null) errors?.Add(parseError); break; } case "isolate_leafnode_interest": case "isolate": if (TryConvertToBool(entry, out var isolate)) options.LeafNode.IsolateLeafnodeInterest = isolate; break; case "write_deadline": options.LeafNode.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings); break; case "write_timeout": options.LeafNode.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses leaf-node authorization block. /// Mirrors parseLeafAuthorization in opts.go. /// public static LeafAuthorization? ParseLeafAuthorization( object? value, ICollection? errors = null, ICollection? warnings = null) { if (!TryGetMap(value, out var map)) { errors?.Add(new InvalidOperationException("leafnode authorization should be a map")); return null; } var auth = new LeafAuthorization(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "user": case "username": auth.Username = entry as string ?? string.Empty; break; case "pass": case "password": auth.Password = entry as string ?? string.Empty; break; case "nkey": auth.Nkey = entry as string ?? string.Empty; break; case "timeout": switch (entry) { case long l: auth.TimeoutSeconds = l; break; case double d: auth.TimeoutSeconds = d; break; case string s: auth.TimeoutSeconds = ParseDuration("timeout", s, errors, warnings).TotalSeconds; break; default: errors?.Add(new InvalidOperationException("error parsing leafnode authorization config, 'timeout' wrong type")); break; } if (auth.TimeoutSeconds > TimeSpan.FromMinutes(1).TotalSeconds) { warnings?.Add(new InvalidOperationException( $"timeout of {entry} ({auth.TimeoutSeconds} seconds) is high, consider keeping it under 60 seconds")); } break; case "users": auth.Users = ParseLeafUsers(entry, errors); break; case "account": auth.AccountName = entry as string ?? string.Empty; break; case "proxy_required": if (TryConvertToBool(entry, out var proxyRequired)) auth.ProxyRequired = proxyRequired; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return auth; } /// /// Parses leaf-node authorization users list. /// Mirrors parseLeafUsers in opts.go. /// public static List ParseLeafUsers(object? value, ICollection? errors = null) { if (!TryGetArray(value, out var usersArray)) throw new InvalidOperationException($"Expected users field to be an array, got {value?.GetType().Name ?? "null"}"); var users = new List(usersArray.Count); foreach (var userEntry in usersArray) { if (!TryGetMap(userEntry, out var userMap)) { errors?.Add(new InvalidOperationException($"Expected user entry to be a map/struct, got {userEntry}")); continue; } var user = new User(); foreach (var (rawKey, rawValue) in userMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "user": case "username": user.Username = entry as string ?? string.Empty; break; case "pass": case "password": user.Password = entry as string ?? string.Empty; break; case "account": user.Account = new Account { Name = entry as string ?? string.Empty }; break; case "proxy_required": if (TryConvertToBool(entry, out var proxyRequired)) user.ProxyRequired = proxyRequired; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } users.Add(user); } return users; } /// /// Parses remote leaf-node definitions. /// Mirrors parseRemoteLeafNodes in opts.go. /// public static List ParseRemoteLeafNodes( object? value, ICollection? errors = null, ICollection? warnings = null) { if (!TryGetArray(value, out var remoteArray)) throw new InvalidOperationException($"Expected remotes field to be an array, got {value?.GetType().Name ?? "null"}"); var remotes = new List(remoteArray.Count); foreach (var remoteEntry in remoteArray) { if (!TryGetMap(remoteEntry, out var remoteMap)) { errors?.Add(new InvalidOperationException( $"Expected remote leafnode entry to be a map/struct, got {remoteEntry}")); continue; } var remote = new RemoteLeafOpts(); foreach (var (rawKey, rawValue) in remoteMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "no_randomize": case "dont_randomize": if (TryConvertToBool(entry, out var noRandomize)) remote.NoRandomize = noRandomize; break; case "url": case "urls": if (entry is string singleUrl) { remote.Urls.Add(ParseURL(singleUrl, "leafnode")); } else if (TryGetArray(entry, out var urlArray)) { remote.Urls = ParseURLs(urlArray, "leafnode", warnings, errors); } break; case "account": case "local": remote.LocalAccount = entry as string ?? string.Empty; break; case "creds": case "credentials": remote.Credentials = ExpandPath(entry as string ?? string.Empty); break; case "nkey": case "seed": remote.Nkey = entry as string ?? string.Empty; break; case "tls": { var (config, tlsOpts, parseError) = GetTLSConfig(entry, requireClientCertificate: false); if (parseError != null) { errors?.Add(parseError); break; } remote.TlsConfig = config; remote.TlsTimeout = tlsOpts?.Timeout > 0 ? tlsOpts.Timeout : ServerConstants.DefaultLeafTlsTimeout.TotalSeconds; remote.TlsHandshakeFirst = tlsOpts?.HandshakeFirst ?? false; remote.TlsConfigOpts = tlsOpts; break; } case "hub": if (TryConvertToBool(entry, out var hub)) remote.Hub = hub; break; case "deny_imports": case "deny_import": remote.DenyImports = ParseStringList(entry); break; case "deny_exports": case "deny_export": remote.DenyExports = ParseStringList(entry); break; case "ws_compress": case "ws_compression": case "websocket_compress": case "websocket_compression": if (TryConvertToBool(entry, out var wsCompression)) remote.Websocket.Compression = wsCompression; break; case "ws_no_masking": case "websocket_no_masking": if (TryConvertToBool(entry, out var wsNoMasking)) remote.Websocket.NoMasking = wsNoMasking; break; case "jetstream_cluster_migrate": case "js_cluster_migrate": if (TryConvertToBool(entry, out var migrateToggle)) { remote.JetStreamClusterMigrate = migrateToggle; } else if (TryGetMap(entry, out var migrateMap)) { remote.JetStreamClusterMigrate = true; if (migrateMap.TryGetValue("leader_migrate_delay", out var delayValue)) { remote.JetStreamClusterMigrateDelay = ParseDuration( "leader_migrate_delay", delayValue, errors, warnings); } } else { errors?.Add(new InvalidOperationException( $"Expected boolean or map for jetstream_cluster_migrate, got {entry?.GetType().Name ?? "null"}")); } break; case "isolate_leafnode_interest": case "isolate": if (TryConvertToBool(entry, out var localIsolation)) remote.LocalIsolation = localIsolation; break; case "request_isolation": if (TryConvertToBool(entry, out var requestIsolation)) remote.RequestIsolation = requestIsolation; break; case "compression": { var parseError = ParseCompression( remote.Compression, CompressionModes.S2Auto, "compression", entry); if (parseError != null) errors?.Add(parseError); break; } case "first_info_timeout": remote.FirstInfoTimeout = ParseDuration("first_info_timeout", entry, errors, warnings); break; case "disabled": if (TryConvertToBool(entry, out var disabled)) remote.Disabled = disabled; break; case "proxy": if (TryGetMap(entry, out var proxyMap)) { foreach (var (proxyKeyRaw, proxyValueRaw) in proxyMap) { var proxyKey = proxyKeyRaw.ToLowerInvariant(); var proxyValue = NormalizeConfigValue(proxyValueRaw); switch (proxyKey) { case "url": remote.Proxy.Url = proxyValue as string ?? string.Empty; break; case "username": remote.Proxy.Username = proxyValue as string ?? string.Empty; break; case "password": remote.Proxy.Password = proxyValue as string ?? string.Empty; break; case "timeout": remote.Proxy.Timeout = ParseDuration("proxy timeout", proxyValue, errors, warnings); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{proxyKeyRaw}\"")); break; } } } break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } remotes.Add(remote); } return remotes; } /// /// Parses TLS config block and builds runtime TLS options. /// Mirrors getTLSConfig in opts.go. /// public static (SslServerAuthenticationOptions? Config, TlsConfigOpts? Options, Exception? Error) GetTLSConfig( object? value, bool requireClientCertificate = true) { if (!TryGetMap(value, out var map)) return (null, null, new InvalidOperationException("TLS options should be a map")); var tlsOpts = new TlsConfigOpts(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "cert_file": case "cert": tlsOpts.CertFile = entry as string ?? string.Empty; break; case "key_file": case "key": tlsOpts.KeyFile = entry as string ?? string.Empty; break; case "ca_file": case "ca": tlsOpts.CaFile = entry as string ?? string.Empty; break; case "verify": if (TryConvertToBool(entry, out var verify)) tlsOpts.Verify = verify; break; case "map": if (TryConvertToBool(entry, out var mapCert)) tlsOpts.Map = mapCert; break; case "timeout": if (entry is string timeoutString) { tlsOpts.Timeout = ParseDuration("tls timeout", timeoutString).TotalSeconds; } else if (TryConvertToDouble(entry, out var timeoutSeconds)) { tlsOpts.Timeout = timeoutSeconds; } break; case "handshake_first": if (TryConvertToBool(entry, out var handshakeFirst)) tlsOpts.HandshakeFirst = handshakeFirst; break; case "handshake_first_fallback": case "fallback_delay": tlsOpts.FallbackDelay = ParseDuration("fallback_delay", entry); break; case "pinned_certs": tlsOpts.PinnedCerts = new PinnedCertSet(ParseStringList(entry)); break; case "min_version": if (entry is string minVersion) { try { tlsOpts.MinVersion = TlsVersionJsonConverter.Parse(minVersion); } catch (Exception ex) { return (null, null, ex); } } break; } } var enabledProtocols = tlsOpts.MinVersion switch { SslProtocols.Tls13 => SslProtocols.Tls13, SslProtocols.Tls12 => SslProtocols.Tls12 | SslProtocols.Tls13, _ => SslProtocols.Tls12 | SslProtocols.Tls13, }; var config = new SslServerAuthenticationOptions { EnabledSslProtocols = enabledProtocols, ClientCertificateRequired = requireClientCertificate || tlsOpts.Verify, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, }; return (config, tlsOpts, null); } /// /// Parses list of remote gateways. /// Mirrors parseGateways in opts.go. /// public static List ParseGateways( object? value, ICollection? errors = null, ICollection? warnings = null) { if (!TryGetArray(value, out var gatewayArray)) throw new InvalidOperationException($"Expected gateways field to be an array, got {value?.GetType().Name ?? "null"}"); var gateways = new List(gatewayArray.Count); foreach (var gatewayEntry in gatewayArray) { if (!TryGetMap(gatewayEntry, out var gatewayMap)) { errors?.Add(new InvalidOperationException($"Expected gateway entry to be a map/struct, got {gatewayEntry}")); continue; } var gateway = new RemoteGatewayOpts(); foreach (var (rawKey, rawValue) in gatewayMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "name": gateway.Name = entry as string ?? string.Empty; break; case "tls": { var (config, tlsOpts, parseError) = GetTLSConfig(entry); if (parseError != null) { errors?.Add(parseError); break; } gateway.TlsConfig = config; gateway.TlsTimeout = tlsOpts?.Timeout ?? 0; gateway.TlsConfigOpts = tlsOpts; break; } case "url": gateway.Urls.Add(ParseURL(entry as string ?? string.Empty, "gateway")); break; case "urls": if (TryGetArray(entry, out var urlArray)) gateway.Urls = ParseURLs(urlArray, "gateway", warnings, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } gateways.Add(gateway); } return gateways; } /// /// Maps parsed pub/sub permissions to route import/export permissions. /// Mirrors setClusterPermissions in opts.go. /// public static void SetClusterPermissions(ClusterOpts options, Permissions permissions) { ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(permissions); options.Permissions = new RoutePermissions { Import = permissions.Publish?.Clone(), Export = permissions.Subscribe?.Clone(), }; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- private static Dictionary CreateDefaultJetStreamAccountTiers() => new(StringComparer.Ordinal) { [string.Empty] = CreateUnlimitedJetStreamAccountLimits(), }; private static JetStreamAccountLimits CreateUnlimitedJetStreamAccountLimits() => new() { MaxMemory = -1, MaxStore = -1, MaxStreams = -1, MaxConsumers = -1, MaxAckPending = -1, MemoryMaxStreamBytes = -1, StoreMaxStreamBytes = -1, MaxBytesRequired = false, }; public sealed class LeafAuthorization { public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; public string Nkey { get; set; } = string.Empty; public double TimeoutSeconds { get; set; } public List? Users { get; set; } public string AccountName { get; set; } = string.Empty; public bool ProxyRequired { get; set; } } private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection? errors) { errors?.Add(new InvalidOperationException( $"write_timeout must be 'default', 'close' or 'retry' (received '{value}')")); return WriteTimeoutPolicy.Default; } private sealed record ParsedAuthorization( string Username, string Password, string Token, double TimeoutSeconds, bool HasUsers, bool HasCallout); private static ParsedAuthorization? ParseSimpleAuthorization( object? value, ICollection? errors, ICollection? warnings) { if (!TryGetMap(value, out var map)) { errors?.Add(new InvalidOperationException("authorization should be a map")); return null; } string user = string.Empty; string pass = string.Empty; string token = string.Empty; double timeout = 0; var hasUsers = false; var hasCallout = false; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "user": case "username": user = entry as string ?? string.Empty; break; case "pass": case "password": pass = entry as string ?? string.Empty; break; case "token": token = entry as string ?? string.Empty; break; case "timeout": case "auth_timeout": if (entry is string timeoutAsString) { timeout = ParseDuration("auth_timeout", timeoutAsString, errors, warnings).TotalSeconds; } else if (TryConvertToDouble(entry, out var timeoutSeconds)) { timeout = timeoutSeconds; } break; case "users": case "nkeys": hasUsers = true; break; case "callout": case "auth_callout": hasCallout = true; break; } } return new ParsedAuthorization(user, pass, token, timeout, hasUsers, hasCallout); } private static bool TrySplitHostPort(string value, out string host, out int port) { host = string.Empty; port = 0; if (Uri.TryCreate($"tcp://{value}", UriKind.Absolute, out var uri)) { host = uri.Host; if (uri.Port >= 0) { port = uri.Port; return true; } } var idx = value.LastIndexOf(':'); if (idx <= 0 || idx >= value.Length - 1) return false; host = value[..idx]; return int.TryParse(value[(idx + 1)..], out port); } private static bool TryGetFirst( IReadOnlyDictionary map, IEnumerable keys, out object? value) { foreach (var key in keys) { if (map.TryGetValue(key, out value)) { value = NormalizeConfigValue(value); return true; } } value = null; return false; } private static bool TryGetMap(object? value, out IReadOnlyDictionary map) { var normalized = NormalizeConfigValue(value); if (normalized is IReadOnlyDictionary readonlyMap) { map = readonlyMap; return true; } if (normalized is Dictionary dict) { map = dict; return true; } map = new Dictionary(StringComparer.OrdinalIgnoreCase); return false; } private static bool TryGetArray(object? value, out IReadOnlyList values) { var normalized = NormalizeConfigValue(value); if (normalized is IReadOnlyList readonlyValues) { values = readonlyValues; return true; } if (normalized is List listValues) { values = listValues; return true; } values = []; return false; } private static List ParseStringList(object? value) { if (TryGetArray(value, out var items)) { var result = new List(items.Count); foreach (var item in items) { var s = NormalizeConfigValue(item) as string; if (!string.IsNullOrEmpty(s)) result.Add(s); } return result; } if (NormalizeConfigValue(value) is string sValue) return [sValue]; return []; } private static object? NormalizeConfigValue(object? value) { if (value is not JsonElement element) return value; return element.ValueKind switch { JsonValueKind.Object => element.EnumerateObject() .ToDictionary(p => p.Name, p => NormalizeConfigValue(p.Value), StringComparer.OrdinalIgnoreCase), JsonValueKind.Array => element.EnumerateArray() .Select(item => NormalizeConfigValue(item)) .ToList(), JsonValueKind.String => element.GetString(), JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, _ => element.GetRawText(), }; } private static bool TryConvertToLong(object? value, out long converted) { switch (NormalizeConfigValue(value)) { case long longValue: converted = longValue; return true; case int intValue: converted = intValue; return true; case short shortValue: converted = shortValue; return true; case byte byteValue: converted = byteValue; return true; case double doubleValue when doubleValue >= long.MinValue && doubleValue <= long.MaxValue: converted = checked((long)doubleValue); return true; case float floatValue when floatValue >= long.MinValue && floatValue <= long.MaxValue: converted = checked((long)floatValue); return true; case string s when long.TryParse(s, out var parsed): converted = parsed; return true; default: converted = 0; return false; } } private static bool TryConvertToDouble(object? value, out double converted) { switch (NormalizeConfigValue(value)) { case double d: converted = d; return true; case float f: converted = f; return true; case long l: converted = l; return true; case int i: converted = i; return true; case string s when double.TryParse(s, out var parsed): converted = parsed; return true; default: converted = 0; return false; } } private static bool TryConvertToBool(object? value, out bool converted) { switch (NormalizeConfigValue(value)) { case bool b: converted = b; return true; case string s when bool.TryParse(s, out var parsed): converted = parsed; return true; default: converted = false; return false; } } private static void MergeRoutes(ServerOptions opts, ServerOptions flagOpts) { var routeUrls = RoutesFromStr(flagOpts.RoutesStr); if (routeUrls.Count == 0) return; opts.Routes = routeUrls; opts.RoutesStr = flagOpts.RoutesStr; } internal static void TrackExplicitVal(Dictionary pm, string name, bool val) => pm[name] = val; private static ClusterOpts CloneClusterOpts(ClusterOpts src) => new() { Name = src.Name, Host = src.Host, Port = src.Port, Username = src.Username, Password = src.Password, AuthTimeout = src.AuthTimeout, TlsTimeout = src.TlsTimeout, TlsConfig = src.TlsConfig, TlsMap = src.TlsMap, TlsCheckKnownUrls = src.TlsCheckKnownUrls, TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null, TlsHandshakeFirst = src.TlsHandshakeFirst, TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback, ListenStr = src.ListenStr, Advertise = src.Advertise, NoAdvertise = src.NoAdvertise, ConnectRetries = src.ConnectRetries, ConnectBackoff = src.ConnectBackoff, PoolSize = src.PoolSize, PinnedAccounts = [.. src.PinnedAccounts], Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] }, PingInterval = src.PingInterval, MaxPingsOut = src.MaxPingsOut, WriteDeadline = src.WriteDeadline, WriteTimeout = src.WriteTimeout, }; private static GatewayOpts CloneGatewayOpts(GatewayOpts src) => new() { Name = src.Name, Host = src.Host, Port = src.Port, Username = src.Username, Password = src.Password, AuthTimeout = src.AuthTimeout, TlsConfig = src.TlsConfig, TlsTimeout = src.TlsTimeout, TlsMap = src.TlsMap, TlsCheckKnownUrls = src.TlsCheckKnownUrls, TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null, Advertise = src.Advertise, ConnectRetries = src.ConnectRetries, ConnectBackoff = src.ConnectBackoff, Gateways = src.Gateways.Select(g => new RemoteGatewayOpts { Name = g.Name, TlsConfig = g.TlsConfig, TlsTimeout = g.TlsTimeout, Urls = g.Urls.Select(u => new Uri(u.ToString())).ToList(), }).ToList(), RejectUnknown = src.RejectUnknown, WriteDeadline = src.WriteDeadline, WriteTimeout = src.WriteTimeout, }; private static LeafNodeOpts CloneLeafNodeOpts(LeafNodeOpts src) => new() { Host = src.Host, Port = src.Port, Username = src.Username, Password = src.Password, ProxyRequired = src.ProxyRequired, Nkey = src.Nkey, Account = src.Account, AuthTimeout = src.AuthTimeout, TlsConfig = src.TlsConfig, TlsTimeout = src.TlsTimeout, TlsMap = src.TlsMap, TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null, TlsHandshakeFirst = src.TlsHandshakeFirst, TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback, Advertise = src.Advertise, NoAdvertise = src.NoAdvertise, ReconnectInterval = src.ReconnectInterval, WriteDeadline = src.WriteDeadline, WriteTimeout = src.WriteTimeout, Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] }, Remotes = src.Remotes.Select(r => new RemoteLeafOpts { LocalAccount = r.LocalAccount, NoRandomize = r.NoRandomize, Urls = r.Urls.Select(u => new Uri(u.ToString())).ToList(), Credentials = r.Credentials, Nkey = r.Nkey, Tls = r.Tls, TlsTimeout = r.TlsTimeout, TlsHandshakeFirst = r.TlsHandshakeFirst, Hub = r.Hub, DenyImports = [.. r.DenyImports], DenyExports = [.. r.DenyExports], FirstInfoTimeout = r.FirstInfoTimeout, Compression = new CompressionOpts { Mode = r.Compression.Mode, RttThresholds = [.. r.Compression.RttThresholds] }, JetStreamClusterMigrate = r.JetStreamClusterMigrate, JetStreamClusterMigrateDelay = r.JetStreamClusterMigrateDelay, LocalIsolation = r.LocalIsolation, RequestIsolation = r.RequestIsolation, Disabled = r.Disabled, }).ToList(), MinVersion = src.MinVersion, IsolateLeafnodeInterest = src.IsolateLeafnodeInterest, }; private static WebsocketOpts CloneWebsocketOpts(WebsocketOpts src) => new() { Host = src.Host, Port = src.Port, Advertise = src.Advertise, NoAuthUser = src.NoAuthUser, JwtCookie = src.JwtCookie, UsernameCookie = src.UsernameCookie, PasswordCookie = src.PasswordCookie, TokenCookie = src.TokenCookie, Username = src.Username, Password = src.Password, Token = src.Token, AuthTimeout = src.AuthTimeout, NoTls = src.NoTls, TlsConfig = src.TlsConfig, TlsMap = src.TlsMap, TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null, SameOrigin = src.SameOrigin, AllowedOrigins = [.. src.AllowedOrigins], Compression = src.Compression, HandshakeTimeout = src.HandshakeTimeout, PingInterval = src.PingInterval, Headers = new Dictionary(src.Headers), }; private static MqttOpts CloneMqttOpts(MqttOpts src) => new() { Host = src.Host, Port = src.Port, NoAuthUser = src.NoAuthUser, Username = src.Username, Password = src.Password, Token = src.Token, JsDomain = src.JsDomain, StreamReplicas = src.StreamReplicas, ConsumerReplicas = src.ConsumerReplicas, ConsumerMemoryStorage = src.ConsumerMemoryStorage, ConsumerInactiveThreshold = src.ConsumerInactiveThreshold, AuthTimeout = src.AuthTimeout, TlsConfig = src.TlsConfig, TlsMap = src.TlsMap, TlsTimeout = src.TlsTimeout, TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null, AckWait = src.AckWait, JsApiTimeout = src.JsApiTimeout, MaxAckPending = src.MaxAckPending, }; }