diff --git a/src/NATS.Server.Host/Program.cs b/src/NATS.Server.Host/Program.cs index 152e5f3..6eb467f 100644 --- a/src/NATS.Server.Host/Program.cs +++ b/src/NATS.Server.Host/Program.cs @@ -1,85 +1,124 @@ 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; + } +} -// 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; } } @@ -136,6 +175,14 @@ Log.Logger = logConfig.CreateLogger(); 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(); diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index e6ac3dd..3b438f9 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -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; @@ -25,8 +26,13 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable 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 _accounts = new(StringComparer.Ordinal); + + // Config reload state + private NatsOptions? _cliSnapshot; + private HashSet _cliFlags = []; + private string? _configDigest; private readonly Account _globalAccount; private readonly Account _systemAccount; private readonly SslServerAuthenticationOptions? _sslOptions; @@ -222,7 +228,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 @@ -284,6 +291,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() @@ -318,8 +339,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); @@ -716,6 +735,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable } } + /// + /// Stores the CLI snapshot and flags so that command-line overrides + /// always take precedence during config reload. + /// + public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet cliFlags) + { + _cliSnapshot = cliSnapshot; + _cliFlags = cliFlags; + } + + /// + /// Reloads the configuration file, diffs against current options, validates + /// the changes, and applies reloadable settings. CLI overrides are preserved. + /// + 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 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) diff --git a/tests/NATS.Server.Tests/ConfigIntegrationTests.cs b/tests/NATS.Server.Tests/ConfigIntegrationTests.cs new file mode 100644 index 0000000..92f25f3 --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigIntegrationTests.cs @@ -0,0 +1,76 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigIntegrationTests +{ + [Fact] + public void Server_WithConfigFile_LoadsOptionsFromFile() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var confPath = Path.Combine(dir, "test.conf"); + File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true"); + + var opts = ConfigProcessor.ProcessConfigFile(confPath); + opts.Port.ShouldBe(14222); + opts.MaxPayload.ShouldBe(2 * 1024 * 1024); + opts.Debug.ShouldBeTrue(); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Server_CliOverridesConfig() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var confPath = Path.Combine(dir, "test.conf"); + File.WriteAllText(confPath, "port: 14222\ndebug: true"); + + var opts = ConfigProcessor.ProcessConfigFile(confPath); + opts.Port.ShouldBe(14222); + + // Simulate CLI override: user passed -p 5222 on command line + var cliSnapshot = new NatsOptions { Port = 5222 }; + var cliFlags = new HashSet { "Port" }; + ConfigReloader.MergeCliOverrides(opts, cliSnapshot, cliFlags); + + opts.Port.ShouldBe(5222); + opts.Debug.ShouldBeTrue(); // Config file value preserved + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Reload_ChangingPort_ReturnsError() + { + var oldOpts = new NatsOptions { Port = 4222 }; + var newOpts = new NatsOptions { Port = 5222 }; + var changes = ConfigReloader.Diff(oldOpts, newOpts); + var errors = ConfigReloader.Validate(changes); + errors.Count.ShouldBeGreaterThan(0); + errors[0].ShouldContain("Port"); + } + + [Fact] + public void Reload_ChangingDebug_IsValid() + { + var oldOpts = new NatsOptions { Debug = false }; + var newOpts = new NatsOptions { Debug = true }; + var changes = ConfigReloader.Diff(oldOpts, newOpts); + var errors = ConfigReloader.Validate(changes); + errors.ShouldBeEmpty(); + changes.ShouldContain(c => c.IsLoggingChange); + } +}