// 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.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; using ZB.MOM.NatsNet.Server.Config; using ZB.MOM.NatsNet.Server.Internal.DataStructures; 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); /// /// 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. /// 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(), }; } // ------------------------------------------------------------------------- // Batch 6: opts.go package-level parse/config helpers (F3) // ------------------------------------------------------------------------- /// /// Returns true for reserved account names. /// Mirrors isReservedAccount in opts.go. /// public static bool IsReservedAccount(string name) => string.Equals(name, ServerConstants.DefaultGlobalAccount, StringComparison.Ordinal); /// /// Parsed account export entry used during account-config parsing. /// public sealed class AccountExportConfig { public Account? Account { get; set; } public string Subject { get; set; } = string.Empty; public List AccountNames { get; set; } = []; public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton; public object? Latency { get; set; } public TimeSpan ResponseThreshold { get; set; } public uint AccountTokenPosition { get; set; } public bool AllowTrace { get; set; } } /// /// Parsed account stream-import entry used during account-config parsing. /// public sealed class AccountImportStreamConfig { public Account? Account { get; set; } public string AccountName { get; set; } = string.Empty; public string Subject { get; set; } = string.Empty; public string Prefix { get; set; } = string.Empty; public string To { get; set; } = string.Empty; public bool AllowTrace { get; set; } } /// /// Parsed account service-import entry used during account-config parsing. /// public sealed class AccountImportServiceConfig { public Account? Account { get; set; } public string AccountName { get; set; } = string.Empty; public string Subject { get; set; } = string.Empty; public string To { get; set; } = string.Empty; public bool Share { get; set; } } /// /// Parsed authorization block used for top-level/config-subtree authorization parsing. /// public sealed class ParsedAuthorizationBlock { public string User { get; set; } = string.Empty; public string Pass { get; set; } = string.Empty; public string Token { get; set; } = string.Empty; public double TimeoutSeconds { get; set; } public bool ProxyRequired { get; set; } public List Users { get; set; } = []; public List Nkeys { get; set; } = []; public Permissions? DefaultPermissions { get; set; } public AuthCalloutOpts? Callout { get; set; } } /// /// Parses a weighted account mapping destination entry. /// Mirrors parseAccountMapDest in opts.go. /// public static MapDest? ParseAccountMapDest(object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { errors?.Add(new InvalidOperationException("Expected an entry for the mapping destination")); return null; } var mdest = new MapDest(); var sawWeight = false; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "dest": case "destination": if (entry is string subject) { mdest.Subject = subject; } else { errors?.Add(new InvalidOperationException("mapping destination must be a string")); return null; } break; case "weight": { long parsedWeight; switch (entry) { case string weightString: { var normalizedWeight = weightString.Trim().TrimEnd('%'); if (!long.TryParse(normalizedWeight, out parsedWeight)) { errors?.Add(new InvalidOperationException( $"Invalid weight \"{weightString}\" for mapping destination")); return null; } break; } default: if (!TryConvertToLong(entry, out parsedWeight)) { errors?.Add(new InvalidOperationException( $"Unknown entry type for weight \"{entry?.GetType().Name ?? "null"}\"")); return null; } break; } if (parsedWeight is > 100 or < 0) { errors?.Add(new InvalidOperationException( $"Invalid weight {parsedWeight} for mapping destination")); return null; } mdest.Weight = (byte)parsedWeight; sawWeight = true; break; } case "cluster": mdest.Cluster = entry as string ?? string.Empty; break; default: errors?.Add(new InvalidOperationException( $"Unknown field \"{rawKey}\" for mapping destination")); return null; } } if (!sawWeight) { errors?.Add(new InvalidOperationException( $"Missing weight for mapping destination \"{mdest.Subject}\"")); return null; } return mdest; } /// /// Parses account subject mappings. /// Mirrors parseAccountMappings in opts.go. /// public static Exception? ParseAccountMappings( object? value, Account account, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(account); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected account mappings map, got {value?.GetType().Name ?? "null"}"); foreach (var (subject, rawDestination) in map) { if (!SubscriptionIndex.IsValidSubject(subject)) { errors?.Add(new InvalidOperationException($"Subject \"{subject}\" is not a valid subject")); continue; } var destination = NormalizeConfigValue(rawDestination); switch (destination) { case string mappedSubject: { var mapError = account.AddMapping(subject, mappedSubject); if (mapError != null) { errors?.Add(new InvalidOperationException( $"Error adding mapping for \"{subject}\" to \"{mappedSubject}\": {mapError.Message}", mapError)); } break; } default: if (TryGetArray(destination, out var destinationArray)) { var destinations = new List(destinationArray.Count); foreach (var entry in destinationArray) { var parsedDestination = ParseAccountMapDest(entry, errors); if (parsedDestination != null) destinations.Add(parsedDestination); } if (destinations.Count == 0) break; var mapError = account.AddWeightedMappings(subject, [.. destinations]); if (mapError != null) { errors?.Add(new InvalidOperationException( $"Error adding mapping for \"{subject}\": {mapError.Message}", mapError)); } break; } if (TryGetMap(destination, out _)) { var parsedDestination = ParseAccountMapDest(destination, errors); if (parsedDestination == null) break; var mapError = account.AddWeightedMappings(subject, parsedDestination); if (mapError != null) { errors?.Add(new InvalidOperationException( $"Error adding mapping for \"{subject}\": {mapError.Message}", mapError)); } break; } errors?.Add(new InvalidOperationException( $"Unknown type {destination?.GetType().Name ?? "null"} for mapping destination")); break; } } return null; } /// /// Parses account-level connection/subscription/payload limits. /// Mirrors parseAccountLimits in opts.go. /// public static Exception? ParseAccountLimits( object? value, Account account, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(account); if (!TryGetMap(value, out var map)) return new InvalidOperationException( $"Expected account limits to be a map/struct, got {value?.GetType().Name ?? "null"}"); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); if (!TryConvertToLong(entry, out var numericValue)) { errors?.Add(new InvalidOperationException( $"Expected numeric value parsing account limit \"{rawKey}\"")); continue; } switch (key) { case "max_connections": case "max_conn": account.MaxConnections = checked((int)numericValue); break; case "max_subscriptions": case "max_subs": account.MaxSubscriptions = checked((int)numericValue); break; case "max_payload": case "max_pay": account.MaxPayload = checked((int)numericValue); break; case "max_leafnodes": case "max_leafs": account.MaxLeafNodes = checked((int)numericValue); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) { errors?.Add(new InvalidOperationException( $"Unknown field \"{rawKey}\" parsing account limits")); } break; } } return null; } /// /// Parses account message trace destination/sampling configuration. /// Mirrors parseAccountMsgTrace in opts.go. /// public static Exception? ParseAccountMsgTrace(object? value, string topKey, Account account) { ArgumentNullException.ThrowIfNull(account); static Exception? ProcessDestination(Account targetAccount, string key, object? rawValue) { if (rawValue is not string destination) { return new InvalidOperationException( $"Field \"{key}\" should be a string, got {rawValue?.GetType().Name ?? "null"}"); } if (!SubscriptionIndex.IsValidPublishSubject(destination)) return new InvalidOperationException($"Trace destination \"{destination}\" is not valid"); targetAccount.SetMessageTraceDestination(destination); return null; } static Exception? ProcessSampling(Account targetAccount, int sampling) { if (sampling is <= 0 or > 100) { return new InvalidOperationException( $"Trace destination sampling value {sampling} is invalid, needs to be [1..100]"); } targetAccount.SetMessageTraceSampling(sampling); return null; } var normalized = NormalizeConfigValue(value); switch (normalized) { case string: return ProcessDestination(account, topKey, normalized); default: if (!TryGetMap(normalized, out var map)) { return new InvalidOperationException( $"Expected account message trace \"{topKey}\" to be a string or map/struct, got {normalized?.GetType().Name ?? "null"}"); } foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "dest": { var destinationError = ProcessDestination(account, rawKey, entry); if (destinationError != null) return destinationError; break; } case "sampling": { int parsedSampling; switch (entry) { case string samplingString: { var normalizedSampling = samplingString.Trim().TrimEnd('%'); if (!int.TryParse(normalizedSampling, out parsedSampling)) { return new InvalidOperationException( $"Invalid trace destination sampling value \"{samplingString}\""); } break; } default: if (!TryConvertToLong(entry, out var longSampling)) { return new InvalidOperationException( $"Trace destination sampling field \"{rawKey}\" should be an integer or a percentage, got {entry?.GetType().Name ?? "null"}"); } parsedSampling = checked((int)longSampling); break; } var samplingError = ProcessSampling(account, parsedSampling); if (samplingError != null) return samplingError; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) { return new InvalidOperationException( $"Unknown field \"{rawKey}\" parsing account message trace map/struct \"{topKey}\""); } break; } } return null; } } /// /// Parses the top-level accounts block. /// Mirrors parseAccounts in opts.go. /// public static Exception? ParseAccounts( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); var pendingImportStreams = new List(); var pendingImportServices = new List(); var pendingExportStreams = new List(); var pendingExportServices = new List(); var normalized = NormalizeConfigValue(value); if (TryGetArray(normalized, out var accountArray)) { var seen = new HashSet(StringComparer.Ordinal); foreach (var accountEntry in accountArray) { var accountName = NormalizeConfigValue(accountEntry) as string ?? string.Empty; if (string.IsNullOrWhiteSpace(accountName)) { errors?.Add(new InvalidOperationException("Expected account name to be a string")); continue; } if (IsReservedAccount(accountName)) { errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account")); continue; } if (!seen.Add(accountName)) { errors?.Add(new InvalidOperationException($"Duplicate Account Entry: {accountName}")); continue; } options.Accounts.Add(Account.NewAccount(accountName)); } return null; } if (!TryGetMap(normalized, out var accountsMap)) { return new InvalidOperationException( $"Expected accounts to be an array or map, got {normalized?.GetType().Name ?? "null"}"); } options.Users ??= []; options.Nkeys ??= []; var identities = SetupUsersAndNKeysDuplicateCheckMap(options); foreach (var (accountName, accountValueRaw) in accountsMap) { var accountValue = NormalizeConfigValue(accountValueRaw); if (!TryGetMap(accountValue, out var accountMap)) { errors?.Add(new InvalidOperationException("Expected map entries for accounts")); continue; } if (IsReservedAccount(accountName)) { errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account")); continue; } var account = Account.NewAccount(accountName); options.Accounts.Add(account); var parsedUsers = new List(); var parsedNkeys = new List(); foreach (var (rawKey, rawValue) in accountMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "nkey": { var accountKey = entry as string ?? string.Empty; if (!IsLikelyPublicNkey(accountKey, 'A')) { errors?.Add(new InvalidOperationException( $"Not a valid public nkey for an account: \"{accountKey}\"")); break; } account.Nkey = accountKey; break; } case "imports": { var (importStreams, importServices, importError) = ParseAccountImports(entry, account, errors); if (importError != null) { errors?.Add(importError); break; } pendingImportStreams.AddRange(importStreams); pendingImportServices.AddRange(importServices); break; } case "exports": { var (exportStreams, exportServices, exportError) = ParseAccountExports(entry, account, errors); if (exportError != null) { errors?.Add(exportError); break; } pendingExportStreams.AddRange(exportStreams); pendingExportServices.AddRange(exportServices); break; } case "jetstream": { var jsError = ParseJetStreamForAccount(entry, account, errors); if (jsError != null) errors?.Add(jsError); break; } case "users": { var (accountNkeys, accountUsers, usersError) = ParseUsers(entry, errors); if (usersError != null) { errors?.Add(usersError); break; } parsedUsers = accountUsers; parsedNkeys = accountNkeys; break; } case "default_permissions": { var permissions = ParsePermissionsValue(entry, errors); if (permissions != null) account.DefaultPerms = permissions; break; } case "mappings": case "maps": { var mappingsError = ParseAccountMappings(entry, account, errors); if (mappingsError != null) errors?.Add(mappingsError); break; } case "limits": { var limitsError = ParseAccountLimits(entry, account, errors); if (limitsError != null) errors?.Add(limitsError); break; } case "msg_trace": case "trace_dest": { var traceError = ParseAccountMsgTrace(entry, key, account); if (traceError != null) { errors?.Add(traceError); break; } if (!string.IsNullOrEmpty(account.GetMessageTraceDestination()) && account.GetMessageTraceSampling() == 0) { account.SetMessageTraceSampling(100); } else if (account.GetMessageTraceSampling() > 0 && string.IsNullOrEmpty(account.GetMessageTraceDestination())) { warnings?.Add(new InvalidOperationException( "Trace destination sampling ignored since no destination was set")); account.SetMessageTraceSampling(0); } break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } if (parsedNkeys.Count > 0 || parsedUsers.Count > 0) { if (!string.IsNullOrEmpty(options.Username)) errors?.Add(new InvalidOperationException("Cannot have a single user/pass and accounts")); if (!string.IsNullOrEmpty(options.Authorization)) errors?.Add(new InvalidOperationException("Cannot have a token and accounts")); } ApplyDefaultPermissions(parsedUsers, parsedNkeys, account.DefaultPerms); foreach (var nkeyUser in parsedNkeys) { if (!identities.Add(nkeyUser.Nkey)) { errors?.Add(new InvalidOperationException($"Duplicate nkey \"{nkeyUser.Nkey}\" detected")); continue; } nkeyUser.Account = account; options.Nkeys.Add(nkeyUser); } foreach (var user in parsedUsers) { if (!identities.Add(user.Username)) { errors?.Add(new InvalidOperationException($"Duplicate user \"{user.Username}\" detected")); continue; } user.Account = account; options.Users.Add(user); } } if (errors is { Count: > 0 }) return null; var accountLookup = options.Accounts.ToDictionary(a => a.Name, StringComparer.Ordinal); foreach (var streamExport in pendingExportStreams) { if (streamExport.Account == null) continue; streamExport.Account.Exports.Streams ??= new Dictionary(StringComparer.Ordinal); var export = new StreamExport { AccountPosition = streamExport.AccountTokenPosition, }; if (streamExport.AccountNames.Count > 0) { export.Approved = new Dictionary(StringComparer.Ordinal); foreach (var accountName in streamExport.AccountNames) { if (!accountLookup.TryGetValue(accountName, out var approvedAccount)) { errors?.Add(new InvalidOperationException( $"\"{accountName}\" account not defined for stream export")); continue; } export.Approved[accountName] = approvedAccount; } } streamExport.Account.Exports.Streams[streamExport.Subject] = export; } foreach (var serviceExport in pendingExportServices) { if (serviceExport.Account == null) continue; serviceExport.Account.Exports.Services ??= new Dictionary(StringComparer.Ordinal); var export = new ServiceExportEntry { Account = serviceExport.Account, ResponseType = serviceExport.ResponseType, Latency = serviceExport.Latency as InternalServiceLatency, ResponseThreshold = serviceExport.ResponseThreshold, AllowTrace = serviceExport.AllowTrace, AccountPosition = serviceExport.AccountTokenPosition, }; if (serviceExport.AccountNames.Count > 0) { export.Approved = new Dictionary(StringComparer.Ordinal); foreach (var accountName in serviceExport.AccountNames) { if (!accountLookup.TryGetValue(accountName, out var approvedAccount)) { errors?.Add(new InvalidOperationException( $"\"{accountName}\" account not defined for service export")); continue; } export.Approved[accountName] = approvedAccount; } } serviceExport.Account.Exports.Services[serviceExport.Subject] = export; } foreach (var streamImport in pendingImportStreams) { if (streamImport.Account == null) continue; if (!accountLookup.TryGetValue(streamImport.AccountName, out var importedAccount)) { errors?.Add(new InvalidOperationException( $"\"{streamImport.AccountName}\" account not defined for stream import")); continue; } streamImport.Account.Imports.Streams ??= []; var targetSubject = !string.IsNullOrEmpty(streamImport.To) ? streamImport.To : !string.IsNullOrEmpty(streamImport.Prefix) ? streamImport.Prefix : streamImport.Subject; streamImport.Account.Imports.Streams.Add(new StreamImportEntry { Account = importedAccount, From = streamImport.Subject, To = targetSubject, AllowTrace = streamImport.AllowTrace, }); } foreach (var serviceImport in pendingImportServices) { if (serviceImport.Account == null) continue; if (!accountLookup.TryGetValue(serviceImport.AccountName, out var importedAccount)) { errors?.Add(new InvalidOperationException( $"\"{serviceImport.AccountName}\" account not defined for service import")); continue; } var targetSubject = string.IsNullOrEmpty(serviceImport.To) ? serviceImport.Subject : serviceImport.To; serviceImport.Account.Imports.Services ??= new Dictionary>(StringComparer.Ordinal); if (!serviceImport.Account.Imports.Services.TryGetValue(targetSubject, out var importsForSubject)) { importsForSubject = []; serviceImport.Account.Imports.Services[targetSubject] = importsForSubject; } importsForSubject.Add(new ServiceImportEntry { Account = importedAccount, From = serviceImport.Subject, To = targetSubject, Share = serviceImport.Share, }); } return null; } /// /// Parses account exports list. /// Mirrors parseAccountExports in opts.go. /// public static (List Streams, List Services, Exception? Error) ParseAccountExports( object? value, Account account, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(account); if (!TryGetArray(value, out var exportArray)) { return ([], [], new InvalidOperationException( $"Exports should be an array, got {value?.GetType().Name ?? "null"}")); } var streamExports = new List(); var serviceExports = new List(); foreach (var exportValue in exportArray) { var (stream, service, parseError) = ParseExportStreamOrService(exportValue, errors); if (parseError != null) { errors?.Add(parseError); continue; } if (stream != null) { stream.Account = account; streamExports.Add(stream); } if (service != null) { service.Account = account; serviceExports.Add(service); } } return (streamExports, serviceExports, null); } /// /// Parses account imports list. /// Mirrors parseAccountImports in opts.go. /// public static (List Streams, List Services, Exception? Error) ParseAccountImports( object? value, Account account, ICollection? errors = null) { ArgumentNullException.ThrowIfNull(account); if (!TryGetArray(value, out var importArray)) { return ([], [], new InvalidOperationException( $"Imports should be an array, got {value?.GetType().Name ?? "null"}")); } var streamImports = new List(); var serviceImports = new List(); var serviceSubjects = new Dictionary>(StringComparer.Ordinal); foreach (var importValue in importArray) { var (stream, service, parseError) = ParseImportStreamOrService(importValue, errors); if (parseError != null) { errors?.Add(parseError); continue; } if (service != null) { var targetSubject = string.IsNullOrEmpty(service.To) ? service.Subject : service.To; if (!serviceSubjects.TryGetValue(targetSubject, out var seenAccounts)) { seenAccounts = new HashSet(StringComparer.Ordinal); serviceSubjects[targetSubject] = seenAccounts; } if (!seenAccounts.Add(service.AccountName)) { errors?.Add(new InvalidOperationException( $"Duplicate service import subject \"{targetSubject}\", previously used in import for account \"{service.AccountName}\"")); continue; } service.Account = account; serviceImports.Add(service); } if (stream != null) { stream.Account = account; streamImports.Add(stream); } } return (streamImports, serviceImports, null); } /// /// Parses account descriptor maps used inside import entries. /// Mirrors parseAccount in opts.go. /// public static (string AccountName, string Subject, Exception? Error) ParseAccount( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (string.Empty, string.Empty, new InvalidOperationException( $"Expected account descriptor map, got {value?.GetType().Name ?? "null"}")); } var accountName = string.Empty; var subject = string.Empty; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "account": accountName = entry as string ?? string.Empty; break; case "subject": subject = entry as string ?? string.Empty; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return (accountName, subject, null); } /// /// Parses a single export entry (stream or service). /// Mirrors parseExportStreamOrService in opts.go. /// public static (AccountExportConfig? Stream, AccountExportConfig? Service, Exception? Error) ParseExportStreamOrService( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, null, new InvalidOperationException( $"Export items should be a map with type entry, got {value?.GetType().Name ?? "null"}")); } AccountExportConfig? stream = null; AccountExportConfig? service = null; var accountNames = new List(); var responseType = ServiceRespType.Singleton; var responseTypeSeen = false; var responseThreshold = TimeSpan.Zero; var thresholdSeen = false; object? latency = null; uint accountTokenPosition = 0; var allowTraceSeen = false; var allowTrace = false; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "stream": { if (service != null) { errors?.Add(new InvalidOperationException( $"Detected stream \"{entry}\" but already saw a service")); break; } var subject = entry as string; if (string.IsNullOrEmpty(subject)) { errors?.Add(new InvalidOperationException( $"Expected stream name to be string, got {entry?.GetType().Name ?? "null"}")); break; } stream = new AccountExportConfig { Subject = subject, AccountNames = [.. accountNames], }; break; } case "service": { if (stream != null) { errors?.Add(new InvalidOperationException( $"Detected service \"{entry}\" but already saw a stream")); break; } var subject = entry as string; if (string.IsNullOrEmpty(subject)) { errors?.Add(new InvalidOperationException( $"Expected service name to be string, got {entry?.GetType().Name ?? "null"}")); break; } service = new AccountExportConfig { Subject = subject, AccountNames = [.. accountNames], ResponseType = responseType, Latency = latency, ResponseThreshold = responseThreshold, AllowTrace = allowTraceSeen && allowTrace, }; break; } case "response": case "response_type": { if (responseTypeSeen) { errors?.Add(new InvalidOperationException("Duplicate response type definition")); break; } responseTypeSeen = true; var responseString = (entry as string ?? string.Empty).ToLowerInvariant(); var responseTypeParsed = responseString switch { "single" or "singleton" => ServiceRespType.Singleton, "stream" => ServiceRespType.Streamed, "chunk" or "chunked" => ServiceRespType.Chunked, _ => (ServiceRespType?)null, }; if (responseTypeParsed == null) { errors?.Add(new InvalidOperationException($"Unknown response type: \"{entry}\"")); break; } responseType = responseTypeParsed.Value; if (stream != null) errors?.Add(new InvalidOperationException("Detected response directive on non-service")); if (service != null) service.ResponseType = responseType; break; } case "threshold": case "response_threshold": case "response_max_time": case "response_time": { if (thresholdSeen) { errors?.Add(new InvalidOperationException("Duplicate response threshold detected")); break; } thresholdSeen = true; responseThreshold = ParseDuration(rawKey, entry, errors, warnings: null); if (stream != null) errors?.Add(new InvalidOperationException("Detected response directive on non-service")); if (service != null) service.ResponseThreshold = responseThreshold; break; } case "accounts": accountNames = ParseStringList(entry); if (stream != null) stream.AccountNames = [.. accountNames]; if (service != null) service.AccountNames = [.. accountNames]; break; case "latency": { var (parsedLatency, latencyError) = ParseServiceLatency(rawKey, entry); if (latencyError != null) { errors?.Add(latencyError); break; } latency = parsedLatency; if (stream != null) { errors?.Add(new InvalidOperationException("Detected latency directive on non-service")); break; } if (service != null) service.Latency = latency; break; } case "account_token_position": if (TryConvertToLong(entry, out var tokenPosition)) accountTokenPosition = checked((uint)tokenPosition); break; case "allow_trace": allowTraceSeen = true; if (TryConvertToBool(entry, out var allowTraceValue)) allowTrace = allowTraceValue; if (stream != null) { errors?.Add(new InvalidOperationException("Detected allow_trace directive on non-service")); break; } if (service != null) service.AllowTrace = allowTrace; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } if (stream != null) stream.AccountTokenPosition = accountTokenPosition; if (service != null) service.AccountTokenPosition = accountTokenPosition; return (stream, service, null); } /// /// Parses service-export latency configuration. /// Mirrors parseServiceLatency in opts.go. /// public static (object? Latency, Exception? Error) ParseServiceLatency(string rootField, object? value) { if (NormalizeConfigValue(value) is string subject) { return (new InternalServiceLatency { Subject = subject, Sampling = ServerConstants.DefaultServiceLatencySampling, }, null); } if (!TryGetMap(value, out var latencyMap)) { return (null, new InvalidOperationException( $"Expected latency entry to be a map/struct or string, got {value?.GetType().Name ?? "null"}")); } var sampling = ServerConstants.DefaultServiceLatencySampling; if (latencyMap.TryGetValue("sampling", out var samplingRaw)) { var samplingValue = NormalizeConfigValue(samplingRaw); var headerMode = false; switch (samplingValue) { case long longSampling: sampling = checked((int)longSampling); break; case string samplingString: { if (samplingString.Trim().Equals("headers", StringComparison.OrdinalIgnoreCase)) { headerMode = true; sampling = 0; break; } var normalizedSampling = samplingString.Trim().TrimEnd('%'); if (!int.TryParse(normalizedSampling, out sampling)) { return (null, new InvalidOperationException( $"Failed to parse latency sample \"{samplingString}\"")); } break; } default: return (null, new InvalidOperationException( $"Expected latency sample to be a string or integer, got {samplingValue?.GetType().Name ?? "null"}")); } if (!headerMode && (sampling < 1 || sampling > 100)) return (null, new InvalidOperationException("sampling value should be in range [1..100]")); } if (!latencyMap.TryGetValue("subject", out var subjectRaw)) { return (null, new InvalidOperationException( $"Latency subject required in \"{rootField}\", but missing")); } var latencySubject = NormalizeConfigValue(subjectRaw) as string; if (string.IsNullOrWhiteSpace(latencySubject)) { return (null, new InvalidOperationException( $"Expected latency subject to be a string, got {subjectRaw?.GetType().Name ?? "null"}")); } return (new InternalServiceLatency { Sampling = sampling, Subject = latencySubject, }, null); } /// /// Parses a single import entry (stream or service). /// Mirrors parseImportStreamOrService in opts.go. /// public static (AccountImportStreamConfig? Stream, AccountImportServiceConfig? Service, Exception? Error) ParseImportStreamOrService( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, null, new InvalidOperationException( $"Import items should be a map with type entry, got {value?.GetType().Name ?? "null"}")); } AccountImportStreamConfig? stream = null; AccountImportServiceConfig? service = null; var prefix = string.Empty; var to = string.Empty; var share = false; var allowTraceSeen = false; var allowTrace = false; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "stream": { if (service != null) { errors?.Add(new InvalidOperationException("Detected stream but already saw a service")); break; } var (accountName, subject, parseError) = ParseAccount(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject)) { errors?.Add(new InvalidOperationException("Expect an account name and a subject")); break; } stream = new AccountImportStreamConfig { AccountName = accountName, Subject = subject, Prefix = prefix, To = to, AllowTrace = allowTraceSeen && allowTrace, }; break; } case "service": { if (stream != null) { errors?.Add(new InvalidOperationException("Detected service but already saw a stream")); break; } if (allowTraceSeen) { errors?.Add(new InvalidOperationException( "Detected allow_trace directive on a non-stream")); break; } var (accountName, subject, parseError) = ParseAccount(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject)) { errors?.Add(new InvalidOperationException("Expect an account name and a subject")); break; } service = new AccountImportServiceConfig { AccountName = accountName, Subject = subject, To = string.IsNullOrEmpty(to) ? subject : to, Share = share, }; break; } case "prefix": prefix = entry as string ?? string.Empty; if (stream != null) stream.Prefix = prefix; break; case "to": to = entry as string ?? string.Empty; if (service != null) service.To = to; if (stream != null) { stream.To = to; if (!string.IsNullOrEmpty(stream.Prefix)) errors?.Add(new InvalidOperationException( "Stream import cannot have both 'prefix' and 'to' properties")); } break; case "share": if (TryConvertToBool(entry, out var shareValue)) share = shareValue; if (service != null) service.Share = share; break; case "allow_trace": if (service != null) { errors?.Add(new InvalidOperationException( "Detected allow_trace directive on a non-stream")); break; } allowTraceSeen = true; if (TryConvertToBool(entry, out var allowTraceValue)) allowTrace = allowTraceValue; if (stream != null) stream.AllowTrace = allowTrace; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return (stream, service, null); } /// /// Applies default permissions to users and nkeys that do not have explicit permissions. /// Mirrors applyDefaultPermissions in opts.go. /// public static void ApplyDefaultPermissions( IReadOnlyList? users, IReadOnlyList? nkeys, Permissions? defaultPermissions) { if (defaultPermissions == null) return; if (users != null) { foreach (var user in users) { user.Permissions ??= defaultPermissions; } } if (nkeys != null) { foreach (var user in nkeys) { user.Permissions ??= defaultPermissions; } } } /// /// Parses an authorization block. /// Mirrors parseAuthorization in opts.go. /// public static (ParsedAuthorizationBlock? Authorization, Exception? Error) ParseAuthorization( object? value, ICollection? errors = null, ICollection? warnings = null) { if (!TryGetMap(value, out var map)) return (null, new InvalidOperationException("authorization should be a map")); var auth = new ParsedAuthorizationBlock(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "user": case "username": auth.User = entry as string ?? string.Empty; break; case "pass": case "password": auth.Pass = entry as string ?? string.Empty; break; case "token": auth.Token = entry as string ?? string.Empty; break; case "timeout": { double timeoutSeconds; switch (entry) { case long longTimeout: timeoutSeconds = longTimeout; break; case double doubleTimeout: timeoutSeconds = doubleTimeout; break; case string duration: timeoutSeconds = ParseDuration("timeout", duration, errors, warnings).TotalSeconds; break; default: return (null, new InvalidOperationException( "error parsing authorization config, 'timeout' wrong type")); } auth.TimeoutSeconds = timeoutSeconds; if (timeoutSeconds > TimeSpan.FromSeconds(60).TotalSeconds) { warnings?.Add(new InvalidOperationException( $"timeout of {entry} ({timeoutSeconds} seconds) is high, consider keeping it under 60 seconds")); } break; } case "users": { var (nkeys, users, parseError) = ParseUsers(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } auth.Users = users; auth.Nkeys = nkeys; break; } case "default_permission": case "default_permissions": case "permissions": auth.DefaultPermissions = ParsePermissionsValue(entry, errors); break; case "auth_callout": case "auth_hook": { var (callout, parseError) = ParseAuthCallout(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } auth.Callout = callout; 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; } } ApplyDefaultPermissions(auth.Users, auth.Nkeys, auth.DefaultPermissions); return (auth, null); } /// /// Parses users/nkeys block. /// Mirrors parseUsers in opts.go. /// public static (List Nkeys, List Users, Exception? Error) ParseUsers( object? value, ICollection? errors = null) { if (!TryGetArray(value, out var usersArray)) { return ([], [], new InvalidOperationException( $"Expected users field to be an array, got {value?.GetType().Name ?? "null"}")); } var users = new List(); var nkeys = new List(); foreach (var userRaw in usersArray) { if (!TryGetMap(userRaw, out var userMap)) { errors?.Add(new InvalidOperationException( $"Expected user entry to be a map/struct, got {userRaw?.GetType().Name ?? "null"}")); continue; } var user = new User(); var nkey = new NkeyUser(); Permissions? permissions = null; foreach (var (rawKey, rawValue) in userMap) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "nkey": nkey.Nkey = entry as string ?? string.Empty; break; 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 "permission": case "permissions": case "authorization": permissions = ParsePermissionsValue(entry, errors); break; case "allowed_connection_types": case "connection_types": case "clients": { var connectionTypes = ParseAllowedConnectionTypes(entry, errors); nkey.AllowedConnectionTypes = connectionTypes; user.AllowedConnectionTypes = connectionTypes; break; } case "proxy_required": if (TryConvertToBool(entry, out var proxyRequired)) { nkey.ProxyRequired = proxyRequired; user.ProxyRequired = proxyRequired; } break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } if (permissions != null) { if (!string.IsNullOrEmpty(nkey.Nkey)) nkey.Permissions = permissions; else user.Permissions = permissions; } if (string.IsNullOrEmpty(nkey.Nkey) && string.IsNullOrEmpty(user.Username)) { return ([], [], new InvalidOperationException("User entry requires a user")); } if (!string.IsNullOrEmpty(nkey.Nkey)) { if (!IsLikelyPublicNkey(nkey.Nkey, 'U')) return ([], [], new InvalidOperationException("Not a valid public nkey for a user")); if (!string.IsNullOrEmpty(user.Username) || !string.IsNullOrEmpty(user.Password)) { return ([], [], new InvalidOperationException( "Nkey users do not take usernames or passwords")); } nkeys.Add(nkey); } else { users.Add(user); } } return (nkeys, users, null); } /// /// Parses allowed connection types map. /// Mirrors parseAllowedConnectionTypes in opts.go. /// public static HashSet? ParseAllowedConnectionTypes( object? value, ICollection? errors = null) { if (!TryGetArray(value, out var values)) { errors?.Add(new InvalidOperationException( $"Expected allowed connection types to be an array, got {value?.GetType().Name ?? "null"}")); return null; } var result = new HashSet(StringComparer.Ordinal); foreach (var item in values) { var connectionType = NormalizeConfigValue(item) as string; if (string.IsNullOrEmpty(connectionType)) { errors?.Add(new InvalidOperationException("allowed connection type entries must be strings")); continue; } result.Add(connectionType); } var validateError = AuthHandler.ValidateAllowedConnectionTypes(result); if (validateError != null) errors?.Add(validateError); return result; } /// /// Parses authorization callout configuration. /// Mirrors parseAuthCallout in opts.go. /// public static (AuthCalloutOpts? Callout, Exception? Error) ParseAuthCallout( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, new InvalidOperationException( $"Expected authorization callout to be a map/struct, got {value?.GetType().Name ?? "null"}")); } var callout = new AuthCalloutOpts(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "issuer": callout.Issuer = entry as string ?? string.Empty; if (!IsLikelyPublicNkey(callout.Issuer, 'A')) { return (null, new InvalidOperationException( $"Expected callout user to be a valid public account nkey, got \"{callout.Issuer}\"")); } break; case "account": case "acc": callout.Account = entry as string ?? string.Empty; break; case "auth_users": case "users": if (!TryGetArray(entry, out var authUsersArray)) { return (null, new InvalidOperationException( $"Expected auth_users field to be an array, got {entry?.GetType().Name ?? "null"}")); } foreach (var userValue in authUsersArray) { var authUser = NormalizeConfigValue(userValue) as string; if (!string.IsNullOrEmpty(authUser)) callout.AuthUsers.Add(authUser); } break; case "xkey": case "key": callout.XKey = entry as string ?? string.Empty; if (!string.IsNullOrEmpty(callout.XKey) && !IsLikelyPublicNkey(callout.XKey, 'X')) { return (null, new InvalidOperationException( $"Expected callout xkey to be a valid public xkey, got \"{callout.XKey}\"")); } break; case "allowed_accounts": if (!TryGetArray(entry, out var allowedAccountsArray)) { return (null, new InvalidOperationException( $"Expected allowed accounts field to be an array, got {entry?.GetType().Name ?? "null"}")); } foreach (var accountValue in allowedAccountsArray) { var accountName = NormalizeConfigValue(accountValue) as string; if (!string.IsNullOrEmpty(accountName)) callout.AllowedAccounts.Add(accountName); } break; default: if (!ConfigFlags.AllowUnknownTopLevelField) { errors?.Add(new InvalidOperationException( $"Unknown field \"{rawKey}\" parsing authorization callout")); } break; } } if (string.IsNullOrEmpty(callout.Account)) callout.Account = ServerConstants.DefaultGlobalAccount; if (string.IsNullOrEmpty(callout.Issuer)) return (null, new InvalidOperationException("Authorization callouts require an issuer to be specified")); if (callout.AuthUsers.Count == 0) return (null, new InvalidOperationException("Authorization callouts require authorized users to be specified")); return (callout, null); } // ------------------------------------------------------------------------- // Batch 6: opts.go package-level parse/config helpers (F4) // ------------------------------------------------------------------------- /// /// Parses user permission blocks. /// Mirrors parseUserPermissions in opts.go. /// public static (Permissions? Permissions, Exception? Error) ParseUserPermissions( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, new InvalidOperationException( $"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}")); } var permissions = new Permissions(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "pub": case "publish": case "import": { var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } permissions.Publish = subjectPermissions; break; } case "sub": case "subscribe": case "export": { var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } permissions.Subscribe = subjectPermissions; break; } case "publish_allow_responses": case "allow_responses": if (TryConvertToBool(entry, out var responsesEnabled)) { if (responsesEnabled) { permissions.Response = new ResponsePermission { MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, Expires = ServerConstants.DefaultAllowResponseExpiration, }; } } else { permissions.Response = ParseAllowResponses(entry, errors); } if (permissions.Response != null) { permissions.Publish ??= new SubjectPermission(); permissions.Publish.Allow ??= []; } break; default: if (!ConfigFlags.AllowUnknownTopLevelField) { errors?.Add(new InvalidOperationException( $"Unknown field \"{rawKey}\" parsing permissions")); } break; } } AuthHandler.ValidateResponsePermissions(permissions); return (permissions, null); } /// /// Parses variable-style publish/subscribe permission values. /// Mirrors parseVariablePermissions in opts.go. /// public static (SubjectPermission? Permissions, Exception? Error) ParseVariablePermissions( object? value, ICollection? errors = null) { return TryGetMap(value, out _) ? ParseSubjectPermission(value, errors) : ParseOldPermissionStyle(value, errors); } /// /// Parses single or array subject values used in permissions. /// Mirrors parsePermSubjects in opts.go. /// public static (List? Subjects, Exception? Error) ParsePermSubjects( object? value, ICollection? errors = null) { var normalized = NormalizeConfigValue(value); var subjects = new List(); switch (normalized) { case string single: subjects.Add(single); break; case IEnumerable array: foreach (var entry in array) { var subject = NormalizeConfigValue(entry) as string; if (subject == null) { return (null, new InvalidOperationException( "Subject in permissions array cannot be cast to string")); } subjects.Add(subject); } break; default: return (null, new InvalidOperationException( $"Expected subject permissions to be a subject, or array of subjects, got {normalized?.GetType().Name ?? "null"}")); } var validateError = CheckPermSubjectArray(subjects); if (validateError != null) return (null, validateError); return (subjects, null); } /// /// Parses response permissions. /// Mirrors parseAllowResponses in opts.go. /// public static ResponsePermission? ParseAllowResponses( object? value, ICollection? errors = null) { var normalized = NormalizeConfigValue(value); if (TryConvertToBool(normalized, out var enabled)) { return enabled ? new ResponsePermission { MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, Expires = ServerConstants.DefaultAllowResponseExpiration, } : null; } if (!TryGetMap(normalized, out var map)) { errors?.Add(new InvalidOperationException( "error parsing response permissions, expected a boolean or a map")); return null; } var responsePermission = new ResponsePermission { MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, Expires = ServerConstants.DefaultAllowResponseExpiration, }; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "max": case "max_msgs": case "max_messages": case "max_responses": if (!TryConvertToLong(entry, out var maxMessages)) { errors?.Add(new InvalidOperationException("error parsing max responses")); break; } if (maxMessages != 0) responsePermission.MaxMsgs = checked((int)maxMessages); break; case "expires": case "expiration": case "ttl": { var ttl = ParseDuration("expires", entry, errors, warnings: null); if (ttl != TimeSpan.Zero) responsePermission.Expires = ttl; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) { errors?.Add(new InvalidOperationException( $"Unknown field \"{rawKey}\" parsing permissions")); } break; } } return responsePermission; } /// /// Parses old-style allow-only permission syntax. /// Mirrors parseOldPermissionStyle in opts.go. /// public static (SubjectPermission? Permissions, Exception? Error) ParseOldPermissionStyle( object? value, ICollection? errors = null) { var (subjects, parseError) = ParsePermSubjects(value, errors); if (parseError != null) return (null, parseError); return (new SubjectPermission { Allow = subjects }, null); } /// /// Parses new-style allow/deny subject permissions. /// Mirrors parseSubjectPermission in opts.go. /// public static (SubjectPermission? Permissions, Exception? Error) ParseSubjectPermission( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, new InvalidOperationException( $"Expected subject permission map, got {value?.GetType().Name ?? "null"}")); } if (map.Count == 0) return (null, null); var permission = new SubjectPermission(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "allow": { var (subjects, parseError) = ParsePermSubjects(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } permission.Allow = subjects; break; } case "deny": { var (subjects, parseError) = ParsePermSubjects(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } permission.Deny = subjects; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) { errors?.Add(new InvalidOperationException( $"Unknown field name \"{rawKey}\" parsing subject permissions, only 'allow' or 'deny' are permitted")); } break; } } return (permission, null); } /// /// Validates permission subjects. /// Mirrors checkPermSubjectArray in opts.go. /// public static Exception? CheckPermSubjectArray(IReadOnlyList subjects) { foreach (var subject in subjects) { if (SubscriptionIndex.IsValidSubject(subject)) continue; var parts = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) return new InvalidOperationException($"subject \"{subject}\" is not a valid subject"); if (!SubscriptionIndex.IsValidSubject(parts[0])) return new InvalidOperationException($"subject \"{parts[0]}\" is not a valid subject"); } return null; } /// /// Prints TLS help text. /// Mirrors PrintTLSHelpAndDie in opts.go. /// public static void PrintTLSHelpAndDie() { Console.WriteLine("TLS configuration help"); Console.WriteLine("Available cipher suites include:"); foreach (var cipher in CipherSuites.CipherMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) Console.WriteLine($" {cipher}"); Console.WriteLine(); Console.WriteLine("Available curve preferences include:"); foreach (var curve in CipherSuites.CurvePreferenceMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) Console.WriteLine($" {curve}"); } /// /// Parses a configured cipher-suite name. /// Mirrors parseCipher in opts.go. /// public static (TlsCipherSuite? Cipher, Exception? Error) ParseCipher(string cipherName) { if (!CipherSuites.CipherMap.TryGetValue(cipherName, out var cipher)) return (null, new InvalidOperationException($"unrecognized cipher {cipherName}")); return (cipher, null); } /// /// Parses a configured curve-preference name. /// Mirrors parseCurvePreferences in opts.go. /// public static (SslApplicationProtocol? Curve, Exception? Error) ParseCurvePreferences(string curveName) { if (!CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve)) return (null, new InvalidOperationException($"unrecognized curve preference {curveName}")); return (curve, null); } /// /// Parses minimum TLS version config value. /// Mirrors parseTLSVersion in opts.go. /// public static (SslProtocols Version, Exception? Error) ParseTLSVersion(object? value) { if (NormalizeConfigValue(value) is not string versionText) return (SslProtocols.None, new InvalidOperationException($"'min_version' wrong type: {value}")); SslProtocols minVersion; try { minVersion = TlsVersionJsonConverter.Parse(versionText); } catch (Exception ex) { return (SslProtocols.None, ex); } if (minVersion != SslProtocols.Tls12 && minVersion != SslProtocols.Tls13) { return (SslProtocols.None, new InvalidOperationException( $"unsupported TLS version: {versionText}")); } return (minVersion, null); } /// /// Parses TLS config options from map values. /// Mirrors parseTLS in opts.go. /// public static (TlsConfigOpts? Options, Exception? Error) ParseTLS(object? value, bool isClientCtx) { if (!TryGetMap(value, out var map)) return (null, new InvalidOperationException("TLS options should be a map")); var tlsOptions = new TlsConfigOpts(); var insecureConfigured = new List(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "cert_file": tlsOptions.CertFile = entry as string ?? string.Empty; break; case "key_file": tlsOptions.KeyFile = entry as string ?? string.Empty; break; case "ca_file": tlsOptions.CaFile = entry as string ?? string.Empty; break; case "insecure": if (!TryConvertToBool(entry, out var insecure)) return (null, new InvalidOperationException("error parsing tls config, expected 'insecure' to be a boolean")); tlsOptions.Insecure = insecure; break; case "verify": if (!TryConvertToBool(entry, out var verify)) return (null, new InvalidOperationException("error parsing tls config, expected 'verify' to be a boolean")); tlsOptions.Verify = verify; break; case "verify_and_map": if (!TryConvertToBool(entry, out var verifyAndMap)) return (null, new InvalidOperationException("error parsing tls config, expected 'verify_and_map' to be a boolean")); if (verifyAndMap) tlsOptions.Verify = true; tlsOptions.Map = verifyAndMap; break; case "verify_cert_and_check_known_urls": if (!TryConvertToBool(entry, out var verifyKnownUrls)) { return (null, new InvalidOperationException( "error parsing tls config, expected 'verify_cert_and_check_known_urls' to be a boolean")); } if (verifyKnownUrls && isClientCtx) { return (null, new InvalidOperationException( "verify_cert_and_check_known_urls not supported in this context")); } if (verifyKnownUrls) tlsOptions.Verify = true; tlsOptions.TlsCheckKnownUrls = verifyKnownUrls; break; case "allow_insecure_cipher_suites": if (!TryConvertToBool(entry, out var allowInsecureCiphers)) { return (null, new InvalidOperationException( "error parsing tls config, expected 'allow_insecure_cipher_suites' to be a boolean")); } tlsOptions.AllowInsecureCiphers = allowInsecureCiphers; break; case "cipher_suites": if (!TryGetArray(entry, out var cipherArray) || cipherArray.Count == 0) { return (null, new InvalidOperationException( "error parsing tls config, 'cipher_suites' cannot be empty")); } tlsOptions.Ciphers.Clear(); foreach (var cipherEntry in cipherArray) { if (NormalizeConfigValue(cipherEntry) is not string cipherName) return (null, new InvalidOperationException("cipher suite name should be a string")); var (cipher, parseError) = ParseCipher(cipherName); if (parseError != null || cipher == null) return (null, parseError); tlsOptions.Ciphers.Add(cipher.Value); if (IsLikelyInsecureCipherSuite(cipher.Value)) insecureConfigured.Add(cipherName); } break; case "curve_preferences": if (!TryGetArray(entry, out var curveArray) || curveArray.Count == 0) { return (null, new InvalidOperationException( "error parsing tls config, 'curve_preferences' cannot be empty")); } tlsOptions.CurvePreferences.Clear(); foreach (var curveEntry in curveArray) { if (NormalizeConfigValue(curveEntry) is not string curveName) return (null, new InvalidOperationException("curve preference should be a string")); var (curve, parseError) = ParseCurvePreferences(curveName); if (parseError != null || curve == null) return (null, parseError); tlsOptions.CurvePreferences.Add(curve.Value); } break; case "timeout": switch (entry) { case long timeoutLong: tlsOptions.Timeout = timeoutLong; break; case double timeoutDouble: tlsOptions.Timeout = timeoutDouble; break; case string timeoutString: tlsOptions.Timeout = ParseDuration("tls timeout", timeoutString).TotalSeconds; break; default: return (null, new InvalidOperationException("error parsing tls config, 'timeout' wrong type")); } break; case "connection_rate_limit": if (!TryConvertToLong(entry, out var rateLimit)) return (null, new InvalidOperationException("error parsing tls config, 'connection_rate_limit' wrong type")); tlsOptions.RateLimit = rateLimit; break; case "pinned_certs": if (!TryGetArray(entry, out var pinArray)) { return (null, new InvalidOperationException( "error parsing tls config, expected 'pinned_certs' to be a list")); } if (pinArray.Count > 0) { var pinned = new PinnedCertSet(); var pinRegex = new Regex("^[A-Fa-f0-9]{64}$", RegexOptions.Compiled); foreach (var pinEntry in pinArray) { var pin = (NormalizeConfigValue(pinEntry) as string ?? string.Empty).ToLowerInvariant(); if (!pinRegex.IsMatch(pin)) { return (null, new InvalidOperationException( $"error parsing tls config, 'pinned_certs' key {pin} does not look like hex-encoded sha256")); } pinned.Add(pin); } tlsOptions.PinnedCerts = pinned; } break; case "handshake_first": case "first": case "immediate": switch (entry) { case bool handshakeFirst: tlsOptions.HandshakeFirst = handshakeFirst; break; case string handshakeValue: switch (handshakeValue.Trim().ToLowerInvariant()) { case "true": case "on": tlsOptions.HandshakeFirst = true; break; case "false": case "off": tlsOptions.HandshakeFirst = false; break; case "auto": case "auto_fallback": tlsOptions.HandshakeFirst = true; tlsOptions.FallbackDelay = ServerConstants.DefaultTlsHandshakeFirstFallbackDelay; break; default: { var delay = ParseDuration("handshake_first", handshakeValue); if (delay == TimeSpan.Zero) { return (null, new InvalidOperationException( $"field \"{rawKey}\" value \"{handshakeValue}\" is invalid")); } tlsOptions.HandshakeFirst = true; tlsOptions.FallbackDelay = delay; break; } } break; default: return (null, new InvalidOperationException( $"field \"{rawKey}\" should be a boolean or a string, got {entry?.GetType().Name ?? "null"}")); } break; case "certs": case "certificates": if (!TryGetArray(entry, out var certsArray)) { return (null, new InvalidOperationException( $"error parsing certificates config: unsupported type {entry?.GetType().Name ?? "null"}")); } tlsOptions.Certificates.Clear(); foreach (var certEntry in certsArray) { if (!TryGetMap(certEntry, out var certMap)) { return (null, new InvalidOperationException( $"error parsing certificates config: unsupported type {certEntry?.GetType().Name ?? "null"}")); } var pair = new TlsCertPairOpt(); foreach (var (certKey, certValueRaw) in certMap) { var certValue = NormalizeConfigValue(certValueRaw) as string; if (string.IsNullOrEmpty(certValue)) { return (null, new InvalidOperationException( $"error parsing certificates config: unsupported type {certValueRaw?.GetType().Name ?? "null"}")); } switch (certKey) { case "cert_file": pair.CertFile = certValue; break; case "key_file": pair.KeyFile = certValue; break; default: return (null, new InvalidOperationException( $"error parsing tls certs config, unknown field \"{certKey}\"")); } } if (string.IsNullOrEmpty(pair.CertFile) || string.IsNullOrEmpty(pair.KeyFile)) { return (null, new InvalidOperationException( "error parsing certificates config: both 'cert_file' and 'key_file' options are required")); } tlsOptions.Certificates.Add(pair); } break; case "min_version": { var (minVersion, parseError) = ParseTLSVersion(entry); if (parseError != null) return (null, new InvalidOperationException($"error parsing tls config: {parseError.Message}", parseError)); tlsOptions.MinVersion = minVersion; break; } case "cert_match": tlsOptions.CertMatch = entry as string ?? string.Empty; break; case "cert_match_skip_invalid": if (!TryConvertToBool(entry, out var certMatchSkipInvalid)) { return (null, new InvalidOperationException( "error parsing tls config, expected 'cert_match_skip_invalid' to be a boolean")); } tlsOptions.CertMatchSkipInvalid = certMatchSkipInvalid; break; case "ca_certs_match": { var (caCertsMatch, parseError) = ParseStringArray("ca_certs_match", entry, errors: null); if (parseError != null || caCertsMatch == null) return (null, parseError); tlsOptions.CaCertsMatch = caCertsMatch; break; } default: return (null, new InvalidOperationException( $"error parsing tls config, unknown field \"{rawKey}\"")); } } if (tlsOptions.Certificates.Count > 0 && !string.IsNullOrEmpty(tlsOptions.CertFile)) { return (null, new InvalidOperationException( "error parsing tls config, cannot combine 'cert_file' option with 'certs' option")); } if (tlsOptions.Ciphers.Count == 0) tlsOptions.Ciphers.AddRange(CipherSuites.DefaultCipherSuites()); if (tlsOptions.CurvePreferences.Count == 0) { foreach (var curveName in CipherSuites.DefaultCurvePreferences()) { if (CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve)) tlsOptions.CurvePreferences.Add(curve); } } if (!tlsOptions.AllowInsecureCiphers && insecureConfigured.Count > 0) { return (null, new InvalidOperationException( $"insecure cipher suites configured without 'allow_insecure_cipher_suites' option set: {string.Join(", ", insecureConfigured)}")); } return (tlsOptions, null); } /// /// Parses simple auth objects (user/pass/token/timeout). /// Mirrors parseSimpleAuth in opts.go. /// public static AuthorizationConfig ParseSimpleAuth( object? value, ICollection? errors = null) { var auth = new AuthorizationConfig(); if (!TryGetMap(value, out var map)) { errors?.Add(new InvalidOperationException("authorization should be a map")); return auth; } foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "user": case "username": auth.User = entry as string ?? string.Empty; break; case "pass": case "password": auth.Pass = entry as string ?? string.Empty; break; case "token": auth.Token = entry as string ?? string.Empty; break; case "timeout": if (TryConvertToDouble(entry, out var timeout)) auth.Timeout = timeout; else errors?.Add(new InvalidOperationException("error parsing authorization timeout")); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return auth; } /// /// Parses string or array-of-string fields. /// Mirrors parseStringArray in opts.go. /// public static (List? Values, Exception? Error) ParseStringArray( string fieldName, object? value, ICollection? errors = null) { switch (NormalizeConfigValue(value)) { case string text: return ([text], null); case IEnumerable array: { var values = new List(); foreach (var entry in array) { if (NormalizeConfigValue(entry) is string item) { values.Add(item); continue; } var parseError = new InvalidOperationException( $"error parsing {fieldName}: unsupported type in array {entry?.GetType().Name ?? "null"}"); errors?.Add(parseError); } return (values, null); } default: { var parseError = new InvalidOperationException( $"error parsing {fieldName}: unsupported type {value?.GetType().Name ?? "null"}"); errors?.Add(parseError); return (null, parseError); } } } /// /// Parses websocket configuration block. /// Mirrors parseWebsocket in opts.go. /// public static Exception? ParseWebsocket( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected websocket to be a map, 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.Websocket.Host = host; options.Websocket.Port = port; } catch (Exception ex) { errors?.Add(ex); } break; case "port": if (TryConvertToLong(entry, out var websocketPort)) options.Websocket.Port = checked((int)websocketPort); break; case "host": case "net": options.Websocket.Host = entry as string ?? string.Empty; break; case "advertise": options.Websocket.Advertise = entry as string ?? string.Empty; break; case "no_tls": if (TryConvertToBool(entry, out var noTls)) options.Websocket.NoTls = noTls; break; case "tls": { var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true); if (parseError != null || tlsOptions == null) { errors?.Add(parseError ?? new InvalidOperationException("unable to parse websocket tls options")); break; } var (tlsConfig, genError) = GenTLSConfig(tlsOptions); if (genError != null) { errors?.Add(genError); break; } options.Websocket.TlsConfig = tlsConfig; options.Websocket.TlsMap = tlsOptions.Map; options.Websocket.TlsPinnedCerts = tlsOptions.PinnedCerts; options.Websocket.TlsConfigOpts = tlsOptions; break; } case "same_origin": if (TryConvertToBool(entry, out var sameOrigin)) options.Websocket.SameOrigin = sameOrigin; break; case "allowed_origins": case "allowed_origin": case "allow_origins": case "allow_origin": case "origins": case "origin": { var (origins, parseError) = ParseStringArray("allowed origins", entry, errors); if (parseError == null && origins != null) options.Websocket.AllowedOrigins = origins; break; } case "handshake_timeout": options.Websocket.HandshakeTimeout = ParseDuration("handshake timeout", entry, errors, warnings); break; case "compress": case "compression": if (TryConvertToBool(entry, out var websocketCompression)) options.Websocket.Compression = websocketCompression; break; case "authorization": case "authentication": { var auth = ParseSimpleAuth(entry, errors); options.Websocket.Username = auth.User; options.Websocket.Password = auth.Pass; options.Websocket.Token = auth.Token; options.Websocket.AuthTimeout = auth.Timeout; break; } case "jwt_cookie": options.Websocket.JwtCookie = entry as string ?? string.Empty; break; case "user_cookie": options.Websocket.UsernameCookie = entry as string ?? string.Empty; break; case "pass_cookie": options.Websocket.PasswordCookie = entry as string ?? string.Empty; break; case "token_cookie": options.Websocket.TokenCookie = entry as string ?? string.Empty; break; case "no_auth_user": options.Websocket.NoAuthUser = entry as string ?? string.Empty; break; case "headers": if (!TryGetMap(entry, out var headerMap)) { errors?.Add(new InvalidOperationException( $"error parsing headers: unsupported type {entry?.GetType().Name ?? "null"}")); break; } var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (headerName, headerValueRaw) in headerMap) { if (NormalizeConfigValue(headerValueRaw) is string headerValue) { headers[headerName] = headerValue; } else { errors?.Add(new InvalidOperationException( $"error parsing header key {headerName}: unsupported type {headerValueRaw?.GetType().Name ?? "null"}")); } } options.Websocket.Headers = headers; break; case "ping_interval": options.Websocket.PingInterval = ParseDuration("ping_interval", entry, errors, warnings); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses MQTT configuration block. /// Mirrors parseMQTT in opts.go. /// public static Exception? ParseMQTT( object? value, ServerOptions options, ICollection? errors = null, ICollection? warnings = null) { ArgumentNullException.ThrowIfNull(options); if (!TryGetMap(value, out var map)) return new InvalidOperationException($"Expected mqtt to be a map, 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.Mqtt.Host = host; options.Mqtt.Port = port; } catch (Exception ex) { errors?.Add(ex); } break; case "port": if (TryConvertToLong(entry, out var mqttPort)) options.Mqtt.Port = checked((int)mqttPort); break; case "host": case "net": options.Mqtt.Host = entry as string ?? string.Empty; break; case "tls": { var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true); if (parseError != null || tlsOptions == null) { errors?.Add(parseError ?? new InvalidOperationException("unable to parse mqtt tls options")); break; } var (tlsConfig, genError) = GenTLSConfig(tlsOptions); if (genError != null) { errors?.Add(genError); break; } options.Mqtt.TlsConfig = tlsConfig; options.Mqtt.TlsTimeout = tlsOptions.Timeout; options.Mqtt.TlsMap = tlsOptions.Map; options.Mqtt.TlsPinnedCerts = tlsOptions.PinnedCerts; options.Mqtt.TlsConfigOpts = tlsOptions; break; } case "authorization": case "authentication": { var auth = ParseSimpleAuth(entry, errors); options.Mqtt.Username = auth.User; options.Mqtt.Password = auth.Pass; options.Mqtt.Token = auth.Token; options.Mqtt.AuthTimeout = auth.Timeout; break; } case "no_auth_user": options.Mqtt.NoAuthUser = entry as string ?? string.Empty; break; case "ack_wait": case "ackwait": options.Mqtt.AckWait = ParseDuration("ack_wait", entry, errors, warnings); break; case "js_api_timeout": case "api_timeout": options.Mqtt.JsApiTimeout = ParseDuration("js_api_timeout", entry, errors, warnings); break; case "max_ack_pending": case "max_pending": case "max_inflight": if (!TryConvertToLong(entry, out var maxAckPending)) { errors?.Add(new InvalidOperationException("invalid max_ack_pending value")); break; } if (maxAckPending is < 0 or > ushort.MaxValue) { errors?.Add(new InvalidOperationException( $"invalid value {maxAckPending}, should be in [0..{ushort.MaxValue}] range")); break; } options.Mqtt.MaxAckPending = (ushort)maxAckPending; break; case "js_domain": options.Mqtt.JsDomain = entry as string ?? string.Empty; break; case "stream_replicas": if (TryConvertToLong(entry, out var streamReplicas)) options.Mqtt.StreamReplicas = checked((int)streamReplicas); break; case "consumer_replicas": warnings?.Add(new InvalidOperationException( "consumer replicas setting ignored in this server version")); break; case "consumer_memory_storage": if (TryConvertToBool(entry, out var consumerMemoryStorage)) options.Mqtt.ConsumerMemoryStorage = consumerMemoryStorage; break; case "consumer_inactive_threshold": case "consumer_auto_cleanup": options.Mqtt.ConsumerInactiveThreshold = ParseDuration("consumer_inactive_threshold", entry, errors, warnings); break; case "reject_qos2_publish": if (TryConvertToBool(entry, out var rejectQoS2Publish)) options.Mqtt.RejectQoS2Pub = rejectQoS2Publish; break; case "downgrade_qos2_subscribe": if (TryConvertToBool(entry, out var downgradeQoS2Subscribe)) options.Mqtt.DowngradeQoS2Sub = downgradeQoS2Subscribe; break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return null; } /// /// Parses proxy configuration block. /// Mirrors parseProxies in opts.go. /// public static (ProxiesConfig? Proxies, Exception? Error) ParseProxies( object? value, ICollection? errors = null) { if (!TryGetMap(value, out var map)) { return (null, new InvalidOperationException( $"expected proxies to be a map/struct, got {value?.GetType().Name ?? "null"}")); } var proxies = new ProxiesConfig(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "trusted": { var (trusted, parseError) = ParseProxiesTrusted(entry, errors); if (parseError != null) { errors?.Add(parseError); break; } proxies.Trusted = trusted; break; } default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return (proxies, null); } /// /// Parses trusted proxy entries. /// Mirrors parseProxiesTrusted in opts.go. /// public static (List Trusted, Exception? Error) ParseProxiesTrusted( object? value, ICollection? errors = null) { if (!TryGetArray(value, out var array)) { return ([], new InvalidOperationException( $"expected proxies' trusted field to be an array, got {value?.GetType().Name ?? "null"}")); } var trusted = new List(); foreach (var entry in array) { if (!TryGetMap(entry, out var proxyMap)) { errors?.Add(new InvalidOperationException( $"expected proxies' trusted entry to be a map/struct, got {entry?.GetType().Name ?? "null"}")); continue; } var proxy = new ProxyConfig(); foreach (var (rawKey, rawValue) in proxyMap) { var key = rawKey.ToLowerInvariant(); var normalized = NormalizeConfigValue(rawValue); switch (key) { case "key": case "public_key": proxy.Key = normalized as string ?? string.Empty; if (!IsValidNatsPublicKey(proxy.Key)) { errors?.Add(new InvalidOperationException($"invalid proxy key \"{proxy.Key}\"")); } break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } trusted.Add(proxy); } return (trusted, null); } /// /// Generates runtime TLS options from parsed TLS config. /// Mirrors GenTLSConfig in opts.go. /// public static (SslServerAuthenticationOptions? Config, Exception? Error) GenTLSConfig(TlsConfigOpts options) { ArgumentNullException.ThrowIfNull(options); var config = new SslServerAuthenticationOptions { EnabledSslProtocols = options.MinVersion == SslProtocols.Tls13 ? SslProtocols.Tls13 : SslProtocols.Tls12 | SslProtocols.Tls13, CertificateRevocationCheckMode = X509RevocationMode.NoCheck, ClientCertificateRequired = options.Verify, }; if (!string.IsNullOrEmpty(options.CertFile) && string.IsNullOrEmpty(options.KeyFile)) return (null, new InvalidOperationException("missing 'key_file' in TLS configuration")); if (string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile)) return (null, new InvalidOperationException("missing 'cert_file' in TLS configuration")); try { if (!string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile)) { var certificate = X509Certificate2.CreateFromPemFile( ExpandPath(options.CertFile), ExpandPath(options.KeyFile)); config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12)); } else if (options.Certificates.Count > 0) { var pair = options.Certificates[0]; var certificate = X509Certificate2.CreateFromPemFile( ExpandPath(pair.CertFile), ExpandPath(pair.KeyFile)); config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12)); } } catch (Exception ex) { return (null, new InvalidOperationException($"error parsing X509 certificate/key pair: {ex.Message}", ex)); } if (options.Ciphers.Count > 0) { try { config.CipherSuitesPolicy = new CipherSuitesPolicy(options.Ciphers); } catch (PlatformNotSupportedException) { // Some platforms do not allow explicit cipher suite policies. } } if (!string.IsNullOrEmpty(options.CaFile) && !File.Exists(ExpandPath(options.CaFile))) return (null, new FileNotFoundException("failed to parse root ca certificate", ExpandPath(options.CaFile))); return (config, null); } /// /// Configures options from command-line arguments. /// Mirrors ConfigureOptions in opts.go. /// public static (ServerOptions? Options, Exception? Error) ConfigureOptions( IReadOnlyList args, Action? printVersion = null, Action? printHelp = null, Action? printTLSHelp = null) { var flagOptions = new ServerOptions(); var explicitBooleans = new Dictionary(StringComparer.Ordinal); var nonFlags = new List(); string configFile = string.Empty; var tlsFlagsSeen = false; var routesFlagSeen = false; for (var i = 0; i < args.Count; i++) { var rawArg = args[i]; if (!rawArg.StartsWith('-')) { nonFlags.Add(rawArg); continue; } var inlineValue = default(string); var arg = rawArg; var equalsIndex = rawArg.IndexOf('='); if (equalsIndex > 0) { arg = rawArg[..equalsIndex]; inlineValue = rawArg[(equalsIndex + 1)..]; } string? ReadValue() { if (!string.IsNullOrEmpty(inlineValue)) return inlineValue; if (i + 1 >= args.Count) return null; i++; return args[i]; } bool ReadBoolean(bool defaultValue = true) { var value = ReadValue(); if (value == null || value.StartsWith('-')) { if (value != null) i--; return defaultValue; } return bool.TryParse(value, out var parsed) ? parsed : defaultValue; } switch (arg) { case "-h": case "--help": printHelp?.Invoke(); return (null, null); case "-v": case "--version": printVersion?.Invoke(); return (null, null); case "--help_tls": printTLSHelp?.Invoke(); return (null, null); case "-p": case "--port": if (!int.TryParse(ReadValue(), out var port)) return (null, new InvalidOperationException("Invalid value for port")); flagOptions.Port = port; break; case "-a": case "--addr": case "--net": flagOptions.Host = ReadValue() ?? string.Empty; break; case "--client_advertise": flagOptions.ClientAdvertise = ReadValue() ?? string.Empty; break; case "-D": case "--debug": flagOptions.Debug = ReadBoolean(); explicitBooleans["Debug"] = flagOptions.Debug; break; case "-V": case "--trace": flagOptions.Trace = ReadBoolean(); explicitBooleans["Trace"] = flagOptions.Trace; break; case "-VV": flagOptions.Trace = ReadBoolean(); flagOptions.TraceVerbose = flagOptions.Trace; explicitBooleans["Trace"] = flagOptions.Trace; explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose; break; case "-DV": flagOptions.Debug = ReadBoolean(); flagOptions.Trace = flagOptions.Debug; explicitBooleans["Debug"] = flagOptions.Debug; explicitBooleans["Trace"] = flagOptions.Trace; break; case "-DVV": flagOptions.Debug = ReadBoolean(); flagOptions.Trace = flagOptions.Debug; flagOptions.TraceVerbose = flagOptions.Debug; explicitBooleans["Debug"] = flagOptions.Debug; explicitBooleans["Trace"] = flagOptions.Trace; explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose; break; case "-T": case "--logtime": flagOptions.Logtime = ReadBoolean(defaultValue: true); explicitBooleans["Logtime"] = flagOptions.Logtime; break; case "--logtime_utc": flagOptions.LogtimeUtc = ReadBoolean(); break; case "--user": flagOptions.Username = ReadValue() ?? string.Empty; break; case "--pass": flagOptions.Password = ReadValue() ?? string.Empty; break; case "--auth": flagOptions.Authorization = ReadValue() ?? string.Empty; break; case "-m": case "--http_port": if (!int.TryParse(ReadValue(), out var httpPort)) return (null, new InvalidOperationException("Invalid value for http_port")); flagOptions.HttpPort = httpPort; break; case "--ms": case "--https_port": if (!int.TryParse(ReadValue(), out var httpsPort)) return (null, new InvalidOperationException("Invalid value for https_port")); flagOptions.HttpsPort = httpsPort; break; case "-c": case "--config": configFile = ReadValue() ?? string.Empty; break; case "-t": flagOptions.CheckConfig = ReadBoolean(); break; case "-P": case "--pid": flagOptions.PidFile = ReadValue() ?? string.Empty; break; case "--ports_file_dir": flagOptions.PortsFileDir = ReadValue() ?? string.Empty; break; case "-l": case "--log": flagOptions.LogFile = ReadValue() ?? string.Empty; break; case "--log_size_limit": if (!long.TryParse(ReadValue(), out var logSizeLimit)) return (null, new InvalidOperationException("Invalid value for log_size_limit")); flagOptions.LogSizeLimit = logSizeLimit; break; case "-s": case "--syslog": flagOptions.Syslog = ReadBoolean(); explicitBooleans["Syslog"] = flagOptions.Syslog; break; case "-r": case "--remote_syslog": flagOptions.RemoteSyslog = ReadValue() ?? string.Empty; break; case "--profile": if (!int.TryParse(ReadValue(), out var profilePort)) return (null, new InvalidOperationException("Invalid value for profile")); flagOptions.ProfPort = profilePort; break; case "--routes": flagOptions.RoutesStr = ReadValue() ?? string.Empty; routesFlagSeen = true; break; case "--cluster": case "--cluster_listen": flagOptions.Cluster.ListenStr = ReadValue() ?? string.Empty; break; case "--cluster_advertise": flagOptions.Cluster.Advertise = ReadValue() ?? string.Empty; break; case "--no_advertise": flagOptions.Cluster.NoAdvertise = ReadBoolean(); explicitBooleans["Cluster.NoAdvertise"] = flagOptions.Cluster.NoAdvertise; break; case "--connect_retries": if (!int.TryParse(ReadValue(), out var connectRetries)) return (null, new InvalidOperationException("Invalid value for connect_retries")); flagOptions.Cluster.ConnectRetries = connectRetries; break; case "--cluster_name": flagOptions.Cluster.Name = ReadValue() ?? string.Empty; break; case "--tls": flagOptions.Tls = ReadBoolean(); tlsFlagsSeen = true; break; case "--tlsverify": flagOptions.TlsVerify = ReadBoolean(); tlsFlagsSeen = true; break; case "--tlscert": flagOptions.TlsCert = ReadValue() ?? string.Empty; tlsFlagsSeen = true; break; case "--tlskey": flagOptions.TlsKey = ReadValue() ?? string.Empty; tlsFlagsSeen = true; break; case "--tlscacert": flagOptions.TlsCaCert = ReadValue() ?? string.Empty; tlsFlagsSeen = true; break; case "--max_traced_msg_len": if (!int.TryParse(ReadValue(), out var maxTraceLength)) return (null, new InvalidOperationException("Invalid value for max_traced_msg_len")); flagOptions.MaxTracedMsgLen = maxTraceLength; break; case "--js": case "--jetstream": flagOptions.JetStream = ReadBoolean(); explicitBooleans["JetStream"] = flagOptions.JetStream; break; case "--sd": case "--store_dir": flagOptions.StoreDir = ReadValue() ?? string.Empty; break; default: nonFlags.Add(rawArg); break; } } var (showVersion, showHelp, parseError) = NatsServer.ProcessCommandLineArgs(nonFlags.ToArray()); if (parseError != null) return (null, parseError); if (showVersion) { printVersion?.Invoke(); return (null, null); } if (showHelp) { printHelp?.Invoke(); return (null, null); } FlagSnapshot = flagOptions.Clone(); foreach (var (name, value) in explicitBooleans) TrackExplicitVal(FlagSnapshot.InCmdLine, name, value); ServerOptions resolvedOptions; if (!string.IsNullOrEmpty(configFile)) { resolvedOptions = ProcessConfigFile(configFile); resolvedOptions = MergeOptions(resolvedOptions, flagOptions); } else { resolvedOptions = flagOptions; } if (resolvedOptions.CheckConfig && string.IsNullOrEmpty(configFile)) { return (null, new InvalidOperationException( "must specify [-c, --config] option to check configuration file syntax")); } foreach (var (name, value) in explicitBooleans) { TrackExplicitVal(resolvedOptions.InCmdLine, name, value); switch (name) { case "Debug": resolvedOptions.Debug = value; break; case "Trace": resolvedOptions.Trace = value; break; case "TraceVerbose": resolvedOptions.TraceVerbose = value; break; case "Logtime": resolvedOptions.Logtime = value; break; case "Syslog": resolvedOptions.Syslog = value; break; case "Cluster.NoAdvertise": resolvedOptions.Cluster.NoAdvertise = value; break; case "JetStream": resolvedOptions.JetStream = value; break; } } if (!string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr)) { var clusterError = resolvedOptions.OverrideCluster(); if (clusterError != null) return (null, clusterError); } if (routesFlagSeen) { resolvedOptions.Routes = string.IsNullOrEmpty(resolvedOptions.RoutesStr) ? [] : RoutesFromStr(resolvedOptions.RoutesStr); } if (tlsFlagsSeen && resolvedOptions.Tls) { var tlsError = resolvedOptions.OverrideTls(); if (tlsError != null) return (null, tlsError); } if (!string.IsNullOrEmpty(resolvedOptions.RoutesStr) && string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr) && string.IsNullOrEmpty(resolvedOptions.Cluster.Host) && resolvedOptions.Cluster.Port == 0) { return (null, new InvalidOperationException( "solicited routes require cluster capabilities, e.g. --cluster")); } return (resolvedOptions, null); } // ------------------------------------------------------------------------- // 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 Permissions? ParsePermissionsValue(object? value, ICollection? errors) { if (!TryGetMap(value, out var map)) { errors?.Add(new InvalidOperationException( $"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}")); return null; } var permissions = new Permissions(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "publish": case "import": permissions.Publish = ParseSubjectPermissionValue(entry, errors); break; case "subscribe": case "export": permissions.Subscribe = ParseSubjectPermissionValue(entry, errors); break; case "responses": case "allow_responses": permissions.Response = ParseAllowResponsesValue(entry, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } AuthHandler.ValidateResponsePermissions(permissions); return permissions; } private static SubjectPermission? ParseSubjectPermissionValue(object? value, ICollection? errors) { var normalized = NormalizeConfigValue(value); if (TryGetMap(normalized, out var map)) { var permission = new SubjectPermission(); foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "allow": permission.Allow = ParsePermissionSubjects(entry, errors); break; case "deny": permission.Deny = ParsePermissionSubjects(entry, errors); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return permission; } return new SubjectPermission { Allow = ParsePermissionSubjects(normalized, errors), }; } private static List ParsePermissionSubjects(object? value, ICollection? errors) { if (TryGetArray(value, out var array)) { var subjects = new List(array.Count); foreach (var entry in array) { var subject = NormalizeConfigValue(entry) as string; if (string.IsNullOrWhiteSpace(subject)) { errors?.Add(new InvalidOperationException("permission subjects must be non-empty strings")); continue; } if (!SubscriptionIndex.IsValidSubject(subject)) { errors?.Add(new InvalidOperationException($"invalid subject \"{subject}\" in permissions")); continue; } subjects.Add(subject); } return subjects; } if (NormalizeConfigValue(value) is string singleSubject) { if (!SubscriptionIndex.IsValidSubject(singleSubject)) { errors?.Add(new InvalidOperationException($"invalid subject \"{singleSubject}\" in permissions")); return []; } return [singleSubject]; } errors?.Add(new InvalidOperationException( $"Expected permission subject array/string, got {value?.GetType().Name ?? "null"}")); return []; } private static ResponsePermission? ParseAllowResponsesValue(object? value, ICollection? errors) { var normalized = NormalizeConfigValue(value); if (TryConvertToBool(normalized, out var enabled)) { return enabled ? new ResponsePermission { MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, Expires = ServerConstants.DefaultAllowResponseExpiration, } : null; } if (!TryGetMap(normalized, out var map)) { errors?.Add(new InvalidOperationException( $"Expected allow_responses to be a boolean or map, got {normalized?.GetType().Name ?? "null"}")); return null; } var response = new ResponsePermission { MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs, Expires = ServerConstants.DefaultAllowResponseExpiration, }; foreach (var (rawKey, rawValue) in map) { var key = rawKey.ToLowerInvariant(); var entry = NormalizeConfigValue(rawValue); switch (key) { case "max": case "max_msgs": if (TryConvertToLong(entry, out var maxMessages)) response.MaxMsgs = checked((int)maxMessages); else errors?.Add(new InvalidOperationException("allow_responses.max should be an integer")); break; case "expires": response.Expires = ParseDuration("allow_responses.expires", entry, errors, warnings: null); break; default: if (!ConfigFlags.AllowUnknownTopLevelField) errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); break; } } return response; } private static bool IsLikelyPublicNkey(string value, char prefix) => !string.IsNullOrWhiteSpace(value) && value.Length >= 10 && value[0] == prefix; private static bool IsValidNatsPublicKey(string value) { if (string.IsNullOrWhiteSpace(value)) return false; try { _ = NATS.NKeys.KeyPair.FromPublicKey(value.AsSpan()); return true; } catch { return false; } } private static bool IsLikelyInsecureCipherSuite(TlsCipherSuite cipher) { var name = cipher.ToString(); return name.Contains("_CBC_", StringComparison.OrdinalIgnoreCase) || name.Contains("RC4", StringComparison.OrdinalIgnoreCase) || name.Contains("3DES", StringComparison.OrdinalIgnoreCase) || name.Contains("DES", StringComparison.OrdinalIgnoreCase) || name.Contains("NULL", StringComparison.OrdinalIgnoreCase); } 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 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); 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, }; }