Files
natsdotnet/src/NATS.Server/Configuration/ConfigProcessor.cs
Joseph Doherty 4de691c9c5 perf: add FileStore buffered writes, O(1) state tracking, and eliminate redundant per-publish work
Implement Go-parity background flush loop (coalesce 16KB/8ms) in MsgBlock/FileStore,
replace O(n) GetStateAsync with incremental counters, skip PruneExpired/LoadAsync/
PrunePerSubject when not needed, and bypass RAFT for single-replica streams. Fix counter
tracking bugs in RemoveMsg/EraseMsg/TTL expiry and ObjectDisposedException races in
flush loop disposal. FileStore optimizations verified with 3112/3112 JetStream tests
passing; async publish benchmark remains at ~174 msg/s due to E2E protocol path bottleneck.
2026-03-13 03:11:11 -04:00

1902 lines
67 KiB
C#

// 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;
/// <summary>
/// Maps a parsed NATS configuration dictionary (produced by <see cref="NatsConfParser"/>)
/// into a fully populated <see cref="NatsOptions"/> instance. Collects all validation
/// errors rather than failing on the first one.
/// </summary>
public static class ConfigProcessor
{
/// <summary>
/// Parses a configuration file and returns the populated options.
/// </summary>
public static NatsOptions ProcessConfigFile(string filePath)
{
var config = NatsConfParser.ParseFile(filePath);
var opts = new NatsOptions { ConfigFile = filePath };
ApplyConfig(config, opts);
return opts;
}
/// <summary>
/// Parses configuration text (not from a file) and returns the populated options.
/// </summary>
public static NatsOptions ProcessConfig(string configText)
{
var config = NatsConfParser.Parse(configText);
var opts = new NatsOptions();
ApplyConfig(config, opts);
return opts;
}
/// <summary>
/// Applies a parsed configuration dictionary to existing options.
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
/// </summary>
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
{
var errors = new List<string>();
var warnings = new List<string>();
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<string> errors,
List<string> 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<string, object?> authDict)
ParseAuthorization(authDict, opts, errors);
break;
case "no_auth_user":
opts.NoAuthUser = ToString(value);
break;
// TLS
case "tls":
if (value is Dictionary<string, object?> 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<string, object?> clusterDict)
opts.Cluster = ParseCluster(clusterDict, errors);
break;
case "gateway":
if (value is Dictionary<string, object?> gatewayDict)
opts.Gateway = ParseGateway(gatewayDict, errors);
break;
case "leaf":
case "leafnode":
case "leafnodes":
if (value is Dictionary<string, object?> leafDict)
opts.LeafNode = ParseLeafNode(leafDict, errors);
break;
case "jetstream":
if (value is Dictionary<string, object?> jsDict)
opts.JetStream = ParseJetStream(jsDict, errors);
break;
// Tags
case "server_tags":
if (value is Dictionary<string, object?> 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<string, object?> mqttDict)
ParseMqtt(mqttDict, opts, errors);
break;
// WebSocket
case "websocket" or "ws":
if (value is Dictionary<string, object?> wsDict)
ParseWebSocket(wsDict, opts, errors);
break;
// Accounts block — each key is an account name containing users/limits
case "accounts":
if (value is Dictionary<string, object?> 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<string, object?> mappingsDict)
{
opts.SubjectMappings ??= new Dictionary<string, string>();
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<string, object?> 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 ────────────────────────────────────────────
/// <summary>
/// Parses a "listen" value that can be:
/// <list type="bullet">
/// <item><c>":4222"</c> — port only</item>
/// <item><c>"0.0.0.0:4222"</c> — host + port</item>
/// <item><c>"4222"</c> — bare number (port only)</item>
/// <item><c>4222</c> — integer (port only)</item>
/// </list>
/// </summary>
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;
}
/// <summary>
/// Parses a monitor listen value. For "http" the port goes to MonitorPort;
/// for "https" the port goes to MonitorHttpsPort.
/// </summary>
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;
}
}
/// <summary>
/// Shared host:port parsing logic.
/// </summary>
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 ──────────────────────────────────────────
/// <summary>
/// Parses a duration value. Accepts:
/// <list type="bullet">
/// <item>A string with unit suffix: "30s", "2m", "1h", "500ms"</item>
/// <item>A number (long/double) treated as seconds</item>
/// </list>
/// </summary>
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<string, object?> dict, List<string> 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<object?> routeList)
options.Routes = ToStringList(routeList).ToList();
break;
case "pool_size":
options.PoolSize = ToInt(value);
break;
default:
break;
}
}
return options;
}
private static GatewayOptions ParseGateway(Dictionary<string, object?> dict, List<string> 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<string, object?> authDict)
ParseGatewayAuthorization(authDict, options, errors);
break;
case "gateways":
// Must be a list, not a map
if (value is List<object?> gwList)
{
foreach (var item in gwList)
{
if (item is Dictionary<string, object?> gwDict)
options.RemoteGateways.Add(ParseRemoteGateway(gwDict, errors));
}
}
else if (value is Dictionary<string, object?>)
{
errors.Add("gateway.gateways must be an array, not a map");
}
break;
default:
break;
}
}
return options;
}
private static void ParseGatewayAuthorization(Dictionary<string, object?> dict, GatewayOptions options, List<string> 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<string, object?> dict, List<string> 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<object?> urlList)
remote.Urls.AddRange(ToStringList(urlList));
break;
default:
break;
}
}
return remote;
}
private static LeafNodeOptions ParseLeafNode(Dictionary<string, object?> dict, List<string> 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<string, object?> authDict)
ParseLeafNodeAuthorization(authDict, options, errors);
break;
case "remotes":
if (value is List<object?> remoteList)
{
foreach (var item in remoteList)
{
if (item is Dictionary<string, object?> 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<string, object?> dict, LeafNodeOptions options, List<string> 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<string, object?> dict, List<string> errors)
{
var remote = new RemoteLeafOptions();
var urls = new List<string>();
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<object?> 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<string, object?> dict, List<string> 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<string, object?> tiers)
{
foreach (var (tierName, rawTier) in tiers)
{
if (rawTier is Dictionary<string, object?> tierDict)
options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors);
}
}
break;
}
}
return options;
}
private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary<string, object?> dict, List<string> 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<string, object?> dict, NatsOptions opts, List<string> errors)
{
string? token = null;
List<object?>? 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<object?> 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 ──────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
private static void ParseAccounts(Dictionary<string, object?> accountsDict, NatsOptions opts, List<string> errors)
{
opts.Accounts ??= new Dictionary<string, AccountConfig>();
foreach (var (accountName, accountValue) in accountsDict)
{
if (accountValue is not Dictionary<string, object?> acctDict)
{
errors.Add($"Expected account '{accountName}' value to be a map");
continue;
}
int maxConnections = 0;
int maxSubscriptions = 0;
List<object?>? userList = null;
List<ExportDefinition>? exports = null;
List<ImportDefinition>? imports = null;
foreach (var (key, value) in acctDict)
{
switch (key.ToLowerInvariant())
{
case "users":
if (value is List<object?> 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<object?> exportList)
exports = ParseExports(exportList);
break;
case "imports":
if (value is List<object?> importList)
imports = ParseImports(importList);
break;
case "mappings" or "maps":
if (value is Dictionary<string, object?> 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;
}
}
}
}
/// <summary>
/// Parses an exports array: [{ service: "sub" }, { stream: "sub" }].
/// Go reference: server/opts.go — parseExportStreamMap / parseExportServiceMap.
/// </summary>
private static List<ExportDefinition> ParseExports(List<object?> exportList)
{
var result = new List<ExportDefinition>();
foreach (var item in exportList)
{
if (item is not Dictionary<string, object?> 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<string, object?> 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;
}
/// <summary>
/// Parses an imports array: [{ service: { account: X, subject: "sub" }, to: "local" }].
/// Go reference: server/opts.go — parseImportStreamMap / parseImportServiceMap.
/// </summary>
private static List<ImportDefinition> ParseImports(List<object?> importList)
{
var result = new List<ImportDefinition>();
foreach (var item in importList)
{
if (item is not Dictionary<string, object?> 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<string, object?> 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<string, object?> 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;
}
/// <summary>
/// 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).
/// </summary>
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors, string? defaultAccount = null)
{
var plainUsers = new List<User>();
var nkeyUsers = new List<Auth.NKeyUser>();
foreach (var item in list)
{
if (item is not Dictionary<string, object?> 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<string, object?> 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);
}
/// <summary>
/// Validates an NKey public key string.
/// Go reference: opts.go — nkey must start with 'U' and be at least 56 chars.
/// </summary>
private const int NKeyMinLen = 56;
private static bool ValidateNkey(string nkey, bool hasPassword, List<string> 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<User> ParseUsers(List<object?> list, List<string> errors)
{
var (plainUsers, _) = ParseUsersAndNkeys(list, errors);
return plainUsers;
}
private static Permissions ParsePermissions(Dictionary<string, object?> dict, List<string> 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<string, object?> respDict)
response = ParseResponsePermission(respDict);
break;
}
}
return new Permissions
{
Publish = publish,
Subscribe = subscribe,
Response = response,
};
}
private static SubjectPermission? ParseSubjectPermission(object? value, List<string> errors)
{
// Can be a simple list of strings (treated as allow) or a dict with allow/deny
if (value is Dictionary<string, object?> dict)
{
IReadOnlyList<string>? allow = null;
IReadOnlyList<string>? 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<object?> list)
{
return new SubjectPermission { Allow = ToStringList(list) };
}
if (value is string s)
{
return new SubjectPermission { Allow = [s] };
}
return null;
}
private static ResponsePermission ParseResponsePermission(Dictionary<string, object?> 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<string, object?> dict, NatsOptions opts, List<string> 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<object?> pinnedList)
{
var certs = new HashSet<string>(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<string> errors)
{
switch (value)
{
case bool verify:
opts.OcspPeerVerify = verify;
return;
case Dictionary<string, object?> 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<string, object?> dict, NatsOptions opts)
{
var tags = new Dictionary<string, string>(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<string, object?> dict, NatsOptions opts, List<string> 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<string, object?> tlsDict)
ParseMqttTls(tlsDict, mqtt, errors);
break;
case "authorization" or "authentication":
if (value is Dictionary<string, object?> 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<string, object?> dict, MqttOptions mqtt, List<string> 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<string, object?> dict, NatsOptions opts, List<string> 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<string, object?> dict, MqttOptions mqtt, List<string> 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<object?> pinnedList)
{
var certs = new HashSet<string>(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 ───────────────────────────────────
// Go: opts.go — strconv.Atoi after strings.TrimSuffix(s, "%") for sampling values.
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.AsSpan().TrimEnd('%'), 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"),
};
/// <summary>
/// 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{}.
/// </summary>
private static string[]? ParseStringArray(object? value)
{
if (value is List<object?> list)
{
var result = new List<string>(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<string> ToStringList(object? value)
{
if (value is List<object?> list)
{
var result = new List<string>(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 [];
}
}
/// <summary>
/// Thrown when one or more configuration validation errors are detected.
/// All errors are collected rather than failing on the first one.
/// </summary>
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
: Exception(message)
{
public IReadOnlyList<string> Errors => errors;
public IReadOnlyList<string> Warnings => warnings ?? [];
}
/// <summary>
/// Represents a non-fatal configuration warning.
/// Go reference: configWarningErr.
/// </summary>
public class ConfigWarningException(string message, string? source = null) : Exception(message)
{
public string? SourceLocation { get; } = source;
}
/// <summary>
/// Warning used when an unknown config field is encountered.
/// Go reference: unknownConfigFieldErr.
/// </summary>
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
: ConfigWarningException($"unknown field {field}", source)
{
public string Field { get; } = field;
}