Compare commits

...

28 Commits

Author SHA1 Message Date
Joseph Doherty
9b784024db docs: update differences.md to reflect SYSTEM/ACCOUNT types and imports/exports implemented 2026-02-23 06:04:29 -05:00
Joseph Doherty
86283a7f97 feat: add latency tracking for service import request-reply 2026-02-23 06:03:37 -05:00
Joseph Doherty
4450c27381 feat: add response routing for service import request-reply patterns 2026-02-23 06:01:53 -05:00
Joseph Doherty
c9066e526d feat: wire service import forwarding into message delivery path
Add ProcessServiceImport method to NatsServer that transforms subjects
from importer to exporter namespace and delivers to destination account
subscribers. Wire service import checking into ProcessMessage so that
publishes matching a service import "From" pattern are automatically
forwarded to the destination account. Includes MapImportSubject for
wildcard-aware subject mapping and WireServiceImports for import setup.
2026-02-23 05:59:36 -05:00
Joseph Doherty
4c2b7fa3de feat: add import/export support to Account with ACCOUNT client lazy creation 2026-02-23 05:54:31 -05:00
Joseph Doherty
591833adbb feat: add import/export model types (ServiceImport, StreamImport, exports, auth) 2026-02-23 05:51:30 -05:00
Joseph Doherty
5bae9cc289 feat: add system request-reply monitoring services ($SYS.REQ.SERVER.*)
Register VARZ, HEALTHZ, SUBSZ, STATSZ, and IDZ request-reply handlers
on $SYS.REQ.SERVER.{id}.* subjects and $SYS.REQ.SERVER.PING.* wildcard
subjects via InitEventTracking. Also excludes the $SYS system account
from the /subz monitoring endpoint by default since its subscriptions
are internal infrastructure.
2026-02-23 05:48:32 -05:00
Joseph Doherty
0b34f8cec4 feat: add periodic server stats and account connection heartbeat publishing 2026-02-23 05:44:09 -05:00
Joseph Doherty
125b71b3b0 feat: wire system event publishing for connect, disconnect, and shutdown 2026-02-23 05:41:44 -05:00
Joseph Doherty
89465450a1 fix: use per-SID callback dictionary in SysSubscribe to support multiple subscriptions 2026-02-23 05:38:10 -05:00
Joseph Doherty
8e790445f4 feat: add InternalEventSystem with Channel-based send/receive loops 2026-02-23 05:34:57 -05:00
Joseph Doherty
fc96b6eb43 feat: add system event DTOs and JSON source generator context 2026-02-23 05:29:40 -05:00
Joseph Doherty
b0c5b4acd8 feat: add system event subject constants and SystemMessageHandler delegate 2026-02-23 05:26:25 -05:00
Joseph Doherty
0c4bca9073 feat: add InternalClient class for socketless internal messaging 2026-02-23 05:22:58 -05:00
Joseph Doherty
0e7db5615e feat: add INatsClient interface and implement on NatsClient
Extract INatsClient interface from NatsClient to enable internal clients
(SYSTEM, ACCOUNT) to participate in the subscription system without
requiring a socket connection. Change Subscription.Client from concrete
NatsClient to INatsClient, keeping IMessageRouter and RemoveClient using
the concrete type since only socket clients need those paths.
2026-02-23 05:18:59 -05:00
Joseph Doherty
5e11785bdf feat: add ClientKind enum with IsInternal extension 2026-02-23 05:15:06 -05:00
Joseph Doherty
4b3890f046 docs: add implementation plan for SYSTEM/ACCOUNT connection types
16 tasks across 6 layers: ClientKind + INatsClient + InternalClient,
event infrastructure, event publishing, request-reply services,
import/export model, and response routing with latency tracking.
2026-02-23 05:12:02 -05:00
Joseph Doherty
e0abce66da docs: add mqtt connection type design 2026-02-23 05:08:44 -05:00
Joseph Doherty
a0926c3a50 docs: add design doc for SYSTEM and ACCOUNT connection types
Covers 6 implementation layers: ClientKind enum + INatsClient interface,
event infrastructure with Channel<T>, system event publishing, request-reply
monitoring services, import/export model with ACCOUNT client, and response
routing with latency tracking.
2026-02-23 05:03:17 -05:00
Joseph Doherty
ad336167b9 docs: update differences.md to reflect config parsing and hot reload implementation 2026-02-23 04:58:53 -05:00
Joseph Doherty
684ee222ad feat: integrate config file loading and SIGHUP hot reload
Wire up the config parsing infrastructure into the server:
- NatsServer: add ReloadConfig() with digest-based change detection,
  diff/validate, CLI override preservation, and side-effect triggers
- Program.cs: two-pass CLI parsing — load config file first, then
  apply CLI args on top with InCmdLine tracking for reload precedence
- SIGHUP handler upgraded from stub warning to actual reload
- Remove config file "not yet supported" warning from StartAsync
- Add integration tests for config loading, CLI overrides, and
  reload validation
2026-02-23 04:57:34 -05:00
Joseph Doherty
d21243bc8a feat: add config reloader with diff, validate, and CLI merge
Port of Go server/reload.go option interface and diffing logic. Compares
NatsOptions property-by-property to detect changes, tags each with category
flags (logging, auth, TLS, non-reloadable), validates that non-reloadable
options (Host, Port, ServerName) are not changed at runtime, and provides
MergeCliOverrides to ensure CLI flags always take precedence over config
file values during hot reload.
2026-02-23 04:53:25 -05:00
Joseph Doherty
8a2ded8e48 feat: add config processor mapping parsed config to NatsOptions
Port of Go server/opts.go processConfigFileLine switch. Maps parsed
NATS config dictionaries to NatsOptions fields including:
- Core options (port, host, server_name, limits, ping, write_deadline)
- Logging (debug, trace, logfile, log rotation)
- Authorization (single user, users array with permissions)
- TLS (cert/key/ca, verify, pinned_certs, handshake_first)
- Monitoring (http_port, https_port, http/https listen, base_path)
- Lifecycle (lame_duck_duration/grace_period)
- Server tags, file paths, system account options

Includes error collection (not fail-fast), duration parsing (ms/s/m/h
strings and numeric seconds), host:port listen parsing, and 56 tests
covering all config sections plus validation edge cases.
2026-02-23 04:47:54 -05:00
Joseph Doherty
5219f77f9b fix: add include depth limit, fix PopContext guard, add SetValue fallback
- Add MaxIncludeDepth = 10 constant and thread _includeDepth through ParserState
  constructors, ProcessInclude, ParseFile (private overload), and ParseEnvValue
  to prevent StackOverflowException from recursive includes
- Fix PopContext to check _ctxs.Count <= 1 instead of == 0 so the root context
  is never popped, replacing silent crash with clear InvalidOperationException
- Add else throw in SetValue so unknown context types surface as bugs rather
  than silently dropping values
2026-02-23 04:42:37 -05:00
Joseph Doherty
9f66ef72c6 feat: add NATS config file parser (port of Go conf/parse.go)
Implements NatsConfParser with Parse, ParseFile, and ParseFileWithDigest
methods. Supports nested maps/arrays, variable resolution with block
scoping and environment fallback, bcrypt password literals, integer
suffix multipliers, include directives, and cycle detection.
2026-02-23 04:35:46 -05:00
Joseph Doherty
ae043136a1 fix: address lexer code review findings (newline handling, emit cleanup, null guard) 2026-02-23 04:30:36 -05:00
Joseph Doherty
f952e6afab feat: add new NatsOptions fields for Go config parity
Adds 10 new fields to NatsOptions (ClientAdvertise, TraceVerbose, MaxTracedMsgLen,
DisableSublistCache, ConnectErrorReports, ReconnectErrorReports, NoHeaderSupport,
MaxClosedClients, NoSystemAccount, SystemAccount) plus InCmdLine tracking set.
Moves MaxClosedClients from a private constant in NatsServer to a configurable option.
2026-02-23 04:23:27 -05:00
Joseph Doherty
9fff5709c4 feat: add NATS config file lexer (port of Go conf/lex.go)
Port the NATS configuration file lexer from Go's conf/lex.go to C#.
The lexer is a state-machine tokenizer that supports the NATS config
format: key-value pairs with =, :, or whitespace separators; nested
maps {}; arrays []; single and double quoted strings with escape
sequences; block strings (); variables $VAR; include directives;
comments (# and //); booleans; integers with size suffixes (kb, mb, gb);
floats; ISO8601 datetimes; and IP addresses.
2026-02-23 04:20:56 -05:00
54 changed files with 10417 additions and 30 deletions

View File

@@ -11,8 +11,8 @@
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| 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 |
| System account setup | Y | Y | `$SYS` account with InternalEventSystem, event publishing, request-reply services |
| 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` |
---
@@ -64,9 +64,9 @@
| ROUTER | Y | N | Excluded per scope |
| GATEWAY | Y | N | Excluded per scope |
| LEAF | Y | N | Excluded per scope |
| SYSTEM (internal) | Y | N | |
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
| JETSTREAM (internal) | Y | N | |
| ACCOUNT (internal) | Y | N | |
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
| WebSocket clients | Y | N | |
| MQTT clients | Y | N | |
@@ -218,7 +218,7 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| Per-account SubList isolation | Y | Y | |
| Multi-account user resolution | Y | Y | `AccountConfig` per account in `NatsOptions.Accounts`; `GetOrCreateAccount` wires limits |
| Account exports/imports | Y | N | |
| Account exports/imports | Y | Y | ServiceImport/StreamImport with ExportAuth, subject transforms, response routing |
| Per-account connection limits | Y | Y | `Account.AddClient()` returns false when `MaxConnections` exceeded |
| Per-account subscription limits | Y | Y | `Account.IncrementSubscriptions()` enforced in `ProcessSub()` |
| Account JetStream limits | Y | N | Excluded per scope |
@@ -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,13 @@ 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
- **SYSTEM client type** — InternalClient with InternalEventSystem, Channel-based send/receive loops, event publishing
- **ACCOUNT client type** — lazy per-account InternalClient with import/export subscription support
- **System event publishing** — connect/disconnect advisories, server stats, shutdown/lame-duck events, auth errors
- **System request-reply services** — $SYS.REQ.SERVER.*.VARZ/CONNZ/SUBSZ/HEALTHZ/IDZ/STATSZ with ping wildcards
- **Account exports/imports** — service and stream imports with ExportAuth, subject transforms, response routing, latency tracking
### 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

View File

@@ -0,0 +1,93 @@
# MQTT Connection Type Port Design
## Goal
Port MQTT-related connection type parity from Go into the .NET server for two scoped areas:
1. JWT `allowed_connection_types` behavior for `MQTT` / `MQTT_WS` (plus existing known types).
2. `/connz` filtering by `mqtt_client`.
## Scope
- In scope:
- JWT allowed connection type normalization and enforcement semantics.
- `/connz?mqtt_client=` option parsing and filtering.
- Unit/integration tests for new and updated behavior.
- `differences.md` updates after implementation is verified.
- Out of scope:
- Full MQTT transport implementation.
- WebSocket transport implementation.
- Leaf/route/gateway transport plumbing.
## Architecture
- Add an auth-facing connection-type model that can be passed through `ClientAuthContext`.
- Implement Go-style allowed connection type conversion and matching in `JwtAuthenticator`:
- normalize input to uppercase.
- retain recognized types.
- collect unknown types as non-fatal if at least one valid type remains.
- reject when only unknown types are present.
- enforce current connection type against the resulting allowed set.
- Extend connz monitoring options to parse `mqtt_client` and apply exact-match filtering before sort/pagination.
## Components
- `src/NATS.Server/Auth/IAuthenticator.cs`
- Extend `ClientAuthContext` with a connection-type value.
- `src/NATS.Server/Auth/Jwt/JwtConnectionTypes.cs` (new)
- Canonical constants for known connection types:
- `STANDARD`, `WEBSOCKET`, `LEAFNODE`, `LEAFNODE_WS`, `MQTT`, `MQTT_WS`, `INPROCESS`.
- Helper(s) for normalization and validation behavior.
- `src/NATS.Server/Auth/JwtAuthenticator.cs`
- Evaluate `userClaims.Nats?.AllowedConnectionTypes` using Go-compatible semantics.
- Enforce against current `ClientAuthContext.ConnectionType`.
- `src/NATS.Server/NatsClient.cs`
- Populate auth context connection type (currently `STANDARD`).
- `src/NATS.Server/Monitoring/Connz.cs`
- Add `MqttClient` to `ConnzOptions` with JSON field `mqtt_client`.
- `src/NATS.Server/Monitoring/ConnzHandler.cs`
- Parse `mqtt_client` query param.
- Filter connection list by exact `MqttClient` match when provided.
- `src/NATS.Server/Monitoring/ClosedClient.cs`
- Add `MqttClient` field to closed snapshots.
- `src/NATS.Server/NatsServer.cs`
- Persist `MqttClient` into `ClosedClient` snapshot (empty for now).
## Data Flow
1. Client sends `CONNECT`.
2. `NatsClient.ProcessConnectAsync` builds `ClientAuthContext` with `ConnectionType=STANDARD`.
3. `AuthService` invokes `JwtAuthenticator` for JWT-based auth.
4. `JwtAuthenticator`:
- converts `allowed_connection_types` to valid/unknown buckets.
- rejects unknown-only lists.
- enforces connection-type membership when valid list is non-empty.
5. Monitoring request `/connz`:
- `ConnzHandler.ParseQueryParams` reads `mqtt_client`.
- open/closed conn rows are materialized.
- rows are filtered on exact `MqttClient` when filter is present.
- sorting and pagination run on filtered results.
## Error Handling and Compatibility
- Auth failures remain non-throwing (`Authenticate` returns `null`).
- Unknown connection type tokens in JWT are tolerated only when at least one known allowed type remains.
- Unknown-only allowed lists are rejected to avoid unintended allow-all behavior.
- `mqtt_client` query parsing is lenient and string-based; empty filter means no filter.
- Existing JSON schema compatibility is preserved.
## Current Runtime Limitation (Explicit)
- MQTT transport is not implemented yet in this repository.
- Runtime connection type currently resolves to `STANDARD` in auth context.
- `mqtt_client` values remain empty until MQTT path populates them.
## Testing Strategy
- `tests/NATS.Server.Tests/JwtAuthenticatorTests.cs`
- allow `STANDARD` for current client context.
- reject `MQTT` for current client context.
- allow mixed known+unknown when current type is known allowed.
- reject unknown-only list.
- validate case normalization behavior.
- `tests/NATS.Server.Tests/MonitorTests.cs`
- `/connz?mqtt_client=<id>` returns matching connections only.
- `/connz?state=closed&mqtt_client=<id>` filters closed snapshots.
- non-existing ID yields empty connection set.
## Success Criteria
- JWT `allowed_connection_types` behavior matches Go semantics for known/unknown mixing and unknown-only rejection.
- `/connz` supports exact `mqtt_client` filtering for open and closed sets.
- Added tests pass.
- `differences.md` accurately reflects implemented parity.

View File

@@ -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<byte> headers, ReadOnlyMemory<byte> payload);
bool QueueOutbound(ReadOnlyMemory<byte> 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<T>-based)
private readonly Channel<PublishMessage> _sendQueue;
private readonly Channel<InternalSystemMessage> _receiveQueue;
private readonly Channel<InternalSystemMessage> _receiveQueuePings;
// Background tasks
private Task? _sendLoop;
private Task? _receiveLoop;
private Task? _receiveLoopPings;
// Remote server tracking
private readonly ConcurrentDictionary<string, ServerUpdate> _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<byte> Headers,
ReadOnlyMemory<byte> Message,
Action<Subscription?, INatsClient?, Account?, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>> 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<string>? ApprovedAccounts { get; init; }
public Dictionary<string, long>? 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<Account>? approved);
public void AddStreamExport(string subject, IEnumerable<Account>? 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<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
}
public sealed class ImportMap
{
public List<StreamImport> Streams { get; } = [];
public Dictionary<string, List<ServiceImport>> 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"planPath": "docs/plans/2026-02-23-system-account-types-plan.md",
"tasks": [
{"id": 6, "subject": "Task 1: Create ClientKind enum and extensions", "status": "pending"},
{"id": 7, "subject": "Task 2: Create INatsClient interface and implement on NatsClient", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 3: Create InternalClient class", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 4: Create event subject constants and SystemMessageHandler delegate", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 5: Create event DTO types and JSON source generator", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 6: Create InternalEventSystem with send/receive loops", "status": "pending", "blockedBy": [10]},
{"id": 12, "subject": "Task 7: Wire system event publishing (connect, disconnect, shutdown)", "status": "pending", "blockedBy": [11]},
{"id": 13, "subject": "Task 8: Add periodic stats and account connection heartbeats", "status": "pending", "blockedBy": [12]},
{"id": 14, "subject": "Task 9: Add system request-reply monitoring services", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 10: Create import/export model types", "status": "pending", "blockedBy": [8]},
{"id": 16, "subject": "Task 11: Add import/export support to Account and ACCOUNT client", "status": "pending", "blockedBy": [15]},
{"id": 17, "subject": "Task 12: Wire service import into message delivery path", "status": "pending", "blockedBy": [16]},
{"id": 18, "subject": "Task 13: Add response routing for service imports", "status": "pending", "blockedBy": [17]},
{"id": 19, "subject": "Task 14: Add latency tracking for service imports", "status": "pending", "blockedBy": [18]},
{"id": 20, "subject": "Task 15: Update differences.md", "status": "pending", "blockedBy": [19]},
{"id": 21, "subject": "Task 16: Final verification — full test suite and build", "status": "pending", "blockedBy": [20]}
],
"lastUpdated": "2026-02-23T00:00:00Z"
}

View File

@@ -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();

View File

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth;
@@ -12,6 +13,8 @@ public sealed class Account : IDisposable
public Permissions? DefaultPermissions { get; set; }
public int MaxConnections { get; set; } // 0 = unlimited
public int MaxSubscriptions { get; set; } // 0 = unlimited
public ExportMap Exports { get; } = new();
public ImportMap Imports { get; } = new();
// JWT fields
public string? Nkey { get; set; }
@@ -89,5 +92,77 @@ public sealed class Account : IDisposable
Interlocked.Add(ref _outBytes, bytes);
}
// Internal (ACCOUNT) client for import/export message routing
private InternalClient? _internalClient;
public InternalClient GetOrCreateInternalClient(ulong clientId)
{
if (_internalClient != null) return _internalClient;
_internalClient = new InternalClient(clientId, ClientKind.Account, this);
return _internalClient;
}
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved)
{
var auth = new ExportAuth
{
ApprovedAccounts = approved != null ? new HashSet<string>(approved.Select(a => a.Name)) : null,
};
Exports.Services[subject] = new ServiceExport
{
Auth = auth,
Account = this,
ResponseType = responseType,
};
}
public void AddStreamExport(string subject, IEnumerable<Account>? approved)
{
var auth = new ExportAuth
{
ApprovedAccounts = approved != null ? new HashSet<string>(approved.Select(a => a.Name)) : null,
};
Exports.Streams[subject] = new StreamExport { Auth = auth };
}
public ServiceImport AddServiceImport(Account destination, string from, string to)
{
if (!destination.Exports.Services.TryGetValue(to, out var export))
throw new InvalidOperationException($"No service export found for '{to}' on account '{destination.Name}'");
if (!export.Auth.IsAuthorized(this))
throw new UnauthorizedAccessException($"Account '{Name}' not authorized to import '{to}' from '{destination.Name}'");
var si = new ServiceImport
{
DestinationAccount = destination,
From = from,
To = to,
Export = export,
ResponseType = export.ResponseType,
};
Imports.AddServiceImport(si);
return si;
}
public void AddStreamImport(Account source, string from, string to)
{
if (!source.Exports.Streams.TryGetValue(from, out var export))
throw new InvalidOperationException($"No stream export found for '{from}' on account '{source.Name}'");
if (!export.Auth.IsAuthorized(this))
throw new UnauthorizedAccessException($"Account '{Name}' not authorized to import '{from}' from '{source.Name}'");
var si = new StreamImport
{
SourceAccount = source,
From = from,
To = to,
};
Imports.Streams.Add(si);
}
public void Dispose() => SubList.Dispose();
}

View File

@@ -0,0 +1,22 @@
namespace NATS.Server;
/// <summary>
/// Identifies the type of a client connection.
/// Maps to Go's client kind constants in client.go:45-65.
/// </summary>
public enum ClientKind
{
Client,
Router,
Gateway,
Leaf,
System,
JetStream,
Account,
}
public static class ClientKindExtensions
{
public static bool IsInternal(this ClientKind kind) =>
kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account;
}

View File

@@ -0,0 +1,685 @@
// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries
// to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400.
using System.Globalization;
using System.Text.RegularExpressions;
using NATS.Server.Auth;
namespace NATS.Server.Configuration;
/// <summary>
/// Maps a parsed NATS configuration dictionary (produced by <see cref="NatsConfParser"/>)
/// into a fully populated <see cref="NatsOptions"/> instance. Collects all validation
/// errors rather than failing on the first one.
/// </summary>
public static class ConfigProcessor
{
/// <summary>
/// Parses a configuration file and returns the populated options.
/// </summary>
public static NatsOptions ProcessConfigFile(string filePath)
{
var config = NatsConfParser.ParseFile(filePath);
var opts = new NatsOptions { ConfigFile = filePath };
ApplyConfig(config, opts);
return opts;
}
/// <summary>
/// Parses configuration text (not from a file) and returns the populated options.
/// </summary>
public static NatsOptions ProcessConfig(string configText)
{
var config = NatsConfParser.Parse(configText);
var opts = new NatsOptions();
ApplyConfig(config, opts);
return opts;
}
/// <summary>
/// Applies a parsed configuration dictionary to existing options.
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
/// </summary>
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
{
var errors = new List<string>();
foreach (var (key, value) in config)
{
try
{
ProcessKey(key, value, opts, errors);
}
catch (Exception ex)
{
errors.Add($"Error processing '{key}': {ex.Message}");
}
}
if (errors.Count > 0)
{
throw new ConfigProcessorException("Configuration errors", errors);
}
}
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
{
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
// but we normalize here for the switch statement.
switch (key.ToLowerInvariant())
{
case "listen":
ParseListen(value, opts);
break;
case "port":
opts.Port = ToInt(value);
break;
case "host" or "net":
opts.Host = ToString(value);
break;
case "server_name":
var name = ToString(value);
if (name.Contains(' '))
errors.Add("server_name cannot contain spaces");
else
opts.ServerName = name;
break;
case "client_advertise":
opts.ClientAdvertise = ToString(value);
break;
// Logging
case "debug":
opts.Debug = ToBool(value);
break;
case "trace":
opts.Trace = ToBool(value);
break;
case "trace_verbose":
opts.TraceVerbose = ToBool(value);
if (opts.TraceVerbose)
opts.Trace = true;
break;
case "logtime":
opts.Logtime = ToBool(value);
break;
case "logtime_utc":
opts.LogtimeUTC = ToBool(value);
break;
case "logfile" or "log_file":
opts.LogFile = ToString(value);
break;
case "log_size_limit":
opts.LogSizeLimit = ToLong(value);
break;
case "log_max_num":
opts.LogMaxFiles = ToInt(value);
break;
case "syslog":
opts.Syslog = ToBool(value);
break;
case "remote_syslog":
opts.RemoteSyslog = ToString(value);
break;
// Limits
case "max_payload":
opts.MaxPayload = ToInt(value);
break;
case "max_control_line":
opts.MaxControlLine = ToInt(value);
break;
case "max_connections" or "max_conn":
opts.MaxConnections = ToInt(value);
break;
case "max_pending":
opts.MaxPending = ToLong(value);
break;
case "max_subs" or "max_subscriptions":
opts.MaxSubs = ToInt(value);
break;
case "max_sub_tokens" or "max_subscription_tokens":
var tokens = ToInt(value);
if (tokens > 256)
errors.Add("max_sub_tokens cannot exceed 256");
else
opts.MaxSubTokens = tokens;
break;
case "max_traced_msg_len":
opts.MaxTracedMsgLen = ToInt(value);
break;
case "max_closed_clients":
opts.MaxClosedClients = ToInt(value);
break;
case "disable_sublist_cache" or "no_sublist_cache":
opts.DisableSublistCache = ToBool(value);
break;
case "write_deadline":
opts.WriteDeadline = ParseDuration(value);
break;
// Ping
case "ping_interval":
opts.PingInterval = ParseDuration(value);
break;
case "ping_max" or "ping_max_out":
opts.MaxPingsOut = ToInt(value);
break;
// Monitoring
case "http_port" or "monitor_port":
opts.MonitorPort = ToInt(value);
break;
case "https_port":
opts.MonitorHttpsPort = ToInt(value);
break;
case "http":
ParseMonitorListen(value, opts, isHttps: false);
break;
case "https":
ParseMonitorListen(value, opts, isHttps: true);
break;
case "http_base_path":
opts.MonitorBasePath = ToString(value);
break;
// Lifecycle
case "lame_duck_duration":
opts.LameDuckDuration = ParseDuration(value);
break;
case "lame_duck_grace_period":
opts.LameDuckGracePeriod = ParseDuration(value);
break;
// Files
case "pidfile" or "pid_file":
opts.PidFile = ToString(value);
break;
case "ports_file_dir":
opts.PortsFileDir = ToString(value);
break;
// Auth
case "authorization":
if (value is Dictionary<string, object?> authDict)
ParseAuthorization(authDict, opts, errors);
break;
case "no_auth_user":
opts.NoAuthUser = ToString(value);
break;
// TLS
case "tls":
if (value is Dictionary<string, object?> tlsDict)
ParseTls(tlsDict, opts, errors);
break;
case "allow_non_tls":
opts.AllowNonTls = ToBool(value);
break;
// Tags
case "server_tags":
if (value is Dictionary<string, object?> tagsDict)
ParseTags(tagsDict, opts);
break;
// Profiling
case "prof_port":
opts.ProfPort = ToInt(value);
break;
// System account
case "system_account":
opts.SystemAccount = ToString(value);
break;
case "no_system_account":
opts.NoSystemAccount = ToBool(value);
break;
case "no_header_support":
opts.NoHeaderSupport = ToBool(value);
break;
case "connect_error_reports":
opts.ConnectErrorReports = ToInt(value);
break;
case "reconnect_error_reports":
opts.ReconnectErrorReports = ToInt(value);
break;
// Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.)
default:
break;
}
}
// ─── Listen parsing ────────────────────────────────────────────
/// <summary>
/// Parses a "listen" value that can be:
/// <list type="bullet">
/// <item><c>":4222"</c> — port only</item>
/// <item><c>"0.0.0.0:4222"</c> — host + port</item>
/// <item><c>"4222"</c> — bare number (port only)</item>
/// <item><c>4222</c> — integer (port only)</item>
/// </list>
/// </summary>
private static void ParseListen(object? value, NatsOptions opts)
{
var (host, port) = ParseHostPort(value);
if (host is not null)
opts.Host = host;
if (port is not null)
opts.Port = port.Value;
}
/// <summary>
/// Parses a monitor listen value. For "http" the port goes to MonitorPort;
/// for "https" the port goes to MonitorHttpsPort.
/// </summary>
private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps)
{
var (host, port) = ParseHostPort(value);
if (host is not null)
opts.MonitorHost = host;
if (port is not null)
{
if (isHttps)
opts.MonitorHttpsPort = port.Value;
else
opts.MonitorPort = port.Value;
}
}
/// <summary>
/// Shared host:port parsing logic.
/// </summary>
private static (string? Host, int? Port) ParseHostPort(object? value)
{
if (value is long l)
return (null, (int)l);
var str = ToString(value);
// Try bare integer
if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort))
return (null, barePort);
// Check for host:port
var colonIdx = str.LastIndexOf(':');
if (colonIdx >= 0)
{
var hostPart = str[..colonIdx];
var portPart = str[(colonIdx + 1)..];
if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
{
var host = hostPart.Length > 0 ? hostPart : null;
return (host, p);
}
}
throw new FormatException($"Cannot parse listen value: '{str}'");
}
// ─── Duration parsing ──────────────────────────────────────────
/// <summary>
/// Parses a duration value. Accepts:
/// <list type="bullet">
/// <item>A string with unit suffix: "30s", "2m", "1h", "500ms"</item>
/// <item>A number (long/double) treated as seconds</item>
/// </list>
/// </summary>
internal static TimeSpan ParseDuration(object? value)
{
return value switch
{
long seconds => TimeSpan.FromSeconds(seconds),
double seconds => TimeSpan.FromSeconds(seconds),
string s => ParseDurationString(s),
_ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"),
};
}
private static readonly Regex DurationPattern = new(
@"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static TimeSpan ParseDurationString(string s)
{
var match = DurationPattern.Match(s);
if (!match.Success)
throw new FormatException($"Cannot parse duration: '{s}'");
var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var unit = match.Groups[2].Value.ToLowerInvariant();
return unit switch
{
"ms" => TimeSpan.FromMilliseconds(amount),
"s" => TimeSpan.FromSeconds(amount),
"m" => TimeSpan.FromMinutes(amount),
"h" => TimeSpan.FromHours(amount),
_ => throw new FormatException($"Unknown duration unit: '{unit}'"),
};
}
// ─── Authorization parsing ─────────────────────────────────────
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
{
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "user" or "username":
opts.Username = ToString(value);
break;
case "pass" or "password":
opts.Password = ToString(value);
break;
case "token":
opts.Authorization = ToString(value);
break;
case "timeout":
opts.AuthTimeout = value switch
{
long l => TimeSpan.FromSeconds(l),
double d => TimeSpan.FromSeconds(d),
string s => ParseDuration(s),
_ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"),
};
break;
case "users":
if (value is List<object?> userList)
opts.Users = ParseUsers(userList, errors);
break;
default:
// Unknown auth keys silently ignored
break;
}
}
}
private static List<User> ParseUsers(List<object?> list, List<string> errors)
{
var users = new List<User>();
foreach (var item in list)
{
if (item is not Dictionary<string, object?> userDict)
{
errors.Add("Expected user entry to be a map");
continue;
}
string? username = null;
string? password = null;
string? account = null;
Permissions? permissions = null;
foreach (var (key, value) in userDict)
{
switch (key.ToLowerInvariant())
{
case "user" or "username":
username = ToString(value);
break;
case "pass" or "password":
password = ToString(value);
break;
case "account":
account = ToString(value);
break;
case "permissions" or "permission":
if (value is Dictionary<string, object?> permDict)
permissions = ParsePermissions(permDict, errors);
break;
}
}
if (username is null)
{
errors.Add("User entry missing 'user' field");
continue;
}
users.Add(new User
{
Username = username,
Password = password ?? string.Empty,
Account = account,
Permissions = permissions,
});
}
return users;
}
private static Permissions ParsePermissions(Dictionary<string, object?> dict, List<string> errors)
{
SubjectPermission? publish = null;
SubjectPermission? subscribe = null;
ResponsePermission? response = null;
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "publish" or "pub":
publish = ParseSubjectPermission(value, errors);
break;
case "subscribe" or "sub":
subscribe = ParseSubjectPermission(value, errors);
break;
case "resp" or "response":
if (value is Dictionary<string, object?> respDict)
response = ParseResponsePermission(respDict);
break;
}
}
return new Permissions
{
Publish = publish,
Subscribe = subscribe,
Response = response,
};
}
private static SubjectPermission? ParseSubjectPermission(object? value, List<string> errors)
{
// Can be a simple list of strings (treated as allow) or a dict with allow/deny
if (value is Dictionary<string, object?> dict)
{
IReadOnlyList<string>? allow = null;
IReadOnlyList<string>? deny = null;
foreach (var (key, v) in dict)
{
switch (key.ToLowerInvariant())
{
case "allow":
allow = ToStringList(v);
break;
case "deny":
deny = ToStringList(v);
break;
}
}
return new SubjectPermission { Allow = allow, Deny = deny };
}
if (value is List<object?> list)
{
return new SubjectPermission { Allow = ToStringList(list) };
}
if (value is string s)
{
return new SubjectPermission { Allow = [s] };
}
return null;
}
private static ResponsePermission ParseResponsePermission(Dictionary<string, object?> dict)
{
var maxMsgs = 0;
var expires = TimeSpan.Zero;
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "max_msgs" or "max":
maxMsgs = ToInt(value);
break;
case "expires" or "ttl":
expires = ParseDuration(value);
break;
}
}
return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires };
}
// ─── TLS parsing ───────────────────────────────────────────────
private static void ParseTls(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
{
foreach (var (key, value) in dict)
{
switch (key.ToLowerInvariant())
{
case "cert_file":
opts.TlsCert = ToString(value);
break;
case "key_file":
opts.TlsKey = ToString(value);
break;
case "ca_file":
opts.TlsCaCert = ToString(value);
break;
case "verify":
opts.TlsVerify = ToBool(value);
break;
case "verify_and_map":
var map = ToBool(value);
opts.TlsMap = map;
if (map)
opts.TlsVerify = true;
break;
case "timeout":
opts.TlsTimeout = value switch
{
long l => TimeSpan.FromSeconds(l),
double d => TimeSpan.FromSeconds(d),
string s => ParseDuration(s),
_ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"),
};
break;
case "connection_rate_limit":
opts.TlsRateLimit = ToLong(value);
break;
case "pinned_certs":
if (value is List<object?> pinnedList)
{
var certs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in pinnedList)
{
if (item is string s)
certs.Add(s.ToLowerInvariant());
}
opts.TlsPinnedCerts = certs;
}
break;
case "handshake_first" or "first" or "immediate":
opts.TlsHandshakeFirst = ToBool(value);
break;
case "handshake_first_fallback":
opts.TlsHandshakeFirstFallback = ParseDuration(value);
break;
default:
// Unknown TLS keys silently ignored
break;
}
}
}
// ─── Tags parsing ──────────────────────────────────────────────
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
{
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in dict)
{
tags[key] = ToString(value);
}
opts.Tags = tags;
}
// ─── Type conversion helpers ───────────────────────────────────
private static int ToInt(object? value) => value switch
{
long l => (int)l,
int i => i,
double d => (int)d,
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"),
};
private static long ToLong(object? value) => value switch
{
long l => l,
int i => i,
double d => (long)d,
string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"),
};
private static bool ToBool(object? value) => value switch
{
bool b => b,
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"),
};
private static string ToString(object? value) => value switch
{
string s => s,
long l => l.ToString(CultureInfo.InvariantCulture),
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"),
};
private static IReadOnlyList<string> ToStringList(object? value)
{
if (value is List<object?> list)
{
var result = new List<string>(list.Count);
foreach (var item in list)
{
if (item is string s)
result.Add(s);
}
return result;
}
if (value is string str)
return [str];
return [];
}
}
/// <summary>
/// Thrown when one or more configuration validation errors are detected.
/// All errors are collected rather than failing on the first one.
/// </summary>
public sealed class ConfigProcessorException(string message, List<string> errors)
: Exception(message)
{
public IReadOnlyList<string> Errors => errors;
}

View File

@@ -0,0 +1,341 @@
// Port of Go server/reload.go — config diffing, validation, and CLI override merging
// for hot reload support. Reference: golang/nats-server/server/reload.go.
namespace NATS.Server.Configuration;
/// <summary>
/// Provides static methods for comparing two <see cref="NatsOptions"/> instances,
/// validating that detected changes are reloadable, and merging CLI overrides
/// so that command-line flags always take precedence over config file values.
/// </summary>
public static class ConfigReloader
{
// Non-reloadable options (match Go server — Host, Port, ServerName require restart)
private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];
// Logging-related options
private static readonly HashSet<string> LoggingOptions =
["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
"LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];
// Auth-related options
private static readonly HashSet<string> AuthOptions =
["Username", "Password", "Authorization", "Users", "NKeys",
"NoAuthUser", "AuthTimeout"];
// TLS-related options
private static readonly HashSet<string> TlsOptions =
["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
"TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
"AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];
/// <summary>
/// Compares two <see cref="NatsOptions"/> instances property by property and returns
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
/// is tagged with the appropriate category flags.
/// </summary>
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
{
var changes = new List<IConfigChange>();
// Non-reloadable
CompareAndAdd(changes, "Host", oldOpts.Host, newOpts.Host);
CompareAndAdd(changes, "Port", oldOpts.Port, newOpts.Port);
CompareAndAdd(changes, "ServerName", oldOpts.ServerName, newOpts.ServerName);
// Logging
CompareAndAdd(changes, "Debug", oldOpts.Debug, newOpts.Debug);
CompareAndAdd(changes, "Trace", oldOpts.Trace, newOpts.Trace);
CompareAndAdd(changes, "TraceVerbose", oldOpts.TraceVerbose, newOpts.TraceVerbose);
CompareAndAdd(changes, "Logtime", oldOpts.Logtime, newOpts.Logtime);
CompareAndAdd(changes, "LogtimeUTC", oldOpts.LogtimeUTC, newOpts.LogtimeUTC);
CompareAndAdd(changes, "LogFile", oldOpts.LogFile, newOpts.LogFile);
CompareAndAdd(changes, "LogSizeLimit", oldOpts.LogSizeLimit, newOpts.LogSizeLimit);
CompareAndAdd(changes, "LogMaxFiles", oldOpts.LogMaxFiles, newOpts.LogMaxFiles);
CompareAndAdd(changes, "Syslog", oldOpts.Syslog, newOpts.Syslog);
CompareAndAdd(changes, "RemoteSyslog", oldOpts.RemoteSyslog, newOpts.RemoteSyslog);
// Auth
CompareAndAdd(changes, "Username", oldOpts.Username, newOpts.Username);
CompareAndAdd(changes, "Password", oldOpts.Password, newOpts.Password);
CompareAndAdd(changes, "Authorization", oldOpts.Authorization, newOpts.Authorization);
CompareCollectionAndAdd(changes, "Users", oldOpts.Users, newOpts.Users);
CompareCollectionAndAdd(changes, "NKeys", oldOpts.NKeys, newOpts.NKeys);
CompareAndAdd(changes, "NoAuthUser", oldOpts.NoAuthUser, newOpts.NoAuthUser);
CompareAndAdd(changes, "AuthTimeout", oldOpts.AuthTimeout, newOpts.AuthTimeout);
// TLS
CompareAndAdd(changes, "TlsCert", oldOpts.TlsCert, newOpts.TlsCert);
CompareAndAdd(changes, "TlsKey", oldOpts.TlsKey, newOpts.TlsKey);
CompareAndAdd(changes, "TlsCaCert", oldOpts.TlsCaCert, newOpts.TlsCaCert);
CompareAndAdd(changes, "TlsVerify", oldOpts.TlsVerify, newOpts.TlsVerify);
CompareAndAdd(changes, "TlsMap", oldOpts.TlsMap, newOpts.TlsMap);
CompareAndAdd(changes, "TlsTimeout", oldOpts.TlsTimeout, newOpts.TlsTimeout);
CompareAndAdd(changes, "TlsHandshakeFirst", oldOpts.TlsHandshakeFirst, newOpts.TlsHandshakeFirst);
CompareAndAdd(changes, "TlsHandshakeFirstFallback", oldOpts.TlsHandshakeFirstFallback, newOpts.TlsHandshakeFirstFallback);
CompareAndAdd(changes, "AllowNonTls", oldOpts.AllowNonTls, newOpts.AllowNonTls);
CompareAndAdd(changes, "TlsRateLimit", oldOpts.TlsRateLimit, newOpts.TlsRateLimit);
CompareCollectionAndAdd(changes, "TlsPinnedCerts", oldOpts.TlsPinnedCerts, newOpts.TlsPinnedCerts);
// Limits
CompareAndAdd(changes, "MaxConnections", oldOpts.MaxConnections, newOpts.MaxConnections);
CompareAndAdd(changes, "MaxPayload", oldOpts.MaxPayload, newOpts.MaxPayload);
CompareAndAdd(changes, "MaxPending", oldOpts.MaxPending, newOpts.MaxPending);
CompareAndAdd(changes, "WriteDeadline", oldOpts.WriteDeadline, newOpts.WriteDeadline);
CompareAndAdd(changes, "PingInterval", oldOpts.PingInterval, newOpts.PingInterval);
CompareAndAdd(changes, "MaxPingsOut", oldOpts.MaxPingsOut, newOpts.MaxPingsOut);
CompareAndAdd(changes, "MaxControlLine", oldOpts.MaxControlLine, newOpts.MaxControlLine);
CompareAndAdd(changes, "MaxSubs", oldOpts.MaxSubs, newOpts.MaxSubs);
CompareAndAdd(changes, "MaxSubTokens", oldOpts.MaxSubTokens, newOpts.MaxSubTokens);
CompareAndAdd(changes, "MaxTracedMsgLen", oldOpts.MaxTracedMsgLen, newOpts.MaxTracedMsgLen);
CompareAndAdd(changes, "MaxClosedClients", oldOpts.MaxClosedClients, newOpts.MaxClosedClients);
// Misc
CompareCollectionAndAdd(changes, "Tags", oldOpts.Tags, newOpts.Tags);
CompareAndAdd(changes, "LameDuckDuration", oldOpts.LameDuckDuration, newOpts.LameDuckDuration);
CompareAndAdd(changes, "LameDuckGracePeriod", oldOpts.LameDuckGracePeriod, newOpts.LameDuckGracePeriod);
CompareAndAdd(changes, "ClientAdvertise", oldOpts.ClientAdvertise, newOpts.ClientAdvertise);
CompareAndAdd(changes, "DisableSublistCache", oldOpts.DisableSublistCache, newOpts.DisableSublistCache);
CompareAndAdd(changes, "ConnectErrorReports", oldOpts.ConnectErrorReports, newOpts.ConnectErrorReports);
CompareAndAdd(changes, "ReconnectErrorReports", oldOpts.ReconnectErrorReports, newOpts.ReconnectErrorReports);
CompareAndAdd(changes, "NoHeaderSupport", oldOpts.NoHeaderSupport, newOpts.NoHeaderSupport);
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
return changes;
}
/// <summary>
/// Validates a list of config changes and returns error messages for any
/// non-reloadable changes (properties that require a server restart).
/// </summary>
public static List<string> Validate(List<IConfigChange> changes)
{
var errors = new List<string>();
foreach (var change in changes)
{
if (change.IsNonReloadable)
{
errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)");
}
}
return errors;
}
/// <summary>
/// Merges CLI overrides into a freshly-parsed config so that command-line flags
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
/// </summary>
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
{
foreach (var flag in cliFlags)
{
switch (flag)
{
// Non-reloadable
case "Host":
fromConfig.Host = cliValues.Host;
break;
case "Port":
fromConfig.Port = cliValues.Port;
break;
case "ServerName":
fromConfig.ServerName = cliValues.ServerName;
break;
// Logging
case "Debug":
fromConfig.Debug = cliValues.Debug;
break;
case "Trace":
fromConfig.Trace = cliValues.Trace;
break;
case "TraceVerbose":
fromConfig.TraceVerbose = cliValues.TraceVerbose;
break;
case "Logtime":
fromConfig.Logtime = cliValues.Logtime;
break;
case "LogtimeUTC":
fromConfig.LogtimeUTC = cliValues.LogtimeUTC;
break;
case "LogFile":
fromConfig.LogFile = cliValues.LogFile;
break;
case "LogSizeLimit":
fromConfig.LogSizeLimit = cliValues.LogSizeLimit;
break;
case "LogMaxFiles":
fromConfig.LogMaxFiles = cliValues.LogMaxFiles;
break;
case "Syslog":
fromConfig.Syslog = cliValues.Syslog;
break;
case "RemoteSyslog":
fromConfig.RemoteSyslog = cliValues.RemoteSyslog;
break;
// Auth
case "Username":
fromConfig.Username = cliValues.Username;
break;
case "Password":
fromConfig.Password = cliValues.Password;
break;
case "Authorization":
fromConfig.Authorization = cliValues.Authorization;
break;
case "Users":
fromConfig.Users = cliValues.Users;
break;
case "NKeys":
fromConfig.NKeys = cliValues.NKeys;
break;
case "NoAuthUser":
fromConfig.NoAuthUser = cliValues.NoAuthUser;
break;
case "AuthTimeout":
fromConfig.AuthTimeout = cliValues.AuthTimeout;
break;
// TLS
case "TlsCert":
fromConfig.TlsCert = cliValues.TlsCert;
break;
case "TlsKey":
fromConfig.TlsKey = cliValues.TlsKey;
break;
case "TlsCaCert":
fromConfig.TlsCaCert = cliValues.TlsCaCert;
break;
case "TlsVerify":
fromConfig.TlsVerify = cliValues.TlsVerify;
break;
case "TlsMap":
fromConfig.TlsMap = cliValues.TlsMap;
break;
case "TlsTimeout":
fromConfig.TlsTimeout = cliValues.TlsTimeout;
break;
case "TlsHandshakeFirst":
fromConfig.TlsHandshakeFirst = cliValues.TlsHandshakeFirst;
break;
case "TlsHandshakeFirstFallback":
fromConfig.TlsHandshakeFirstFallback = cliValues.TlsHandshakeFirstFallback;
break;
case "AllowNonTls":
fromConfig.AllowNonTls = cliValues.AllowNonTls;
break;
case "TlsRateLimit":
fromConfig.TlsRateLimit = cliValues.TlsRateLimit;
break;
case "TlsPinnedCerts":
fromConfig.TlsPinnedCerts = cliValues.TlsPinnedCerts;
break;
// Limits
case "MaxConnections":
fromConfig.MaxConnections = cliValues.MaxConnections;
break;
case "MaxPayload":
fromConfig.MaxPayload = cliValues.MaxPayload;
break;
case "MaxPending":
fromConfig.MaxPending = cliValues.MaxPending;
break;
case "WriteDeadline":
fromConfig.WriteDeadline = cliValues.WriteDeadline;
break;
case "PingInterval":
fromConfig.PingInterval = cliValues.PingInterval;
break;
case "MaxPingsOut":
fromConfig.MaxPingsOut = cliValues.MaxPingsOut;
break;
case "MaxControlLine":
fromConfig.MaxControlLine = cliValues.MaxControlLine;
break;
case "MaxSubs":
fromConfig.MaxSubs = cliValues.MaxSubs;
break;
case "MaxSubTokens":
fromConfig.MaxSubTokens = cliValues.MaxSubTokens;
break;
case "MaxTracedMsgLen":
fromConfig.MaxTracedMsgLen = cliValues.MaxTracedMsgLen;
break;
case "MaxClosedClients":
fromConfig.MaxClosedClients = cliValues.MaxClosedClients;
break;
// Misc
case "Tags":
fromConfig.Tags = cliValues.Tags;
break;
case "LameDuckDuration":
fromConfig.LameDuckDuration = cliValues.LameDuckDuration;
break;
case "LameDuckGracePeriod":
fromConfig.LameDuckGracePeriod = cliValues.LameDuckGracePeriod;
break;
case "ClientAdvertise":
fromConfig.ClientAdvertise = cliValues.ClientAdvertise;
break;
case "DisableSublistCache":
fromConfig.DisableSublistCache = cliValues.DisableSublistCache;
break;
case "ConnectErrorReports":
fromConfig.ConnectErrorReports = cliValues.ConnectErrorReports;
break;
case "ReconnectErrorReports":
fromConfig.ReconnectErrorReports = cliValues.ReconnectErrorReports;
break;
case "NoHeaderSupport":
fromConfig.NoHeaderSupport = cliValues.NoHeaderSupport;
break;
case "NoSystemAccount":
fromConfig.NoSystemAccount = cliValues.NoSystemAccount;
break;
case "SystemAccount":
fromConfig.SystemAccount = cliValues.SystemAccount;
break;
}
}
}
// ─── Comparison helpers ─────────────────────────────────────────
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
{
if (!Equals(oldVal, newVal))
{
changes.Add(new ConfigChange(
name,
isLoggingChange: LoggingOptions.Contains(name),
isAuthChange: AuthOptions.Contains(name),
isTlsChange: TlsOptions.Contains(name),
isNonReloadable: NonReloadable.Contains(name)));
}
}
private static void CompareCollectionAndAdd<T>(List<IConfigChange> changes, string name, T? oldVal, T? newVal)
where T : class
{
// For collections we compare by reference and null state.
// A change from null to non-null (or vice versa), or a different reference, counts as changed.
if (ReferenceEquals(oldVal, newVal))
return;
if (oldVal is null || newVal is null || !ReferenceEquals(oldVal, newVal))
{
changes.Add(new ConfigChange(
name,
isLoggingChange: LoggingOptions.Contains(name),
isAuthChange: AuthOptions.Contains(name),
isTlsChange: TlsOptions.Contains(name),
isNonReloadable: NonReloadable.Contains(name)));
}
}
}

View File

@@ -0,0 +1,54 @@
// Port of Go server/reload.go option interface — represents a single detected
// configuration change with category flags for reload handling.
// Reference: golang/nats-server/server/reload.go lines 42-74.
namespace NATS.Server.Configuration;
/// <summary>
/// Represents a single detected configuration change during a hot reload.
/// Category flags indicate what kind of reload action is needed.
/// </summary>
public interface IConfigChange
{
/// <summary>
/// The property name that changed (matches NatsOptions property name).
/// </summary>
string Name { get; }
/// <summary>
/// Whether this change requires reloading the logger.
/// </summary>
bool IsLoggingChange { get; }
/// <summary>
/// Whether this change requires reloading authorization.
/// </summary>
bool IsAuthChange { get; }
/// <summary>
/// Whether this change requires reloading TLS configuration.
/// </summary>
bool IsTlsChange { get; }
/// <summary>
/// Whether this option cannot be changed at runtime (requires restart).
/// </summary>
bool IsNonReloadable { get; }
}
/// <summary>
/// Default implementation of <see cref="IConfigChange"/> using a primary constructor.
/// </summary>
public sealed class ConfigChange(
string name,
bool isLoggingChange = false,
bool isAuthChange = false,
bool isTlsChange = false,
bool isNonReloadable = false) : IConfigChange
{
public string Name => name;
public bool IsLoggingChange => isLoggingChange;
public bool IsAuthChange => isAuthChange;
public bool IsTlsChange => isTlsChange;
public bool IsNonReloadable => isNonReloadable;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
// Port of Go conf/parse.go — recursive-descent parser for NATS config files.
// Reference: golang/nats-server/conf/parse.go
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Configuration;
/// <summary>
/// Parses NATS configuration data (tokenized by <see cref="NatsConfLexer"/>) into
/// a <c>Dictionary&lt;string, object?&gt;</c> tree. Supports nested maps, arrays,
/// variable references (block-scoped + environment), include directives, bcrypt
/// password literals, and integer suffix multipliers.
/// </summary>
public static class NatsConfParser
{
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
// and emits a Variable token whose value begins with "2a$" or "2b$".
private const string BcryptPrefix2A = "2a$";
private const string BcryptPrefix2B = "2b$";
// Maximum nesting depth for include directives to prevent infinite recursion.
private const int MaxIncludeDepth = 10;
/// <summary>
/// Parses a NATS configuration string into a dictionary.
/// </summary>
public static Dictionary<string, object?> Parse(string data)
{
var tokens = NatsConfLexer.Tokenize(data);
var state = new ParserState(tokens, baseDir: string.Empty);
state.Run();
return state.Mapping;
}
/// <summary>
/// Parses a NATS configuration file into a dictionary.
/// </summary>
public static Dictionary<string, object?> ParseFile(string filePath) =>
ParseFile(filePath, includeDepth: 0);
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
{
var data = File.ReadAllText(filePath);
var tokens = NatsConfLexer.Tokenize(data);
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
var state = new ParserState(tokens, baseDir, [], includeDepth);
state.Run();
return state.Mapping;
}
/// <summary>
/// Parses a NATS configuration file and returns the parsed config plus a
/// SHA-256 digest of the raw file content formatted as "sha256:&lt;hex&gt;".
/// </summary>
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
{
var rawBytes = File.ReadAllBytes(filePath);
var hashBytes = SHA256.HashData(rawBytes);
var digest = "sha256:" + Convert.ToHexStringLower(hashBytes);
var data = Encoding.UTF8.GetString(rawBytes);
var tokens = NatsConfLexer.Tokenize(data);
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
var state = new ParserState(tokens, baseDir, [], includeDepth: 0);
state.Run();
return (state.Mapping, digest);
}
/// <summary>
/// Internal: parse an environment variable value by wrapping it in a synthetic
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
/// </summary>
private static Dictionary<string, object?> ParseEnvValue(string value, HashSet<string> envVarReferences, int includeDepth)
{
var synthetic = $"pk={value}";
var tokens = NatsConfLexer.Tokenize(synthetic);
var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences, includeDepth);
state.Run();
return state.Mapping;
}
/// <summary>
/// Encapsulates the mutable parsing state: context stack, key stack, token cursor.
/// Mirrors the Go <c>parser</c> struct from conf/parse.go.
/// </summary>
private sealed class ParserState
{
private readonly IReadOnlyList<Token> _tokens;
private readonly string _baseDir;
private readonly HashSet<string> _envVarReferences;
private readonly int _includeDepth;
private int _pos;
// The context stack holds either Dictionary<string, object?> (map) or List<object?> (array).
private readonly List<object> _ctxs = new(4);
private object _ctx = null!;
// Key stack for map assignments.
private readonly List<string> _keys = new(4);
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
: this(tokens, baseDir, [], includeDepth: 0)
{
}
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
{
_tokens = tokens;
_baseDir = baseDir;
_envVarReferences = envVarReferences;
_includeDepth = includeDepth;
}
public void Run()
{
PushContext(Mapping);
Token prevToken = default;
while (true)
{
var token = Next();
if (token.Type == TokenType.Eof)
{
// Allow a trailing '}' (JSON-like configs) — mirror Go behavior.
if (prevToken.Type == TokenType.Key && prevToken.Value != "}")
{
throw new FormatException($"Config is invalid at line {token.Line}:{token.Position}");
}
break;
}
prevToken = token;
ProcessItem(token);
}
}
private Token Next()
{
if (_pos >= _tokens.Count)
{
return new Token(TokenType.Eof, string.Empty, 0, 0);
}
return _tokens[_pos++];
}
private void PushContext(object ctx)
{
_ctxs.Add(ctx);
_ctx = ctx;
}
private object PopContext()
{
if (_ctxs.Count <= 1)
{
throw new InvalidOperationException("BUG in parser, context stack underflow");
}
var last = _ctxs[^1];
_ctxs.RemoveAt(_ctxs.Count - 1);
_ctx = _ctxs[^1];
return last;
}
private void PushKey(string key) => _keys.Add(key);
private string PopKey()
{
if (_keys.Count == 0)
{
throw new InvalidOperationException("BUG in parser, keys stack empty");
}
var last = _keys[^1];
_keys.RemoveAt(_keys.Count - 1);
return last;
}
private void SetValue(object? val)
{
// Array context: append the value.
if (_ctx is List<object?> array)
{
array.Add(val);
return;
}
// Map context: pop the pending key and assign.
if (_ctx is Dictionary<string, object?> map)
{
var key = PopKey();
map[key] = val;
return;
}
throw new InvalidOperationException($"BUG in parser, unexpected context type {_ctx?.GetType().Name ?? "null"}");
}
private void ProcessItem(Token token)
{
switch (token.Type)
{
case TokenType.Error:
throw new FormatException($"Parse error on line {token.Line}: '{token.Value}'");
case TokenType.Key:
PushKey(token.Value);
break;
case TokenType.String:
SetValue(token.Value);
break;
case TokenType.Bool:
SetValue(ParseBool(token.Value));
break;
case TokenType.Integer:
SetValue(ParseInteger(token.Value));
break;
case TokenType.Float:
SetValue(ParseFloat(token.Value));
break;
case TokenType.DateTime:
SetValue(DateTimeOffset.Parse(token.Value, CultureInfo.InvariantCulture));
break;
case TokenType.ArrayStart:
PushContext(new List<object?>());
break;
case TokenType.ArrayEnd:
{
var array = _ctx;
PopContext();
SetValue(array);
break;
}
case TokenType.MapStart:
PushContext(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
break;
case TokenType.MapEnd:
SetValue(PopContext());
break;
case TokenType.Variable:
ResolveVariable(token);
break;
case TokenType.Include:
ProcessInclude(token.Value);
break;
case TokenType.Comment:
// Skip comments entirely.
break;
case TokenType.Eof:
// Handled in the Run loop; should not reach here.
break;
default:
throw new FormatException($"Unexpected token type {token.Type} on line {token.Line}");
}
}
private static bool ParseBool(string value) =>
value.ToLowerInvariant() switch
{
"true" or "yes" or "on" => true,
"false" or "no" or "off" => false,
_ => throw new FormatException($"Expected boolean value, but got '{value}'"),
};
/// <summary>
/// Parses an integer token value, handling optional size suffixes
/// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does.
/// </summary>
private static long ParseInteger(string value)
{
// Find where digits end and potential suffix begins.
var lastDigit = 0;
foreach (var c in value)
{
if (!char.IsDigit(c) && c != '-')
{
break;
}
lastDigit++;
}
var numStr = value[..lastDigit];
if (!long.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
{
throw new FormatException($"Expected integer, but got '{value}'");
}
var suffix = value[lastDigit..].Trim().ToLowerInvariant();
return suffix switch
{
"" => num,
"k" => num * 1000,
"kb" or "ki" or "kib" => num * 1024,
"m" => num * 1_000_000,
"mb" or "mi" or "mib" => num * 1024 * 1024,
"g" => num * 1_000_000_000,
"gb" or "gi" or "gib" => num * 1024 * 1024 * 1024,
"t" => num * 1_000_000_000_000,
"tb" or "ti" or "tib" => num * 1024L * 1024 * 1024 * 1024,
"p" => num * 1_000_000_000_000_000,
"pb" or "pi" or "pib" => num * 1024L * 1024 * 1024 * 1024 * 1024,
"e" => num * 1_000_000_000_000_000_000,
"eb" or "ei" or "eib" => num * 1024L * 1024 * 1024 * 1024 * 1024 * 1024,
_ => throw new FormatException($"Unknown integer suffix '{suffix}' in '{value}'"),
};
}
private static double ParseFloat(string value)
{
if (!double.TryParse(value, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result))
{
throw new FormatException($"Expected float, but got '{value}'");
}
return result;
}
/// <summary>
/// Resolves a variable reference using block scoping: walks the context stack
/// top-down looking in map contexts, then falls back to environment variables.
/// Detects bcrypt password literals and reference cycles.
/// </summary>
private void ResolveVariable(Token token)
{
var varName = token.Value;
// Special case: raw bcrypt strings ($2a$... or $2b$...).
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
{
SetValue("$" + varName);
return;
}
// Walk context stack from top (innermost scope) to bottom (outermost).
for (var i = _ctxs.Count - 1; i >= 0; i--)
{
if (_ctxs[i] is Dictionary<string, object?> map && map.TryGetValue(varName, out var found))
{
SetValue(found);
return;
}
}
// Not found in any context map. Check environment variables.
// First, detect cycles.
if (!_envVarReferences.Add(varName))
{
throw new FormatException($"Variable reference cycle for '{varName}'");
}
try
{
var envValue = Environment.GetEnvironmentVariable(varName);
if (envValue is not null)
{
// Parse the env value through the full parser to get correct typing
// (e.g., "42" becomes long 42, "true" becomes bool, etc.).
var subResult = ParseEnvValue(envValue, _envVarReferences, _includeDepth);
if (subResult.TryGetValue("pk", out var parsedValue))
{
SetValue(parsedValue);
return;
}
}
}
finally
{
_envVarReferences.Remove(varName);
}
// Not found anywhere.
throw new FormatException(
$"Variable reference for '{varName}' on line {token.Line} can not be found");
}
/// <summary>
/// Processes an include directive by parsing the referenced file and merging
/// all its top-level keys into the current context.
/// </summary>
private void ProcessInclude(string includePath)
{
if (_includeDepth >= MaxIncludeDepth)
{
throw new FormatException(
$"Include depth limit of {MaxIncludeDepth} exceeded while processing '{includePath}'");
}
var fullPath = Path.Combine(_baseDir, includePath);
var includeResult = ParseFile(fullPath, _includeDepth + 1);
foreach (var (key, value) in includeResult)
{
PushKey(key);
SetValue(value);
}
}
}
}

View File

@@ -0,0 +1,24 @@
// Port of Go conf/lex.go token types.
namespace NATS.Server.Configuration;
public enum TokenType
{
Error,
Eof,
Key,
String,
Bool,
Integer,
Float,
DateTime,
ArrayStart,
ArrayEnd,
MapStart,
MapEnd,
Variable,
Include,
Comment,
}
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Events;
[JsonSerializable(typeof(ConnectEventMsg))]
[JsonSerializable(typeof(DisconnectEventMsg))]
[JsonSerializable(typeof(AccountNumConns))]
[JsonSerializable(typeof(ServerStatsMsg))]
[JsonSerializable(typeof(ShutdownEventMsg))]
[JsonSerializable(typeof(LameDuckEventMsg))]
[JsonSerializable(typeof(AuthErrorEventMsg))]
internal partial class EventJsonContext : JsonSerializerContext;

View File

@@ -0,0 +1,49 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Events;
/// <summary>
/// System event subject patterns.
/// Maps to Go events.go:41-97 subject constants.
/// </summary>
public static class EventSubjects
{
// Account-scoped events
public const string ConnectEvent = "$SYS.ACCOUNT.{0}.CONNECT";
public const string DisconnectEvent = "$SYS.ACCOUNT.{0}.DISCONNECT";
public const string AccountConnsNew = "$SYS.ACCOUNT.{0}.SERVER.CONNS";
public const string AccountConnsOld = "$SYS.SERVER.ACCOUNT.{0}.CONNS";
// Server-scoped events
public const string ServerStats = "$SYS.SERVER.{0}.STATSZ";
public const string ServerShutdown = "$SYS.SERVER.{0}.SHUTDOWN";
public const string ServerLameDuck = "$SYS.SERVER.{0}.LAMEDUCK";
public const string AuthError = "$SYS.SERVER.{0}.CLIENT.AUTH.ERR";
public const string AuthErrorAccount = "$SYS.ACCOUNT.CLIENT.AUTH.ERR";
// Request-reply subjects (server-specific)
public const string ServerReq = "$SYS.REQ.SERVER.{0}.{1}";
// Wildcard ping subjects (all servers respond)
public const string ServerPing = "$SYS.REQ.SERVER.PING.{0}";
// Account-scoped request subjects
public const string AccountReq = "$SYS.REQ.ACCOUNT.{0}.{1}";
// Inbox for responses
public const string InboxResponse = "$SYS._INBOX_.{0}";
}
/// <summary>
/// Callback signature for system message handlers.
/// Maps to Go's sysMsgHandler type in events.go:109.
/// </summary>
public delegate void SystemMessageHandler(
Subscription? sub,
INatsClient? client,
Account? account,
string subject,
string? reply,
ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> message);

View File

@@ -0,0 +1,270 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Events;
/// <summary>
/// Server identity block embedded in all system events.
/// </summary>
public sealed class EventServerInfo
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("host")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Host { get; set; }
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("cluster")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Cluster { get; set; }
[JsonPropertyName("domain")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Domain { get; set; }
[JsonPropertyName("ver")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Version { get; set; }
[JsonPropertyName("seq")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ulong Seq { get; set; }
[JsonPropertyName("tags")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, string>? Tags { get; set; }
}
/// <summary>
/// Client identity block for connect/disconnect events.
/// </summary>
public sealed class EventClientInfo
{
[JsonPropertyName("start")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public DateTime Start { get; set; }
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public DateTime Stop { get; set; }
[JsonPropertyName("host")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Host { get; set; }
[JsonPropertyName("id")]
public ulong Id { get; set; }
[JsonPropertyName("acc")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("lang")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Lang { get; set; }
[JsonPropertyName("ver")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Version { get; set; }
[JsonPropertyName("rtt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long RttNanos { get; set; }
}
public sealed class DataStats
{
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("bytes")]
public long Bytes { get; set; }
}
/// <summary>Client connect advisory. Go events.go:155-160.</summary>
public sealed class ConnectEventMsg
{
public const string EventType = "io.nats.server.advisory.v1.client_connect";
[JsonPropertyName("type")]
public string Type { get; set; } = EventType;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public EventClientInfo Client { get; set; } = new();
}
/// <summary>Client disconnect advisory. Go events.go:167-174.</summary>
public sealed class DisconnectEventMsg
{
public const string EventType = "io.nats.server.advisory.v1.client_disconnect";
[JsonPropertyName("type")]
public string Type { get; set; } = EventType;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public EventClientInfo Client { get; set; } = new();
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>Account connection count heartbeat. Go events.go:210-214.</summary>
public sealed class AccountNumConns
{
public const string EventType = "io.nats.server.advisory.v1.account_connections";
[JsonPropertyName("type")]
public string Type { get; set; } = EventType;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("acc")]
public string AccountName { get; set; } = string.Empty;
[JsonPropertyName("conns")]
public int Connections { get; set; }
[JsonPropertyName("total_conns")]
public long TotalConnections { get; set; }
[JsonPropertyName("subs")]
public int Subscriptions { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
}
/// <summary>Server stats broadcast. Go events.go:150-153.</summary>
public sealed class ServerStatsMsg
{
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("statsz")]
public ServerStatsData Stats { get; set; } = new();
}
public sealed class ServerStatsData
{
[JsonPropertyName("start")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public DateTime Start { get; set; }
[JsonPropertyName("mem")]
public long Mem { get; set; }
[JsonPropertyName("cores")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Cores { get; set; }
[JsonPropertyName("connections")]
public int Connections { get; set; }
[JsonPropertyName("total_connections")]
public long TotalConnections { get; set; }
[JsonPropertyName("active_accounts")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ActiveAccounts { get; set; }
[JsonPropertyName("subscriptions")]
public long Subscriptions { get; set; }
[JsonPropertyName("in_msgs")]
public long InMsgs { get; set; }
[JsonPropertyName("out_msgs")]
public long OutMsgs { get; set; }
[JsonPropertyName("in_bytes")]
public long InBytes { get; set; }
[JsonPropertyName("out_bytes")]
public long OutBytes { get; set; }
[JsonPropertyName("slow_consumers")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long SlowConsumers { get; set; }
}
/// <summary>Server shutdown notification.</summary>
public sealed class ShutdownEventMsg
{
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>Lame duck mode notification.</summary>
public sealed class LameDuckEventMsg
{
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
}
/// <summary>Auth error advisory.</summary>
public sealed class AuthErrorEventMsg
{
public const string EventType = "io.nats.server.advisory.v1.client_auth";
[JsonPropertyName("type")]
public string Type { get; set; } = EventType;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
[JsonPropertyName("server")]
public EventServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public EventClientInfo Client { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,333 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Events;
/// <summary>
/// Internal publish message queued for the send loop.
/// </summary>
public sealed class PublishMessage
{
public InternalClient? Client { get; init; }
public required string Subject { get; init; }
public string? Reply { get; init; }
public byte[]? Headers { get; init; }
public object? Body { get; init; }
public bool Echo { get; init; }
public bool IsLast { get; init; }
}
/// <summary>
/// Internal received message queued for the receive loop.
/// </summary>
public sealed class InternalSystemMessage
{
public required Subscription? Sub { get; init; }
public required INatsClient? Client { get; init; }
public required Account? Account { get; init; }
public required string Subject { get; init; }
public required string? Reply { get; init; }
public required ReadOnlyMemory<byte> Headers { get; init; }
public required ReadOnlyMemory<byte> Message { get; init; }
public required SystemMessageHandler Callback { get; init; }
}
/// <summary>
/// Manages the server's internal event system with Channel-based send/receive loops.
/// Maps to Go's internal struct in events.go:124-147 and the goroutines
/// internalSendLoop (events.go:495) and internalReceiveLoop (events.go:476).
/// </summary>
public sealed class InternalEventSystem : IAsyncDisposable
{
private readonly ILogger _logger;
private readonly Channel<PublishMessage> _sendQueue;
private readonly Channel<InternalSystemMessage> _receiveQueue;
private readonly Channel<InternalSystemMessage> _receiveQueuePings;
private readonly CancellationTokenSource _cts = new();
private Task? _sendLoop;
private Task? _receiveLoop;
private Task? _receiveLoopPings;
private NatsServer? _server;
private ulong _sequence;
private int _subscriptionId;
private readonly ConcurrentDictionary<string, SystemMessageHandler> _callbacks = new();
public Account SystemAccount { get; }
public InternalClient SystemClient { get; }
public string ServerHash { get; }
public InternalEventSystem(Account systemAccount, InternalClient systemClient, string serverName, ILogger logger)
{
_logger = logger;
SystemAccount = systemAccount;
SystemClient = systemClient;
// Hash server name for inbox routing (matches Go's shash)
ServerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serverName)))[..8].ToLowerInvariant();
_sendQueue = Channel.CreateUnbounded<PublishMessage>(new UnboundedChannelOptions { SingleReader = true });
_receiveQueue = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
_receiveQueuePings = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
}
public void Start(NatsServer server)
{
_server = server;
var ct = _cts.Token;
_sendLoop = Task.Run(() => InternalSendLoopAsync(ct), ct);
_receiveLoop = Task.Run(() => InternalReceiveLoopAsync(_receiveQueue, ct), ct);
_receiveLoopPings = Task.Run(() => InternalReceiveLoopAsync(_receiveQueuePings, ct), ct);
// Periodic stats publish every 10 seconds
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
while (await timer.WaitForNextTickAsync(ct))
{
PublishServerStats();
}
}, ct);
}
/// <summary>
/// Registers system request-reply monitoring services for this server.
/// Maps to Go's initEventTracking in events.go.
/// Sets up handlers for $SYS.REQ.SERVER.{id}.VARZ, HEALTHZ, SUBSZ, STATSZ, IDZ
/// and wildcard $SYS.REQ.SERVER.PING.* subjects.
/// </summary>
public void InitEventTracking(NatsServer server)
{
_server = server;
var serverId = server.ServerId;
// Server-specific monitoring services
RegisterService(serverId, "VARZ", server.HandleVarzRequest);
RegisterService(serverId, "HEALTHZ", server.HandleHealthzRequest);
RegisterService(serverId, "SUBSZ", server.HandleSubszRequest);
RegisterService(serverId, "STATSZ", server.HandleStatszRequest);
RegisterService(serverId, "IDZ", server.HandleIdzRequest);
// Wildcard ping services (all servers respond)
SysSubscribe(string.Format(EventSubjects.ServerPing, "VARZ"), WrapRequestHandler(server.HandleVarzRequest));
SysSubscribe(string.Format(EventSubjects.ServerPing, "HEALTHZ"), WrapRequestHandler(server.HandleHealthzRequest));
SysSubscribe(string.Format(EventSubjects.ServerPing, "IDZ"), WrapRequestHandler(server.HandleIdzRequest));
SysSubscribe(string.Format(EventSubjects.ServerPing, "STATSZ"), WrapRequestHandler(server.HandleStatszRequest));
}
private void RegisterService(string serverId, string name, Action<string, string?> handler)
{
var subject = string.Format(EventSubjects.ServerReq, serverId, name);
SysSubscribe(subject, WrapRequestHandler(handler));
}
private SystemMessageHandler WrapRequestHandler(Action<string, string?> handler)
{
return (sub, client, acc, subject, reply, hdr, msg) =>
{
handler(subject, reply);
};
}
/// <summary>
/// Publishes a $SYS.SERVER.{id}.STATSZ message with current server statistics.
/// Maps to Go's sendStatsz in events.go.
/// Can be called manually for testing or is invoked periodically by the stats timer.
/// </summary>
public void PublishServerStats()
{
if (_server == null) return;
var subject = string.Format(EventSubjects.ServerStats, _server.ServerId);
var process = System.Diagnostics.Process.GetCurrentProcess();
var statsMsg = new ServerStatsMsg
{
Server = _server.BuildEventServerInfo(),
Stats = new ServerStatsData
{
Start = _server.StartTime,
Mem = process.WorkingSet64,
Cores = Environment.ProcessorCount,
Connections = _server.ClientCount,
TotalConnections = Interlocked.Read(ref _server.Stats.TotalConnections),
Subscriptions = SystemAccount.SubList.Count,
InMsgs = Interlocked.Read(ref _server.Stats.InMsgs),
OutMsgs = Interlocked.Read(ref _server.Stats.OutMsgs),
InBytes = Interlocked.Read(ref _server.Stats.InBytes),
OutBytes = Interlocked.Read(ref _server.Stats.OutBytes),
SlowConsumers = Interlocked.Read(ref _server.Stats.SlowConsumers),
},
};
Enqueue(new PublishMessage { Subject = subject, Body = statsMsg });
}
/// <summary>
/// Creates a system subscription in the system account's SubList.
/// Maps to Go's sysSubscribe in events.go:2796.
/// </summary>
public Subscription SysSubscribe(string subject, SystemMessageHandler callback)
{
var sid = Interlocked.Increment(ref _subscriptionId).ToString();
var sub = new Subscription
{
Subject = subject,
Sid = sid,
Client = SystemClient,
};
// Store callback keyed by SID so multiple subscriptions work
_callbacks[sid] = callback;
// Set a single routing callback on the system client that dispatches by SID
SystemClient.MessageCallback = (subj, s, reply, hdr, msg) =>
{
if (_callbacks.TryGetValue(s, out var cb))
{
_receiveQueue.Writer.TryWrite(new InternalSystemMessage
{
Sub = sub,
Client = SystemClient,
Account = SystemAccount,
Subject = subj,
Reply = reply,
Headers = hdr,
Message = msg,
Callback = cb,
});
}
};
SystemAccount.SubList.Insert(sub);
return sub;
}
/// <summary>
/// Returns the next monotonically increasing sequence number for event ordering.
/// </summary>
public ulong NextSequence() => Interlocked.Increment(ref _sequence);
/// <summary>
/// Enqueue an internal message for publishing through the send loop.
/// </summary>
public void Enqueue(PublishMessage message)
{
_sendQueue.Writer.TryWrite(message);
}
/// <summary>
/// The send loop: serializes messages and delivers them via the server's routing.
/// Maps to Go's internalSendLoop in events.go:495-668.
/// </summary>
private async Task InternalSendLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pm in _sendQueue.Reader.ReadAllAsync(ct))
{
try
{
var seq = Interlocked.Increment(ref _sequence);
// Serialize body to JSON
byte[] payload;
if (pm.Body is byte[] raw)
{
payload = raw;
}
else if (pm.Body != null)
{
// Try source-generated context first, fall back to reflection-based for unknown types
var bodyType = pm.Body.GetType();
var typeInfo = EventJsonContext.Default.GetTypeInfo(bodyType);
payload = typeInfo != null
? JsonSerializer.SerializeToUtf8Bytes(pm.Body, typeInfo)
: JsonSerializer.SerializeToUtf8Bytes(pm.Body, bodyType);
}
else
{
payload = [];
}
// Deliver via the system account's SubList matching
var result = SystemAccount.SubList.Match(pm.Subject);
foreach (var sub in result.PlainSubs)
{
sub.Client?.SendMessage(pm.Subject, sub.Sid, pm.Reply,
pm.Headers ?? ReadOnlyMemory<byte>.Empty,
payload);
}
foreach (var queueGroup in result.QueueSubs)
{
if (queueGroup.Length == 0) continue;
var sub = queueGroup[0]; // Simple pick for internal
sub.Client?.SendMessage(pm.Subject, sub.Sid, pm.Reply,
pm.Headers ?? ReadOnlyMemory<byte>.Empty,
payload);
}
if (pm.IsLast)
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error in internal send loop processing message on {Subject}", pm.Subject);
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
}
/// <summary>
/// The receive loop: dispatches callbacks for internally-received messages.
/// Maps to Go's internalReceiveLoop in events.go:476-491.
/// </summary>
private async Task InternalReceiveLoopAsync(Channel<InternalSystemMessage> queue, CancellationToken ct)
{
try
{
await foreach (var msg in queue.Reader.ReadAllAsync(ct))
{
try
{
msg.Callback(msg.Sub, msg.Client, msg.Account, msg.Subject, msg.Reply, msg.Headers, msg.Message);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error in internal receive loop processing {Subject}", msg.Subject);
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
_sendQueue.Writer.TryComplete();
_receiveQueue.Writer.TryComplete();
_receiveQueuePings.Writer.TryComplete();
if (_sendLoop != null) await _sendLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (_receiveLoop != null) await _receiveLoop.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
if (_receiveLoopPings != null) await _receiveLoopPings.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
_cts.Dispose();
}
}

View File

@@ -0,0 +1,19 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
namespace NATS.Server;
public interface INatsClient
{
ulong Id { get; }
ClientKind Kind { get; }
bool IsInternal => Kind.IsInternal();
Account? Account { get; }
ClientOptions? ClientOpts { get; }
ClientPermissions? Permissions { get; }
void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
bool QueueOutbound(ReadOnlyMemory<byte> data);
void RemoveSubscription(string sid);
}

View File

@@ -0,0 +1,25 @@
using NATS.Server.Auth;
namespace NATS.Server.Imports;
public sealed class ExportAuth
{
public bool TokenRequired { get; init; }
public uint AccountPosition { get; init; }
public HashSet<string>? ApprovedAccounts { get; init; }
public Dictionary<string, long>? RevokedAccounts { get; init; }
public bool IsAuthorized(Account account)
{
if (RevokedAccounts != null && RevokedAccounts.ContainsKey(account.Name))
return false;
if (ApprovedAccounts == null && !TokenRequired && AccountPosition == 0)
return true;
if (ApprovedAccounts != null)
return ApprovedAccounts.Contains(account.Name);
return false;
}
}

View File

@@ -0,0 +1,8 @@
namespace NATS.Server.Imports;
public sealed class ExportMap
{
public Dictionary<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
}

View File

@@ -0,0 +1,18 @@
namespace NATS.Server.Imports;
public sealed class ImportMap
{
public List<StreamImport> Streams { get; } = [];
public Dictionary<string, List<ServiceImport>> Services { get; } = new(StringComparer.Ordinal);
public void AddServiceImport(ServiceImport si)
{
if (!Services.TryGetValue(si.From, out var list))
{
list = [];
Services[si.From] = list;
}
list.Add(si);
}
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Imports;
public sealed class ServiceLatencyMsg
{
[JsonPropertyName("type")]
public string Type { get; set; } = "io.nats.server.metric.v1.service_latency";
[JsonPropertyName("requestor")]
public string Requestor { get; set; } = string.Empty;
[JsonPropertyName("responder")]
public string Responder { get; set; } = string.Empty;
[JsonPropertyName("status")]
public int Status { get; set; } = 200;
[JsonPropertyName("svc_latency")]
public long ServiceLatencyNanos { get; set; }
[JsonPropertyName("total_latency")]
public long TotalLatencyNanos { get; set; }
}
public static class LatencyTracker
{
public static bool ShouldSample(ServiceLatency latency)
{
if (latency.SamplingPercentage <= 0) return false;
if (latency.SamplingPercentage >= 100) return true;
return Random.Shared.Next(100) < latency.SamplingPercentage;
}
public static ServiceLatencyMsg BuildLatencyMsg(
string requestor, string responder,
TimeSpan serviceLatency, TimeSpan totalLatency)
{
return new ServiceLatencyMsg
{
Requestor = requestor,
Responder = responder,
ServiceLatencyNanos = serviceLatency.Ticks * 100,
TotalLatencyNanos = totalLatency.Ticks * 100,
};
}
}

View File

@@ -0,0 +1,64 @@
using System.Security.Cryptography;
using NATS.Server.Auth;
namespace NATS.Server.Imports;
/// <summary>
/// Handles response routing for service imports.
/// Maps to Go's service reply prefix generation and response cleanup.
/// Reference: golang/nats-server/server/accounts.go — addRespServiceImport, removeRespServiceImport
/// </summary>
public static class ResponseRouter
{
private static readonly char[] Base62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".ToCharArray();
/// <summary>
/// Generates a unique reply prefix for response routing.
/// Format: "_R_.{10 random base62 chars}."
/// </summary>
public static string GenerateReplyPrefix()
{
Span<byte> bytes = stackalloc byte[10];
RandomNumberGenerator.Fill(bytes);
var chars = new char[10];
for (int i = 0; i < 10; i++)
chars[i] = Base62[bytes[i] % 62];
return $"_R_.{new string(chars)}.";
}
/// <summary>
/// Creates a response service import that maps the generated reply prefix
/// back to the original reply subject on the requesting account.
/// </summary>
public static ServiceImport CreateResponseImport(
Account exporterAccount,
ServiceImport originalImport,
string originalReply)
{
var replyPrefix = GenerateReplyPrefix();
var responseSi = new ServiceImport
{
DestinationAccount = exporterAccount,
From = replyPrefix + ">",
To = originalReply,
IsResponse = true,
ResponseType = originalImport.ResponseType,
Export = originalImport.Export,
TimestampTicks = DateTime.UtcNow.Ticks,
};
exporterAccount.Exports.Responses[replyPrefix] = responseSi;
return responseSi;
}
/// <summary>
/// Removes a response import from the account's export map.
/// For Singleton responses, this is called after the first reply is delivered.
/// For Streamed/Chunked, it is called when the response stream ends.
/// </summary>
public static void CleanupResponse(Account account, string replyPrefix, ServiceImport responseSi)
{
account.Exports.Responses.Remove(replyPrefix);
}
}

View File

@@ -0,0 +1,13 @@
using NATS.Server.Auth;
namespace NATS.Server.Imports;
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; }
}

View File

@@ -0,0 +1,21 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Imports;
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; } = ServiceResponseType.Singleton;
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; }
public long TimestampTicks { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace NATS.Server.Imports;
public sealed class ServiceLatency
{
public int SamplingPercentage { get; init; } = 100;
public string Subject { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,8 @@
namespace NATS.Server.Imports;
public enum ServiceResponseType
{
Singleton,
Streamed,
Chunked,
}

View File

@@ -0,0 +1,6 @@
namespace NATS.Server.Imports;
public sealed class StreamExport
{
public ExportAuth Auth { get; init; } = new();
}

View File

@@ -0,0 +1,14 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Imports;
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; }
}

View File

@@ -0,0 +1,59 @@
using NATS.Server.Auth;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
namespace NATS.Server;
/// <summary>
/// Lightweight socketless client for internal messaging (SYSTEM, ACCOUNT, JETSTREAM).
/// Maps to Go's internal client created by createInternalClient() in server.go:1910-1936.
/// No network I/O — messages are delivered via callback.
/// </summary>
public sealed class InternalClient : INatsClient
{
public ulong Id { get; }
public ClientKind Kind { get; }
public bool IsInternal => Kind.IsInternal();
public Account? Account { get; }
public ClientOptions? ClientOpts => null;
public ClientPermissions? Permissions => null;
/// <summary>
/// Callback invoked when a message is delivered to this internal client.
/// Set by the event system or account import infrastructure.
/// </summary>
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? MessageCallback { get; set; }
private readonly Dictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
public InternalClient(ulong id, ClientKind kind, Account account)
{
if (!kind.IsInternal())
throw new ArgumentException($"InternalClient requires an internal ClientKind, got {kind}", nameof(kind));
Id = id;
Kind = kind;
Account = account;
}
public void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
MessageCallback?.Invoke(subject, sid, replyTo, headers, payload);
}
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true; // no-op for internal clients
public void RemoveSubscription(string sid)
{
if (_subs.Remove(sid))
Account?.DecrementSubscriptions();
}
public void AddSubscription(Subscription sub)
{
_subs[sub.Sid] = sub;
}
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
}

View File

@@ -14,12 +14,16 @@ public sealed class SubszHandler(NatsServer server)
var opts = ParseQueryParams(ctx);
var now = DateTime.UtcNow;
// Collect subscriptions from all accounts (or filtered)
// Collect subscriptions from all accounts (or filtered).
// Exclude the $SYS system account unless explicitly requested — its internal
// subscriptions are infrastructure and not user-facing.
var allSubs = new List<Subscription>();
foreach (var account in server.GetAccounts())
{
if (!string.IsNullOrEmpty(opts.Account) && account.Name != opts.Account)
continue;
if (string.IsNullOrEmpty(opts.Account) && account.Name == "$SYS")
continue;
allSubs.AddRange(account.SubList.GetAllSubscriptions());
}
@@ -31,10 +35,10 @@ public sealed class SubszHandler(NatsServer server)
var total = allSubs.Count;
var numSubs = server.GetAccounts()
.Where(a => string.IsNullOrEmpty(opts.Account) || a.Name == opts.Account)
.Where(a => (string.IsNullOrEmpty(opts.Account) && a.Name != "$SYS") || a.Name == opts.Account)
.Aggregate(0u, (sum, a) => sum + a.SubList.Count);
var numCache = server.GetAccounts()
.Where(a => string.IsNullOrEmpty(opts.Account) || a.Name == opts.Account)
.Where(a => (string.IsNullOrEmpty(opts.Account) && a.Name != "$SYS") || a.Name == opts.Account)
.Sum(a => a.SubList.CacheCount);
SubDetail[] details = [];

View File

@@ -1,4 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<InternalsVisibleTo Include="NATS.Server.Tests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NATS.NKeys" />

View File

@@ -19,6 +19,8 @@ public interface IMessageRouter
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
void PublishConnectEvent(NatsClient client);
void PublishDisconnectEvent(NatsClient client);
}
public interface ISubListAccess
@@ -26,7 +28,7 @@ public interface ISubListAccess
SubList SubList { get; }
}
public sealed class NatsClient : IDisposable
public sealed class NatsClient : INatsClient, IDisposable
{
private readonly Socket _socket;
private readonly Stream _stream;
@@ -45,6 +47,7 @@ public sealed class NatsClient : IDisposable
private readonly ServerStats _serverStats;
public ulong Id { get; }
public ClientKind Kind => ClientKind.Client;
public ClientOptions? ClientOpts { get; private set; }
public IMessageRouter? Router { get; set; }
public Account? Account { get; private set; }
@@ -444,6 +447,9 @@ public sealed class NatsClient : IDisposable
_flags.SetFlag(ClientFlags.ConnectProcessFinished);
_logger.LogDebug("CONNECT received from client {ClientId}, name={ClientName}", Id, ClientOpts?.Name);
// Publish connect advisory to the system event bus
Router?.PublishConnectEvent(this);
// Start auth expiry timer if needed
if (_authService.IsAuthRequired && authResult?.Expiry is { } expiry)
{

View File

@@ -72,6 +72,21 @@ public sealed class NatsOptions
// Profiling (0 = disabled)
public int ProfPort { get; set; }
// Extended options for Go parity
public string? ClientAdvertise { get; set; }
public bool TraceVerbose { get; set; }
public int MaxTracedMsgLen { get; set; }
public bool DisableSublistCache { get; set; }
public int ConnectErrorReports { get; set; } = 3600;
public int ReconnectErrorReports { get; set; } = 1;
public bool NoHeaderSupport { get; set; }
public int MaxClosedClients { get; set; } = 10_000;
public bool NoSystemAccount { get; set; }
public string? SystemAccount { get; set; }
// Tracks which fields were set via CLI flags (for reload precedence)
public HashSet<string> InCmdLine { get; } = [];
// TLS
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }

View File

@@ -8,6 +8,9 @@ using System.Text;
using Microsoft.Extensions.Logging;
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.Events;
using NATS.Server.Imports;
using NATS.Server.Monitoring;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
@@ -20,16 +23,21 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private readonly NatsOptions _options;
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly ConcurrentQueue<ClosedClient> _closedClients = new();
private const int MaxClosedClients = 10_000;
private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ServerStats _stats = new();
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AuthService _authService;
private AuthService _authService;
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
// Config reload state
private NatsOptions? _cliSnapshot;
private HashSet<string> _cliFlags = [];
private string? _configDigest;
private readonly Account _globalAccount;
private readonly Account _systemAccount;
private InternalEventSystem? _eventSystem;
private readonly SslServerAuthenticationOptions? _sslOptions;
private readonly TlsRateLimiter? _tlsRateLimiter;
private readonly SubjectTransform[] _subjectTransforms;
@@ -65,6 +73,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public int Port => _options.Port;
public Account SystemAccount => _systemAccount;
public string ServerNKey { get; }
public InternalEventSystem? EventSystem => _eventSystem;
public bool IsShuttingDown => Volatile.Read(ref _shutdown) != 0;
public bool IsLameDuckMode => Volatile.Read(ref _lameDuck) != 0;
public Action? ReOpenLogFile { get; set; }
@@ -85,6 +94,21 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_logger.LogInformation("Initiating Shutdown...");
// Publish shutdown advisory before tearing down the event system
if (_eventSystem != null)
{
var shutdownSubject = string.Format(EventSubjects.ServerShutdown, _serverInfo.ServerId);
_eventSystem.Enqueue(new PublishMessage
{
Subject = shutdownSubject,
Body = new ShutdownEventMsg { Server = BuildEventServerInfo(), Reason = "Server Shutdown" },
IsLast = true,
});
// Give the send loop time to process the shutdown event
await Task.Delay(100);
await _eventSystem.DisposeAsync();
}
// Signal all internal loops to stop
await _quitCts.CancelAsync();
@@ -224,7 +248,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
@@ -259,6 +284,14 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_systemAccount = new Account("$SYS");
_accounts["$SYS"] = _systemAccount;
// Create system internal client and event system
var sysClientId = Interlocked.Increment(ref _nextClientId);
var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount);
_eventSystem = new InternalEventSystem(
_systemAccount, sysClient,
options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
_loggerFactory.CreateLogger<InternalEventSystem>());
// Generate Ed25519 server NKey identity
using var serverKeyPair = KeyPair.CreatePair(PrefixByte.Server);
ServerNKey = serverKeyPair.GetPublicKey();
@@ -320,6 +353,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()
@@ -351,11 +398,12 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_listeningStarted.TrySetResult();
_eventSystem?.Start(this);
_eventSystem?.InitEventTracking(this);
_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);
@@ -584,6 +632,27 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
// Check for service imports that match this subject.
// When a client in the importer account publishes to a subject
// that matches a service import "From" pattern, we forward the
// message to the destination (exporter) account's subscribers
// using the mapped "To" subject.
if (sender.Account != null)
{
foreach (var kvp in sender.Account.Imports.Services)
{
foreach (var si in kvp.Value)
{
if (si.Invalid) continue;
if (SubjectMatch.MatchLiteral(subject, si.From))
{
ProcessServiceImport(si, subject, replyTo, headers, payload);
delivered = true;
}
}
}
}
// No-responders: if nobody received the message and the publisher
// opted in, send back a 503 status HMSG on the reply subject.
if (!delivered && replyTo != null && sender.ClientOpts?.NoResponders == true)
@@ -623,6 +692,153 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
/// <summary>
/// Processes a service import by transforming the subject from the importer's
/// subject space to the exporter's subject space, then delivering to matching
/// subscribers in the destination account.
/// Reference: Go server/accounts.go addServiceImport / processServiceImport.
/// </summary>
public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
if (si.Invalid) return;
// Transform subject: map from importer subject space to exporter subject space
string targetSubject;
if (si.Transform != null)
{
var transformed = si.Transform.Apply(subject);
targetSubject = transformed ?? si.To;
}
else if (si.UsePub)
{
targetSubject = subject;
}
else
{
// Default: use the "To" subject from the import definition.
// For wildcard imports (e.g. "requests.>" -> "api.>"), we need
// to map the specific subject tokens from the source pattern to
// the destination pattern.
targetSubject = MapImportSubject(subject, si.From, si.To);
}
// Match against destination account's SubList
var destSubList = si.DestinationAccount.SubList;
var result = destSubList.Match(targetSubject);
// Deliver to plain subscribers in the destination account
foreach (var sub in result.PlainSubs)
{
if (sub.Client == null) continue;
DeliverMessage(sub, targetSubject, replyTo, headers, payload);
}
// Deliver to one member of each queue group
foreach (var queueGroup in result.QueueSubs)
{
if (queueGroup.Length == 0) continue;
var sub = queueGroup[0]; // Simple selection: first available
if (sub.Client != null)
DeliverMessage(sub, targetSubject, replyTo, headers, payload);
}
}
/// <summary>
/// Maps a published subject from the import "From" pattern to the "To" pattern.
/// For example, if From="requests.>" and To="api.>" and subject="requests.test",
/// this returns "api.test".
/// </summary>
private static string MapImportSubject(string subject, string fromPattern, string toPattern)
{
// If "To" doesn't contain wildcards, use it directly
if (SubjectMatch.IsLiteral(toPattern))
return toPattern;
// For wildcard patterns, replace matching wildcard segments.
// Split into tokens and map from source to destination.
var subTokens = subject.Split('.');
var fromTokens = fromPattern.Split('.');
var toTokens = toPattern.Split('.');
var result = new string[toTokens.Length];
int subIdx = 0;
// Build a mapping: for each wildcard position in "from",
// capture the corresponding subject token(s)
var wildcardValues = new List<string>();
string? fwcValue = null;
for (int i = 0; i < fromTokens.Length && subIdx < subTokens.Length; i++)
{
if (fromTokens[i] == "*")
{
wildcardValues.Add(subTokens[subIdx]);
subIdx++;
}
else if (fromTokens[i] == ">")
{
// Capture all remaining tokens
fwcValue = string.Join(".", subTokens[subIdx..]);
subIdx = subTokens.Length;
}
else
{
subIdx++; // Skip literal match
}
}
// Now build the output using the "to" pattern
int wcIdx = 0;
var sb = new StringBuilder();
for (int i = 0; i < toTokens.Length; i++)
{
if (i > 0) sb.Append('.');
if (toTokens[i] == "*")
{
sb.Append(wcIdx < wildcardValues.Count ? wildcardValues[wcIdx] : "*");
wcIdx++;
}
else if (toTokens[i] == ">")
{
sb.Append(fwcValue ?? ">");
}
else
{
sb.Append(toTokens[i]);
}
}
return sb.ToString();
}
/// <summary>
/// Wires service import subscriptions for an account. Creates marker
/// subscriptions in the account's SubList so that the import paths
/// are tracked. The actual forwarding happens in ProcessMessage when
/// it checks the account's Imports.Services.
/// Reference: Go server/accounts.go addServiceImportSub.
/// </summary>
public void WireServiceImports(Account account)
{
foreach (var kvp in account.Imports.Services)
{
foreach (var si in kvp.Value)
{
if (si.Invalid) continue;
// Create a marker subscription in the importer account.
// This subscription doesn't directly deliver messages;
// the ProcessMessage method checks service imports after
// the regular SubList match.
_logger.LogDebug(
"Wired service import for account {Account}: {From} -> {To} (dest: {DestAccount})",
account.Name, si.From, si.To, si.DestinationAccount.Name);
}
}
}
private static void SendNoResponders(NatsClient sender, string replyTo)
{
// Find the sid for a subscription matching the reply subject
@@ -668,8 +884,194 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
});
}
public void SendInternalMsg(string subject, string? reply, object? msg)
{
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
}
public void SendInternalAccountMsg(Account account, string subject, object? msg)
{
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Body = msg });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.VARZ requests.
/// Returns core server information including stats counters.
/// </summary>
public void HandleVarzRequest(string subject, string? reply)
{
if (reply == null) return;
var varz = new
{
server_id = _serverInfo.ServerId,
server_name = _serverInfo.ServerName,
version = NatsProtocol.Version,
host = _options.Host,
port = _options.Port,
max_payload = _options.MaxPayload,
connections = ClientCount,
total_connections = Interlocked.Read(ref _stats.TotalConnections),
in_msgs = Interlocked.Read(ref _stats.InMsgs),
out_msgs = Interlocked.Read(ref _stats.OutMsgs),
in_bytes = Interlocked.Read(ref _stats.InBytes),
out_bytes = Interlocked.Read(ref _stats.OutBytes),
};
SendInternalMsg(reply, null, varz);
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.HEALTHZ requests.
/// Returns a simple health status response.
/// </summary>
public void HandleHealthzRequest(string subject, string? reply)
{
if (reply == null) return;
SendInternalMsg(reply, null, new { status = "ok" });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.SUBSZ requests.
/// Returns the current subscription count.
/// </summary>
public void HandleSubszRequest(string subject, string? reply)
{
if (reply == null) return;
SendInternalMsg(reply, null, new { num_subscriptions = SubList.Count });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.STATSZ requests.
/// Publishes current server statistics through the event system.
/// </summary>
public void HandleStatszRequest(string subject, string? reply)
{
if (reply == null) return;
var process = System.Diagnostics.Process.GetCurrentProcess();
var statsMsg = new Events.ServerStatsMsg
{
Server = BuildEventServerInfo(),
Stats = new Events.ServerStatsData
{
Start = StartTime,
Mem = process.WorkingSet64,
Cores = Environment.ProcessorCount,
Connections = ClientCount,
TotalConnections = Interlocked.Read(ref _stats.TotalConnections),
Subscriptions = SubList.Count,
InMsgs = Interlocked.Read(ref _stats.InMsgs),
OutMsgs = Interlocked.Read(ref _stats.OutMsgs),
InBytes = Interlocked.Read(ref _stats.InBytes),
OutBytes = Interlocked.Read(ref _stats.OutBytes),
SlowConsumers = Interlocked.Read(ref _stats.SlowConsumers),
},
};
SendInternalMsg(reply, null, statsMsg);
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.IDZ requests.
/// Returns basic server identity information.
/// </summary>
public void HandleIdzRequest(string subject, string? reply)
{
if (reply == null) return;
var idz = new
{
server_id = _serverInfo.ServerId,
server_name = _serverInfo.ServerName,
version = NatsProtocol.Version,
host = _options.Host,
port = _options.Port,
};
SendInternalMsg(reply, null, idz);
}
/// <summary>
/// Builds an EventServerInfo block for embedding in system event messages.
/// Maps to Go's serverInfo() helper used in events.go advisory publishing.
/// </summary>
public EventServerInfo BuildEventServerInfo()
{
var seq = _eventSystem?.NextSequence() ?? 0;
return new EventServerInfo
{
Name = _serverInfo.ServerName,
Host = _options.Host,
Id = _serverInfo.ServerId,
Version = NatsProtocol.Version,
Seq = seq,
};
}
private static EventClientInfo BuildEventClientInfo(NatsClient client)
{
return new EventClientInfo
{
Id = client.Id,
Host = client.RemoteIp,
Account = client.Account?.Name,
Name = client.ClientOpts?.Name,
Lang = client.ClientOpts?.Lang,
Version = client.ClientOpts?.Version,
Start = client.StartTime,
};
}
/// <summary>
/// Publishes a $SYS.ACCOUNT.{account}.CONNECT advisory when a client
/// completes authentication. Maps to Go's sendConnectEvent in events.go.
/// </summary>
public void PublishConnectEvent(NatsClient client)
{
if (_eventSystem == null) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.ConnectEvent, accountName);
var evt = new ConnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
};
SendInternalMsg(subject, null, evt);
}
/// <summary>
/// Publishes a $SYS.ACCOUNT.{account}.DISCONNECT advisory when a client
/// disconnects. Maps to Go's sendDisconnectEvent in events.go.
/// </summary>
public void PublishDisconnectEvent(NatsClient client)
{
if (_eventSystem == null) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.DisconnectEvent, accountName);
var evt = new DisconnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
Sent = new DataStats
{
Msgs = Interlocked.Read(ref client.OutMsgs),
Bytes = Interlocked.Read(ref client.OutBytes),
},
Received = new DataStats
{
Msgs = Interlocked.Read(ref client.InMsgs),
Bytes = Interlocked.Read(ref client.InBytes),
},
Reason = client.CloseReason.ToReasonString(),
};
SendInternalMsg(subject, null, evt);
}
public void RemoveClient(NatsClient client)
{
// Publish disconnect advisory before removing client state
if (client.ConnectReceived)
PublishDisconnectEvent(client);
_clients.TryRemove(client.Id, out _);
_logger.LogDebug("Removed client {ClientId}", client.Id);
@@ -696,7 +1098,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 +1168,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
/// <summary>
/// Stores the CLI snapshot and flags so that command-line overrides
/// always take precedence during config reload.
/// </summary>
public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
{
_cliSnapshot = cliSnapshot;
_cliFlags = cliFlags;
}
/// <summary>
/// Reloads the configuration file, diffs against current options, validates
/// the changes, and applies reloadable settings. CLI overrides are preserved.
/// </summary>
public void ReloadConfig()
{
if (_options.ConfigFile == null)
{
_logger.LogWarning("No config file specified, cannot reload");
return;
}
try
{
var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile);
if (digest == _configDigest)
{
_logger.LogInformation("Config file unchanged, no reload needed");
return;
}
var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile };
ConfigProcessor.ApplyConfig(newConfig, newOpts);
// CLI flags override config
if (_cliSnapshot != null)
ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags);
var changes = ConfigReloader.Diff(_options, newOpts);
var errors = ConfigReloader.Validate(changes);
if (errors.Count > 0)
{
foreach (var err in errors)
_logger.LogError("Config reload error: {Error}", err);
return;
}
// Apply changes to running options
ApplyConfigChanges(changes, newOpts);
_configDigest = digest;
_logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile);
}
}
private void ApplyConfigChanges(List<IConfigChange> changes, NatsOptions newOpts)
{
bool hasLoggingChanges = false;
bool hasAuthChanges = false;
foreach (var change in changes)
{
if (change.IsLoggingChange) hasLoggingChanges = true;
if (change.IsAuthChange) hasAuthChanges = true;
}
// Copy reloadable values from newOpts to _options
CopyReloadableOptions(newOpts);
// Trigger side effects
if (hasLoggingChanges)
{
ReOpenLogFile?.Invoke();
_logger.LogInformation("Logging configuration reloaded");
}
if (hasAuthChanges)
{
// Rebuild auth service with new options
_authService = AuthService.Build(_options);
_logger.LogInformation("Authorization configuration reloaded");
}
}
private void CopyReloadableOptions(NatsOptions newOpts)
{
// Logging
_options.Debug = newOpts.Debug;
_options.Trace = newOpts.Trace;
_options.TraceVerbose = newOpts.TraceVerbose;
_options.Logtime = newOpts.Logtime;
_options.LogtimeUTC = newOpts.LogtimeUTC;
_options.LogFile = newOpts.LogFile;
_options.LogSizeLimit = newOpts.LogSizeLimit;
_options.LogMaxFiles = newOpts.LogMaxFiles;
_options.Syslog = newOpts.Syslog;
_options.RemoteSyslog = newOpts.RemoteSyslog;
// Auth
_options.Username = newOpts.Username;
_options.Password = newOpts.Password;
_options.Authorization = newOpts.Authorization;
_options.Users = newOpts.Users;
_options.NKeys = newOpts.NKeys;
_options.NoAuthUser = newOpts.NoAuthUser;
_options.AuthTimeout = newOpts.AuthTimeout;
// Limits
_options.MaxConnections = newOpts.MaxConnections;
_options.MaxPayload = newOpts.MaxPayload;
_options.MaxPending = newOpts.MaxPending;
_options.WriteDeadline = newOpts.WriteDeadline;
_options.PingInterval = newOpts.PingInterval;
_options.MaxPingsOut = newOpts.MaxPingsOut;
_options.MaxControlLine = newOpts.MaxControlLine;
_options.MaxSubs = newOpts.MaxSubs;
_options.MaxSubTokens = newOpts.MaxSubTokens;
_options.MaxTracedMsgLen = newOpts.MaxTracedMsgLen;
_options.MaxClosedClients = newOpts.MaxClosedClients;
// TLS
_options.TlsCert = newOpts.TlsCert;
_options.TlsKey = newOpts.TlsKey;
_options.TlsCaCert = newOpts.TlsCaCert;
_options.TlsVerify = newOpts.TlsVerify;
_options.TlsMap = newOpts.TlsMap;
_options.TlsTimeout = newOpts.TlsTimeout;
_options.TlsHandshakeFirst = newOpts.TlsHandshakeFirst;
_options.TlsHandshakeFirstFallback = newOpts.TlsHandshakeFirstFallback;
_options.AllowNonTls = newOpts.AllowNonTls;
_options.TlsRateLimit = newOpts.TlsRateLimit;
_options.TlsPinnedCerts = newOpts.TlsPinnedCerts;
// Misc
_options.Tags = newOpts.Tags;
_options.LameDuckDuration = newOpts.LameDuckDuration;
_options.LameDuckGracePeriod = newOpts.LameDuckGracePeriod;
_options.ClientAdvertise = newOpts.ClientAdvertise;
_options.DisableSublistCache = newOpts.DisableSublistCache;
_options.ConnectErrorReports = newOpts.ConnectErrorReports;
_options.ReconnectErrorReports = newOpts.ReconnectErrorReports;
_options.NoHeaderSupport = newOpts.NoHeaderSupport;
_options.NoSystemAccount = newOpts.NoSystemAccount;
_options.SystemAccount = newOpts.SystemAccount;
}
public void Dispose()
{
if (!IsShuttingDown)

View File

@@ -1,4 +1,5 @@
using NATS.Server;
using NATS.Server.Imports;
namespace NATS.Server.Subscriptions;
@@ -9,5 +10,7 @@ public sealed class Subscription
public required string Sid { get; init; }
public long MessageCount; // Interlocked
public long MaxMessages; // 0 = unlimited
public NatsClient? Client { get; set; }
public INatsClient? Client { get; set; }
public ServiceImport? ServiceImport { get; set; }
public StreamImport? StreamImport { get; set; }
}

View File

@@ -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<string> { "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);
}
}

View File

@@ -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<ConfigProcessorException>(() =>
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<ConfigProcessorException>(() =>
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<ConfigProcessorException>(() =>
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();
}
}

View File

@@ -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<IConfigChange>
{
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<string> { "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)
}
}

View File

@@ -0,0 +1,121 @@
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server.Events;
namespace NATS.Server.Tests;
public class EventSystemTests
{
[Fact]
public void ConnectEventMsg_serializes_with_correct_type()
{
var evt = new ConnectEventMsg
{
Type = ConnectEventMsg.EventType,
Id = "test123",
Time = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
Server = new EventServerInfo { Name = "test-server", Id = "SRV1" },
Client = new EventClientInfo { Id = 1, Account = "$G" },
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.ConnectEventMsg);
json.ShouldContain("\"type\":\"io.nats.server.advisory.v1.client_connect\"");
json.ShouldContain("\"server\":");
json.ShouldContain("\"client\":");
}
[Fact]
public void DisconnectEventMsg_serializes_with_reason()
{
var evt = new DisconnectEventMsg
{
Type = DisconnectEventMsg.EventType,
Id = "test456",
Time = DateTime.UtcNow,
Server = new EventServerInfo { Name = "test-server", Id = "SRV1" },
Client = new EventClientInfo { Id = 2, Account = "myacc" },
Reason = "Client Closed",
Sent = new DataStats { Msgs = 10, Bytes = 1024 },
Received = new DataStats { Msgs = 5, Bytes = 512 },
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.DisconnectEventMsg);
json.ShouldContain("\"reason\":\"Client Closed\"");
}
[Fact]
public void ServerStatsMsg_serializes()
{
var evt = new ServerStatsMsg
{
Server = new EventServerInfo { Name = "srv1", Id = "ABC" },
Stats = new ServerStatsData
{
Connections = 10,
TotalConnections = 100,
InMsgs = 5000,
OutMsgs = 4500,
InBytes = 1_000_000,
OutBytes = 900_000,
Mem = 50 * 1024 * 1024,
Subscriptions = 42,
},
};
var json = JsonSerializer.Serialize(evt, EventJsonContext.Default.ServerStatsMsg);
json.ShouldContain("\"connections\":10");
json.ShouldContain("\"in_msgs\":5000");
}
[Fact]
public async Task InternalEventSystem_start_and_stop_lifecycle()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var eventSystem = server.EventSystem;
eventSystem.ShouldNotBeNull();
eventSystem.SystemClient.ShouldNotBeNull();
eventSystem.SystemClient.Kind.ShouldBe(ClientKind.System);
await server.ShutdownAsync();
}
[Fact]
public async Task SendInternalMsg_delivers_to_system_subscriber()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("test.subject", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
server.SendInternalMsg("test.subject", null, new { Value = "hello" });
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldBe("test.subject");
await server.ShutdownAsync();
}
private static NatsServer CreateTestServer()
{
var port = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
}

View File

@@ -0,0 +1,338 @@
using Microsoft.Extensions.Logging.Abstractions;
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Tests;
public class ImportExportTests
{
[Fact]
public void ExportAuth_public_export_authorizes_any_account()
{
var auth = new ExportAuth();
var account = new Account("test");
auth.IsAuthorized(account).ShouldBeTrue();
}
[Fact]
public void ExportAuth_approved_accounts_restricts_access()
{
var auth = new ExportAuth { ApprovedAccounts = ["allowed"] };
var allowed = new Account("allowed");
var denied = new Account("denied");
auth.IsAuthorized(allowed).ShouldBeTrue();
auth.IsAuthorized(denied).ShouldBeFalse();
}
[Fact]
public void ExportAuth_revoked_account_denied()
{
var auth = new ExportAuth
{
ApprovedAccounts = ["test"],
RevokedAccounts = new() { ["test"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds() },
};
var account = new Account("test");
auth.IsAuthorized(account).ShouldBeFalse();
}
[Fact]
public void ServiceResponseType_defaults_to_singleton()
{
var import = new ServiceImport
{
DestinationAccount = new Account("dest"),
From = "requests.>",
To = "api.>",
};
import.ResponseType.ShouldBe(ServiceResponseType.Singleton);
}
[Fact]
public void ExportMap_stores_and_retrieves_exports()
{
var map = new ExportMap();
map.Services["api.>"] = new ServiceExport { Account = new Account("svc") };
map.Streams["events.>"] = new StreamExport();
map.Services.ShouldContainKey("api.>");
map.Streams.ShouldContainKey("events.>");
}
[Fact]
public void ImportMap_stores_service_imports()
{
var map = new ImportMap();
var si = new ServiceImport
{
DestinationAccount = new Account("dest"),
From = "requests.>",
To = "api.>",
};
map.AddServiceImport(si);
map.Services.ShouldContainKey("requests.>");
map.Services["requests.>"].Count.ShouldBe(1);
}
[Fact]
public void Account_add_service_export_and_import()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
exporter.Exports.Services.ShouldContainKey("api.>");
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
si.ShouldNotBeNull();
si.From.ShouldBe("requests.>");
si.To.ShouldBe("api.>");
si.DestinationAccount.ShouldBe(exporter);
importer.Imports.Services.ShouldContainKey("requests.>");
}
[Fact]
public void Account_add_stream_export_and_import()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddStreamExport("events.>", null);
exporter.Exports.Streams.ShouldContainKey("events.>");
importer.AddStreamImport(exporter, "events.>", "imported.events.>");
importer.Imports.Streams.Count.ShouldBe(1);
importer.Imports.Streams[0].From.ShouldBe("events.>");
importer.Imports.Streams[0].To.ShouldBe("imported.events.>");
}
[Fact]
public void Account_service_import_auth_rejected()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, [new Account("other")]);
Should.Throw<UnauthorizedAccessException>(() =>
importer.AddServiceImport(exporter, "requests.>", "api.>"));
}
[Fact]
public void Account_lazy_creates_internal_client()
{
var account = new Account("test");
var client = account.GetOrCreateInternalClient(99);
client.ShouldNotBeNull();
client.Kind.ShouldBe(ClientKind.Account);
client.Account.ShouldBe(account);
// Second call returns same instance
var client2 = account.GetOrCreateInternalClient(100);
client2.ShouldBeSameAs(client);
}
[Fact]
public async Task Service_import_forwards_message_to_export_account()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
// Set up exporter and importer accounts
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
// Wire the import subscriptions into the importer account
server.WireServiceImports(importer);
// Subscribe in exporter account to receive forwarded message
var exportSub = new Subscription { Subject = "api.test", Sid = "export-1", Client = null };
exporter.SubList.Insert(exportSub);
// Verify import infrastructure is wired: the importer should have service import entries
importer.Imports.Services.ShouldContainKey("requests.>");
importer.Imports.Services["requests.>"].Count.ShouldBe(1);
importer.Imports.Services["requests.>"][0].DestinationAccount.ShouldBe(exporter);
await server.ShutdownAsync();
}
[Fact]
public void ProcessServiceImport_delivers_to_destination_account_subscribers()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
// Add a subscriber in the exporter account's SubList
var received = new List<(string Subject, string Sid)>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var exportSub = new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
// Process a service import directly
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(1);
received[0].Subject.ShouldBe("api.test");
received[0].Sid.ShouldBe("s1");
}
[Fact]
public void ProcessServiceImport_with_transform_applies_subject_mapping()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
var si = importer.AddServiceImport(exporter, "requests.>", "api.>");
// Create a transform from requests.> to api.>
var transform = SubjectTransform.Create("requests.>", "api.>");
transform.ShouldNotBeNull();
// Create a new import with the transform set
var siWithTransform = new ServiceImport
{
DestinationAccount = exporter,
From = "requests.>",
To = "api.>",
Transform = transform,
};
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) =>
received.Add(subject);
var exportSub = new Subscription { Subject = "api.hello", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
server.ProcessServiceImport(siWithTransform, "requests.hello", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(1);
received[0].ShouldBe("api.hello");
}
[Fact]
public void ProcessServiceImport_skips_invalid_imports()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
// Mark the import as invalid
var si = importer.Imports.Services["requests.>"][0];
si.Invalid = true;
// Add a subscriber in the exporter account
var received = new List<string>();
var mockClient = new TestNatsClient(1, exporter);
mockClient.OnMessage = (subject, _, _, _, _) =>
received.Add(subject);
var exportSub = new Subscription { Subject = "api.test", Sid = "s1", Client = mockClient };
exporter.SubList.Insert(exportSub);
// ProcessServiceImport should be a no-op for invalid imports
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
received.Count.ShouldBe(0);
}
[Fact]
public void ProcessServiceImport_delivers_to_queue_groups()
{
using var server = CreateTestServer();
var exporter = server.GetOrCreateAccount("exporter");
var importer = server.GetOrCreateAccount("importer");
exporter.AddServiceExport("api.>", ServiceResponseType.Singleton, null);
importer.AddServiceImport(exporter, "requests.>", "api.>");
// Add queue group subscribers in the exporter account
var received = new List<(string Subject, string Sid)>();
var mockClient1 = new TestNatsClient(1, exporter);
mockClient1.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var mockClient2 = new TestNatsClient(2, exporter);
mockClient2.OnMessage = (subject, sid, _, _, _) =>
received.Add((subject, sid));
var qSub1 = new Subscription { Subject = "api.test", Sid = "q1", Queue = "workers", Client = mockClient1 };
var qSub2 = new Subscription { Subject = "api.test", Sid = "q2", Queue = "workers", Client = mockClient2 };
exporter.SubList.Insert(qSub1);
exporter.SubList.Insert(qSub2);
var si = importer.Imports.Services["requests.>"][0];
server.ProcessServiceImport(si, "requests.test", null,
ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
// One member of the queue group should receive the message
received.Count.ShouldBe(1);
}
private static NatsServer CreateTestServer()
{
var port = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
/// <summary>
/// Minimal test double for INatsClient used in import/export tests.
/// </summary>
private sealed class TestNatsClient(ulong id, Account account) : INatsClient
{
public ulong Id => id;
public ClientKind Kind => ClientKind.Client;
public Account? Account => account;
public Protocol.ClientOptions? ClientOpts => null;
public ClientPermissions? Permissions => null;
public Action<string, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? OnMessage { get; set; }
public void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
OnMessage?.Invoke(subject, sid, replyTo, headers, payload);
}
public bool QueueOutbound(ReadOnlyMemory<byte> data) => true;
public void RemoveSubscription(string sid) { }
}
}

View File

@@ -0,0 +1,85 @@
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class InternalClientTests
{
[Theory]
[InlineData(ClientKind.Client, false)]
[InlineData(ClientKind.Router, false)]
[InlineData(ClientKind.Gateway, false)]
[InlineData(ClientKind.Leaf, false)]
[InlineData(ClientKind.System, true)]
[InlineData(ClientKind.JetStream, true)]
[InlineData(ClientKind.Account, true)]
public void IsInternal_returns_correct_value(ClientKind kind, bool expected)
{
kind.IsInternal().ShouldBe(expected);
}
[Fact]
public void NatsClient_implements_INatsClient()
{
typeof(NatsClient).GetInterfaces().ShouldContain(typeof(INatsClient));
}
[Fact]
public void NatsClient_kind_is_Client()
{
typeof(NatsClient).GetProperty("Kind")!.PropertyType.ShouldBe(typeof(ClientKind));
}
[Fact]
public void InternalClient_system_kind()
{
var account = new Account("$SYS");
var client = new InternalClient(1, ClientKind.System, account);
client.Kind.ShouldBe(ClientKind.System);
client.IsInternal.ShouldBeTrue();
client.Id.ShouldBe(1UL);
client.Account.ShouldBe(account);
}
[Fact]
public void InternalClient_account_kind()
{
var account = new Account("myaccount");
var client = new InternalClient(2, ClientKind.Account, account);
client.Kind.ShouldBe(ClientKind.Account);
client.IsInternal.ShouldBeTrue();
}
[Fact]
public void InternalClient_rejects_non_internal_kind()
{
var account = new Account("test");
Should.Throw<ArgumentException>(() => new InternalClient(1, ClientKind.Client, account));
}
[Fact]
public void InternalClient_SendMessage_invokes_callback()
{
var account = new Account("$SYS");
var client = new InternalClient(1, ClientKind.System, account);
string? capturedSubject = null;
string? capturedSid = null;
client.MessageCallback = (subject, sid, replyTo, headers, payload) =>
{
capturedSubject = subject;
capturedSid = sid;
};
client.SendMessage("test.subject", "1", null, ReadOnlyMemory<byte>.Empty, ReadOnlyMemory<byte>.Empty);
capturedSubject.ShouldBe("test.subject");
capturedSid.ShouldBe("1");
}
[Fact]
public void InternalClient_QueueOutbound_returns_true_noop()
{
var account = new Account("$SYS");
var client = new InternalClient(1, ClientKind.System, account);
client.QueueOutbound(ReadOnlyMemory<byte>.Empty).ShouldBeTrue();
}
}

View File

@@ -21,6 +21,10 @@
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<None Update="TestData\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\NATS.Server\NATS.Server.csproj" />
</ItemGroup>

View File

@@ -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");
}
}

View File

@@ -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<Dictionary<string, object?>>();
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<List<object?>>();
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<Dictionary<string, object?>>();
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<FormatException>(() =>
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<FormatException>(() => 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<Dictionary<string, object?>>();
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<List<object?>>();
users.Count.ShouldBe(2);
var first = users[0].ShouldBeOfType<Dictionary<string, object?>>();
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);
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,149 @@
using NATS.Server.Auth;
using NATS.Server.Imports;
namespace NATS.Server.Tests;
public class ResponseRoutingTests
{
[Fact]
public void GenerateReplyPrefix_creates_unique_prefix()
{
var prefix1 = ResponseRouter.GenerateReplyPrefix();
var prefix2 = ResponseRouter.GenerateReplyPrefix();
prefix1.ShouldStartWith("_R_.");
prefix2.ShouldStartWith("_R_.");
prefix1.ShouldNotBe(prefix2);
prefix1.Length.ShouldBeGreaterThan(4);
}
[Fact]
public void GenerateReplyPrefix_ends_with_dot()
{
var prefix = ResponseRouter.GenerateReplyPrefix();
prefix.ShouldEndWith(".");
// Format: "_R_." + 10 chars + "." = 15 chars
prefix.Length.ShouldBe(15);
}
[Fact]
public void Singleton_response_import_removed_after_delivery()
{
var exporter = new Account("exporter");
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
var replyPrefix = ResponseRouter.GenerateReplyPrefix();
var responseSi = new ServiceImport
{
DestinationAccount = exporter,
From = replyPrefix + ">",
To = "_INBOX.original.reply",
IsResponse = true,
ResponseType = ServiceResponseType.Singleton,
};
exporter.Exports.Responses[replyPrefix] = responseSi;
exporter.Exports.Responses.ShouldContainKey(replyPrefix);
// Simulate singleton delivery cleanup
ResponseRouter.CleanupResponse(exporter, replyPrefix, responseSi);
exporter.Exports.Responses.ShouldNotContainKey(replyPrefix);
}
[Fact]
public void CreateResponseImport_registers_in_exporter_responses()
{
var exporter = new Account("exporter");
var importer = new Account("importer");
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
var originalSi = new ServiceImport
{
DestinationAccount = exporter,
From = "api.test",
To = "api.test",
Export = exporter.Exports.Services["api.test"],
ResponseType = ServiceResponseType.Singleton,
};
var responseSi = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.abc123");
responseSi.IsResponse.ShouldBeTrue();
responseSi.ResponseType.ShouldBe(ServiceResponseType.Singleton);
responseSi.To.ShouldBe("_INBOX.abc123");
responseSi.DestinationAccount.ShouldBe(exporter);
responseSi.From.ShouldEndWith(">");
responseSi.Export.ShouldBe(originalSi.Export);
// Should be registered in the exporter's response map
exporter.Exports.Responses.Count.ShouldBe(1);
}
[Fact]
public void CreateResponseImport_preserves_streamed_response_type()
{
var exporter = new Account("exporter");
exporter.AddServiceExport("api.stream", ServiceResponseType.Streamed, null);
var originalSi = new ServiceImport
{
DestinationAccount = exporter,
From = "api.stream",
To = "api.stream",
Export = exporter.Exports.Services["api.stream"],
ResponseType = ServiceResponseType.Streamed,
};
var responseSi = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.xyz789");
responseSi.ResponseType.ShouldBe(ServiceResponseType.Streamed);
}
[Fact]
public void Multiple_response_imports_each_get_unique_prefix()
{
var exporter = new Account("exporter");
exporter.AddServiceExport("api.test", ServiceResponseType.Singleton, null);
var originalSi = new ServiceImport
{
DestinationAccount = exporter,
From = "api.test",
To = "api.test",
Export = exporter.Exports.Services["api.test"],
ResponseType = ServiceResponseType.Singleton,
};
var resp1 = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.reply1");
var resp2 = ResponseRouter.CreateResponseImport(exporter, originalSi, "_INBOX.reply2");
exporter.Exports.Responses.Count.ShouldBe(2);
resp1.To.ShouldBe("_INBOX.reply1");
resp2.To.ShouldBe("_INBOX.reply2");
resp1.From.ShouldNotBe(resp2.From);
}
[Fact]
public void LatencyTracker_should_sample_respects_percentage()
{
var latency = new ServiceLatency { SamplingPercentage = 0, Subject = "latency.test" };
LatencyTracker.ShouldSample(latency).ShouldBeFalse();
var latency100 = new ServiceLatency { SamplingPercentage = 100, Subject = "latency.test" };
LatencyTracker.ShouldSample(latency100).ShouldBeTrue();
}
[Fact]
public void LatencyTracker_builds_latency_message()
{
var msg = LatencyTracker.BuildLatencyMsg("requester", "responder",
TimeSpan.FromMilliseconds(5), TimeSpan.FromMilliseconds(10));
msg.Requestor.ShouldBe("requester");
msg.Responder.ShouldBe("responder");
msg.ServiceLatencyNanos.ShouldBeGreaterThan(0);
msg.TotalLatencyNanos.ShouldBeGreaterThan(0);
}
}

View File

@@ -0,0 +1,133 @@
using System.Text.Json;
using NATS.Server;
using NATS.Server.Events;
using Microsoft.Extensions.Logging.Abstractions;
namespace NATS.Server.Tests;
public class SystemEventsTests
{
[Fact]
public async Task Server_publishes_connect_event_on_client_auth()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.ACCOUNT.*.CONNECT", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Connect a real client
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
await sock.ConnectAsync(System.Net.IPAddress.Loopback, server.Port);
// Read INFO
var buf = new byte[4096];
await sock.ReceiveAsync(buf);
// Send CONNECT
var connect = System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n");
await sock.SendAsync(connect);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldStartWith("$SYS.ACCOUNT.");
result.ShouldEndWith(".CONNECT");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_disconnect_event_on_client_close()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.ACCOUNT.*.DISCONNECT", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Connect and then disconnect
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
await sock.ConnectAsync(System.Net.IPAddress.Loopback, server.Port);
var buf = new byte[4096];
await sock.ReceiveAsync(buf);
await sock.SendAsync(System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
await Task.Delay(100);
sock.Shutdown(System.Net.Sockets.SocketShutdown.Both);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldStartWith("$SYS.ACCOUNT.");
result.ShouldEndWith(".DISCONNECT");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_statsz_periodically()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.SERVER.*.STATSZ", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
// Trigger a manual stats publish (don't wait 10s)
server.EventSystem!.PublishServerStats();
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldContain(".STATSZ");
await server.ShutdownAsync();
}
[Fact]
public async Task Server_publishes_shutdown_event()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<string>();
server.EventSystem!.SysSubscribe("$SYS.SERVER.*.SHUTDOWN", (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(subject);
});
await server.ShutdownAsync();
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
result.ShouldContain(".SHUTDOWN");
}
private static NatsServer CreateTestServer()
{
var port = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
}

View File

@@ -0,0 +1,170 @@
using System.Text;
using System.Text.Json;
using NATS.Server;
using NATS.Server.Events;
using Microsoft.Extensions.Logging.Abstractions;
namespace NATS.Server.Tests;
public class SystemRequestReplyTests
{
[Fact]
public async Task Varz_request_reply_returns_server_info()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "VARZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
json.ShouldContain("\"version\"");
json.ShouldContain("\"host\"");
json.ShouldContain("\"port\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Healthz_request_reply_returns_ok()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "HEALTHZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("ok");
await server.ShutdownAsync();
}
[Fact]
public async Task Subsz_request_reply_returns_subscription_count()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "SUBSZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"num_subscriptions\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Idz_request_reply_returns_server_identity()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "IDZ");
server.SendInternalMsg(reqSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
json.ShouldContain("\"server_name\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Ping_varz_responds_via_wildcard_subject()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
var received = new TaskCompletionSource<byte[]>();
var replySubject = $"_INBOX.test.{Guid.NewGuid():N}";
server.EventSystem!.SysSubscribe(replySubject, (sub, client, acc, subject, reply, hdr, msg) =>
{
received.TrySetResult(msg.ToArray());
});
var pingSubject = string.Format(EventSubjects.ServerPing, "VARZ");
server.SendInternalMsg(pingSubject, replySubject, null);
var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
var json = Encoding.UTF8.GetString(result);
json.ShouldContain("\"server_id\"");
await server.ShutdownAsync();
}
[Fact]
public async Task Request_without_reply_is_ignored()
{
using var server = CreateTestServer();
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
// Send a request with no reply subject -- should not crash
var reqSubject = string.Format(EventSubjects.ServerReq, server.ServerId, "VARZ");
server.SendInternalMsg(reqSubject, null, null);
// Give it a moment to process without error
await Task.Delay(200);
// Server should still be running
server.IsShuttingDown.ShouldBeFalse();
await server.ShutdownAsync();
}
private static NatsServer CreateTestServer()
{
var port = GetFreePort();
return new NatsServer(new NatsOptions { Port = port }, NullLoggerFactory.Instance);
}
private static int GetFreePort()
{
using var sock = new System.Net.Sockets.Socket(
System.Net.Sockets.AddressFamily.InterNetwork,
System.Net.Sockets.SocketType.Stream,
System.Net.Sockets.ProtocolType.Tcp);
sock.Bind(new System.Net.IPEndPoint(System.Net.IPAddress.Loopback, 0));
return ((System.Net.IPEndPoint)sock.LocalEndPoint!).Port;
}
}

View File

@@ -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"

View File

@@ -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

View File

@@ -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
}

View File

@@ -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