docs: add design doc for SYSTEM and ACCOUNT connection types
Covers 6 implementation layers: ClientKind enum + INatsClient interface, event infrastructure with Channel<T>, system event publishing, request-reply monitoring services, import/export model with ACCOUNT client, and response routing with latency tracking.
This commit is contained in:
@@ -1,86 +1,126 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
|
||||
var options = new NatsOptions();
|
||||
// First pass: scan args for -c flag to get config file path
|
||||
string? configFile = null;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
if (args[i] == "-c" && i + 1 < args.Length)
|
||||
{
|
||||
configFile = args[++i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var windowsService = false;
|
||||
|
||||
// Parse ALL CLI flags into NatsOptions first
|
||||
// If config file specified, load it as the base options
|
||||
var options = configFile != null
|
||||
? ConfigProcessor.ProcessConfigFile(configFile)
|
||||
: new NatsOptions();
|
||||
|
||||
// Second pass: apply CLI args on top of config-loaded options, tracking InCmdLine
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "-p" or "--port" when i + 1 < args.Length:
|
||||
options.Port = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("Port");
|
||||
break;
|
||||
case "-a" or "--addr" when i + 1 < args.Length:
|
||||
options.Host = args[++i];
|
||||
options.InCmdLine.Add("Host");
|
||||
break;
|
||||
case "-n" or "--name" when i + 1 < args.Length:
|
||||
options.ServerName = args[++i];
|
||||
options.InCmdLine.Add("ServerName");
|
||||
break;
|
||||
case "-m" or "--http_port" when i + 1 < args.Length:
|
||||
options.MonitorPort = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("MonitorPort");
|
||||
break;
|
||||
case "--http_base_path" when i + 1 < args.Length:
|
||||
options.MonitorBasePath = args[++i];
|
||||
options.InCmdLine.Add("MonitorBasePath");
|
||||
break;
|
||||
case "--https_port" when i + 1 < args.Length:
|
||||
options.MonitorHttpsPort = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("MonitorHttpsPort");
|
||||
break;
|
||||
case "-c" when i + 1 < args.Length:
|
||||
options.ConfigFile = args[++i];
|
||||
// Already handled in first pass; skip the value
|
||||
i++;
|
||||
break;
|
||||
case "--pid" when i + 1 < args.Length:
|
||||
options.PidFile = args[++i];
|
||||
options.InCmdLine.Add("PidFile");
|
||||
break;
|
||||
case "--ports_file_dir" when i + 1 < args.Length:
|
||||
options.PortsFileDir = args[++i];
|
||||
options.InCmdLine.Add("PortsFileDir");
|
||||
break;
|
||||
case "--tls":
|
||||
break;
|
||||
case "--tlscert" when i + 1 < args.Length:
|
||||
options.TlsCert = args[++i];
|
||||
options.InCmdLine.Add("TlsCert");
|
||||
break;
|
||||
case "--tlskey" when i + 1 < args.Length:
|
||||
options.TlsKey = args[++i];
|
||||
options.InCmdLine.Add("TlsKey");
|
||||
break;
|
||||
case "--tlscacert" when i + 1 < args.Length:
|
||||
options.TlsCaCert = args[++i];
|
||||
options.InCmdLine.Add("TlsCaCert");
|
||||
break;
|
||||
case "--tlsverify":
|
||||
options.TlsVerify = true;
|
||||
options.InCmdLine.Add("TlsVerify");
|
||||
break;
|
||||
case "-D" or "--debug":
|
||||
options.Debug = true;
|
||||
options.InCmdLine.Add("Debug");
|
||||
break;
|
||||
case "-V" or "-T" or "--trace":
|
||||
options.Trace = true;
|
||||
options.InCmdLine.Add("Trace");
|
||||
break;
|
||||
case "-DV":
|
||||
options.Debug = true;
|
||||
options.Trace = true;
|
||||
options.InCmdLine.Add("Debug");
|
||||
options.InCmdLine.Add("Trace");
|
||||
break;
|
||||
case "-l" or "--log" or "--log_file" when i + 1 < args.Length:
|
||||
options.LogFile = args[++i];
|
||||
options.InCmdLine.Add("LogFile");
|
||||
break;
|
||||
case "--log_size_limit" when i + 1 < args.Length:
|
||||
options.LogSizeLimit = long.Parse(args[++i]);
|
||||
options.InCmdLine.Add("LogSizeLimit");
|
||||
break;
|
||||
case "--log_max_files" when i + 1 < args.Length:
|
||||
options.LogMaxFiles = int.Parse(args[++i]);
|
||||
options.InCmdLine.Add("LogMaxFiles");
|
||||
break;
|
||||
case "--logtime" when i + 1 < args.Length:
|
||||
options.Logtime = bool.Parse(args[++i]);
|
||||
options.InCmdLine.Add("Logtime");
|
||||
break;
|
||||
case "--logtime_utc":
|
||||
options.LogtimeUTC = true;
|
||||
options.InCmdLine.Add("LogtimeUTC");
|
||||
break;
|
||||
case "--syslog":
|
||||
options.Syslog = true;
|
||||
options.InCmdLine.Add("Syslog");
|
||||
break;
|
||||
case "--remote_syslog" when i + 1 < args.Length:
|
||||
options.RemoteSyslog = args[++i];
|
||||
options.InCmdLine.Add("RemoteSyslog");
|
||||
break;
|
||||
case "--service":
|
||||
windowsService = true;
|
||||
@@ -163,6 +203,14 @@ if (windowsService)
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
using var server = new NatsServer(options, loggerFactory);
|
||||
|
||||
// Store CLI snapshot for reload precedence (CLI flags always win over config file)
|
||||
if (configFile != null && options.InCmdLine.Count > 0)
|
||||
{
|
||||
var cliSnapshot = new NatsOptions();
|
||||
ConfigReloader.MergeCliOverrides(cliSnapshot, options, options.InCmdLine);
|
||||
server.SetCliSnapshot(cliSnapshot, options.InCmdLine);
|
||||
}
|
||||
|
||||
// Register signal handlers
|
||||
server.HandleSignals();
|
||||
|
||||
|
||||
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
// 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;
|
||||
|
||||
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>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Error processing '{key}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
|
||||
// Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.)
|
||||
default:
|
||||
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 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}'"),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
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":
|
||||
opts.Authorization = ToString(value);
|
||||
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?> userList)
|
||||
opts.Users = ParseUsers(userList, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown auth keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<User> ParseUsers(List<object?> list, List<string> errors)
|
||||
{
|
||||
var users = new List<User>();
|
||||
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? 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 "account":
|
||||
account = ToString(value);
|
||||
break;
|
||||
case "permissions" or "permission":
|
||||
if (value is Dictionary<string, object?> permDict)
|
||||
permissions = ParsePermissions(permDict, errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (username is null)
|
||||
{
|
||||
errors.Add("User entry missing 'user' field");
|
||||
continue;
|
||||
}
|
||||
|
||||
users.Add(new User
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
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;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
// ─── Type conversion helpers ───────────────────────────────────
|
||||
|
||||
private static int ToInt(object? value) => value switch
|
||||
{
|
||||
long l => (int)l,
|
||||
int i => i,
|
||||
double d => (int)d,
|
||||
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"),
|
||||
};
|
||||
|
||||
private static long ToLong(object? value) => value switch
|
||||
{
|
||||
long l => l,
|
||||
int i => i,
|
||||
double d => (long)d,
|
||||
string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"),
|
||||
};
|
||||
|
||||
private static 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 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)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
}
|
||||
341
src/NATS.Server/Configuration/ConfigReloader.cs
Normal file
341
src/NATS.Server/Configuration/ConfigReloader.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
// Port of Go server/reload.go — config diffing, validation, and CLI override merging
|
||||
// for hot reload support. Reference: golang/nats-server/server/reload.go.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides static methods for comparing two <see cref="NatsOptions"/> instances,
|
||||
/// validating that detected changes are reloadable, and merging CLI overrides
|
||||
/// so that command-line flags always take precedence over config file values.
|
||||
/// </summary>
|
||||
public static class ConfigReloader
|
||||
{
|
||||
// Non-reloadable options (match Go server — Host, Port, ServerName require restart)
|
||||
private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];
|
||||
|
||||
// Logging-related options
|
||||
private static readonly HashSet<string> LoggingOptions =
|
||||
["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
|
||||
"LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];
|
||||
|
||||
// Auth-related options
|
||||
private static readonly HashSet<string> AuthOptions =
|
||||
["Username", "Password", "Authorization", "Users", "NKeys",
|
||||
"NoAuthUser", "AuthTimeout"];
|
||||
|
||||
// TLS-related options
|
||||
private static readonly HashSet<string> TlsOptions =
|
||||
["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
|
||||
"TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
|
||||
"AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="NatsOptions"/> instances property by property and returns
|
||||
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
|
||||
/// is tagged with the appropriate category flags.
|
||||
/// </summary>
|
||||
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var changes = new List<IConfigChange>();
|
||||
|
||||
// Non-reloadable
|
||||
CompareAndAdd(changes, "Host", oldOpts.Host, newOpts.Host);
|
||||
CompareAndAdd(changes, "Port", oldOpts.Port, newOpts.Port);
|
||||
CompareAndAdd(changes, "ServerName", oldOpts.ServerName, newOpts.ServerName);
|
||||
|
||||
// Logging
|
||||
CompareAndAdd(changes, "Debug", oldOpts.Debug, newOpts.Debug);
|
||||
CompareAndAdd(changes, "Trace", oldOpts.Trace, newOpts.Trace);
|
||||
CompareAndAdd(changes, "TraceVerbose", oldOpts.TraceVerbose, newOpts.TraceVerbose);
|
||||
CompareAndAdd(changes, "Logtime", oldOpts.Logtime, newOpts.Logtime);
|
||||
CompareAndAdd(changes, "LogtimeUTC", oldOpts.LogtimeUTC, newOpts.LogtimeUTC);
|
||||
CompareAndAdd(changes, "LogFile", oldOpts.LogFile, newOpts.LogFile);
|
||||
CompareAndAdd(changes, "LogSizeLimit", oldOpts.LogSizeLimit, newOpts.LogSizeLimit);
|
||||
CompareAndAdd(changes, "LogMaxFiles", oldOpts.LogMaxFiles, newOpts.LogMaxFiles);
|
||||
CompareAndAdd(changes, "Syslog", oldOpts.Syslog, newOpts.Syslog);
|
||||
CompareAndAdd(changes, "RemoteSyslog", oldOpts.RemoteSyslog, newOpts.RemoteSyslog);
|
||||
|
||||
// Auth
|
||||
CompareAndAdd(changes, "Username", oldOpts.Username, newOpts.Username);
|
||||
CompareAndAdd(changes, "Password", oldOpts.Password, newOpts.Password);
|
||||
CompareAndAdd(changes, "Authorization", oldOpts.Authorization, newOpts.Authorization);
|
||||
CompareCollectionAndAdd(changes, "Users", oldOpts.Users, newOpts.Users);
|
||||
CompareCollectionAndAdd(changes, "NKeys", oldOpts.NKeys, newOpts.NKeys);
|
||||
CompareAndAdd(changes, "NoAuthUser", oldOpts.NoAuthUser, newOpts.NoAuthUser);
|
||||
CompareAndAdd(changes, "AuthTimeout", oldOpts.AuthTimeout, newOpts.AuthTimeout);
|
||||
|
||||
// TLS
|
||||
CompareAndAdd(changes, "TlsCert", oldOpts.TlsCert, newOpts.TlsCert);
|
||||
CompareAndAdd(changes, "TlsKey", oldOpts.TlsKey, newOpts.TlsKey);
|
||||
CompareAndAdd(changes, "TlsCaCert", oldOpts.TlsCaCert, newOpts.TlsCaCert);
|
||||
CompareAndAdd(changes, "TlsVerify", oldOpts.TlsVerify, newOpts.TlsVerify);
|
||||
CompareAndAdd(changes, "TlsMap", oldOpts.TlsMap, newOpts.TlsMap);
|
||||
CompareAndAdd(changes, "TlsTimeout", oldOpts.TlsTimeout, newOpts.TlsTimeout);
|
||||
CompareAndAdd(changes, "TlsHandshakeFirst", oldOpts.TlsHandshakeFirst, newOpts.TlsHandshakeFirst);
|
||||
CompareAndAdd(changes, "TlsHandshakeFirstFallback", oldOpts.TlsHandshakeFirstFallback, newOpts.TlsHandshakeFirstFallback);
|
||||
CompareAndAdd(changes, "AllowNonTls", oldOpts.AllowNonTls, newOpts.AllowNonTls);
|
||||
CompareAndAdd(changes, "TlsRateLimit", oldOpts.TlsRateLimit, newOpts.TlsRateLimit);
|
||||
CompareCollectionAndAdd(changes, "TlsPinnedCerts", oldOpts.TlsPinnedCerts, newOpts.TlsPinnedCerts);
|
||||
|
||||
// Limits
|
||||
CompareAndAdd(changes, "MaxConnections", oldOpts.MaxConnections, newOpts.MaxConnections);
|
||||
CompareAndAdd(changes, "MaxPayload", oldOpts.MaxPayload, newOpts.MaxPayload);
|
||||
CompareAndAdd(changes, "MaxPending", oldOpts.MaxPending, newOpts.MaxPending);
|
||||
CompareAndAdd(changes, "WriteDeadline", oldOpts.WriteDeadline, newOpts.WriteDeadline);
|
||||
CompareAndAdd(changes, "PingInterval", oldOpts.PingInterval, newOpts.PingInterval);
|
||||
CompareAndAdd(changes, "MaxPingsOut", oldOpts.MaxPingsOut, newOpts.MaxPingsOut);
|
||||
CompareAndAdd(changes, "MaxControlLine", oldOpts.MaxControlLine, newOpts.MaxControlLine);
|
||||
CompareAndAdd(changes, "MaxSubs", oldOpts.MaxSubs, newOpts.MaxSubs);
|
||||
CompareAndAdd(changes, "MaxSubTokens", oldOpts.MaxSubTokens, newOpts.MaxSubTokens);
|
||||
CompareAndAdd(changes, "MaxTracedMsgLen", oldOpts.MaxTracedMsgLen, newOpts.MaxTracedMsgLen);
|
||||
CompareAndAdd(changes, "MaxClosedClients", oldOpts.MaxClosedClients, newOpts.MaxClosedClients);
|
||||
|
||||
// Misc
|
||||
CompareCollectionAndAdd(changes, "Tags", oldOpts.Tags, newOpts.Tags);
|
||||
CompareAndAdd(changes, "LameDuckDuration", oldOpts.LameDuckDuration, newOpts.LameDuckDuration);
|
||||
CompareAndAdd(changes, "LameDuckGracePeriod", oldOpts.LameDuckGracePeriod, newOpts.LameDuckGracePeriod);
|
||||
CompareAndAdd(changes, "ClientAdvertise", oldOpts.ClientAdvertise, newOpts.ClientAdvertise);
|
||||
CompareAndAdd(changes, "DisableSublistCache", oldOpts.DisableSublistCache, newOpts.DisableSublistCache);
|
||||
CompareAndAdd(changes, "ConnectErrorReports", oldOpts.ConnectErrorReports, newOpts.ConnectErrorReports);
|
||||
CompareAndAdd(changes, "ReconnectErrorReports", oldOpts.ReconnectErrorReports, newOpts.ReconnectErrorReports);
|
||||
CompareAndAdd(changes, "NoHeaderSupport", oldOpts.NoHeaderSupport, newOpts.NoHeaderSupport);
|
||||
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
|
||||
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a list of config changes and returns error messages for any
|
||||
/// non-reloadable changes (properties that require a server restart).
|
||||
/// </summary>
|
||||
public static List<string> Validate(List<IConfigChange> changes)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.IsNonReloadable)
|
||||
{
|
||||
errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges CLI overrides into a freshly-parsed config so that command-line flags
|
||||
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
|
||||
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
|
||||
/// </summary>
|
||||
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
|
||||
{
|
||||
foreach (var flag in cliFlags)
|
||||
{
|
||||
switch (flag)
|
||||
{
|
||||
// Non-reloadable
|
||||
case "Host":
|
||||
fromConfig.Host = cliValues.Host;
|
||||
break;
|
||||
case "Port":
|
||||
fromConfig.Port = cliValues.Port;
|
||||
break;
|
||||
case "ServerName":
|
||||
fromConfig.ServerName = cliValues.ServerName;
|
||||
break;
|
||||
|
||||
// Logging
|
||||
case "Debug":
|
||||
fromConfig.Debug = cliValues.Debug;
|
||||
break;
|
||||
case "Trace":
|
||||
fromConfig.Trace = cliValues.Trace;
|
||||
break;
|
||||
case "TraceVerbose":
|
||||
fromConfig.TraceVerbose = cliValues.TraceVerbose;
|
||||
break;
|
||||
case "Logtime":
|
||||
fromConfig.Logtime = cliValues.Logtime;
|
||||
break;
|
||||
case "LogtimeUTC":
|
||||
fromConfig.LogtimeUTC = cliValues.LogtimeUTC;
|
||||
break;
|
||||
case "LogFile":
|
||||
fromConfig.LogFile = cliValues.LogFile;
|
||||
break;
|
||||
case "LogSizeLimit":
|
||||
fromConfig.LogSizeLimit = cliValues.LogSizeLimit;
|
||||
break;
|
||||
case "LogMaxFiles":
|
||||
fromConfig.LogMaxFiles = cliValues.LogMaxFiles;
|
||||
break;
|
||||
case "Syslog":
|
||||
fromConfig.Syslog = cliValues.Syslog;
|
||||
break;
|
||||
case "RemoteSyslog":
|
||||
fromConfig.RemoteSyslog = cliValues.RemoteSyslog;
|
||||
break;
|
||||
|
||||
// Auth
|
||||
case "Username":
|
||||
fromConfig.Username = cliValues.Username;
|
||||
break;
|
||||
case "Password":
|
||||
fromConfig.Password = cliValues.Password;
|
||||
break;
|
||||
case "Authorization":
|
||||
fromConfig.Authorization = cliValues.Authorization;
|
||||
break;
|
||||
case "Users":
|
||||
fromConfig.Users = cliValues.Users;
|
||||
break;
|
||||
case "NKeys":
|
||||
fromConfig.NKeys = cliValues.NKeys;
|
||||
break;
|
||||
case "NoAuthUser":
|
||||
fromConfig.NoAuthUser = cliValues.NoAuthUser;
|
||||
break;
|
||||
case "AuthTimeout":
|
||||
fromConfig.AuthTimeout = cliValues.AuthTimeout;
|
||||
break;
|
||||
|
||||
// TLS
|
||||
case "TlsCert":
|
||||
fromConfig.TlsCert = cliValues.TlsCert;
|
||||
break;
|
||||
case "TlsKey":
|
||||
fromConfig.TlsKey = cliValues.TlsKey;
|
||||
break;
|
||||
case "TlsCaCert":
|
||||
fromConfig.TlsCaCert = cliValues.TlsCaCert;
|
||||
break;
|
||||
case "TlsVerify":
|
||||
fromConfig.TlsVerify = cliValues.TlsVerify;
|
||||
break;
|
||||
case "TlsMap":
|
||||
fromConfig.TlsMap = cliValues.TlsMap;
|
||||
break;
|
||||
case "TlsTimeout":
|
||||
fromConfig.TlsTimeout = cliValues.TlsTimeout;
|
||||
break;
|
||||
case "TlsHandshakeFirst":
|
||||
fromConfig.TlsHandshakeFirst = cliValues.TlsHandshakeFirst;
|
||||
break;
|
||||
case "TlsHandshakeFirstFallback":
|
||||
fromConfig.TlsHandshakeFirstFallback = cliValues.TlsHandshakeFirstFallback;
|
||||
break;
|
||||
case "AllowNonTls":
|
||||
fromConfig.AllowNonTls = cliValues.AllowNonTls;
|
||||
break;
|
||||
case "TlsRateLimit":
|
||||
fromConfig.TlsRateLimit = cliValues.TlsRateLimit;
|
||||
break;
|
||||
case "TlsPinnedCerts":
|
||||
fromConfig.TlsPinnedCerts = cliValues.TlsPinnedCerts;
|
||||
break;
|
||||
|
||||
// Limits
|
||||
case "MaxConnections":
|
||||
fromConfig.MaxConnections = cliValues.MaxConnections;
|
||||
break;
|
||||
case "MaxPayload":
|
||||
fromConfig.MaxPayload = cliValues.MaxPayload;
|
||||
break;
|
||||
case "MaxPending":
|
||||
fromConfig.MaxPending = cliValues.MaxPending;
|
||||
break;
|
||||
case "WriteDeadline":
|
||||
fromConfig.WriteDeadline = cliValues.WriteDeadline;
|
||||
break;
|
||||
case "PingInterval":
|
||||
fromConfig.PingInterval = cliValues.PingInterval;
|
||||
break;
|
||||
case "MaxPingsOut":
|
||||
fromConfig.MaxPingsOut = cliValues.MaxPingsOut;
|
||||
break;
|
||||
case "MaxControlLine":
|
||||
fromConfig.MaxControlLine = cliValues.MaxControlLine;
|
||||
break;
|
||||
case "MaxSubs":
|
||||
fromConfig.MaxSubs = cliValues.MaxSubs;
|
||||
break;
|
||||
case "MaxSubTokens":
|
||||
fromConfig.MaxSubTokens = cliValues.MaxSubTokens;
|
||||
break;
|
||||
case "MaxTracedMsgLen":
|
||||
fromConfig.MaxTracedMsgLen = cliValues.MaxTracedMsgLen;
|
||||
break;
|
||||
case "MaxClosedClients":
|
||||
fromConfig.MaxClosedClients = cliValues.MaxClosedClients;
|
||||
break;
|
||||
|
||||
// Misc
|
||||
case "Tags":
|
||||
fromConfig.Tags = cliValues.Tags;
|
||||
break;
|
||||
case "LameDuckDuration":
|
||||
fromConfig.LameDuckDuration = cliValues.LameDuckDuration;
|
||||
break;
|
||||
case "LameDuckGracePeriod":
|
||||
fromConfig.LameDuckGracePeriod = cliValues.LameDuckGracePeriod;
|
||||
break;
|
||||
case "ClientAdvertise":
|
||||
fromConfig.ClientAdvertise = cliValues.ClientAdvertise;
|
||||
break;
|
||||
case "DisableSublistCache":
|
||||
fromConfig.DisableSublistCache = cliValues.DisableSublistCache;
|
||||
break;
|
||||
case "ConnectErrorReports":
|
||||
fromConfig.ConnectErrorReports = cliValues.ConnectErrorReports;
|
||||
break;
|
||||
case "ReconnectErrorReports":
|
||||
fromConfig.ReconnectErrorReports = cliValues.ReconnectErrorReports;
|
||||
break;
|
||||
case "NoHeaderSupport":
|
||||
fromConfig.NoHeaderSupport = cliValues.NoHeaderSupport;
|
||||
break;
|
||||
case "NoSystemAccount":
|
||||
fromConfig.NoSystemAccount = cliValues.NoSystemAccount;
|
||||
break;
|
||||
case "SystemAccount":
|
||||
fromConfig.SystemAccount = cliValues.SystemAccount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comparison helpers ─────────────────────────────────────────
|
||||
|
||||
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
||||
{
|
||||
if (!Equals(oldVal, newVal))
|
||||
{
|
||||
changes.Add(new ConfigChange(
|
||||
name,
|
||||
isLoggingChange: LoggingOptions.Contains(name),
|
||||
isAuthChange: AuthOptions.Contains(name),
|
||||
isTlsChange: TlsOptions.Contains(name),
|
||||
isNonReloadable: NonReloadable.Contains(name)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareCollectionAndAdd<T>(List<IConfigChange> changes, string name, T? oldVal, T? newVal)
|
||||
where T : class
|
||||
{
|
||||
// For collections we compare by reference and null state.
|
||||
// A change from null to non-null (or vice versa), or a different reference, counts as changed.
|
||||
if (ReferenceEquals(oldVal, newVal))
|
||||
return;
|
||||
|
||||
if (oldVal is null || newVal is null || !ReferenceEquals(oldVal, newVal))
|
||||
{
|
||||
changes.Add(new ConfigChange(
|
||||
name,
|
||||
isLoggingChange: LoggingOptions.Contains(name),
|
||||
isAuthChange: AuthOptions.Contains(name),
|
||||
isTlsChange: TlsOptions.Contains(name),
|
||||
isNonReloadable: NonReloadable.Contains(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/NATS.Server/Configuration/IConfigChange.cs
Normal file
54
src/NATS.Server/Configuration/IConfigChange.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
// Port of Go server/reload.go option interface — represents a single detected
|
||||
// configuration change with category flags for reload handling.
|
||||
// Reference: golang/nats-server/server/reload.go lines 42-74.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single detected configuration change during a hot reload.
|
||||
/// Category flags indicate what kind of reload action is needed.
|
||||
/// </summary>
|
||||
public interface IConfigChange
|
||||
{
|
||||
/// <summary>
|
||||
/// The property name that changed (matches NatsOptions property name).
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading the logger.
|
||||
/// </summary>
|
||||
bool IsLoggingChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading authorization.
|
||||
/// </summary>
|
||||
bool IsAuthChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading TLS configuration.
|
||||
/// </summary>
|
||||
bool IsTlsChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this option cannot be changed at runtime (requires restart).
|
||||
/// </summary>
|
||||
bool IsNonReloadable { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IConfigChange"/> using a primary constructor.
|
||||
/// </summary>
|
||||
public sealed class ConfigChange(
|
||||
string name,
|
||||
bool isLoggingChange = false,
|
||||
bool isAuthChange = false,
|
||||
bool isTlsChange = false,
|
||||
bool isNonReloadable = false) : IConfigChange
|
||||
{
|
||||
public string Name => name;
|
||||
public bool IsLoggingChange => isLoggingChange;
|
||||
public bool IsAuthChange => isAuthChange;
|
||||
public bool IsTlsChange => isTlsChange;
|
||||
public bool IsNonReloadable => isNonReloadable;
|
||||
}
|
||||
1503
src/NATS.Server/Configuration/NatsConfLexer.cs
Normal file
1503
src/NATS.Server/Configuration/NatsConfLexer.cs
Normal file
File diff suppressed because it is too large
Load Diff
421
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
421
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
// Port of Go conf/parse.go — recursive-descent parser for NATS config files.
|
||||
// Reference: golang/nats-server/conf/parse.go
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NATS configuration data (tokenized by <see cref="NatsConfLexer"/>) into
|
||||
/// a <c>Dictionary<string, object?></c> tree. Supports nested maps, arrays,
|
||||
/// variable references (block-scoped + environment), include directives, bcrypt
|
||||
/// password literals, and integer suffix multipliers.
|
||||
/// </summary>
|
||||
public static class NatsConfParser
|
||||
{
|
||||
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
|
||||
// and emits a Variable token whose value begins with "2a$" or "2b$".
|
||||
private const string BcryptPrefix2A = "2a$";
|
||||
private const string BcryptPrefix2B = "2b$";
|
||||
|
||||
// Maximum nesting depth for include directives to prevent infinite recursion.
|
||||
private const int MaxIncludeDepth = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration string into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Parse(string data)
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||
ParseFile(filePath, includeDepth: 0);
|
||||
|
||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(filePath);
|
||||
var hashBytes = SHA256.HashData(rawBytes);
|
||||
var digest = "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
|
||||
var data = Encoding.UTF8.GetString(rawBytes);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth: 0);
|
||||
state.Run();
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: parse an environment variable value by wrapping it in a synthetic
|
||||
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> ParseEnvValue(string value, HashSet<string> envVarReferences, int includeDepth)
|
||||
{
|
||||
var synthetic = $"pk={value}";
|
||||
var tokens = NatsConfLexer.Tokenize(synthetic);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences, includeDepth);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the mutable parsing state: context stack, key stack, token cursor.
|
||||
/// Mirrors the Go <c>parser</c> struct from conf/parse.go.
|
||||
/// </summary>
|
||||
private sealed class ParserState
|
||||
{
|
||||
private readonly IReadOnlyList<Token> _tokens;
|
||||
private readonly string _baseDir;
|
||||
private readonly HashSet<string> _envVarReferences;
|
||||
private readonly int _includeDepth;
|
||||
private int _pos;
|
||||
|
||||
// The context stack holds either Dictionary<string, object?> (map) or List<object?> (array).
|
||||
private readonly List<object> _ctxs = new(4);
|
||||
private object _ctx = null!;
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||
: this(tokens, baseDir, [], includeDepth: 0)
|
||||
{
|
||||
}
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
|
||||
{
|
||||
_tokens = tokens;
|
||||
_baseDir = baseDir;
|
||||
_envVarReferences = envVarReferences;
|
||||
_includeDepth = includeDepth;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
PushContext(Mapping);
|
||||
|
||||
Token prevToken = default;
|
||||
while (true)
|
||||
{
|
||||
var token = Next();
|
||||
if (token.Type == TokenType.Eof)
|
||||
{
|
||||
// Allow a trailing '}' (JSON-like configs) — mirror Go behavior.
|
||||
if (prevToken.Type == TokenType.Key && prevToken.Value != "}")
|
||||
{
|
||||
throw new FormatException($"Config is invalid at line {token.Line}:{token.Position}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
prevToken = token;
|
||||
ProcessItem(token);
|
||||
}
|
||||
}
|
||||
|
||||
private Token Next()
|
||||
{
|
||||
if (_pos >= _tokens.Count)
|
||||
{
|
||||
return new Token(TokenType.Eof, string.Empty, 0, 0);
|
||||
}
|
||||
|
||||
return _tokens[_pos++];
|
||||
}
|
||||
|
||||
private void PushContext(object ctx)
|
||||
{
|
||||
_ctxs.Add(ctx);
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
private object PopContext()
|
||||
{
|
||||
if (_ctxs.Count <= 1)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, context stack underflow");
|
||||
}
|
||||
|
||||
var last = _ctxs[^1];
|
||||
_ctxs.RemoveAt(_ctxs.Count - 1);
|
||||
_ctx = _ctxs[^1];
|
||||
return last;
|
||||
}
|
||||
|
||||
private void PushKey(string key) => _keys.Add(key);
|
||||
|
||||
private string PopKey()
|
||||
{
|
||||
if (_keys.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, keys stack empty");
|
||||
}
|
||||
|
||||
var last = _keys[^1];
|
||||
_keys.RemoveAt(_keys.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void SetValue(object? val)
|
||||
{
|
||||
// Array context: append the value.
|
||||
if (_ctx is List<object?> array)
|
||||
{
|
||||
array.Add(val);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map context: pop the pending key and assign.
|
||||
if (_ctx is Dictionary<string, object?> map)
|
||||
{
|
||||
var key = PopKey();
|
||||
map[key] = val;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"BUG in parser, unexpected context type {_ctx?.GetType().Name ?? "null"}");
|
||||
}
|
||||
|
||||
private void ProcessItem(Token token)
|
||||
{
|
||||
switch (token.Type)
|
||||
{
|
||||
case TokenType.Error:
|
||||
throw new FormatException($"Parse error on line {token.Line}: '{token.Value}'");
|
||||
|
||||
case TokenType.Key:
|
||||
PushKey(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.String:
|
||||
SetValue(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Bool:
|
||||
SetValue(ParseBool(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Integer:
|
||||
SetValue(ParseInteger(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Float:
|
||||
SetValue(ParseFloat(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.DateTime:
|
||||
SetValue(DateTimeOffset.Parse(token.Value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
|
||||
case TokenType.ArrayStart:
|
||||
PushContext(new List<object?>());
|
||||
break;
|
||||
|
||||
case TokenType.ArrayEnd:
|
||||
{
|
||||
var array = _ctx;
|
||||
PopContext();
|
||||
SetValue(array);
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.MapStart:
|
||||
PushContext(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
break;
|
||||
|
||||
case TokenType.MapEnd:
|
||||
SetValue(PopContext());
|
||||
break;
|
||||
|
||||
case TokenType.Variable:
|
||||
ResolveVariable(token);
|
||||
break;
|
||||
|
||||
case TokenType.Include:
|
||||
ProcessInclude(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Comment:
|
||||
// Skip comments entirely.
|
||||
break;
|
||||
|
||||
case TokenType.Eof:
|
||||
// Handled in the Run loop; should not reach here.
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new FormatException($"Unexpected token type {token.Type} on line {token.Line}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ParseBool(string value) =>
|
||||
value.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "yes" or "on" => true,
|
||||
"false" or "no" or "off" => false,
|
||||
_ => throw new FormatException($"Expected boolean value, but got '{value}'"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses an integer token value, handling optional size suffixes
|
||||
/// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does.
|
||||
/// </summary>
|
||||
private static long ParseInteger(string value)
|
||||
{
|
||||
// Find where digits end and potential suffix begins.
|
||||
var lastDigit = 0;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsDigit(c) && c != '-')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lastDigit++;
|
||||
}
|
||||
|
||||
var numStr = value[..lastDigit];
|
||||
if (!long.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
|
||||
{
|
||||
throw new FormatException($"Expected integer, but got '{value}'");
|
||||
}
|
||||
|
||||
var suffix = value[lastDigit..].Trim().ToLowerInvariant();
|
||||
return suffix switch
|
||||
{
|
||||
"" => num,
|
||||
"k" => num * 1000,
|
||||
"kb" or "ki" or "kib" => num * 1024,
|
||||
"m" => num * 1_000_000,
|
||||
"mb" or "mi" or "mib" => num * 1024 * 1024,
|
||||
"g" => num * 1_000_000_000,
|
||||
"gb" or "gi" or "gib" => num * 1024 * 1024 * 1024,
|
||||
"t" => num * 1_000_000_000_000,
|
||||
"tb" or "ti" or "tib" => num * 1024L * 1024 * 1024 * 1024,
|
||||
"p" => num * 1_000_000_000_000_000,
|
||||
"pb" or "pi" or "pib" => num * 1024L * 1024 * 1024 * 1024 * 1024,
|
||||
"e" => num * 1_000_000_000_000_000_000,
|
||||
"eb" or "ei" or "eib" => num * 1024L * 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
_ => throw new FormatException($"Unknown integer suffix '{suffix}' in '{value}'"),
|
||||
};
|
||||
}
|
||||
|
||||
private static double ParseFloat(string value)
|
||||
{
|
||||
if (!double.TryParse(value, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new FormatException($"Expected float, but got '{value}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a variable reference using block scoping: walks the context stack
|
||||
/// top-down looking in map contexts, then falls back to environment variables.
|
||||
/// Detects bcrypt password literals and reference cycles.
|
||||
/// </summary>
|
||||
private void ResolveVariable(Token token)
|
||||
{
|
||||
var varName = token.Value;
|
||||
|
||||
// Special case: raw bcrypt strings ($2a$... or $2b$...).
|
||||
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
|
||||
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
|
||||
{
|
||||
SetValue("$" + varName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk context stack from top (innermost scope) to bottom (outermost).
|
||||
for (var i = _ctxs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_ctxs[i] is Dictionary<string, object?> map && map.TryGetValue(varName, out var found))
|
||||
{
|
||||
SetValue(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found in any context map. Check environment variables.
|
||||
// First, detect cycles.
|
||||
if (!_envVarReferences.Add(varName))
|
||||
{
|
||||
throw new FormatException($"Variable reference cycle for '{varName}'");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envValue = Environment.GetEnvironmentVariable(varName);
|
||||
if (envValue is not null)
|
||||
{
|
||||
// Parse the env value through the full parser to get correct typing
|
||||
// (e.g., "42" becomes long 42, "true" becomes bool, etc.).
|
||||
var subResult = ParseEnvValue(envValue, _envVarReferences, _includeDepth);
|
||||
if (subResult.TryGetValue("pk", out var parsedValue))
|
||||
{
|
||||
SetValue(parsedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_envVarReferences.Remove(varName);
|
||||
}
|
||||
|
||||
// Not found anywhere.
|
||||
throw new FormatException(
|
||||
$"Variable reference for '{varName}' on line {token.Line} can not be found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an include directive by parsing the referenced file and merging
|
||||
/// all its top-level keys into the current context.
|
||||
/// </summary>
|
||||
private void ProcessInclude(string includePath)
|
||||
{
|
||||
if (_includeDepth >= MaxIncludeDepth)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Include depth limit of {MaxIncludeDepth} exceeded while processing '{includePath}'");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_baseDir, includePath);
|
||||
var includeResult = ParseFile(fullPath, _includeDepth + 1);
|
||||
|
||||
foreach (var (key, value) in includeResult)
|
||||
{
|
||||
PushKey(key);
|
||||
SetValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/NATS.Server/Configuration/NatsConfToken.cs
Normal file
24
src/NATS.Server/Configuration/NatsConfToken.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Port of Go conf/lex.go token types.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
Error,
|
||||
Eof,
|
||||
Key,
|
||||
String,
|
||||
Bool,
|
||||
Integer,
|
||||
Float,
|
||||
DateTime,
|
||||
ArrayStart,
|
||||
ArrayEnd,
|
||||
MapStart,
|
||||
MapEnd,
|
||||
Variable,
|
||||
Include,
|
||||
Comment,
|
||||
}
|
||||
|
||||
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
|
||||
@@ -72,6 +72,21 @@ public sealed class NatsOptions
|
||||
// Profiling (0 = disabled)
|
||||
public int ProfPort { get; set; }
|
||||
|
||||
// Extended options for Go parity
|
||||
public string? ClientAdvertise { get; set; }
|
||||
public bool TraceVerbose { get; set; }
|
||||
public int MaxTracedMsgLen { get; set; }
|
||||
public bool DisableSublistCache { get; set; }
|
||||
public int ConnectErrorReports { get; set; } = 3600;
|
||||
public int ReconnectErrorReports { get; set; } = 1;
|
||||
public bool NoHeaderSupport { get; set; }
|
||||
public int MaxClosedClients { get; set; } = 10_000;
|
||||
public bool NoSystemAccount { get; set; }
|
||||
public string? SystemAccount { get; set; }
|
||||
|
||||
// Tracks which fields were set via CLI flags (for reload precedence)
|
||||
public HashSet<string> InCmdLine { get; } = [];
|
||||
|
||||
// TLS
|
||||
public string? TlsCert { get; set; }
|
||||
public string? TlsKey { get; set; }
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Monitoring;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
@@ -20,14 +21,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly NatsOptions _options;
|
||||
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
|
||||
private readonly ConcurrentQueue<ClosedClient> _closedClients = new();
|
||||
private const int MaxClosedClients = 10_000;
|
||||
private readonly ServerInfo _serverInfo;
|
||||
private readonly ILogger<NatsServer> _logger;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ServerStats _stats = new();
|
||||
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AuthService _authService;
|
||||
private AuthService _authService;
|
||||
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
|
||||
|
||||
// Config reload state
|
||||
private NatsOptions? _cliSnapshot;
|
||||
private HashSet<string> _cliFlags = [];
|
||||
private string? _configDigest;
|
||||
private readonly Account _globalAccount;
|
||||
private readonly Account _systemAccount;
|
||||
private readonly SslServerAuthenticationOptions? _sslOptions;
|
||||
@@ -224,7 +229,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
|
||||
{
|
||||
ctx.Cancel = true;
|
||||
_logger.LogWarning("Trapped SIGHUP signal — config reload not yet supported");
|
||||
_logger.LogInformation("Trapped SIGHUP signal — reloading configuration");
|
||||
_ = Task.Run(() => ReloadConfig());
|
||||
}));
|
||||
|
||||
// SIGUSR1 and SIGUSR2 only on non-Windows
|
||||
@@ -320,6 +326,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
|
||||
BuildCachedInfo();
|
||||
|
||||
// Store initial config digest for reload change detection
|
||||
if (options.ConfigFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile);
|
||||
_configDigest = digest;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not compute initial config digest for {ConfigFile}", options.ConfigFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildCachedInfo()
|
||||
@@ -354,8 +374,6 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port);
|
||||
|
||||
// Warn about stub features
|
||||
if (_options.ConfigFile != null)
|
||||
_logger.LogWarning("Config file parsing not yet supported (file: {ConfigFile})", _options.ConfigFile);
|
||||
if (_options.ProfPort > 0)
|
||||
_logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);
|
||||
|
||||
@@ -696,7 +714,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
});
|
||||
|
||||
// Cap closed clients list
|
||||
while (_closedClients.Count > MaxClosedClients)
|
||||
while (_closedClients.Count > _options.MaxClosedClients)
|
||||
_closedClients.TryDequeue(out _);
|
||||
|
||||
var subList = client.Account?.SubList ?? _globalAccount.SubList;
|
||||
@@ -766,6 +784,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the CLI snapshot and flags so that command-line overrides
|
||||
/// always take precedence during config reload.
|
||||
/// </summary>
|
||||
public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
|
||||
{
|
||||
_cliSnapshot = cliSnapshot;
|
||||
_cliFlags = cliFlags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the configuration file, diffs against current options, validates
|
||||
/// the changes, and applies reloadable settings. CLI overrides are preserved.
|
||||
/// </summary>
|
||||
public void ReloadConfig()
|
||||
{
|
||||
if (_options.ConfigFile == null)
|
||||
{
|
||||
_logger.LogWarning("No config file specified, cannot reload");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile);
|
||||
if (digest == _configDigest)
|
||||
{
|
||||
_logger.LogInformation("Config file unchanged, no reload needed");
|
||||
return;
|
||||
}
|
||||
|
||||
var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile };
|
||||
ConfigProcessor.ApplyConfig(newConfig, newOpts);
|
||||
|
||||
// CLI flags override config
|
||||
if (_cliSnapshot != null)
|
||||
ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags);
|
||||
|
||||
var changes = ConfigReloader.Diff(_options, newOpts);
|
||||
var errors = ConfigReloader.Validate(changes);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var err in errors)
|
||||
_logger.LogError("Config reload error: {Error}", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply changes to running options
|
||||
ApplyConfigChanges(changes, newOpts);
|
||||
_configDigest = digest;
|
||||
_logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyConfigChanges(List<IConfigChange> changes, NatsOptions newOpts)
|
||||
{
|
||||
bool hasLoggingChanges = false;
|
||||
bool hasAuthChanges = false;
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.IsLoggingChange) hasLoggingChanges = true;
|
||||
if (change.IsAuthChange) hasAuthChanges = true;
|
||||
}
|
||||
|
||||
// Copy reloadable values from newOpts to _options
|
||||
CopyReloadableOptions(newOpts);
|
||||
|
||||
// Trigger side effects
|
||||
if (hasLoggingChanges)
|
||||
{
|
||||
ReOpenLogFile?.Invoke();
|
||||
_logger.LogInformation("Logging configuration reloaded");
|
||||
}
|
||||
|
||||
if (hasAuthChanges)
|
||||
{
|
||||
// Rebuild auth service with new options
|
||||
_authService = AuthService.Build(_options);
|
||||
_logger.LogInformation("Authorization configuration reloaded");
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyReloadableOptions(NatsOptions newOpts)
|
||||
{
|
||||
// Logging
|
||||
_options.Debug = newOpts.Debug;
|
||||
_options.Trace = newOpts.Trace;
|
||||
_options.TraceVerbose = newOpts.TraceVerbose;
|
||||
_options.Logtime = newOpts.Logtime;
|
||||
_options.LogtimeUTC = newOpts.LogtimeUTC;
|
||||
_options.LogFile = newOpts.LogFile;
|
||||
_options.LogSizeLimit = newOpts.LogSizeLimit;
|
||||
_options.LogMaxFiles = newOpts.LogMaxFiles;
|
||||
_options.Syslog = newOpts.Syslog;
|
||||
_options.RemoteSyslog = newOpts.RemoteSyslog;
|
||||
|
||||
// Auth
|
||||
_options.Username = newOpts.Username;
|
||||
_options.Password = newOpts.Password;
|
||||
_options.Authorization = newOpts.Authorization;
|
||||
_options.Users = newOpts.Users;
|
||||
_options.NKeys = newOpts.NKeys;
|
||||
_options.NoAuthUser = newOpts.NoAuthUser;
|
||||
_options.AuthTimeout = newOpts.AuthTimeout;
|
||||
|
||||
// Limits
|
||||
_options.MaxConnections = newOpts.MaxConnections;
|
||||
_options.MaxPayload = newOpts.MaxPayload;
|
||||
_options.MaxPending = newOpts.MaxPending;
|
||||
_options.WriteDeadline = newOpts.WriteDeadline;
|
||||
_options.PingInterval = newOpts.PingInterval;
|
||||
_options.MaxPingsOut = newOpts.MaxPingsOut;
|
||||
_options.MaxControlLine = newOpts.MaxControlLine;
|
||||
_options.MaxSubs = newOpts.MaxSubs;
|
||||
_options.MaxSubTokens = newOpts.MaxSubTokens;
|
||||
_options.MaxTracedMsgLen = newOpts.MaxTracedMsgLen;
|
||||
_options.MaxClosedClients = newOpts.MaxClosedClients;
|
||||
|
||||
// TLS
|
||||
_options.TlsCert = newOpts.TlsCert;
|
||||
_options.TlsKey = newOpts.TlsKey;
|
||||
_options.TlsCaCert = newOpts.TlsCaCert;
|
||||
_options.TlsVerify = newOpts.TlsVerify;
|
||||
_options.TlsMap = newOpts.TlsMap;
|
||||
_options.TlsTimeout = newOpts.TlsTimeout;
|
||||
_options.TlsHandshakeFirst = newOpts.TlsHandshakeFirst;
|
||||
_options.TlsHandshakeFirstFallback = newOpts.TlsHandshakeFirstFallback;
|
||||
_options.AllowNonTls = newOpts.AllowNonTls;
|
||||
_options.TlsRateLimit = newOpts.TlsRateLimit;
|
||||
_options.TlsPinnedCerts = newOpts.TlsPinnedCerts;
|
||||
|
||||
// Misc
|
||||
_options.Tags = newOpts.Tags;
|
||||
_options.LameDuckDuration = newOpts.LameDuckDuration;
|
||||
_options.LameDuckGracePeriod = newOpts.LameDuckGracePeriod;
|
||||
_options.ClientAdvertise = newOpts.ClientAdvertise;
|
||||
_options.DisableSublistCache = newOpts.DisableSublistCache;
|
||||
_options.ConnectErrorReports = newOpts.ConnectErrorReports;
|
||||
_options.ReconnectErrorReports = newOpts.ReconnectErrorReports;
|
||||
_options.NoHeaderSupport = newOpts.NoHeaderSupport;
|
||||
_options.NoSystemAccount = newOpts.NoSystemAccount;
|
||||
_options.SystemAccount = newOpts.SystemAccount;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!IsShuttingDown)
|
||||
|
||||
Reference in New Issue
Block a user