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.
1902 lines
67 KiB
C#
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;
|
|
}
|