diff --git a/differences.md b/differences.md index 9adb727..1a921ba 100644 --- a/differences.md +++ b/differences.md @@ -12,7 +12,7 @@ |---------|:--:|:----:|-------| | NKey generation (server identity) | Y | Y | Ed25519 key pair via NATS.NKeys at startup | | System account setup | Y | Y | `$SYS` account created; no event publishing yet (stub) | -| Config file validation on startup | Y | Stub | `-c` flag parsed, `ConfigFile` stored, but no config parser | +| Config file validation on startup | Y | Y | Full config parsing with error collection via `ConfigProcessor` | | PID file writing | Y | Y | Written on startup, deleted on shutdown | | Profiling HTTP endpoint (`/debug/pprof`) | Y | Stub | `ProfPort` option exists but endpoint not implemented | | Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup | @@ -42,7 +42,7 @@ | SIGTERM | Y | Y | `PosixSignalRegistration` triggers `ShutdownAsync()` | | SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile | | SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` | -| SIGHUP (config reload) | Y | Stub | Signal registered, handler logs "not yet implemented" | +| SIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset; CLI flags preserved | | Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` | --- @@ -247,7 +247,7 @@ Go implements a sophisticated slow consumer detection system: | `-a/--addr` | Y | Y | | | `-n/--name` (ServerName) | Y | Y | | | `-m/--http_port` (monitoring) | Y | Y | | -| `-c` (config file) | Y | Stub | Flag parsed, stored in `ConfigFile`, no config parser | +| `-c` (config file) | Y | Y | Full config parsing: lexer → parser → processor; CLI args override config | | `-D/-V/-DV` (debug/trace) | Y | Y | `-D`/`--debug` for debug, `-V`/`-T`/`--trace` for trace, `-DV` for both | | `--tlscert/--tlskey/--tlscacert` | Y | Y | | | `--tlsverify` | Y | Y | | @@ -257,10 +257,10 @@ Go implements a sophisticated slow consumer detection system: ### Configuration System | Feature | Go | .NET | Notes | |---------|:--:|:----:|-------| -| Config file parsing | Y | N | Go has custom `conf` parser with includes | -| Hot reload (SIGHUP) | Y | N | | -| Config change detection | Y | N | Go tracks `inConfig`/`inCmdLine` origins | -| ~450 option fields | Y | ~62 | .NET covers core + debug/trace/logging/limits/tags options | +| Config file parsing | Y | Y | Custom NATS conf lexer/parser ported from Go; supports includes, variables, blocks | +| Hot reload (SIGHUP) | Y | Y | Reloads logging, auth, limits, TLS certs on SIGHUP; rejects non-reloadable changes | +| Config change detection | Y | Y | SHA256 digest comparison; `InCmdLine` tracks CLI flag precedence | +| ~450 option fields | Y | ~72 | .NET covers core + all single-server options; cluster/JetStream keys silently ignored | ### Missing Options Categories - ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps, per-subsystem log control all implemented @@ -404,10 +404,8 @@ The following items from the original gap list have been implemented: - **Permission templates** — `PermissionTemplates.Expand()` with 6 functions and cartesian product - **Bearer tokens** — `UserClaims.BearerToken` skips nonce verification - **User revocation** — per-account tracking with wildcard (`*`) revocation - -### Remaining High Priority -1. **Config file parsing** — needed for production deployment (CLI stub exists) -2. **Hot reload** — needed for zero-downtime config changes (SIGHUP stub exists) +- **Config file parsing** — custom lexer/parser ported from Go; supports includes, variables, nested blocks, size suffixes +- **Hot reload (SIGHUP)** — re-parses config, diffs changes, validates reloadable set, applies with CLI precedence ### Remaining Lower Priority -3. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections +1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections diff --git a/docs/plans/2026-02-23-system-account-types-design.md b/docs/plans/2026-02-23-system-account-types-design.md new file mode 100644 index 0000000..bc04afb --- /dev/null +++ b/docs/plans/2026-02-23-system-account-types-design.md @@ -0,0 +1,565 @@ +# Design: SYSTEM and ACCOUNT Connection Types + +**Date:** 2026-02-23 +**Status:** Approved +**Approach:** Bottom-Up Layered Build (6 layers) + +## Overview + +Port the SYSTEM and ACCOUNT internal connection types from the Go NATS server to .NET. This includes: +- Client type differentiation (ClientKind enum) +- Internal client infrastructure (socketless clients with callback-based delivery) +- Full system event publishing ($SYS.ACCOUNT.*.CONNECT, DISCONNECT, STATSZ, etc.) +- System request-reply monitoring services ($SYS.REQ.SERVER.*.VARZ, CONNZ, etc.) +- Account service/stream imports and exports (cross-account message routing) +- Response routing for service imports with latency tracking + +**Go reference files:** +- `golang/nats-server/server/client.go` — client type constants (lines 45-65), `isInternalClient()`, message delivery (lines 3789-3803) +- `golang/nats-server/server/server.go` — system account setup (lines 1822-1892), `createInternalClient()` (lines 1910-1936) +- `golang/nats-server/server/events.go` — `internal` struct (lines 124-147), event subjects (lines 41-97), send/receive loops (lines 474-668), event publishing, subscriptions (lines 1172-1495) +- `golang/nats-server/server/accounts.go` — `Account` struct (lines 52-119), import/export structs (lines 142-263), `addServiceImport()` (lines 1560-2112), `addServiceImportSub()` (lines 2156-2187), `internalClient()` (lines 2114-2122) + +--- + +## Layer 1: ClientKind Enum + INatsClient Interface + InternalClient + +### ClientKind Enum + +**New file:** `src/NATS.Server/ClientKind.cs` + +```csharp +public enum ClientKind +{ + Client, // End user connection + Router, // Cluster peer (out of scope) + Gateway, // Inter-cluster bridge (out of scope) + Leaf, // Leaf node (out of scope) + System, // Internal system client + JetStream, // Internal JetStream client (out of scope) + Account, // Internal per-account client +} + +public static class ClientKindExtensions +{ + public static bool IsInternal(this ClientKind kind) => + kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account; +} +``` + +### INatsClient Interface + +Extract from `NatsClient` the surface used by `Subscription`, `DeliverMessage`, `ProcessMessage`: + +```csharp +public interface INatsClient +{ + ulong Id { get; } + ClientKind Kind { get; } + bool IsInternal { get; } + Account? Account { get; } + ClientOptions? ClientOpts { get; } + ClientPermissions? Permissions { get; } + void SendMessage(string subject, string sid, string? replyTo, + ReadOnlyMemory headers, ReadOnlyMemory payload); + bool QueueOutbound(ReadOnlyMemory data); +} +``` + +### InternalClient Class + +**New file:** `src/NATS.Server/InternalClient.cs` + +Lightweight, socketless client for internal messaging: + +- `ClientKind Kind` — System, Account, or JetStream +- `Account Account` — associated account +- `ulong Id` — unique client ID from server's ID counter +- Headers always enabled, echo always disabled +- `SendMessage` invokes internal callback delegate or pushes to Channel +- No socket, no read/write loops, no parser +- `QueueOutbound` is a no-op (internal clients don't write wire protocol) + +### Subscription Change + +`Subscription.Client` changes from `NatsClient?` to `INatsClient?`. This is the biggest refactoring step — all code referencing `sub.Client` as `NatsClient` needs updating. + +`NatsClient` implements `INatsClient` with `Kind = ClientKind.Client`. + +--- + +## Layer 2: System Event Infrastructure + +### InternalEventSystem Class + +**New file:** `src/NATS.Server/Events/InternalEventSystem.cs` + +Core class managing the server's internal event system, mirroring Go's `internal` struct: + +```csharp +public sealed class InternalEventSystem : IAsyncDisposable +{ + // Core state + public Account SystemAccount { get; } + public InternalClient SystemClient { get; } + private ulong _sequence; + private int _subscriptionId; + private readonly string _serverHash; + private readonly string _inboxPrefix; + + // Message queues (Channel-based) + private readonly Channel _sendQueue; + private readonly Channel _receiveQueue; + private readonly Channel _receiveQueuePings; + + // Background tasks + private Task? _sendLoop; + private Task? _receiveLoop; + private Task? _receiveLoopPings; + + // Remote server tracking + private readonly ConcurrentDictionary _remoteServers = new(); + + // Timers + private PeriodicTimer? _statszTimer; // 10s interval + private PeriodicTimer? _accountConnsTimer; // 30s interval + private PeriodicTimer? _orphanSweeper; // 90s interval +} +``` + +### Message Types + +```csharp +public record PublishMessage( + InternalClient? Client, // Use specific client or default to system client + string Subject, + string? Reply, + ServerInfo? Info, + byte[]? Headers, + object? Body, // JSON-serializable + bool Echo = false, + bool IsLast = false); + +public record InternalSystemMessage( + Subscription? Sub, + INatsClient? Client, + Account? Account, + string Subject, + string? Reply, + ReadOnlyMemory Headers, + ReadOnlyMemory Message, + Action, ReadOnlyMemory> Callback); +``` + +### Lifecycle + +- `StartAsync(NatsServer server)` — creates system client, starts 3 background Tasks +- `StopAsync()` — publishes shutdown event with `IsLast=true`, signals channels complete, awaits all tasks + +### Send Loop + +Consumes from `_sendQueue`: +1. Fills in ServerInfo metadata (name, host, ID, sequence, version, tags) +2. Serializes body to JSON using source-generated serializer +3. Calls `server.ProcessMessage()` on the system account to deliver locally +4. Handles compression if configured + +### Receive Loop(s) + +Two instances (general + pings) consuming from their respective channels: +- Pop messages, invoke callbacks +- Exit on cancellation + +### APIs on NatsServer + +```csharp +public void SendInternalMsg(string subject, string? reply, object? msg); +public void SendInternalAccountMsg(Account account, string subject, object? msg); +public Subscription SysSubscribe(string subject, SystemMessageHandler callback); +public Subscription SysSubscribeInternal(string subject, SystemMessageHandler callback); +``` + +### noInlineCallback Pattern + +Wraps a `SystemMessageHandler` so that instead of executing inline during message delivery, it enqueues to `_receiveQueue` for async dispatch. This prevents system event handlers from blocking the publishing path. + +--- + +## Layer 3: System Event Publishing + +### Event Types (DTOs) + +**New folder:** `src/NATS.Server/Events/` + +All events embed a `TypedEvent` base: + +```csharp +public record TypedEvent(string Type, string Id, DateTime Time); +``` + +| Event Class | Type String | Published On | +|-------------|-------------|-------------| +| `ConnectEventMsg` | `io.nats.server.advisory.v1.client_connect` | `$SYS.ACCOUNT.{acc}.CONNECT` | +| `DisconnectEventMsg` | `io.nats.server.advisory.v1.client_disconnect` | `$SYS.ACCOUNT.{acc}.DISCONNECT` | +| `AccountNumConns` | `io.nats.server.advisory.v1.account_connections` | `$SYS.ACCOUNT.{acc}.SERVER.CONNS` | +| `ServerStatsMsg` | (stats) | `$SYS.SERVER.{id}.STATSZ` | +| `ShutdownEventMsg` | (shutdown) | `$SYS.SERVER.{id}.SHUTDOWN` | +| `LameDuckEventMsg` | (lameduck) | `$SYS.SERVER.{id}.LAMEDUCK` | +| `AuthErrorEventMsg` | `io.nats.server.advisory.v1.client_auth` | `$SYS.SERVER.{id}.CLIENT.AUTH.ERR` | + +### Integration Points + +| Location | Event | Trigger | +|----------|-------|---------| +| `NatsServer.HandleClientAsync()` after auth | `ConnectEventMsg` | Client authenticated | +| `NatsServer.RemoveClient()` | `DisconnectEventMsg` | Client disconnected | +| `NatsServer.ShutdownAsync()` | `ShutdownEventMsg` | Server shutting down | +| `NatsServer.LameDuckShutdownAsync()` | `LameDuckEventMsg` | Lame duck mode | +| Auth failure in `NatsClient.ProcessConnect()` | `AuthErrorEventMsg` | Auth rejected | +| Periodic timer (10s) | `ServerStatsMsg` | Timer tick | +| Periodic timer (30s) | `AccountNumConns` | Timer tick, for each account with connections | + +### JSON Serialization + +`System.Text.Json` source generator context: + +```csharp +[JsonSerializable(typeof(ConnectEventMsg))] +[JsonSerializable(typeof(DisconnectEventMsg))] +[JsonSerializable(typeof(ServerStatsMsg))] +// ... etc +internal partial class EventJsonContext : JsonSerializerContext { } +``` + +--- + +## Layer 4: System Request-Reply Services + +### Subscriptions Created in initEventTracking() + +Server-specific (only this server responds): + +| Subject | Handler | Response | +|---------|---------|----------| +| `$SYS.REQ.SERVER.{id}.IDZ` | `IdzReq` | Server identity | +| `$SYS.REQ.SERVER.{id}.STATSZ` | `StatszReq` | Server stats (same as /varz stats) | +| `$SYS.REQ.SERVER.{id}.VARZ` | `VarzReq` | Same as /varz JSON | +| `$SYS.REQ.SERVER.{id}.CONNZ` | `ConnzReq` | Same as /connz JSON | +| `$SYS.REQ.SERVER.{id}.SUBSZ` | `SubszReq` | Same as /subz JSON | +| `$SYS.REQ.SERVER.{id}.HEALTHZ` | `HealthzReq` | Health status | +| `$SYS.REQ.SERVER.{id}.ACCOUNTZ` | `AccountzReq` | Account info | + +Wildcard ping (all servers respond): + +| Subject | Handler | +|---------|---------| +| `$SYS.REQ.SERVER.PING.STATSZ` | `StatszReq` | +| `$SYS.REQ.SERVER.PING.VARZ` | `VarzReq` | +| `$SYS.REQ.SERVER.PING.IDZ` | `IdzReq` | +| `$SYS.REQ.SERVER.PING.HEALTHZ` | `HealthzReq` | + +Account-scoped: + +| Subject | Handler | +|---------|---------| +| `$SYS.REQ.ACCOUNT.*.CONNZ` | `AccountConnzReq` | +| `$SYS.REQ.ACCOUNT.*.SUBSZ` | `AccountSubszReq` | +| `$SYS.REQ.ACCOUNT.*.INFO` | `AccountInfoReq` | +| `$SYS.REQ.ACCOUNT.*.STATZ` | `AccountStatzReq` | + +### Implementation + +Handlers reuse existing `MonitorServer` data builders. The request body (if present) is parsed for options (e.g., sort, limit for CONNZ). Response is serialized to JSON and published on the request's reply subject via `SendInternalMsg`. + +--- + +## Layer 5: Import/Export Model + ACCOUNT Client + +### Export Types + +**New file:** `src/NATS.Server/Imports/StreamExport.cs` + +```csharp +public sealed class StreamExport +{ + public ExportAuth Auth { get; init; } = new(); +} +``` + +**New file:** `src/NATS.Server/Imports/ServiceExport.cs` + +```csharp +public sealed class ServiceExport +{ + public ExportAuth Auth { get; init; } = new(); + public Account? Account { get; init; } + public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton; + public TimeSpan ResponseThreshold { get; init; } = TimeSpan.FromMinutes(2); + public ServiceLatency? Latency { get; init; } + public bool AllowTrace { get; init; } +} +``` + +**New file:** `src/NATS.Server/Imports/ExportAuth.cs` + +```csharp +public sealed class ExportAuth +{ + public bool TokenRequired { get; init; } + public uint AccountPosition { get; init; } + public HashSet? ApprovedAccounts { get; init; } + public Dictionary? RevokedAccounts { get; init; } + + public bool IsAuthorized(Account account) { ... } +} +``` + +### Import Types + +**New file:** `src/NATS.Server/Imports/StreamImport.cs` + +```csharp +public sealed class StreamImport +{ + public required Account SourceAccount { get; init; } + public required string From { get; init; } + public required string To { get; init; } + public SubjectTransform? Transform { get; init; } + public bool UsePub { get; init; } + public bool Invalid { get; set; } +} +``` + +**New file:** `src/NATS.Server/Imports/ServiceImport.cs` + +```csharp +public sealed class ServiceImport +{ + public required Account DestinationAccount { get; init; } + public required string From { get; init; } + public required string To { get; init; } + public SubjectTransform? Transform { get; init; } + public ServiceExport? Export { get; init; } + public ServiceResponseType ResponseType { get; init; } + public byte[]? Sid { get; set; } + public bool IsResponse { get; init; } + public bool UsePub { get; init; } + public bool Invalid { get; set; } + public bool Share { get; init; } + public bool Tracking { get; init; } +} +``` + +### Account Extensions + +Add to `Account`: + +```csharp +// Export/Import maps +public ExportMap Exports { get; } = new(); +public ImportMap Imports { get; } = new(); + +// Internal ACCOUNT client (lazy) +private InternalClient? _internalClient; +public InternalClient GetOrCreateInternalClient(NatsServer server) { ... } + +// Internal subscription management +private ulong _internalSubId; +public Subscription SubscribeInternal(string subject, SystemMessageHandler callback) { ... } + +// Import/Export APIs +public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable? approved); +public void AddStreamExport(string subject, IEnumerable? approved); +public ServiceImport AddServiceImport(Account destination, string from, string to); +public void AddStreamImport(Account source, string from, string to); +``` + +### ExportMap / ImportMap + +```csharp +public sealed class ExportMap +{ + public Dictionary Streams { get; } = new(StringComparer.Ordinal); + public Dictionary Services { get; } = new(StringComparer.Ordinal); + public Dictionary Responses { get; } = new(StringComparer.Ordinal); +} + +public sealed class ImportMap +{ + public List Streams { get; } = []; + public Dictionary> Services { get; } = new(StringComparer.Ordinal); +} +``` + +### Service Import Subscription Flow + +1. `account.AddServiceImport(dest, "requests.>", "api.>")` called +2. Account creates its `InternalClient` (Kind=Account) if needed +3. Creates subscription on `"requests.>"` in account's SubList with `Client = internalClient` +4. Subscription carries a `ServiceImport` reference +5. When message matches, `DeliverMessage` detects internal client → invokes `ProcessServiceImport` + +### ProcessServiceImport Callback + +1. Transform subject if transform configured +2. Match against destination account's SubList +3. Deliver to destination subscribers (rewriting reply subject for response routing) +4. If reply present: set up response service import (see Layer 6) + +### Stream Import Delivery + +In `DeliverMessage`, before sending to subscriber: +- If subscription has `StreamImport` reference, apply subject transform +- Deliver with transformed subject + +### Message Delivery Path Changes + +`NatsServer.ProcessMessage` needs modification: +- After matching local account SubList, also check for service imports that might forward to other accounts +- For subscriptions with `sub.StreamImport != null`, transform subject before delivery + +--- + +## Layer 6: Response Routing + Latency Tracking + +### Service Reply Prefix + +Generated per request: `_R_.{random10chars}.` — unique reply namespace in the exporting account. + +### Response Service Import Creation + +When `ProcessServiceImport` handles a request with a reply subject: + +1. Generate new reply prefix: `_R_.{random}.` +2. Create response `ServiceImport` in the exporting account: + - `From = newReplyPrefix + ">"` (wildcard to catch all responses) + - `To = originalReply` (original reply subject in importing account) + - `IsResponse = true` +3. Subscribe to new prefix in exporting account +4. Rewrite reply in forwarded message to new prefix +5. Store in `ExportMap.Responses[newPrefix]` + +### Response Delivery + +When exporting account service responds on the rewritten reply: +1. Response matches the `_R_.{random}.>` subscription +2. Response service import callback fires +3. Transforms reply back to original subject +4. Delivers to original account's subscribers + +### Cleanup + +- **Singleton:** Remove response import after first response delivery +- **Streamed:** Track timestamp, clean up via timer after `ResponseThreshold` (default 2 min) +- **Chunked:** Same as Streamed + +Timer runs periodically (every 30s), checks `ServiceImport.Timestamp` against threshold, removes stale entries. + +### Latency Tracking + +```csharp +public sealed class ServiceLatency +{ + public int SamplingPercentage { get; init; } // 1-100 + public string Subject { get; init; } = string.Empty; // where to publish metrics +} + +public record ServiceLatencyMsg( + TypedEvent Event, + string Status, + string Requestor, // Account name + string Responder, // Account name + TimeSpan RequestStart, + TimeSpan ServiceLatency, + TimeSpan TotalLatency); +``` + +When tracking is enabled: +1. Record request timestamp when creating response import +2. On response delivery, calculate latency +3. Publish `ServiceLatencyMsg` to configured subject +4. Sampling: only track if `Random.Shared.Next(100) < SamplingPercentage` + +--- + +## Testing Strategy + +### Layer 1 Tests +- Verify `ClientKind.IsInternal()` for all kinds +- Create `InternalClient`, verify properties (Kind, Id, Account, IsInternal) +- Verify `INatsClient` interface on both `NatsClient` and `InternalClient` + +### Layer 2 Tests +- Start/stop `InternalEventSystem` lifecycle +- `SysSubscribe` creates subscription in system account SubList +- `SendInternalMsg` delivers to system subscribers via send loop +- `noInlineCallback` queues to receive loop rather than executing inline +- Concurrent publish/subscribe stress test + +### Layer 3 Tests +- Connect event published on `$SYS.ACCOUNT.{acc}.CONNECT` when client authenticates +- Disconnect event published when client closes +- Server stats published every 10s on `$SYS.SERVER.{id}.STATSZ` +- Account conns published every 30s for accounts with connections +- Shutdown event published during shutdown +- Auth error event published on auth failure +- Event JSON structure matches Go format + +### Layer 4 Tests +- Subscribe to `$SYS.REQ.SERVER.{id}.VARZ`, send request, verify response matches /varz +- Subscribe to `$SYS.REQ.SERVER.{id}.CONNZ`, verify response +- Ping wildcard `$SYS.REQ.SERVER.PING.HEALTHZ` receives response +- Account-scoped requests work + +### Layer 5 Tests +- `AddServiceExport` + `AddServiceImport` creates internal subscription +- Message published on import subject is forwarded to export account +- Wildcard imports with subject transforms +- Authorization: only approved accounts can import +- Stream import with subject transform +- Cycle detection in service imports +- Account internal client lazy creation + +### Layer 6 Tests +- Service import request-reply: request forwarded with rewritten reply, response routed back +- Singleton response: import cleaned up after one response +- Streamed response: multiple responses, cleaned up after timeout +- Latency tracking: metrics published to configured subject +- Response threshold timer cleans up stale entries + +--- + +## Files to Create/Modify + +### New Files +- `src/NATS.Server/ClientKind.cs` +- `src/NATS.Server/INatsClient.cs` +- `src/NATS.Server/InternalClient.cs` +- `src/NATS.Server/Events/InternalEventSystem.cs` +- `src/NATS.Server/Events/EventTypes.cs` (all event DTOs) +- `src/NATS.Server/Events/EventJsonContext.cs` (source gen) +- `src/NATS.Server/Events/EventSubjects.cs` (subject constants) +- `src/NATS.Server/Imports/ServiceImport.cs` +- `src/NATS.Server/Imports/StreamImport.cs` +- `src/NATS.Server/Imports/ServiceExport.cs` +- `src/NATS.Server/Imports/StreamExport.cs` +- `src/NATS.Server/Imports/ExportAuth.cs` +- `src/NATS.Server/Imports/ExportMap.cs` +- `src/NATS.Server/Imports/ImportMap.cs` +- `src/NATS.Server/Imports/ServiceResponseType.cs` +- `src/NATS.Server/Imports/ServiceLatency.cs` +- `tests/NATS.Server.Tests/InternalClientTests.cs` +- `tests/NATS.Server.Tests/EventSystemTests.cs` +- `tests/NATS.Server.Tests/SystemEventsTests.cs` +- `tests/NATS.Server.Tests/SystemRequestReplyTests.cs` +- `tests/NATS.Server.Tests/ImportExportTests.cs` +- `tests/NATS.Server.Tests/ResponseRoutingTests.cs` + +### Modified Files +- `src/NATS.Server/NatsClient.cs` — implement `INatsClient`, add `Kind` property +- `src/NATS.Server/NatsServer.cs` — integrate event system, add import/export message path, system event publishing +- `src/NATS.Server/Auth/Account.cs` — add exports/imports, internal client, subscription APIs +- `src/NATS.Server/Subscriptions/Subscription.cs` — `Client` → `INatsClient?`, add `ServiceImport?`, `StreamImport?` +- `src/NATS.Server/Subscriptions/SubList.cs` — work with `INatsClient` if needed +- `src/NATS.Server/Monitoring/MonitorServer.cs` — expose data builders for request-reply handlers +- `differences.md` — update SYSTEM, ACCOUNT, import/export status diff --git a/src/NATS.Server.Host/Program.cs b/src/NATS.Server.Host/Program.cs index 5f69a3c..0d34632 100644 --- a/src/NATS.Server.Host/Program.cs +++ b/src/NATS.Server.Host/Program.cs @@ -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(); diff --git a/src/NATS.Server/Configuration/ConfigProcessor.cs b/src/NATS.Server/Configuration/ConfigProcessor.cs new file mode 100644 index 0000000..88b36ae --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigProcessor.cs @@ -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; + +/// +/// Maps a parsed NATS configuration dictionary (produced by ) +/// into a fully populated instance. Collects all validation +/// errors rather than failing on the first one. +/// +public static class ConfigProcessor +{ + /// + /// Parses a configuration file and returns the populated options. + /// + public static NatsOptions ProcessConfigFile(string filePath) + { + var config = NatsConfParser.ParseFile(filePath); + var opts = new NatsOptions { ConfigFile = filePath }; + ApplyConfig(config, opts); + return opts; + } + + /// + /// Parses configuration text (not from a file) and returns the populated options. + /// + public static NatsOptions ProcessConfig(string configText) + { + var config = NatsConfParser.Parse(configText); + var opts = new NatsOptions(); + ApplyConfig(config, opts); + return opts; + } + + /// + /// Applies a parsed configuration dictionary to existing options. + /// Throws if any validation errors are collected. + /// + public static void ApplyConfig(Dictionary config, NatsOptions opts) + { + var errors = new List(); + + 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 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 authDict) + ParseAuthorization(authDict, opts, errors); + break; + case "no_auth_user": + opts.NoAuthUser = ToString(value); + break; + + // TLS + case "tls": + if (value is Dictionary tlsDict) + ParseTls(tlsDict, opts, errors); + break; + case "allow_non_tls": + opts.AllowNonTls = ToBool(value); + break; + + // Tags + case "server_tags": + if (value is Dictionary tagsDict) + ParseTags(tagsDict, opts); + break; + + // Profiling + case "prof_port": + opts.ProfPort = ToInt(value); + break; + + // System account + case "system_account": + opts.SystemAccount = ToString(value); + break; + case "no_system_account": + opts.NoSystemAccount = ToBool(value); + break; + case "no_header_support": + opts.NoHeaderSupport = ToBool(value); + break; + case "connect_error_reports": + opts.ConnectErrorReports = ToInt(value); + break; + case "reconnect_error_reports": + opts.ReconnectErrorReports = ToInt(value); + break; + + // Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.) + default: + break; + } + } + + // ─── Listen parsing ──────────────────────────────────────────── + + /// + /// Parses a "listen" value that can be: + /// + /// ":4222" — port only + /// "0.0.0.0:4222" — host + port + /// "4222" — bare number (port only) + /// 4222 — integer (port only) + /// + /// + private static void ParseListen(object? value, NatsOptions opts) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.Host = host; + if (port is not null) + opts.Port = port.Value; + } + + /// + /// Parses a monitor listen value. For "http" the port goes to MonitorPort; + /// for "https" the port goes to MonitorHttpsPort. + /// + private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps) + { + var (host, port) = ParseHostPort(value); + if (host is not null) + opts.MonitorHost = host; + if (port is not null) + { + if (isHttps) + opts.MonitorHttpsPort = port.Value; + else + opts.MonitorPort = port.Value; + } + } + + /// + /// Shared host:port parsing logic. + /// + private static (string? Host, int? Port) ParseHostPort(object? value) + { + if (value is long l) + return (null, (int)l); + + var str = ToString(value); + + // Try bare integer + if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort)) + return (null, barePort); + + // Check for host:port + var colonIdx = str.LastIndexOf(':'); + if (colonIdx >= 0) + { + var hostPart = str[..colonIdx]; + var portPart = str[(colonIdx + 1)..]; + if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p)) + { + var host = hostPart.Length > 0 ? hostPart : null; + return (host, p); + } + } + + throw new FormatException($"Cannot parse listen value: '{str}'"); + } + + // ─── Duration parsing ────────────────────────────────────────── + + /// + /// Parses a duration value. Accepts: + /// + /// A string with unit suffix: "30s", "2m", "1h", "500ms" + /// A number (long/double) treated as seconds + /// + /// + internal static TimeSpan ParseDuration(object? value) + { + return value switch + { + long seconds => TimeSpan.FromSeconds(seconds), + double seconds => TimeSpan.FromSeconds(seconds), + string s => ParseDurationString(s), + _ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"), + }; + } + + private static readonly Regex DurationPattern = new( + @"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static 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 dict, NatsOptions opts, List 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 userList) + opts.Users = ParseUsers(userList, errors); + break; + default: + // Unknown auth keys silently ignored + break; + } + } + } + + private static List ParseUsers(List list, List errors) + { + var users = new List(); + foreach (var item in list) + { + if (item is not Dictionary userDict) + { + errors.Add("Expected user entry to be a map"); + continue; + } + + string? username = null; + string? password = null; + string? 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 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 dict, List errors) + { + SubjectPermission? publish = null; + SubjectPermission? subscribe = null; + ResponsePermission? response = null; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "publish" or "pub": + publish = ParseSubjectPermission(value, errors); + break; + case "subscribe" or "sub": + subscribe = ParseSubjectPermission(value, errors); + break; + case "resp" or "response": + if (value is Dictionary respDict) + response = ParseResponsePermission(respDict); + break; + } + } + + return new Permissions + { + Publish = publish, + Subscribe = subscribe, + Response = response, + }; + } + + private static SubjectPermission? ParseSubjectPermission(object? value, List errors) + { + // Can be a simple list of strings (treated as allow) or a dict with allow/deny + if (value is Dictionary dict) + { + IReadOnlyList? allow = null; + IReadOnlyList? deny = null; + + foreach (var (key, v) in dict) + { + switch (key.ToLowerInvariant()) + { + case "allow": + allow = ToStringList(v); + break; + case "deny": + deny = ToStringList(v); + break; + } + } + + return new SubjectPermission { Allow = allow, Deny = deny }; + } + + if (value is List list) + { + return new SubjectPermission { Allow = ToStringList(list) }; + } + + if (value is string s) + { + return new SubjectPermission { Allow = [s] }; + } + + return null; + } + + private static ResponsePermission ParseResponsePermission(Dictionary dict) + { + var maxMsgs = 0; + var expires = TimeSpan.Zero; + + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "max_msgs" or "max": + maxMsgs = ToInt(value); + break; + case "expires" or "ttl": + expires = ParseDuration(value); + break; + } + } + + return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires }; + } + + // ─── TLS parsing ─────────────────────────────────────────────── + + private static void ParseTls(Dictionary dict, NatsOptions opts, List errors) + { + foreach (var (key, value) in dict) + { + switch (key.ToLowerInvariant()) + { + case "cert_file": + opts.TlsCert = ToString(value); + break; + case "key_file": + opts.TlsKey = ToString(value); + break; + case "ca_file": + opts.TlsCaCert = ToString(value); + break; + case "verify": + opts.TlsVerify = ToBool(value); + break; + case "verify_and_map": + var map = ToBool(value); + opts.TlsMap = map; + if (map) + opts.TlsVerify = true; + break; + case "timeout": + opts.TlsTimeout = value switch + { + long l => TimeSpan.FromSeconds(l), + double d => TimeSpan.FromSeconds(d), + string s => ParseDuration(s), + _ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"), + }; + break; + case "connection_rate_limit": + opts.TlsRateLimit = ToLong(value); + break; + case "pinned_certs": + if (value is List pinnedList) + { + var certs = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in pinnedList) + { + if (item is string s) + certs.Add(s.ToLowerInvariant()); + } + + opts.TlsPinnedCerts = certs; + } + + break; + case "handshake_first" or "first" or "immediate": + opts.TlsHandshakeFirst = ToBool(value); + break; + case "handshake_first_fallback": + opts.TlsHandshakeFirstFallback = ParseDuration(value); + break; + default: + // Unknown TLS keys silently ignored + break; + } + } + } + + // ─── Tags parsing ────────────────────────────────────────────── + + private static void ParseTags(Dictionary dict, NatsOptions opts) + { + var tags = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in dict) + { + tags[key] = ToString(value); + } + + opts.Tags = tags; + } + + // ─── 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 ToStringList(object? value) + { + if (value is List list) + { + var result = new List(list.Count); + foreach (var item in list) + { + if (item is string s) + result.Add(s); + } + + return result; + } + + if (value is string str) + return [str]; + + return []; + } +} + +/// +/// Thrown when one or more configuration validation errors are detected. +/// All errors are collected rather than failing on the first one. +/// +public sealed class ConfigProcessorException(string message, List errors) + : Exception(message) +{ + public IReadOnlyList Errors => errors; +} diff --git a/src/NATS.Server/Configuration/ConfigReloader.cs b/src/NATS.Server/Configuration/ConfigReloader.cs new file mode 100644 index 0000000..3813323 --- /dev/null +++ b/src/NATS.Server/Configuration/ConfigReloader.cs @@ -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; + +/// +/// Provides static methods for comparing two instances, +/// validating that detected changes are reloadable, and merging CLI overrides +/// so that command-line flags always take precedence over config file values. +/// +public static class ConfigReloader +{ + // Non-reloadable options (match Go server — Host, Port, ServerName require restart) + private static readonly HashSet NonReloadable = ["Host", "Port", "ServerName"]; + + // Logging-related options + private static readonly HashSet LoggingOptions = + ["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile", + "LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"]; + + // Auth-related options + private static readonly HashSet AuthOptions = + ["Username", "Password", "Authorization", "Users", "NKeys", + "NoAuthUser", "AuthTimeout"]; + + // TLS-related options + private static readonly HashSet TlsOptions = + ["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap", + "TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback", + "AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"]; + + /// + /// Compares two instances property by property and returns + /// a list of for every property that differs. Each change + /// is tagged with the appropriate category flags. + /// + public static List Diff(NatsOptions oldOpts, NatsOptions newOpts) + { + var changes = new List(); + + // 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; + } + + /// + /// Validates a list of config changes and returns error messages for any + /// non-reloadable changes (properties that require a server restart). + /// + public static List Validate(List changes) + { + var errors = new List(); + foreach (var change in changes) + { + if (change.IsNonReloadable) + { + errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)"); + } + } + + return errors; + } + + /// + /// Merges CLI overrides into a freshly-parsed config so that command-line flags + /// always take precedence. Only properties whose names appear in + /// are copied from to . + /// + public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet 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(List 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(List 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))); + } + } +} diff --git a/src/NATS.Server/Configuration/IConfigChange.cs b/src/NATS.Server/Configuration/IConfigChange.cs new file mode 100644 index 0000000..538de12 --- /dev/null +++ b/src/NATS.Server/Configuration/IConfigChange.cs @@ -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; + +/// +/// Represents a single detected configuration change during a hot reload. +/// Category flags indicate what kind of reload action is needed. +/// +public interface IConfigChange +{ + /// + /// The property name that changed (matches NatsOptions property name). + /// + string Name { get; } + + /// + /// Whether this change requires reloading the logger. + /// + bool IsLoggingChange { get; } + + /// + /// Whether this change requires reloading authorization. + /// + bool IsAuthChange { get; } + + /// + /// Whether this change requires reloading TLS configuration. + /// + bool IsTlsChange { get; } + + /// + /// Whether this option cannot be changed at runtime (requires restart). + /// + bool IsNonReloadable { get; } +} + +/// +/// Default implementation of using a primary constructor. +/// +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; +} diff --git a/src/NATS.Server/Configuration/NatsConfLexer.cs b/src/NATS.Server/Configuration/NatsConfLexer.cs new file mode 100644 index 0000000..2a17cab --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfLexer.cs @@ -0,0 +1,1503 @@ +// Port of Go conf/lex.go — state-machine tokenizer for NATS config files. +// Reference: golang/nats-server/conf/lex.go + +namespace NATS.Server.Configuration; + +public sealed class NatsConfLexer +{ + private const char Eof = '\0'; + private const char MapStartChar = '{'; + private const char MapEndChar = '}'; + private const char KeySepEqual = '='; + private const char KeySepColon = ':'; + private const char ArrayStartChar = '['; + private const char ArrayEndChar = ']'; + private const char ArrayValTerm = ','; + private const char MapValTerm = ','; + private const char CommentHashStart = '#'; + private const char CommentSlashStart = '/'; + private const char DqStringStart = '"'; + private const char DqStringEnd = '"'; + private const char SqStringStart = '\''; + private const char SqStringEnd = '\''; + private const char OptValTerm = ';'; + private const char TopOptStart = '{'; + private const char TopOptTerm = '}'; + private const char TopOptValTerm = ','; + private const char BlockStartChar = '('; + private const char BlockEndChar = ')'; + + private delegate LexState? LexState(NatsConfLexer lx); + + private readonly string _input; + private int _start; + private int _pos; + private int _width; + private int _line; + private readonly List _items; + private readonly Stack _stack; + private readonly List _stringParts; + private LexState? _stringStateFn; + + // Start position of the current line (after newline character). + private int _lstart; + + // Start position of the line from the current item. + private int _ilstart; + + private NatsConfLexer(string input) + { + _input = input; + _start = 0; + _pos = 0; + _width = 0; + _line = 1; + _items = []; + _stack = new Stack(); + _stringParts = []; + _lstart = 0; + _ilstart = 0; + } + + public static IReadOnlyList Tokenize(string input) + { + ArgumentNullException.ThrowIfNull(input); + var lx = new NatsConfLexer(input); + LexState? state = LexTop; + while (state is not null) + { + state = state(lx); + } + + return lx._items; + } + + private void Push(LexState state) => _stack.Push(state); + + private LexState? Pop() + { + if (_stack.Count == 0) + { + return Errorf("BUG in lexer: no states to pop."); + } + + return _stack.Pop(); + } + + private void Emit(TokenType type) + { + string val; + if (_stringParts.Count > 0) + { + val = string.Concat(_stringParts) + _input[_start.._pos]; + _stringParts.Clear(); + } + else + { + val = _input[_start.._pos]; + } + + var pos = _pos - _ilstart - val.Length; + _items.Add(new Token(type, val, _line, pos)); + _start = _pos; + _ilstart = _lstart; + } + + private void EmitString() + { + string finalString; + if (_stringParts.Count > 0) + { + finalString = string.Concat(_stringParts) + _input[_start.._pos]; + _stringParts.Clear(); + } + else + { + finalString = _input[_start.._pos]; + } + + var pos = _pos - _ilstart - finalString.Length; + _items.Add(new Token(TokenType.String, finalString, _line, pos)); + _start = _pos; + _ilstart = _lstart; + } + + private void AddCurrentStringPart(int offset) + { + _stringParts.Add(_input[_start..(_pos - offset)]); + _start = _pos; + } + + private LexState? AddStringPart(string s) + { + _stringParts.Add(s); + _start = _pos; + return _stringStateFn; + } + + private bool HasEscapedParts() => _stringParts.Count > 0; + + private char Next() + { + if (_pos >= _input.Length) + { + _width = 0; + return Eof; + } + + if (_input[_pos] == '\n') + { + _line++; + _lstart = _pos; + } + + var c = _input[_pos]; + _width = 1; + _pos += _width; + return c; + } + + private void Ignore() + { + _start = _pos; + _ilstart = _lstart; + } + + private void Backup() + { + _pos -= _width; + if (_pos < _input.Length && _input[_pos] == '\n') + { + _line--; + } + } + + private char Peek() + { + var r = Next(); + Backup(); + return r; + } + + private LexState? Errorf(string message) + { + var pos = _pos - _lstart; + _items.Add(new Token(TokenType.Error, message, _line, pos)); + return null; + } + + // --- Helper methods --- + + private static bool IsWhitespace(char c) => c is '\t' or ' '; + + private static bool IsNL(char c) => c is '\n' or '\r'; + + private static bool IsKeySeparator(char c) => c is KeySepEqual or KeySepColon; + + private static bool IsNumberSuffix(char c) => + c is 'k' or 'K' or 'm' or 'M' or 'g' or 'G' or 't' or 'T' or 'p' or 'P' or 'e' or 'E'; + + // --- State functions --- + + private static LexState? LexTop(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexTop); + } + + switch (r) + { + case TopOptStart: + lx.Push(LexTop); + return LexSkip(lx, LexBlockStart); + case CommentHashStart: + lx.Push(LexTop); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexTop); + return LexCommentStart; + } + + lx.Backup(); + goto case Eof; + } + + case Eof: + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + // Back up and let the key lexer handle it. + lx.Backup(); + lx.Push(LexTopValueEnd); + return LexKeyStart; + } + + private static LexState? LexTopValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexTop); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexTop); + return LexCommentStart; + } + + lx.Backup(); + if (IsWhitespace(r)) + { + return LexTopValueEnd; + } + + break; + } + + default: + if (IsWhitespace(r)) + { + return LexTopValueEnd; + } + + break; + } + + if (IsNL(r) || r == Eof || r == OptValTerm || r == TopOptValTerm || r == TopOptTerm) + { + lx.Ignore(); + return LexTop; + } + + return lx.Errorf($"Expected a top-level value to end with a new line, comment or EOF, but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexBlockStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexBlockStart); + } + + switch (r) + { + case TopOptStart: + lx.Push(LexBlockEnd); + return LexSkip(lx, LexBlockStart); + case TopOptTerm: + lx.Ignore(); + return lx.Pop(); + case CommentHashStart: + lx.Push(LexBlockStart); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockStart); + return LexCommentStart; + } + + lx.Backup(); + goto case Eof; + } + + case Eof: + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Backup(); + lx.Push(LexBlockValueEnd); + return LexKeyStart; + } + + private static LexState? LexBlockValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexBlockValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockValueEnd); + return LexCommentStart; + } + + lx.Backup(); + if (IsWhitespace(r)) + { + return LexBlockValueEnd; + } + + break; + } + + default: + if (IsWhitespace(r)) + { + return LexBlockValueEnd; + } + + break; + } + + if (IsNL(r) || r == OptValTerm || r == TopOptValTerm) + { + lx.Ignore(); + return LexBlockStart; + } + + if (r == TopOptTerm) + { + lx.Backup(); + return LexBlockEnd; + } + + return lx.Errorf($"Expected a block-level value to end with a new line, comment or EOF, but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexBlockEnd(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case CommentHashStart: + lx.Push(LexBlockStart); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexBlockStart); + return LexCommentStart; + } + + lx.Backup(); + if (IsNL(r) || IsWhitespace(r)) + { + return LexBlockEnd; + } + + break; + } + + default: + if (IsNL(r) || IsWhitespace(r)) + { + return LexBlockEnd; + } + + break; + } + + if (r == OptValTerm || r == TopOptValTerm) + { + lx.Ignore(); + return LexBlockStart; + } + + if (r == TopOptTerm) + { + lx.Ignore(); + return lx.Pop(); + } + + return lx.Errorf($"Expected a block-level to end with a '}}', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexKeyStart(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsKeySeparator(r)) + { + return lx.Errorf($"Unexpected key separator '{r}'"); + } + + if (char.IsWhiteSpace(r)) + { + lx.Next(); + return LexSkip(lx, LexKeyStart); + } + + if (r == DqStringStart) + { + lx.Next(); + return LexSkip(lx, LexDubQuotedKey); + } + + if (r == SqStringStart) + { + lx.Next(); + return LexSkip(lx, LexQuotedKey); + } + + lx.Ignore(); + lx.Next(); + return LexKey; + } + + private static LexState? LexDubQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == DqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexKeyEnd); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Next(); + return LexDubQuotedKey; + } + + private static LexState? LexQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == SqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexKeyEnd); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + lx.Next(); + return LexQuotedKey; + } + + private LexState? KeyCheckKeyword(LexState fallThrough, LexState? push) + { + var key = _input[_start.._pos].ToLowerInvariant(); + if (key == "include") + { + Ignore(); + if (push is not null) + { + Push(push); + } + + return LexIncludeStart; + } + + Emit(TokenType.Key); + return fallThrough; + } + + private static LexState? LexIncludeStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexIncludeStart); + } + + lx.Backup(); + return LexInclude; + } + + private static LexState? LexIncludeQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF in quoted include"); + } + + return LexIncludeQuotedString; + } + + private static LexState? LexIncludeDubQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == DqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF in double quoted include"); + } + + return LexIncludeDubQuotedString; + } + + private static LexState? LexIncludeString(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsNL(r) || r == Eof || r == OptValTerm || r == MapEndChar || IsWhitespace(r)) + { + lx.Backup(); + lx.Emit(TokenType.Include); + return lx.Pop(); + } + + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.Include); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + return LexIncludeString; + } + + private static LexState? LexInclude(NatsConfLexer lx) + { + var r = lx.Next(); + switch (r) + { + case SqStringStart: + lx.Ignore(); + return LexIncludeQuotedString; + case DqStringStart: + lx.Ignore(); + return LexIncludeDubQuotedString; + case ArrayStartChar: + return lx.Errorf("Expected include value but found start of an array"); + case MapStartChar: + return lx.Errorf("Expected include value but found start of a map"); + case BlockStartChar: + return lx.Errorf("Expected include value but found start of a block"); + case '\\': + return lx.Errorf("Expected include value but found escape sequence"); + } + + if (char.IsDigit(r) || r == '-') + { + return lx.Errorf("Expected include value but found start of a number"); + } + + if (IsNL(r)) + { + return lx.Errorf("Expected include value but found new line"); + } + + lx.Backup(); + return LexIncludeString; + } + + private static LexState? LexKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + // Spaces signal we could be looking at a keyword, e.g. include. + return lx.KeyCheckKeyword(LexKeyEnd, null); + } + + if (IsKeySeparator(r) || r == Eof) + { + lx.Emit(TokenType.Key); + return LexKeyEnd; + } + + if (IsNL(r)) + { + // Newline after key with no separator — check for keyword. + return lx.KeyCheckKeyword(LexKeyEnd, null); + } + + lx.Next(); + return LexKey; + } + + private static LexState? LexKeyEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexKeyEnd); + } + + if (IsKeySeparator(r)) + { + return LexSkip(lx, LexValue); + } + + if (r == Eof) + { + lx.Emit(TokenType.Eof); + return null; + } + + // We start the value here. + lx.Backup(); + return LexValue; + } + + private static LexState? LexValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexValue); + } + + switch (r) + { + case ArrayStartChar: + lx.Ignore(); + lx.Emit(TokenType.ArrayStart); + return LexArrayValue; + case MapStartChar: + lx.Ignore(); + lx.Emit(TokenType.MapStart); + return LexMapKeyStart; + case SqStringStart: + lx.Ignore(); + return LexQuotedString; + case DqStringStart: + lx.Ignore(); + lx._stringStateFn = LexDubQuotedString; + return LexDubQuotedString; + case '-': + return LexNegNumberStart; + case BlockStartChar: + lx.Ignore(); + return LexBlock; + case '.': + return lx.Errorf("Floats must start with a digit"); + } + + if (char.IsDigit(r)) + { + lx.Backup(); + return LexNumberOrDateOrStringOrIPStart; + } + + if (IsNL(r)) + { + return lx.Errorf("Expected value but found new line"); + } + + lx.Backup(); + lx._stringStateFn = LexString; + return LexString; + } + + private static LexState? LexArrayValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexArrayValue); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexArrayValue); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexArrayValue); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough to ArrayValTerm check + if (r == ArrayValTerm) + { + return lx.Errorf($"Unexpected array value terminator '{ArrayValTerm}'."); + } + + break; + } + + case ArrayValTerm: + return lx.Errorf($"Unexpected array value terminator '{ArrayValTerm}'."); + case ArrayEndChar: + return LexArrayEnd; + } + + lx.Backup(); + lx.Push(LexArrayValueEnd); + return LexValue; + } + + private static LexState? LexArrayValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexArrayValueEnd); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexArrayValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexArrayValueEnd); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough + if (r == ArrayValTerm || IsNL(r)) + { + return LexSkip(lx, LexArrayValue); + } + + break; + } + + case ArrayEndChar: + return LexArrayEnd; + } + + if (r == ArrayValTerm || IsNL(r)) + { + return LexSkip(lx, LexArrayValue); + } + + return lx.Errorf($"Expected an array value terminator ',' or an array terminator ']', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexArrayEnd(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.ArrayEnd); + return lx.Pop(); + } + + private static LexState? LexMapKeyStart(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsKeySeparator(r)) + { + return lx.Errorf($"Unexpected key separator '{r}'."); + } + + if (r == ArrayEndChar) + { + return lx.Errorf($"Unexpected array end '{r}' processing map."); + } + + if (char.IsWhiteSpace(r)) + { + lx.Next(); + return LexSkip(lx, LexMapKeyStart); + } + + if (r == MapEndChar) + { + lx.Next(); + return LexSkip(lx, LexMapEnd); + } + + if (r == CommentHashStart) + { + lx.Next(); + lx.Push(LexMapKeyStart); + return LexCommentStart; + } + + if (r == CommentSlashStart) + { + lx.Next(); + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexMapKeyStart); + return LexCommentStart; + } + + lx.Backup(); + } + + if (r == SqStringStart) + { + lx.Next(); + return LexSkip(lx, LexMapQuotedKey); + } + + if (r == DqStringStart) + { + lx.Next(); + return LexSkip(lx, LexMapDubQuotedKey); + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing map."); + } + + lx.Ignore(); + lx.Next(); + return LexMapKey; + } + + private static LexState? LexMapQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing quoted map key."); + } + + if (r == SqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexMapKeyEnd); + } + + lx.Next(); + return LexMapQuotedKey; + } + + private static LexState? LexMapDubQuotedKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing double quoted map key."); + } + + if (r == DqStringEnd) + { + lx.Emit(TokenType.Key); + lx.Next(); + return LexSkip(lx, LexMapKeyEnd); + } + + lx.Next(); + return LexMapDubQuotedKey; + } + + private static LexState? LexMapKey(NatsConfLexer lx) + { + var r = lx.Peek(); + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing map key."); + } + + if (char.IsWhiteSpace(r) && !IsNL(r)) + { + return lx.KeyCheckKeyword(LexMapKeyEnd, LexMapValueEnd); + } + + if (IsNL(r)) + { + return lx.KeyCheckKeyword(LexMapKeyEnd, LexMapValueEnd); + } + + if (IsKeySeparator(r)) + { + lx.Emit(TokenType.Key); + return LexMapKeyEnd; + } + + lx.Next(); + return LexMapKey; + } + + private static LexState? LexMapKeyEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexMapKeyEnd); + } + + if (IsKeySeparator(r)) + { + return LexSkip(lx, LexMapValue); + } + + // We start the value here. + lx.Backup(); + return LexMapValue; + } + + private static LexState? LexMapValue(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsWhiteSpace(r)) + { + return LexSkip(lx, LexMapValue); + } + + if (r == MapValTerm) + { + return lx.Errorf($"Unexpected map value terminator '{MapValTerm}'."); + } + + if (r == MapEndChar) + { + return LexSkip(lx, LexMapEnd); + } + + lx.Backup(); + lx.Push(LexMapValueEnd); + return LexValue; + } + + private static LexState? LexMapValueEnd(NatsConfLexer lx) + { + var r = lx.Next(); + if (IsWhitespace(r)) + { + return LexSkip(lx, LexMapValueEnd); + } + + switch (r) + { + case CommentHashStart: + lx.Push(LexMapValueEnd); + return LexCommentStart; + case CommentSlashStart: + { + var rn = lx.Next(); + if (rn == CommentSlashStart) + { + lx.Push(LexMapValueEnd); + return LexCommentStart; + } + + lx.Backup(); + // fallthrough + if (r == OptValTerm || r == MapValTerm || IsNL(r)) + { + return LexSkip(lx, LexMapKeyStart); + } + + break; + } + } + + if (r == OptValTerm || r == MapValTerm || IsNL(r)) + { + return LexSkip(lx, LexMapKeyStart); + } + + if (r == MapEndChar) + { + return LexSkip(lx, LexMapEnd); + } + + return lx.Errorf($"Expected a map value terminator ',' or a map terminator '}}', but got '{EscapeSpecial(r)}' instead."); + } + + private static LexState? LexMapEnd(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.MapEnd); + return lx.Pop(); + } + + private bool IsBool() + { + var str = _input[_start.._pos].ToLowerInvariant(); + return str is "true" or "false" or "on" or "off" or "yes" or "no"; + } + + private bool IsVariable() + { + if (_start >= _input.Length) + { + return false; + } + + if (_input[_start] == '$') + { + _start += 1; + return true; + } + + return false; + } + + private static LexState? LexQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == SqStringEnd) + { + lx.Backup(); + lx.Emit(TokenType.String); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + return LexQuotedString; + } + + private static LexState? LexDubQuotedString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '\\') + { + lx.AddCurrentStringPart(1); + return LexStringEscape; + } + + if (r == DqStringEnd) + { + lx.Backup(); + lx.EmitString(); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + if (r == Eof) + { + if (lx._pos > lx._start) + { + return lx.Errorf("Unexpected EOF."); + } + + lx.Emit(TokenType.Eof); + return null; + } + + return LexDubQuotedString; + } + + private static LexState? LexString(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '\\') + { + lx.AddCurrentStringPart(1); + return LexStringEscape; + } + + // Termination of non-quoted strings. + if (IsNL(r) || r == Eof || r == OptValTerm || + r == ArrayValTerm || r == ArrayEndChar || r == MapEndChar || + IsWhitespace(r)) + { + lx.Backup(); + if (lx.HasEscapedParts()) + { + lx.EmitString(); + } + else if (lx.IsBool()) + { + lx.Emit(TokenType.Bool); + } + else if (lx.IsVariable()) + { + lx.Emit(TokenType.Variable); + } + else + { + lx.EmitString(); + } + + return lx.Pop(); + } + + if (r == SqStringEnd) + { + lx.Backup(); + lx.EmitString(); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + return LexString; + } + + private static LexState? LexBlock(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == BlockEndChar) + { + lx.Backup(); + lx.Backup(); + + // Looking for a ')' character on a line by itself. + // If the previous character isn't a newline, keep processing. + if (lx.Next() != '\n') + { + lx.Next(); + return LexBlock; + } + + lx.Next(); + + // Make sure the next character is a newline or EOF. + var next = lx.Next(); + if (next is '\n' or Eof) + { + lx.Backup(); + lx.Backup(); + lx.Emit(TokenType.String); + lx.Next(); + lx.Ignore(); + return lx.Pop(); + } + + lx.Backup(); + return LexBlock; + } + + if (r == Eof) + { + return lx.Errorf("Unexpected EOF processing block."); + } + + return LexBlock; + } + + private static LexState? LexStringEscape(NatsConfLexer lx) + { + var r = lx.Next(); + return r switch + { + 'x' => LexStringBinary(lx), + 't' => lx.AddStringPart("\t"), + 'n' => lx.AddStringPart("\n"), + 'r' => lx.AddStringPart("\r"), + '"' => lx.AddStringPart("\""), + '\\' => lx.AddStringPart("\\"), + _ => lx.Errorf($"Invalid escape character '{EscapeSpecial(r)}'. Only the following escape characters are allowed: \\xXX, \\t, \\n, \\r, \\\", \\\\."), + }; + } + + private static LexState? LexStringBinary(NatsConfLexer lx) + { + var r1 = lx.Next(); + if (IsNL(r1) || r1 == Eof) + { + return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); + } + + var r2 = lx.Next(); + if (IsNL(r2) || r2 == Eof) + { + return lx.Errorf("Expected two hexadecimal digits after '\\x', but hit end of line"); + } + + var hexStr = lx._input[(lx._pos - 2)..lx._pos]; + try + { + var bytes = Convert.FromHexString(hexStr); + return lx.AddStringPart(System.Text.Encoding.Latin1.GetString(bytes)); + } + catch (FormatException) + { + return lx.Errorf($"Expected two hexadecimal digits after '\\x', but got '{hexStr}'"); + } + } + + private static LexState? LexNumberOrDateOrStringOrIPStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + if (r == '.') + { + return lx.Errorf("Floats must start with a digit, not '.'."); + } + + return lx.Errorf($"Expected a digit but got '{EscapeSpecial(r)}'."); + } + + return LexNumberOrDateOrStringOrIP; + } + + private static LexState? LexNumberOrDateOrStringOrIP(NatsConfLexer lx) + { + var r = lx.Next(); + if (r == '-') + { + if (lx._pos - lx._start != 5) + { + return lx.Errorf("All ISO8601 dates must be in full Zulu form."); + } + + return LexDateAfterYear; + } + + if (char.IsDigit(r)) + { + return LexNumberOrDateOrStringOrIP; + } + + if (r == '.') + { + return LexFloatStart; + } + + if (IsNumberSuffix(r)) + { + return LexConvenientNumber; + } + + // Check if this is a terminator or a string character. + if (!(IsNL(r) || r == Eof || r == MapEndChar || r == OptValTerm || r == MapValTerm || IsWhitespace(r) || r == ArrayValTerm || r == ArrayEndChar)) + { + // Treat it as a string value. + lx._stringStateFn = LexString; + return LexString; + } + + lx.Backup(); + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + private static LexState? LexConvenientNumber(NatsConfLexer lx) + { + var r = lx.Next(); + if (r is 'b' or 'B' or 'i' or 'I') + { + return LexConvenientNumber; + } + + lx.Backup(); + if (IsNL(r) || r == Eof || r == MapEndChar || r == OptValTerm || r == MapValTerm || + IsWhitespace(r) || char.IsDigit(r) || r == ArrayValTerm || r == ArrayEndChar) + { + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + // This is not a number, treat as string. + lx._stringStateFn = LexString; + return LexString; + } + + private static LexState? LexDateAfterYear(NatsConfLexer lx) + { + // Expected: MM-DDTHH:MM:SSZ + char[] formats = + [ + '0', '0', '-', '0', '0', + 'T', + '0', '0', ':', '0', '0', ':', '0', '0', + 'Z', + ]; + + foreach (var f in formats) + { + var r = lx.Next(); + if (f == '0') + { + if (!char.IsDigit(r)) + { + return lx.Errorf($"Expected digit in ISO8601 datetime, but found '{EscapeSpecial(r)}' instead."); + } + } + else if (f != r) + { + return lx.Errorf($"Expected '{f}' in ISO8601 datetime, but found '{EscapeSpecial(r)}' instead."); + } + } + + lx.Emit(TokenType.DateTime); + return lx.Pop(); + } + + private static LexState? LexNegNumberStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + if (r == '.') + { + return lx.Errorf("Floats must start with a digit, not '.'."); + } + + return lx.Errorf($"Expected a digit but got '{EscapeSpecial(r)}'."); + } + + return LexNegNumber; + } + + private static LexState? LexNegNumber(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r)) + { + return LexNegNumber; + } + + if (r == '.') + { + return LexFloatStart; + } + + if (IsNumberSuffix(r)) + { + return LexConvenientNumber; + } + + lx.Backup(); + lx.Emit(TokenType.Integer); + return lx.Pop(); + } + + private static LexState? LexFloatStart(NatsConfLexer lx) + { + var r = lx.Next(); + if (!char.IsDigit(r)) + { + return lx.Errorf($"Floats must have a digit after the '.', but got '{EscapeSpecial(r)}' instead."); + } + + return LexFloat; + } + + private static LexState? LexFloat(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r)) + { + return LexFloat; + } + + // Not a digit; if it's another '.', this might be an IP address. + if (r == '.') + { + return LexIPAddr; + } + + lx.Backup(); + lx.Emit(TokenType.Float); + return lx.Pop(); + } + + private static LexState? LexIPAddr(NatsConfLexer lx) + { + var r = lx.Next(); + if (char.IsDigit(r) || r is '.' or ':' or '-') + { + return LexIPAddr; + } + + lx.Backup(); + lx.Emit(TokenType.String); + return lx.Pop(); + } + + private static LexState? LexCommentStart(NatsConfLexer lx) + { + lx.Ignore(); + lx.Emit(TokenType.Comment); + return LexComment; + } + + private static LexState? LexComment(NatsConfLexer lx) + { + var r = lx.Peek(); + if (IsNL(r) || r == Eof) + { + // Consume the comment text but don't emit it as a user-visible token. + // Just ignore it and pop back. + lx.Ignore(); + return lx.Pop(); + } + + lx.Next(); + return LexComment; + } + + private static LexState LexSkip(NatsConfLexer lx, LexState nextState) + { + lx.Ignore(); + return nextState; + } + + private static string EscapeSpecial(char c) => c switch + { + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + Eof => "EOF", + _ => c.ToString(), + }; +} diff --git a/src/NATS.Server/Configuration/NatsConfParser.cs b/src/NATS.Server/Configuration/NatsConfParser.cs new file mode 100644 index 0000000..e948bb8 --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfParser.cs @@ -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; + +/// +/// Parses NATS configuration data (tokenized by ) into +/// a Dictionary<string, object?> tree. Supports nested maps, arrays, +/// variable references (block-scoped + environment), include directives, bcrypt +/// password literals, and integer suffix multipliers. +/// +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; + + /// + /// Parses a NATS configuration string into a dictionary. + /// + public static Dictionary Parse(string data) + { + var tokens = NatsConfLexer.Tokenize(data); + var state = new ParserState(tokens, baseDir: string.Empty); + state.Run(); + return state.Mapping; + } + + /// + /// Parses a NATS configuration file into a dictionary. + /// + public static Dictionary ParseFile(string filePath) => + ParseFile(filePath, includeDepth: 0); + + private static Dictionary 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; + } + + /// + /// Parses a NATS configuration file and returns the parsed config plus a + /// SHA-256 digest of the raw file content formatted as "sha256:<hex>". + /// + public static (Dictionary 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); + } + + /// + /// 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. + /// + private static Dictionary ParseEnvValue(string value, HashSet 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; + } + + /// + /// Encapsulates the mutable parsing state: context stack, key stack, token cursor. + /// Mirrors the Go parser struct from conf/parse.go. + /// + private sealed class ParserState + { + private readonly IReadOnlyList _tokens; + private readonly string _baseDir; + private readonly HashSet _envVarReferences; + private readonly int _includeDepth; + private int _pos; + + // The context stack holds either Dictionary (map) or List (array). + private readonly List _ctxs = new(4); + private object _ctx = null!; + + // Key stack for map assignments. + private readonly List _keys = new(4); + + public Dictionary Mapping { get; } = new(StringComparer.OrdinalIgnoreCase); + + public ParserState(IReadOnlyList tokens, string baseDir) + : this(tokens, baseDir, [], includeDepth: 0) + { + } + + public ParserState(IReadOnlyList tokens, string baseDir, HashSet 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 array) + { + array.Add(val); + return; + } + + // Map context: pop the pending key and assign. + if (_ctx is Dictionary 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()); + break; + + case TokenType.ArrayEnd: + { + var array = _ctx; + PopContext(); + SetValue(array); + break; + } + + case TokenType.MapStart: + PushContext(new Dictionary(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}'"), + }; + + /// + /// Parses an integer token value, handling optional size suffixes + /// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does. + /// + 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; + } + + /// + /// 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. + /// + 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 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"); + } + + /// + /// Processes an include directive by parsing the referenced file and merging + /// all its top-level keys into the current context. + /// + 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); + } + } + } +} diff --git a/src/NATS.Server/Configuration/NatsConfToken.cs b/src/NATS.Server/Configuration/NatsConfToken.cs new file mode 100644 index 0000000..8054239 --- /dev/null +++ b/src/NATS.Server/Configuration/NatsConfToken.cs @@ -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); diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index c9978a2..fb5d831 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -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 InCmdLine { get; } = []; + // TLS public string? TlsCert { get; set; } public string? TlsKey { get; set; } diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index 2c2f0e2..89c3bd4 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; @@ -20,14 +21,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable private readonly NatsOptions _options; private readonly ConcurrentDictionary _clients = new(); private readonly ConcurrentQueue _closedClients = new(); - private const int MaxClosedClients = 10_000; private readonly ServerInfo _serverInfo; private readonly ILogger _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 _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; @@ -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 } } + /// + /// 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); + } +} diff --git a/tests/NATS.Server.Tests/ConfigProcessorTests.cs b/tests/NATS.Server.Tests/ConfigProcessorTests.cs new file mode 100644 index 0000000..0ee2f39 --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigProcessorTests.cs @@ -0,0 +1,504 @@ +using NATS.Server; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigProcessorTests +{ + private static string TestDataPath(string fileName) => + Path.Combine(AppContext.BaseDirectory, "TestData", fileName); + + // ─── Basic config ────────────────────────────────────────────── + + [Fact] + public void BasicConf_Port() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Port.ShouldBe(4222); + } + + [Fact] + public void BasicConf_Host() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Host.ShouldBe("0.0.0.0"); + } + + [Fact] + public void BasicConf_ServerName() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.ServerName.ShouldBe("test-server"); + } + + [Fact] + public void BasicConf_MaxPayload() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPayload.ShouldBe(2 * 1024 * 1024); + } + + [Fact] + public void BasicConf_MaxConnections() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxConnections.ShouldBe(1000); + } + + [Fact] + public void BasicConf_Debug() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Debug.ShouldBeTrue(); + } + + [Fact] + public void BasicConf_Trace() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Trace.ShouldBeFalse(); + } + + [Fact] + public void BasicConf_PingInterval() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30)); + } + + [Fact] + public void BasicConf_MaxPingsOut() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPingsOut.ShouldBe(3); + } + + [Fact] + public void BasicConf_WriteDeadline() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MaxSubs() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubs.ShouldBe(100); + } + + [Fact] + public void BasicConf_MaxSubTokens() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxSubTokens.ShouldBe(16); + } + + [Fact] + public void BasicConf_MaxControlLine() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxControlLine.ShouldBe(2048); + } + + [Fact] + public void BasicConf_MaxPending() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MaxPending.ShouldBe(32L * 1024 * 1024); + } + + [Fact] + public void BasicConf_LameDuckDuration() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void BasicConf_LameDuckGracePeriod() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void BasicConf_MonitorPort() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.MonitorPort.ShouldBe(8222); + } + + [Fact] + public void BasicConf_Logtime() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf")); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + } + + // ─── Auth config ─────────────────────────────────────────────── + + [Fact] + public void AuthConf_SimpleUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("s3cret"); + } + + [Fact] + public void AuthConf_AuthTimeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void AuthConf_NoAuthUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.NoAuthUser.ShouldBe("guest"); + } + + [Fact] + public void AuthConf_UsersArray() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + opts.Users.ShouldNotBeNull(); + opts.Users.Count.ShouldBe(2); + } + + [Fact] + public void AuthConf_AliceUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var alice = opts.Users!.First(u => u.Username == "alice"); + alice.Password.ShouldBe("pw1"); + alice.Permissions.ShouldNotBeNull(); + alice.Permissions!.Publish.ShouldNotBeNull(); + alice.Permissions.Publish!.Allow.ShouldNotBeNull(); + alice.Permissions.Publish.Allow!.ShouldContain("foo.>"); + alice.Permissions.Subscribe.ShouldNotBeNull(); + alice.Permissions.Subscribe!.Allow.ShouldNotBeNull(); + alice.Permissions.Subscribe.Allow!.ShouldContain(">"); + } + + [Fact] + public void AuthConf_BobUser() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf")); + var bob = opts.Users!.First(u => u.Username == "bob"); + bob.Password.ShouldBe("pw2"); + bob.Permissions.ShouldBeNull(); + } + + // ─── TLS config ──────────────────────────────────────────────── + + [Fact] + public void TlsConf_CertFiles() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + } + + [Fact] + public void TlsConf_Verify() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsMap.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_Timeout() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3)); + } + + [Fact] + public void TlsConf_RateLimit() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsRateLimit.ShouldBe(100); + } + + [Fact] + public void TlsConf_PinnedCerts() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsPinnedCerts.ShouldNotBeNull(); + opts.TlsPinnedCerts!.Count.ShouldBe(1); + opts.TlsPinnedCerts.ShouldContain("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"); + } + + [Fact] + public void TlsConf_HandshakeFirst() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + [Fact] + public void TlsConf_AllowNonTls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.AllowNonTls.ShouldBeFalse(); + } + + // ─── Full config ─────────────────────────────────────────────── + + [Fact] + public void FullConf_CoreOptions() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Port.ShouldBe(4222); + opts.Host.ShouldBe("0.0.0.0"); + opts.ServerName.ShouldBe("full-test"); + opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222"); + } + + [Fact] + public void FullConf_Limits() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MaxPayload.ShouldBe(1024 * 1024); + opts.MaxControlLine.ShouldBe(4096); + opts.MaxConnections.ShouldBe(65536); + opts.MaxPending.ShouldBe(64L * 1024 * 1024); + opts.MaxSubs.ShouldBe(0); + opts.MaxSubTokens.ShouldBe(0); + opts.MaxTracedMsgLen.ShouldBe(1024); + opts.DisableSublistCache.ShouldBeFalse(); + opts.MaxClosedClients.ShouldBe(5000); + } + + [Fact] + public void FullConf_Logging() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Debug.ShouldBeFalse(); + opts.Trace.ShouldBeFalse(); + opts.TraceVerbose.ShouldBeFalse(); + opts.Logtime.ShouldBeTrue(); + opts.LogtimeUTC.ShouldBeFalse(); + opts.LogFile.ShouldBe("/var/log/nats.log"); + opts.LogSizeLimit.ShouldBe(100L * 1024 * 1024); + opts.LogMaxFiles.ShouldBe(5); + } + + [Fact] + public void FullConf_Monitoring() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.MonitorPort.ShouldBe(8222); + opts.MonitorBasePath.ShouldBe("/nats"); + } + + [Fact] + public void FullConf_Files() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.PidFile.ShouldBe("/var/run/nats.pid"); + opts.PortsFileDir.ShouldBe("/var/run"); + } + + [Fact] + public void FullConf_Lifecycle() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2)); + opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void FullConf_Tags() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Tags.ShouldNotBeNull(); + opts.Tags!["region"].ShouldBe("us-east"); + opts.Tags["env"].ShouldBe("production"); + } + + [Fact] + public void FullConf_Auth() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.Username.ShouldBe("admin"); + opts.Password.ShouldBe("secret"); + opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + } + + [Fact] + public void FullConf_Tls() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf")); + opts.TlsCert.ShouldBe("/path/to/cert.pem"); + opts.TlsKey.ShouldBe("/path/to/key.pem"); + opts.TlsCaCert.ShouldBe("/path/to/ca.pem"); + opts.TlsVerify.ShouldBeTrue(); + opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2)); + opts.TlsHandshakeFirst.ShouldBeTrue(); + } + + // ─── Listen combined format ──────────────────────────────────── + + [Fact] + public void ListenCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("listen: \"10.0.0.1:5222\""); + opts.Host.ShouldBe("10.0.0.1"); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_PortOnly() + { + var opts = ConfigProcessor.ProcessConfig("listen: \":5222\""); + opts.Port.ShouldBe(5222); + } + + [Fact] + public void ListenCombined_BarePort() + { + var opts = ConfigProcessor.ProcessConfig("listen: 5222"); + opts.Port.ShouldBe(5222); + } + + // ─── HTTP combined format ────────────────────────────────────── + + [Fact] + public void HttpCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("http: \"10.0.0.1:8333\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorPort.ShouldBe(8333); + } + + [Fact] + public void HttpsCombined_HostAndPort() + { + var opts = ConfigProcessor.ProcessConfig("https: \"10.0.0.1:8444\""); + opts.MonitorHost.ShouldBe("10.0.0.1"); + opts.MonitorHttpsPort.ShouldBe(8444); + } + + // ─── Duration as number ──────────────────────────────────────── + + [Fact] + public void DurationAsNumber_TreatedAsSeconds() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: 60"); + opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60)); + } + + [Fact] + public void DurationAsString_Milliseconds() + { + var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\""); + opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500)); + } + + [Fact] + public void DurationAsString_Hours() + { + var opts = ConfigProcessor.ProcessConfig("ping_interval: \"1h\""); + opts.PingInterval.ShouldBe(TimeSpan.FromHours(1)); + } + + // ─── Unknown keys ────────────────────────────────────────────── + + [Fact] + public void UnknownKeys_SilentlyIgnored() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 4222 + cluster { name: "my-cluster" } + jetstream { store_dir: "/tmp/js" } + unknown_key: "whatever" + """); + opts.Port.ShouldBe(4222); + } + + // ─── Server name validation ──────────────────────────────────── + + [Fact] + public void ServerNameWithSpaces_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("server_name: \"my server\"")); + ex.Errors.ShouldContain(e => e.Contains("server_name cannot contain spaces")); + } + + // ─── Max sub tokens validation ───────────────────────────────── + + [Fact] + public void MaxSubTokens_ExceedsLimit_ReportsError() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig("max_sub_tokens: 300")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens cannot exceed 256")); + } + + // ─── ProcessConfig from string ───────────────────────────────── + + [Fact] + public void ProcessConfig_FromString() + { + var opts = ConfigProcessor.ProcessConfig(""" + port: 9222 + host: "127.0.0.1" + debug: true + """); + opts.Port.ShouldBe(9222); + opts.Host.ShouldBe("127.0.0.1"); + opts.Debug.ShouldBeTrue(); + } + + // ─── TraceVerbose sets Trace ──────────────────────────────────── + + [Fact] + public void TraceVerbose_AlsoSetsTrace() + { + var opts = ConfigProcessor.ProcessConfig("trace_verbose: true"); + opts.TraceVerbose.ShouldBeTrue(); + opts.Trace.ShouldBeTrue(); + } + + // ─── Error collection (not fail-fast) ────────────────────────── + + [Fact] + public void MultipleErrors_AllCollected() + { + var ex = Should.Throw(() => + ConfigProcessor.ProcessConfig(""" + server_name: "bad name" + max_sub_tokens: 999 + """)); + ex.Errors.Count.ShouldBe(2); + ex.Errors.ShouldContain(e => e.Contains("server_name")); + ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens")); + } + + // ─── ConfigFile path tracking ────────────────────────────────── + + [Fact] + public void ProcessConfigFile_SetsConfigFilePath() + { + var path = TestDataPath("basic.conf"); + var opts = ConfigProcessor.ProcessConfigFile(path); + opts.ConfigFile.ShouldBe(path); + } + + // ─── HasTls derived property ─────────────────────────────────── + + [Fact] + public void HasTls_TrueWhenCertAndKeySet() + { + var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf")); + opts.HasTls.ShouldBeTrue(); + } +} diff --git a/tests/NATS.Server.Tests/ConfigReloadTests.cs b/tests/NATS.Server.Tests/ConfigReloadTests.cs new file mode 100644 index 0000000..a1d7fcd --- /dev/null +++ b/tests/NATS.Server.Tests/ConfigReloadTests.cs @@ -0,0 +1,89 @@ +using NATS.Server; +using NATS.Server.Auth; +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class ConfigReloadTests +{ + [Fact] + public void Diff_NoChanges_ReturnsEmpty() + { + var old = new NatsOptions { Port = 4222, Debug = true }; + var @new = new NatsOptions { Port = 4222, Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.ShouldBeEmpty(); + } + + [Fact] + public void Diff_ReloadableChange_ReturnsChange() + { + var old = new NatsOptions { Debug = false }; + var @new = new NatsOptions { Debug = true }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].Name.ShouldBe("Debug"); + changes[0].IsLoggingChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_NonReloadableChange_ReturnsNonReloadableChange() + { + var old = new NatsOptions { Port = 4222 }; + var @new = new NatsOptions { Port = 5222 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(1); + changes[0].IsNonReloadable.ShouldBeTrue(); + } + + [Fact] + public void Diff_MultipleChanges_ReturnsAll() + { + var old = new NatsOptions { Debug = false, MaxPayload = 1024 }; + var @new = new NatsOptions { Debug = true, MaxPayload = 2048 }; + var changes = ConfigReloader.Diff(old, @new); + changes.Count.ShouldBe(2); + } + + [Fact] + public void Diff_AuthChange_MarkedCorrectly() + { + var old = new NatsOptions { Username = "alice" }; + var @new = new NatsOptions { Username = "bob" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsAuthChange.ShouldBeTrue(); + } + + [Fact] + public void Diff_TlsChange_MarkedCorrectly() + { + var old = new NatsOptions { TlsCert = "/old/cert.pem" }; + var @new = new NatsOptions { TlsCert = "/new/cert.pem" }; + var changes = ConfigReloader.Diff(old, @new); + changes[0].IsTlsChange.ShouldBeTrue(); + } + + [Fact] + public void Validate_NonReloadableChanges_ReturnsErrors() + { + var changes = new List + { + new ConfigChange("Port", isNonReloadable: true), + }; + var errors = ConfigReloader.Validate(changes); + errors.Count.ShouldBe(1); + errors[0].ShouldContain("Port"); + } + + [Fact] + public void MergeWithCli_CliOverridesConfig() + { + var fromConfig = new NatsOptions { Port = 5222, Debug = true }; + var cliFlags = new HashSet { "Port" }; + var cliValues = new NatsOptions { Port = 4222 }; + + ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags); + fromConfig.Port.ShouldBe(4222); // CLI wins + fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI) + } +} diff --git a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj index 67f3fe4..503f4df 100644 --- a/tests/NATS.Server.Tests/NATS.Server.Tests.csproj +++ b/tests/NATS.Server.Tests/NATS.Server.Tests.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/tests/NATS.Server.Tests/NatsConfLexerTests.cs b/tests/NATS.Server.Tests/NatsConfLexerTests.cs new file mode 100644 index 0000000..d664fc1 --- /dev/null +++ b/tests/NATS.Server.Tests/NatsConfLexerTests.cs @@ -0,0 +1,221 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class NatsConfLexerTests +{ + [Fact] + public void Lex_SimpleKeyStringValue_ReturnsKeyAndString() + { + var tokens = NatsConfLexer.Tokenize("foo = \"bar\"").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("foo"); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("bar"); + tokens[2].Type.ShouldBe(TokenType.Eof); + } + + [Fact] + public void Lex_SingleQuotedString_ReturnsString() + { + var tokens = NatsConfLexer.Tokenize("foo = 'bar'").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("bar"); + } + + [Fact] + public void Lex_IntegerValue_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("port = 4222").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("port"); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("4222"); + } + + [Fact] + public void Lex_IntegerWithSuffix_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("size = 64mb").ToList(); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("64mb"); + } + + [Fact] + public void Lex_BooleanValues_ReturnsBool() + { + foreach (var val in new[] { "true", "false", "yes", "no", "on", "off" }) + { + var tokens = NatsConfLexer.Tokenize($"flag = {val}").ToList(); + tokens[1].Type.ShouldBe(TokenType.Bool); + } + } + + [Fact] + public void Lex_FloatValue_ReturnsFloat() + { + var tokens = NatsConfLexer.Tokenize("rate = 2.5").ToList(); + tokens[1].Type.ShouldBe(TokenType.Float); + tokens[1].Value.ShouldBe("2.5"); + } + + [Fact] + public void Lex_NegativeNumber_ReturnsInteger() + { + var tokens = NatsConfLexer.Tokenize("offset = -10").ToList(); + tokens[1].Type.ShouldBe(TokenType.Integer); + tokens[1].Value.ShouldBe("-10"); + } + + [Fact] + public void Lex_DatetimeValue_ReturnsDatetime() + { + var tokens = NatsConfLexer.Tokenize("ts = 2024-01-15T10:30:00Z").ToList(); + tokens[1].Type.ShouldBe(TokenType.DateTime); + } + + [Fact] + public void Lex_HashComment_IsIgnored() + { + var tokens = NatsConfLexer.Tokenize("# this is a comment\nfoo = 1").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(1); + keys[0].Value.ShouldBe("foo"); + } + + [Fact] + public void Lex_SlashComment_IsIgnored() + { + var tokens = NatsConfLexer.Tokenize("// comment\nfoo = 1").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(1); + } + + [Fact] + public void Lex_MapBlock_ReturnsMapStartEnd() + { + var tokens = NatsConfLexer.Tokenize("auth { user: admin }").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[0].Value.ShouldBe("auth"); + tokens[1].Type.ShouldBe(TokenType.MapStart); + tokens[2].Type.ShouldBe(TokenType.Key); + tokens[2].Value.ShouldBe("user"); + tokens[3].Type.ShouldBe(TokenType.String); + tokens[3].Value.ShouldBe("admin"); + tokens[4].Type.ShouldBe(TokenType.MapEnd); + } + + [Fact] + public void Lex_Array_ReturnsArrayStartEnd() + { + var tokens = NatsConfLexer.Tokenize("items = [1, 2, 3]").ToList(); + tokens[1].Type.ShouldBe(TokenType.ArrayStart); + tokens[2].Type.ShouldBe(TokenType.Integer); + tokens[2].Value.ShouldBe("1"); + tokens[5].Type.ShouldBe(TokenType.ArrayEnd); + } + + [Fact] + public void Lex_Variable_ReturnsVariable() + { + var tokens = NatsConfLexer.Tokenize("secret = $MY_VAR").ToList(); + tokens[1].Type.ShouldBe(TokenType.Variable); + tokens[1].Value.ShouldBe("MY_VAR"); + } + + [Fact] + public void Lex_Include_ReturnsInclude() + { + var tokens = NatsConfLexer.Tokenize("include \"auth.conf\"").ToList(); + tokens[0].Type.ShouldBe(TokenType.Include); + tokens[0].Value.ShouldBe("auth.conf"); + } + + [Fact] + public void Lex_EscapeSequences_AreProcessed() + { + var tokens = NatsConfLexer.Tokenize("msg = \"hello\\tworld\\n\"").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("hello\tworld\n"); + } + + [Fact] + public void Lex_HexEscape_IsProcessed() + { + var tokens = NatsConfLexer.Tokenize("val = \"\\x41\\x42\"").ToList(); + tokens[1].Value.ShouldBe("AB"); + } + + [Fact] + public void Lex_ColonSeparator_Works() + { + var tokens = NatsConfLexer.Tokenize("foo: bar").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_WhitespaceSeparator_Works() + { + var tokens = NatsConfLexer.Tokenize("foo bar").ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_SemicolonTerminator_IsHandled() + { + var tokens = NatsConfLexer.Tokenize("foo = 1; bar = 2").ToList(); + var keys = tokens.Where(t => t.Type == TokenType.Key).ToList(); + keys.Count.ShouldBe(2); + } + + [Fact] + public void Lex_EmptyInput_ReturnsEof() + { + var tokens = NatsConfLexer.Tokenize("").ToList(); + tokens.Count.ShouldBe(1); + tokens[0].Type.ShouldBe(TokenType.Eof); + } + + [Fact] + public void Lex_BlockString_ReturnsString() + { + var input = "desc (\nthis is\na block\n)\n"; + var tokens = NatsConfLexer.Tokenize(input).ToList(); + tokens[0].Type.ShouldBe(TokenType.Key); + tokens[1].Type.ShouldBe(TokenType.String); + } + + [Fact] + public void Lex_IPAddress_ReturnsString() + { + var tokens = NatsConfLexer.Tokenize("host = 127.0.0.1").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("127.0.0.1"); + } + + [Fact] + public void Lex_TrackLineNumbers() + { + var tokens = NatsConfLexer.Tokenize("a = 1\nb = 2\nc = 3").ToList(); + tokens[0].Line.ShouldBe(1); // a + tokens[2].Line.ShouldBe(2); // b + tokens[4].Line.ShouldBe(3); // c + } + + [Fact] + public void Lex_UnterminatedString_ReturnsError() + { + var tokens = NatsConfLexer.Tokenize("foo = \"unterminated").ToList(); + tokens.ShouldContain(t => t.Type == TokenType.Error); + } + + [Fact] + public void Lex_StringStartingWithDigit_TreatedAsString() + { + var tokens = NatsConfLexer.Tokenize("foo = 3xyz").ToList(); + tokens[1].Type.ShouldBe(TokenType.String); + tokens[1].Value.ShouldBe("3xyz"); + } +} diff --git a/tests/NATS.Server.Tests/NatsConfParserTests.cs b/tests/NATS.Server.Tests/NatsConfParserTests.cs new file mode 100644 index 0000000..bb7bb5d --- /dev/null +++ b/tests/NATS.Server.Tests/NatsConfParserTests.cs @@ -0,0 +1,184 @@ +using NATS.Server.Configuration; + +namespace NATS.Server.Tests; + +public class NatsConfParserTests +{ + [Fact] + public void Parse_SimpleTopLevel_ReturnsCorrectTypes() + { + var result = NatsConfParser.Parse("foo = '1'; bar = 2.2; baz = true; boo = 22"); + result["foo"].ShouldBe("1"); + result["bar"].ShouldBe(2.2); + result["baz"].ShouldBe(true); + result["boo"].ShouldBe(22L); + } + + [Fact] + public void Parse_Booleans_AllVariants() + { + foreach (var (input, expected) in new[] { + ("true", true), ("TRUE", true), ("yes", true), ("on", true), + ("false", false), ("FALSE", false), ("no", false), ("off", false) + }) + { + var result = NatsConfParser.Parse($"flag = {input}"); + result["flag"].ShouldBe(expected); + } + } + + [Fact] + public void Parse_IntegerWithSuffix_AppliesMultiplier() + { + var result = NatsConfParser.Parse("a = 1k; b = 2mb; c = 3gb; d = 4kb"); + result["a"].ShouldBe(1000L); + result["b"].ShouldBe(2L * 1024 * 1024); + result["c"].ShouldBe(3L * 1024 * 1024 * 1024); + result["d"].ShouldBe(4L * 1024); + } + + [Fact] + public void Parse_NestedMap_ReturnsDictionary() + { + var result = NatsConfParser.Parse("auth { user: admin, pass: secret }"); + var auth = result["auth"].ShouldBeOfType>(); + auth["user"].ShouldBe("admin"); + auth["pass"].ShouldBe("secret"); + } + + [Fact] + public void Parse_Array_ReturnsList() + { + var result = NatsConfParser.Parse("items = [1, 2, 3]"); + var items = result["items"].ShouldBeOfType>(); + items.Count.ShouldBe(3); + items[0].ShouldBe(1L); + } + + [Fact] + public void Parse_Variable_ResolvesFromContext() + { + var result = NatsConfParser.Parse("index = 22\nfoo = $index"); + result["foo"].ShouldBe(22L); + } + + [Fact] + public void Parse_NestedVariable_UsesBlockScope() + { + var input = "index = 22\nnest {\n index = 11\n foo = $index\n}\nbar = $index"; + var result = NatsConfParser.Parse(input); + var nest = result["nest"].ShouldBeOfType>(); + nest["foo"].ShouldBe(11L); + result["bar"].ShouldBe(22L); + } + + [Fact] + public void Parse_EnvironmentVariable_ResolvesFromEnv() + { + Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", "hello"); + try + { + var result = NatsConfParser.Parse("val = $NATS_TEST_VAR_12345"); + result["val"].ShouldBe("hello"); + } + finally + { + Environment.SetEnvironmentVariable("NATS_TEST_VAR_12345", null); + } + } + + [Fact] + public void Parse_UndefinedVariable_Throws() + { + Should.Throw(() => + NatsConfParser.Parse("val = $UNDEFINED_VAR_XYZZY_99999")); + } + + [Fact] + public void Parse_IncludeDirective_MergesFile() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + File.WriteAllText(Path.Combine(dir, "main.conf"), "port = 4222\ninclude \"sub.conf\""); + File.WriteAllText(Path.Combine(dir, "sub.conf"), "host = \"localhost\""); + var result = NatsConfParser.ParseFile(Path.Combine(dir, "main.conf")); + result["port"].ShouldBe(4222L); + result["host"].ShouldBe("localhost"); + } + finally + { + Directory.Delete(dir, true); + } + } + + [Fact] + public void Parse_MultipleKeySeparators_AllWork() + { + var r1 = NatsConfParser.Parse("a = 1"); + var r2 = NatsConfParser.Parse("a : 1"); + var r3 = NatsConfParser.Parse("a 1"); + r1["a"].ShouldBe(1L); + r2["a"].ShouldBe(1L); + r3["a"].ShouldBe(1L); + } + + [Fact] + public void Parse_ErrorOnInvalidInput_Throws() + { + Should.Throw(() => NatsConfParser.Parse("= invalid")); + } + + [Fact] + public void Parse_CommentsInsideBlocks_AreIgnored() + { + var input = "auth {\n # comment\n user: admin\n // another comment\n pass: secret\n}"; + var result = NatsConfParser.Parse(input); + var auth = result["auth"].ShouldBeOfType>(); + auth["user"].ShouldBe("admin"); + auth["pass"].ShouldBe("secret"); + } + + [Fact] + public void Parse_ArrayOfMaps_Works() + { + var input = "users = [\n { user: alice, pass: pw1 }\n { user: bob, pass: pw2 }\n]"; + var result = NatsConfParser.Parse(input); + var users = result["users"].ShouldBeOfType>(); + users.Count.ShouldBe(2); + var first = users[0].ShouldBeOfType>(); + first["user"].ShouldBe("alice"); + } + + [Fact] + public void Parse_BcryptPassword_HandledAsString() + { + var input = "pass = $2a$04$P/.bd.7unw9Ew7yWJqXsl.f4oNRLQGvadEL2YnqQXbbb.IVQajRdK"; + var result = NatsConfParser.Parse(input); + ((string)result["pass"]!).ShouldStartWith("$2a$"); + } + + [Fact] + public void ParseFile_WithDigest_ReturnsStableHash() + { + var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "test.conf"); + File.WriteAllText(conf, "port = 4222\nhost = \"localhost\""); + var (result, digest) = NatsConfParser.ParseFileWithDigest(conf); + result["port"].ShouldBe(4222L); + digest.ShouldStartWith("sha256:"); + digest.Length.ShouldBeGreaterThan(10); + + var (_, digest2) = NatsConfParser.ParseFileWithDigest(conf); + digest2.ShouldBe(digest); + } + finally + { + Directory.Delete(dir, true); + } + } +} diff --git a/tests/NATS.Server.Tests/NatsOptionsTests.cs b/tests/NATS.Server.Tests/NatsOptionsTests.cs index 535af0d..91d79c0 100644 --- a/tests/NATS.Server.Tests/NatsOptionsTests.cs +++ b/tests/NATS.Server.Tests/NatsOptionsTests.cs @@ -14,6 +14,22 @@ public class NatsOptionsTests opts.LogSizeLimit.ShouldBe(0L); opts.Tags.ShouldBeNull(); } + + [Fact] + public void New_fields_have_correct_defaults() + { + var opts = new NatsOptions(); + opts.ClientAdvertise.ShouldBeNull(); + opts.TraceVerbose.ShouldBeFalse(); + opts.MaxTracedMsgLen.ShouldBe(0); + opts.DisableSublistCache.ShouldBeFalse(); + opts.ConnectErrorReports.ShouldBe(3600); + opts.ReconnectErrorReports.ShouldBe(1); + opts.NoHeaderSupport.ShouldBeFalse(); + opts.MaxClosedClients.ShouldBe(10_000); + opts.NoSystemAccount.ShouldBeFalse(); + opts.SystemAccount.ShouldBeNull(); + } } public class LogOverrideTests diff --git a/tests/NATS.Server.Tests/TestData/auth.conf b/tests/NATS.Server.Tests/TestData/auth.conf new file mode 100644 index 0000000..bb16a3e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/auth.conf @@ -0,0 +1,11 @@ +authorization { + user: admin + password: "s3cret" + timeout: 5 + + users = [ + { user: alice, password: "pw1", permissions: { publish: { allow: ["foo.>"] }, subscribe: { allow: [">"] } } } + { user: bob, password: "pw2" } + ] +} +no_auth_user: "guest" diff --git a/tests/NATS.Server.Tests/TestData/basic.conf b/tests/NATS.Server.Tests/TestData/basic.conf new file mode 100644 index 0000000..27ad7ef --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/basic.conf @@ -0,0 +1,19 @@ +port: 4222 +host: "0.0.0.0" +server_name: "test-server" +max_payload: 2mb +max_connections: 1000 +debug: true +trace: false +logtime: true +logtime_utc: false +ping_interval: "30s" +ping_max: 3 +write_deadline: "5s" +max_subs: 100 +max_sub_tokens: 16 +max_control_line: 2048 +max_pending: 32mb +lame_duck_duration: "60s" +lame_duck_grace_period: "5s" +http_port: 8222 diff --git a/tests/NATS.Server.Tests/TestData/full.conf b/tests/NATS.Server.Tests/TestData/full.conf new file mode 100644 index 0000000..bb4d61e --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/full.conf @@ -0,0 +1,57 @@ +# Full configuration with all supported options +port: 4222 +host: "0.0.0.0" +server_name: "full-test" +client_advertise: "nats://public.example.com:4222" + +max_payload: 1mb +max_control_line: 4096 +max_connections: 65536 +max_pending: 64mb +write_deadline: "10s" +max_subs: 0 +max_sub_tokens: 0 +max_traced_msg_len: 1024 +disable_sublist_cache: false +max_closed_clients: 5000 + +ping_interval: "2m" +ping_max: 2 + +debug: false +trace: false +trace_verbose: false +logtime: true +logtime_utc: false +logfile: "/var/log/nats.log" +log_size_limit: 100mb +log_max_num: 5 + +http_port: 8222 +http_base_path: "/nats" + +pidfile: "/var/run/nats.pid" +ports_file_dir: "/var/run" + +lame_duck_duration: "2m" +lame_duck_grace_period: "10s" + +server_tags { + region: "us-east" + env: "production" +} + +authorization { + user: admin + password: "secret" + timeout: 2 +} + +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + timeout: 2 + handshake_first: true +} diff --git a/tests/NATS.Server.Tests/TestData/tls.conf b/tests/NATS.Server.Tests/TestData/tls.conf new file mode 100644 index 0000000..d8a99de --- /dev/null +++ b/tests/NATS.Server.Tests/TestData/tls.conf @@ -0,0 +1,12 @@ +tls { + cert_file: "/path/to/cert.pem" + key_file: "/path/to/key.pem" + ca_file: "/path/to/ca.pem" + verify: true + verify_and_map: true + timeout: 3 + connection_rate_limit: 100 + pinned_certs: ["abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"] + handshake_first: true +} +allow_non_tls: false