// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries // to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400. using System.Globalization; using System.Text.RegularExpressions; using NATS.Server.Auth; using NATS.Server.JetStream; using NATS.Server.Tls; namespace NATS.Server.Configuration; /// /// Maps a parsed NATS configuration dictionary (produced by ) /// into a fully populated instance. Collects all validation /// errors rather than failing on the first one. /// public static class ConfigProcessor { /// /// Parses a configuration file and returns the populated options. /// public static NatsOptions ProcessConfigFile(string filePath) { var config = NatsConfParser.ParseFile(filePath); var opts = new NatsOptions { ConfigFile = filePath }; ApplyConfig(config, opts); return opts; } /// /// Parses configuration text (not from a file) and returns the populated options. /// public static NatsOptions ProcessConfig(string configText) { var config = NatsConfParser.Parse(configText); var opts = new NatsOptions(); ApplyConfig(config, opts); return opts; } /// /// Applies a parsed configuration dictionary to existing options. /// Throws if any validation errors are collected. /// public static void ApplyConfig(Dictionary config, NatsOptions opts) { var errors = new List(); var warnings = new List(); foreach (var (key, value) in config) { try { ProcessKey(key, value, opts, errors, warnings); } catch (Exception ex) { errors.Add($"Error processing '{key}': {ex.Message}"); } } if (errors.Count > 0) { throw new ConfigProcessorException("Configuration errors", errors, warnings); } } private static void ProcessKey( string key, object? value, NatsOptions opts, List errors, List warnings) { // Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries), // but we normalize here for the switch statement. switch (key.ToLowerInvariant()) { case "listen": ParseListen(value, opts); break; case "port": opts.Port = ToInt(value); break; case "host" or "net": opts.Host = ToString(value); break; case "server_name": var name = ToString(value); if (name.Contains(' ')) errors.Add("server_name cannot contain spaces"); else opts.ServerName = name; break; case "client_advertise": opts.ClientAdvertise = ToString(value); break; // Logging case "debug": opts.Debug = ToBool(value); break; case "trace": opts.Trace = ToBool(value); break; case "trace_verbose": opts.TraceVerbose = ToBool(value); if (opts.TraceVerbose) opts.Trace = true; break; case "logtime": opts.Logtime = ToBool(value); break; case "logtime_utc": opts.LogtimeUTC = ToBool(value); break; case "logfile" or "log_file": opts.LogFile = ToString(value); break; case "log_size_limit": opts.LogSizeLimit = ToLong(value); break; case "log_max_num": opts.LogMaxFiles = ToInt(value); break; case "syslog": opts.Syslog = ToBool(value); break; case "remote_syslog": opts.RemoteSyslog = ToString(value); break; // Limits case "max_payload": opts.MaxPayload = ToInt(value); break; case "max_control_line": opts.MaxControlLine = ToInt(value); break; case "max_connections" or "max_conn": opts.MaxConnections = ToInt(value); break; case "max_pending": opts.MaxPending = ToLong(value); break; case "max_subs" or "max_subscriptions": opts.MaxSubs = ToInt(value); break; case "max_sub_tokens" or "max_subscription_tokens": var tokens = ToInt(value); if (tokens > 256) errors.Add("max_sub_tokens cannot exceed 256"); else opts.MaxSubTokens = tokens; break; case "max_traced_msg_len": opts.MaxTracedMsgLen = ToInt(value); break; case "max_closed_clients": opts.MaxClosedClients = ToInt(value); break; case "disable_sublist_cache" or "no_sublist_cache": opts.DisableSublistCache = ToBool(value); break; case "write_deadline": opts.WriteDeadline = ParseDuration(value); break; // Ping case "ping_interval": opts.PingInterval = ParseDuration(value); break; case "ping_max" or "ping_max_out": opts.MaxPingsOut = ToInt(value); break; // Monitoring case "http_port" or "monitor_port": opts.MonitorPort = ToInt(value); break; case "https_port": opts.MonitorHttpsPort = ToInt(value); break; case "http": ParseMonitorListen(value, opts, isHttps: false); break; case "https": ParseMonitorListen(value, opts, isHttps: true); break; case "http_base_path": opts.MonitorBasePath = ToString(value); break; // Lifecycle case "lame_duck_duration": opts.LameDuckDuration = ParseDuration(value); break; case "lame_duck_grace_period": opts.LameDuckGracePeriod = ParseDuration(value); break; // Files case "pidfile" or "pid_file": opts.PidFile = ToString(value); break; case "ports_file_dir": opts.PortsFileDir = ToString(value); break; // Auth case "authorization": if (value is Dictionary authDict) ParseAuthorization(authDict, opts, errors); break; case "no_auth_user": opts.NoAuthUser = ToString(value); break; // TLS case "tls": if (value is Dictionary tlsDict) ParseTls(tlsDict, opts, errors); break; case "allow_non_tls": opts.AllowNonTls = ToBool(value); break; // Cluster / inter-server / JetStream case "cluster": if (value is Dictionary clusterDict) opts.Cluster = ParseCluster(clusterDict, errors); break; case "gateway": if (value is Dictionary gatewayDict) opts.Gateway = ParseGateway(gatewayDict, errors); break; case "leaf": case "leafnode": case "leafnodes": if (value is Dictionary leafDict) opts.LeafNode = ParseLeafNode(leafDict, errors); break; case "jetstream": if (value is Dictionary jsDict) opts.JetStream = ParseJetStream(jsDict, errors); break; // Tags case "server_tags": if (value is Dictionary tagsDict) ParseTags(tagsDict, opts); break; // Profiling case "prof_port": opts.ProfPort = ToInt(value); break; // System account case "system_account": opts.SystemAccount = ToString(value); break; case "no_system_account": opts.NoSystemAccount = ToBool(value); break; case "no_header_support": opts.NoHeaderSupport = ToBool(value); break; case "connect_error_reports": opts.ConnectErrorReports = ToInt(value); break; case "reconnect_error_reports": opts.ReconnectErrorReports = ToInt(value); break; // MQTT case "mqtt": if (value is Dictionary mqttDict) ParseMqtt(mqttDict, opts, errors); break; // WebSocket case "websocket" or "ws": if (value is Dictionary wsDict) ParseWebSocket(wsDict, opts, errors); break; // Accounts block — each key is an account name containing users/limits case "accounts": if (value is Dictionary accountsDict) ParseAccounts(accountsDict, opts, errors); break; // Server-level subject mappings: mappings { src: dest } // Go reference: server/opts.go — "mappings" case case "mappings" or "maps": if (value is Dictionary mappingsDict) { opts.SubjectMappings ??= new Dictionary(); foreach (var (src, dest) in mappingsDict) { if (dest is string destStr) opts.SubjectMappings[src] = destStr; } } break; // JWT operator mode — trusted operator public NKeys // Go reference: server/opts.go — "trusted_keys" / "trusted" case case "trusted_keys" or "trusted": opts.TrustedKeys = ParseStringArray(value); break; // JWT resolver type and preload // Go reference: server/opts.go — "resolver" case case "resolver" or "account_resolver" or "accounts_resolver": if (value is string resolverStr && resolverStr.Equals("MEMORY", StringComparison.OrdinalIgnoreCase)) opts.AccountResolver = new Auth.Jwt.MemAccountResolver(); break; // Pre-load account JWTs into the resolver // Go reference: server/opts.go — "resolver_preload" case case "resolver_preload": if (value is Dictionary preloadDict && opts.AccountResolver != null) { foreach (var (accNkey, jwtObj) in preloadDict) { if (jwtObj is string jwt) opts.AccountResolver.StoreAsync(accNkey, jwt).GetAwaiter().GetResult(); } } break; // Operator key (can derive trusted_keys from operator JWT — for now just accept NKeys directly) case "operator" or "operators" or "root" or "roots" or "root_operators" or "root_operator": // For simple mode: treat as trusted_keys alias if string array opts.TrustedKeys ??= ParseStringArray(value); break; // Unknown keys silently ignored default: warnings.Add(new UnknownConfigFieldWarning(key).Message); break; } } // ─── Listen parsing ──────────────────────────────────────────── /// /// Parses a "listen" value that can be: /// /// ":4222" — port only /// "0.0.0.0:4222" — host + port /// "4222" — bare number (port only) /// 4222 — integer (port only) /// /// private static void ParseListen(object? value, NatsOptions opts) { var (host, port) = ParseHostPort(value); if (host is not null) opts.Host = host; if (port is not null) opts.Port = port.Value; } /// /// Parses a monitor listen value. For "http" the port goes to MonitorPort; /// for "https" the port goes to MonitorHttpsPort. /// private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps) { var (host, port) = ParseHostPort(value); if (host is not null) opts.MonitorHost = host; if (port is not null) { if (isHttps) opts.MonitorHttpsPort = port.Value; else opts.MonitorPort = port.Value; } } /// /// Shared host:port parsing logic. /// private static (string? Host, int? Port) ParseHostPort(object? value) { if (value is long l) return (null, (int)l); var str = ToString(value); // Try bare integer if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort)) return (null, barePort); // Check for host:port var colonIdx = str.LastIndexOf(':'); if (colonIdx >= 0) { var hostPart = str[..colonIdx]; var portPart = str[(colonIdx + 1)..]; if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) { var host = hostPart.Length > 0 ? hostPart : null; return (host, p); } } throw new FormatException($"Cannot parse listen value: '{str}'"); } // ─── Duration parsing ────────────────────────────────────────── /// /// Parses a duration value. Accepts: /// /// A string with unit suffix: "30s", "2m", "1h", "500ms" /// A number (long/double) treated as seconds /// /// internal static TimeSpan ParseDuration(object? value) { return value switch { long seconds => TimeSpan.FromSeconds(seconds), double seconds => TimeSpan.FromSeconds(seconds), string s => ParseDurationString(s), _ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"), }; } private static readonly Regex DurationPattern = new( @"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex ByteSizePattern = new( @"^(\d+)\s*(b|kb|mb|gb|tb)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static TimeSpan ParseDurationString(string s) { var match = DurationPattern.Match(s); if (!match.Success) throw new FormatException($"Cannot parse duration: '{s}'"); var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToLowerInvariant(); return unit switch { "ms" => TimeSpan.FromMilliseconds(amount), "s" => TimeSpan.FromSeconds(amount), "m" => TimeSpan.FromMinutes(amount), "h" => TimeSpan.FromHours(amount), _ => throw new FormatException($"Unknown duration unit: '{unit}'"), }; } // ─── Cluster / gateway / leafnode / JetStream parsing ──────── private static ClusterOptions ParseCluster(Dictionary dict, List errors) { var options = new ClusterOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "name": options.Name = ToString(value); break; case "listen": try { var (host, port) = ParseHostPort(value); if (host is not null) options.Host = host; if (port is not null) options.Port = port.Value; } catch (Exception ex) { errors.Add($"Invalid cluster.listen: {ex.Message}"); } break; case "write_deadline": try { options.WriteDeadline = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid cluster.write_deadline: {ex.Message}"); } break; case "routes": if (value is List routeList) options.Routes = ToStringList(routeList).ToList(); break; case "pool_size": options.PoolSize = ToInt(value); break; default: break; } } return options; } private static GatewayOptions ParseGateway(Dictionary dict, List errors) { var options = new GatewayOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "name": options.Name = ToString(value); break; case "host" or "net": options.Host = ToString(value); break; case "port": options.Port = ToInt(value); break; case "listen": try { var (host, port) = ParseHostPort(value); if (host is not null) options.Host = host; if (port is not null) options.Port = port.Value; } catch (Exception ex) { errors.Add($"Invalid gateway.listen: {ex.Message}"); } break; case "reject_unknown_cluster" or "reject_unknown": options.RejectUnknown = ToBool(value); break; case "advertise": options.Advertise = ToString(value); break; case "connect_retries": options.ConnectRetries = ToInt(value); break; case "connect_backoff": options.ConnectBackoff = ToBool(value); break; case "write_deadline": try { options.WriteDeadline = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid gateway.write_deadline: {ex.Message}"); } break; case "authorization" or "authentication": if (value is Dictionary authDict) ParseGatewayAuthorization(authDict, options, errors); break; case "gateways": // Must be a list, not a map if (value is List gwList) { foreach (var item in gwList) { if (item is Dictionary gwDict) options.RemoteGateways.Add(ParseRemoteGateway(gwDict, errors)); } } else if (value is Dictionary) { errors.Add("gateway.gateways must be an array, not a map"); } break; default: break; } } return options; } private static void ParseGatewayAuthorization(Dictionary dict, GatewayOptions options, List errors) { // Gateway authorization only supports a single user — users array is not allowed. // Go reference: opts.go parseGateway — "does not allow multiple users" foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "user" or "username": options.Username = ToString(value); break; case "pass" or "password": options.Password = ToString(value); break; case "token": // Token-only auth options.Username = ToString(value); break; case "timeout": options.AuthTimeout = ToDouble(value); break; case "users": // Not supported in gateway auth errors.Add("gateway authorization does not allow multiple users"); break; default: break; } } } private static RemoteGatewayOptions ParseRemoteGateway(Dictionary dict, List errors) { var remote = new RemoteGatewayOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "name": remote.Name = ToString(value); break; case "url": remote.Urls.Add(ToString(value)); break; case "urls": if (value is List urlList) remote.Urls.AddRange(ToStringList(urlList)); break; default: break; } } return remote; } private static LeafNodeOptions ParseLeafNode(Dictionary dict, List errors) { var options = new LeafNodeOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "host" or "net": options.Host = ToString(value); break; case "port": options.Port = ToInt(value); break; case "listen": try { var (host, port) = ParseHostPort(value); if (host is not null) options.Host = host; if (port is not null) options.Port = port.Value; } catch (Exception ex) { errors.Add($"Invalid leafnode.listen: {ex.Message}"); } break; case "advertise": options.Advertise = ToString(value); break; case "write_deadline": try { options.WriteDeadline = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid leafnode.write_deadline: {ex.Message}"); } break; case "authorization" or "authentication": if (value is Dictionary authDict) ParseLeafNodeAuthorization(authDict, options, errors); break; case "remotes": if (value is List remoteList) { foreach (var item in remoteList) { if (item is Dictionary remoteDict) options.RemoteLeaves.Add(ParseRemoteLeaf(remoteDict, errors)); } } break; case "no_advertise": case "compress": case "tls": case "deny_exports": case "deny_imports": // Silently accepted fields (some are handled elsewhere) break; default: break; } } return options; } private static void ParseLeafNodeAuthorization(Dictionary dict, LeafNodeOptions options, List errors) { // Go reference: opts.go parseLeafNode authorization block foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "user" or "username": options.Username = ToString(value); break; case "pass" or "password": options.Password = ToString(value); break; case "token": options.Username = ToString(value); break; case "timeout": // Go stores leafnode auth_timeout as float64 seconds. // Supports plain numbers and duration strings like "1m". options.AuthTimeout = value switch { long l => (double)l, double d => d, string s => ParseDuration(s).TotalSeconds, _ => throw new FormatException($"Invalid leafnode auth timeout: {value?.GetType().Name}"), }; break; default: break; } } } private static RemoteLeafOptions ParseRemoteLeaf(Dictionary dict, List errors) { var remote = new RemoteLeafOptions(); var urls = new List(); string? localAccount = null; string? credentials = null; var dontRandomize = false; foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "url": urls.Add(ToString(value)); break; case "urls": if (value is List urlList) urls.AddRange(ToStringList(urlList)); break; case "account": localAccount = ToString(value); break; case "credentials" or "creds": credentials = ToString(value); break; case "dont_randomize" or "no_randomize": dontRandomize = ToBool(value); break; default: break; } } return new RemoteLeafOptions { LocalAccount = localAccount, Credentials = credentials, Urls = urls, DontRandomize = dontRandomize, }; } private static JetStreamOptions ParseJetStream(Dictionary dict, List errors) { var options = new JetStreamOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "store_dir": options.StoreDir = ToString(value); break; case "domain": options.Domain = ToString(value); break; case "max_mem_store": try { options.MaxMemoryStore = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.max_mem_store: {ex.Message}"); } break; case "max_file_store": try { options.MaxFileStore = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.max_file_store: {ex.Message}"); } break; case "sync_interval": try { options.SyncInterval = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.sync_interval: {ex.Message}"); } break; case "sync_always": options.SyncAlways = ToBool(value); break; case "compress_ok": options.CompressOk = ToBool(value); break; case "unique_tag": options.UniqueTag = ToString(value); break; case "strict": options.Strict = ToBool(value); break; case "max_ack_pending": options.MaxAckPending = ToInt(value); break; case "memory_max_stream_bytes": try { options.MemoryMaxStreamBytes = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}"); } break; case "store_max_stream_bytes": try { options.StoreMaxStreamBytes = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}"); } break; case "max_bytes_required": options.MaxBytesRequired = ToBool(value); break; case "tiers": if (value is Dictionary tiers) { foreach (var (tierName, rawTier) in tiers) { if (rawTier is Dictionary tierDict) options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors); } } break; } } return options; } private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary dict, List errors) { var tier = new JetStreamTier { Name = tierName }; foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "memory" or "max_memory": try { tier.Memory = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}"); } break; case "store" or "max_store": try { tier.Store = ParseByteSize(value); } catch (Exception ex) { errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}"); } break; case "streams" or "max_streams": tier.Streams = ToInt(value); break; case "consumers" or "max_consumers": tier.Consumers = ToInt(value); break; } } return tier; } // ─── Authorization parsing ───────────────────────────────────── private static void ParseAuthorization(Dictionary dict, NatsOptions opts, List errors) { string? token = null; List? userList = null; foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "user" or "username": opts.Username = ToString(value); break; case "pass" or "password": opts.Password = ToString(value); break; case "token": token = ToString(value); opts.Authorization = token; break; case "timeout": opts.AuthTimeout = value switch { long l => TimeSpan.FromSeconds(l), double d => TimeSpan.FromSeconds(d), string s => ParseDuration(s), _ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"), }; break; case "users": if (value is List ul) userList = ul; break; default: // Unknown auth keys silently ignored break; } } // Validate: token cannot be combined with users array. // Go reference: opts.go — "cannot have a token with a users array" if (token is not null && userList is not null) { errors.Add("Cannot have a token with a users array"); return; } if (userList is not null) { var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors); if (plainUsers.Count > 0) opts.Users = plainUsers; if (nkeyUsers.Count > 0) opts.NKeys = nkeyUsers; } } // ─── Accounts parsing ────────────────────────────────────────── /// /// Parses the top-level "accounts" block. Each key is an account name, and each /// value is a dictionary that may contain "users" (array) and account-level limits. /// Users are stamped with the account name and appended to opts.Users / opts.NKeys. /// Go reference: opts.go — configureAccounts / parseAccounts. /// private static void ParseAccounts(Dictionary accountsDict, NatsOptions opts, List errors) { opts.Accounts ??= new Dictionary(); foreach (var (accountName, accountValue) in accountsDict) { if (accountValue is not Dictionary acctDict) { errors.Add($"Expected account '{accountName}' value to be a map"); continue; } int maxConnections = 0; int maxSubscriptions = 0; List? userList = null; List? exports = null; List? imports = null; foreach (var (key, value) in acctDict) { switch (key.ToLowerInvariant()) { case "users": if (value is List ul) userList = ul; break; case "max_connections" or "max_conns": maxConnections = ToInt(value); break; case "max_subscriptions" or "max_subs": maxSubscriptions = ToInt(value); break; case "exports": if (value is List exportList) exports = ParseExports(exportList); break; case "imports": if (value is List importList) imports = ParseImports(importList); break; case "mappings" or "maps": if (value is Dictionary mappingsDict) { // Account-level subject mappings not yet supported } break; } } opts.Accounts[accountName] = new AccountConfig { MaxConnections = maxConnections, MaxSubscriptions = maxSubscriptions, Exports = exports, Imports = imports, }; if (userList is not null) { var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName); if (plainUsers.Count > 0) { var existing = opts.Users?.ToList() ?? []; existing.AddRange(plainUsers); opts.Users = existing; } if (nkeyUsers.Count > 0) { var existing = opts.NKeys?.ToList() ?? []; existing.AddRange(nkeyUsers); opts.NKeys = existing; } } } } /// /// Parses an exports array: [{ service: "sub" }, { stream: "sub" }]. /// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap. /// private static List ParseExports(List exportList) { var result = new List(); foreach (var item in exportList) { if (item is not Dictionary dict) continue; string? service = null, stream = null; string? latencySubject = null; int latencySampling = 100; foreach (var (k, v) in dict) { switch (k.ToLowerInvariant()) { case "service": service = ToString(v); break; case "stream": stream = ToString(v); break; case "latency": // latency can be a string (subject only) or a map { subject, sampling } // Go reference: server/opts.go — parseServiceLatency if (v is string latStr) { latencySubject = latStr; } else if (v is Dictionary latDict) { foreach (var (lk, lv) in latDict) { switch (lk.ToLowerInvariant()) { case "subject": latencySubject = ToString(lv); break; case "sampling": latencySampling = ToInt(lv); break; } } } break; } } result.Add(new ExportDefinition { Service = service, Stream = stream, LatencySubject = latencySubject, LatencySampling = latencySampling, }); } return result; } /// /// Parses an imports array: [{ service: { account: X, subject: "sub" }, to: "local" }]. /// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap. /// private static List ParseImports(List importList) { var result = new List(); foreach (var item in importList) { if (item is not Dictionary dict) continue; string? serviceAccount = null, serviceSubject = null; string? streamAccount = null, streamSubject = null; string? to = null; foreach (var (k, v) in dict) { switch (k.ToLowerInvariant()) { case "service" when v is Dictionary svcDict: foreach (var (sk, sv) in svcDict) { switch (sk.ToLowerInvariant()) { case "account": serviceAccount = ToString(sv); break; case "subject": serviceSubject = ToString(sv); break; } } break; case "stream" when v is Dictionary strmDict: foreach (var (sk, sv) in strmDict) { switch (sk.ToLowerInvariant()) { case "account": streamAccount = ToString(sv); break; case "subject": streamSubject = ToString(sv); break; } } break; case "to": to = ToString(v); break; } } result.Add(new ImportDefinition { ServiceAccount = serviceAccount, ServiceSubject = serviceSubject, StreamAccount = streamAccount, StreamSubject = streamSubject, To = to, }); } return result; } /// /// Splits a users array into plain users and NKey users. /// An entry with an "nkey" field is an NKey user; entries with "user" are plain users. /// Go reference: opts.go — parseUsers (lines ~2500-2700). /// private static (List PlainUsers, List NkeyUsers) ParseUsersAndNkeys(List list, List errors, string? defaultAccount = null) { var plainUsers = new List(); var nkeyUsers = new List(); foreach (var item in list) { if (item is not Dictionary userDict) { errors.Add("Expected user entry to be a map"); continue; } string? username = null; string? password = null; string? nkey = null; string? account = null; Permissions? permissions = null; foreach (var (key, value) in userDict) { switch (key.ToLowerInvariant()) { case "user" or "username": username = ToString(value); break; case "pass" or "password": password = ToString(value); break; case "nkey": nkey = ToString(value); break; case "account": account = ToString(value); break; case "permissions" or "permission": if (value is Dictionary permDict) permissions = ParsePermissions(permDict, errors); break; } } if (nkey is not null) { // NKey user: validate no password and valid NKey format if (!ValidateNkey(nkey, password is not null, errors)) continue; nkeyUsers.Add(new Auth.NKeyUser { Nkey = nkey, Permissions = permissions, Account = account ?? defaultAccount, }); continue; } if (username is null) { errors.Add("User entry missing 'user' field"); continue; } plainUsers.Add(new User { Username = username, Password = password ?? string.Empty, Account = account ?? defaultAccount, Permissions = permissions, }); } return (plainUsers, nkeyUsers); } /// /// Validates an NKey public key string. /// Go reference: opts.go — nkey must start with 'U' and be at least 56 chars. /// private const int NKeyMinLen = 56; private static bool ValidateNkey(string nkey, bool hasPassword, List errors) { if (nkey.Length < NKeyMinLen || !nkey.StartsWith('U')) { errors.Add($"Not a valid public NKey: {nkey}"); return false; } if (hasPassword) { errors.Add("NKey user entry cannot have a password"); return false; } return true; } private static List ParseUsers(List list, List errors) { var (plainUsers, _) = ParseUsersAndNkeys(list, errors); return plainUsers; } private static Permissions ParsePermissions(Dictionary dict, List errors) { SubjectPermission? publish = null; SubjectPermission? subscribe = null; ResponsePermission? response = null; foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "publish" or "pub": publish = ParseSubjectPermission(value, errors); break; case "subscribe" or "sub": subscribe = ParseSubjectPermission(value, errors); break; case "resp" or "response": if (value is Dictionary respDict) response = ParseResponsePermission(respDict); break; } } return new Permissions { Publish = publish, Subscribe = subscribe, Response = response, }; } private static SubjectPermission? ParseSubjectPermission(object? value, List errors) { // Can be a simple list of strings (treated as allow) or a dict with allow/deny if (value is Dictionary dict) { IReadOnlyList? allow = null; IReadOnlyList? deny = null; foreach (var (key, v) in dict) { switch (key.ToLowerInvariant()) { case "allow": allow = ToStringList(v); break; case "deny": deny = ToStringList(v); break; } } return new SubjectPermission { Allow = allow, Deny = deny }; } if (value is List list) { return new SubjectPermission { Allow = ToStringList(list) }; } if (value is string s) { return new SubjectPermission { Allow = [s] }; } return null; } private static ResponsePermission ParseResponsePermission(Dictionary dict) { var maxMsgs = 0; var expires = TimeSpan.Zero; foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "max_msgs" or "max": maxMsgs = ToInt(value); break; case "expires" or "ttl": expires = ParseDuration(value); break; } } return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires }; } // ─── TLS parsing ─────────────────────────────────────────────── private static void ParseTls(Dictionary dict, NatsOptions opts, List errors) { foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "cert_file": opts.TlsCert = ToString(value); break; case "key_file": opts.TlsKey = ToString(value); break; case "ca_file": opts.TlsCaCert = ToString(value); break; case "verify": opts.TlsVerify = ToBool(value); break; case "verify_and_map": var map = ToBool(value); opts.TlsMap = map; if (map) opts.TlsVerify = true; break; case "timeout": opts.TlsTimeout = value switch { long l => TimeSpan.FromSeconds(l), double d => TimeSpan.FromSeconds(d), string s => ParseDuration(s), _ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"), }; break; case "connection_rate_limit": opts.TlsRateLimit = ToLong(value); break; case "pinned_certs": if (value is List pinnedList) { var certs = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var item in pinnedList) { if (item is string s) certs.Add(s.ToLowerInvariant()); } opts.TlsPinnedCerts = certs; } break; case "handshake_first" or "first" or "immediate": opts.TlsHandshakeFirst = ToBool(value); break; case "handshake_first_fallback": opts.TlsHandshakeFirstFallback = ParseDuration(value); break; case "ocsp_peer": ParseOcspPeer(value, opts, errors); break; default: // Unknown TLS keys silently ignored break; } } } private static void ParseOcspPeer(object? value, NatsOptions opts, List errors) { switch (value) { case bool verify: opts.OcspPeerVerify = verify; return; case Dictionary dict: try { var cfg = OCSPPeerConfig.Parse(dict); opts.OcspPeerVerify = cfg.Verify; } catch (FormatException ex) { errors.Add(ex.Message); } return; default: errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]"); return; } } // ─── Tags parsing ────────────────────────────────────────────── private static void ParseTags(Dictionary dict, NatsOptions opts) { var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (key, value) in dict) { tags[key] = ToString(value); } opts.Tags = tags; } // ─── MQTT parsing ──────────────────────────────────────────────── // Reference: Go server/opts.go parseMQTT (lines ~5443-5541) private static void ParseMqtt(Dictionary dict, NatsOptions opts, List errors) { var mqtt = opts.Mqtt ?? new MqttOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "listen": var (host, port) = ParseHostPort(value); if (host is not null) mqtt.Host = host; if (port is not null) mqtt.Port = port.Value; break; case "port": mqtt.Port = ToInt(value); break; case "host" or "net": mqtt.Host = ToString(value); break; case "no_auth_user": mqtt.NoAuthUser = ToString(value); break; case "tls": if (value is Dictionary tlsDict) ParseMqttTls(tlsDict, mqtt, errors); break; case "authorization" or "authentication": if (value is Dictionary authDict) ParseMqttAuth(authDict, mqtt, errors); break; case "ack_wait" or "ackwait": mqtt.AckWait = ParseDuration(value); break; case "js_api_timeout" or "api_timeout": mqtt.JsApiTimeout = ParseDuration(value); break; case "max_ack_pending" or "max_pending" or "max_inflight": var pending = ToInt(value); if (pending < 0 || pending > 0xFFFF) errors.Add($"mqtt max_ack_pending invalid value {pending}, should be in [0..{0xFFFF}] range"); else mqtt.MaxAckPending = (ushort)pending; break; case "js_domain": mqtt.JsDomain = ToString(value); break; case "stream_replicas": mqtt.StreamReplicas = ToInt(value); break; case "consumer_replicas": mqtt.ConsumerReplicas = ToInt(value); break; case "consumer_memory_storage": mqtt.ConsumerMemoryStorage = ToBool(value); break; case "consumer_inactive_threshold" or "consumer_auto_cleanup": mqtt.ConsumerInactiveThreshold = ParseDuration(value); break; default: break; } } opts.Mqtt = mqtt; } private static void ParseMqttAuth(Dictionary dict, MqttOptions mqtt, List errors) { foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "user" or "username": mqtt.Username = ToString(value); break; case "pass" or "password": mqtt.Password = ToString(value); break; case "token": mqtt.Token = ToString(value); break; case "timeout": mqtt.AuthTimeout = ToDouble(value); break; default: break; } } } // ─── WebSocket parsing ──────────────────────────────────────────────────── // Reference: Go server/opts.go parseWebsocket (lines ~5600-5700) private static void ParseWebSocket(Dictionary dict, NatsOptions opts, List errors) { var ws = opts.WebSocket ?? new WebSocketOptions(); foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "listen": try { var (host, port) = ParseHostPort(value); if (host is not null) ws.Host = host; if (port is not null) ws.Port = port.Value; } catch (Exception ex) { errors.Add($"Invalid websocket.listen: {ex.Message}"); } break; case "port": ws.Port = ToInt(value); break; case "host" or "net": ws.Host = ToString(value); break; case "advertise": ws.Advertise = ToString(value); break; case "no_auth_user": ws.NoAuthUser = ToString(value); break; case "no_tls": ws.NoTls = ToBool(value); break; case "same_origin": ws.SameOrigin = ToBool(value); break; case "compression": ws.Compression = ToBool(value); break; case "ping_interval": try { ws.PingInterval = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid websocket.ping_interval: {ex.Message}"); } break; case "handshake_timeout": try { ws.HandshakeTimeout = ParseDuration(value); } catch (Exception ex) { errors.Add($"Invalid websocket.handshake_timeout: {ex.Message}"); } break; case "jwt_cookie": ws.JwtCookie = ToString(value); break; case "username_header" or "username_cookie": ws.UsernameCookie = ToString(value); break; case "token_cookie": ws.TokenCookie = ToString(value); break; default: break; } } opts.WebSocket = ws; } private static void ParseMqttTls(Dictionary dict, MqttOptions mqtt, List errors) { foreach (var (key, value) in dict) { switch (key.ToLowerInvariant()) { case "cert_file": mqtt.TlsCert = ToString(value); break; case "key_file": mqtt.TlsKey = ToString(value); break; case "ca_file": mqtt.TlsCaCert = ToString(value); break; case "verify": mqtt.TlsVerify = ToBool(value); break; case "verify_and_map": var map = ToBool(value); mqtt.TlsMap = map; if (map) mqtt.TlsVerify = true; break; case "timeout": mqtt.TlsTimeout = ToDouble(value); break; case "pinned_certs": if (value is List pinnedList) { var certs = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var item in pinnedList) { if (item is string s) certs.Add(s.ToLowerInvariant()); } mqtt.TlsPinnedCerts = certs; } break; default: break; } } } // ─── Type conversion helpers ─────────────────────────────────── private static int ToInt(object? value) => value switch { long l => (int)l, int i => i, double d => (int)d, string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i, _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"), }; private static long ToLong(object? value) => value switch { long l => l, int i => i, double d => (long)d, string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l, _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"), }; private static long ParseByteSize(object? value) { if (value is long l) return l; if (value is int i) return i; if (value is double d) return (long)d; if (value is not string s) throw new FormatException($"Cannot parse byte size from {value?.GetType().Name ?? "null"}"); var trimmed = s.Trim(); var match = ByteSizePattern.Match(trimmed); if (!match.Success) throw new FormatException($"Cannot parse byte size: '{s}'"); var amount = long.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToLowerInvariant(); var multiplier = unit switch { "" or "b" => 1L, "kb" => 1024L, "mb" => 1024L * 1024L, "gb" => 1024L * 1024L * 1024L, "tb" => 1024L * 1024L * 1024L * 1024L, _ => throw new FormatException($"Unknown byte-size unit: '{unit}'"), }; checked { return amount * multiplier; } } private static bool ToBool(object? value) => value switch { bool b => b, _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"), }; private static string ToString(object? value) => value switch { string s => s, long l => l.ToString(CultureInfo.InvariantCulture), _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"), }; private static double ToDouble(object? value) => value switch { double d => d, long l => l, int i => i, string s when double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => d, _ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to double"), }; /// /// Parses a config value that can be a single string or a list of strings into a string[]. /// Go reference: server/opts.go — parseTrustedKeys accepts string, []string, []interface{}. /// private static string[]? ParseStringArray(object? value) { if (value is List list) { var result = new List(list.Count); foreach (var item in list) { if (item is string s) result.Add(s); } return result.Count > 0 ? result.ToArray() : null; } if (value is string str) return [str]; return null; } private static IReadOnlyList ToStringList(object? value) { if (value is List list) { var result = new List(list.Count); foreach (var item in list) { if (item is string s) result.Add(s); } return result; } if (value is string str) return [str]; return []; } } /// /// Thrown when one or more configuration validation errors are detected. /// All errors are collected rather than failing on the first one. /// public sealed class ConfigProcessorException(string message, List errors, List? warnings = null) : Exception(message) { public IReadOnlyList Errors => errors; public IReadOnlyList Warnings => warnings ?? []; } /// /// Represents a non-fatal configuration warning. /// Go reference: configWarningErr. /// public class ConfigWarningException(string message, string? source = null) : Exception(message) { public string? SourceLocation { get; } = source; } /// /// Warning used when an unknown config field is encountered. /// Go reference: unknownConfigFieldErr. /// public sealed class UnknownConfigFieldWarning(string field, string? source = null) : ConfigWarningException($"unknown field {field}", source) { public string Field { get; } = field; }