Compare commits
28 Commits
4e9c415fd2
...
feature/sy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b784024db | ||
|
|
86283a7f97 | ||
|
|
4450c27381 | ||
|
|
c9066e526d | ||
|
|
4c2b7fa3de | ||
|
|
591833adbb | ||
|
|
5bae9cc289 | ||
|
|
0b34f8cec4 | ||
|
|
125b71b3b0 | ||
|
|
89465450a1 | ||
|
|
8e790445f4 | ||
|
|
fc96b6eb43 | ||
|
|
b0c5b4acd8 | ||
|
|
0c4bca9073 | ||
|
|
0e7db5615e | ||
|
|
5e11785bdf | ||
|
|
4b3890f046 | ||
|
|
e0abce66da | ||
|
|
a0926c3a50 | ||
|
|
ad336167b9 | ||
|
|
684ee222ad | ||
|
|
d21243bc8a | ||
|
|
8a2ded8e48 | ||
|
|
5219f77f9b | ||
|
|
9f66ef72c6 | ||
|
|
ae043136a1 | ||
|
|
f952e6afab | ||
|
|
9fff5709c4 |
@@ -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
|
||||
|
||||
93
docs/plans/2026-02-23-mqtt-connection-type-design.md
Normal file
93
docs/plans/2026-02-23-mqtt-connection-type-design.md
Normal 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.
|
||||
565
docs/plans/2026-02-23-system-account-types-design.md
Normal file
565
docs/plans/2026-02-23-system-account-types-design.md
Normal 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
|
||||
2787
docs/plans/2026-02-23-system-account-types-plan.md
Normal file
2787
docs/plans/2026-02-23-system-account-types-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
22
src/NATS.Server/ClientKind.cs
Normal file
22
src/NATS.Server/ClientKind.cs
Normal 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;
|
||||
}
|
||||
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
685
src/NATS.Server/Configuration/ConfigProcessor.cs
Normal file
@@ -0,0 +1,685 @@
|
||||
// Port of Go server/opts.go processConfigFileLine — maps parsed config dictionaries
|
||||
// to NatsOptions. Reference: golang/nats-server/server/opts.go lines 1050-1400.
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a parsed NATS configuration dictionary (produced by <see cref="NatsConfParser"/>)
|
||||
/// into a fully populated <see cref="NatsOptions"/> instance. Collects all validation
|
||||
/// errors rather than failing on the first one.
|
||||
/// </summary>
|
||||
public static class ConfigProcessor
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses a configuration file and returns the populated options.
|
||||
/// </summary>
|
||||
public static NatsOptions ProcessConfigFile(string filePath)
|
||||
{
|
||||
var config = NatsConfParser.ParseFile(filePath);
|
||||
var opts = new NatsOptions { ConfigFile = filePath };
|
||||
ApplyConfig(config, opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses configuration text (not from a file) and returns the populated options.
|
||||
/// </summary>
|
||||
public static NatsOptions ProcessConfig(string configText)
|
||||
{
|
||||
var config = NatsConfParser.Parse(configText);
|
||||
var opts = new NatsOptions();
|
||||
ApplyConfig(config, opts);
|
||||
return opts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a parsed configuration dictionary to existing options.
|
||||
/// Throws <see cref="ConfigProcessorException"/> if any validation errors are collected.
|
||||
/// </summary>
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Error processing '{key}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
|
||||
// but we normalize here for the switch statement.
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "listen":
|
||||
ParseListen(value, opts);
|
||||
break;
|
||||
case "port":
|
||||
opts.Port = ToInt(value);
|
||||
break;
|
||||
case "host" or "net":
|
||||
opts.Host = ToString(value);
|
||||
break;
|
||||
case "server_name":
|
||||
var name = ToString(value);
|
||||
if (name.Contains(' '))
|
||||
errors.Add("server_name cannot contain spaces");
|
||||
else
|
||||
opts.ServerName = name;
|
||||
break;
|
||||
case "client_advertise":
|
||||
opts.ClientAdvertise = ToString(value);
|
||||
break;
|
||||
|
||||
// Logging
|
||||
case "debug":
|
||||
opts.Debug = ToBool(value);
|
||||
break;
|
||||
case "trace":
|
||||
opts.Trace = ToBool(value);
|
||||
break;
|
||||
case "trace_verbose":
|
||||
opts.TraceVerbose = ToBool(value);
|
||||
if (opts.TraceVerbose)
|
||||
opts.Trace = true;
|
||||
break;
|
||||
case "logtime":
|
||||
opts.Logtime = ToBool(value);
|
||||
break;
|
||||
case "logtime_utc":
|
||||
opts.LogtimeUTC = ToBool(value);
|
||||
break;
|
||||
case "logfile" or "log_file":
|
||||
opts.LogFile = ToString(value);
|
||||
break;
|
||||
case "log_size_limit":
|
||||
opts.LogSizeLimit = ToLong(value);
|
||||
break;
|
||||
case "log_max_num":
|
||||
opts.LogMaxFiles = ToInt(value);
|
||||
break;
|
||||
case "syslog":
|
||||
opts.Syslog = ToBool(value);
|
||||
break;
|
||||
case "remote_syslog":
|
||||
opts.RemoteSyslog = ToString(value);
|
||||
break;
|
||||
|
||||
// Limits
|
||||
case "max_payload":
|
||||
opts.MaxPayload = ToInt(value);
|
||||
break;
|
||||
case "max_control_line":
|
||||
opts.MaxControlLine = ToInt(value);
|
||||
break;
|
||||
case "max_connections" or "max_conn":
|
||||
opts.MaxConnections = ToInt(value);
|
||||
break;
|
||||
case "max_pending":
|
||||
opts.MaxPending = ToLong(value);
|
||||
break;
|
||||
case "max_subs" or "max_subscriptions":
|
||||
opts.MaxSubs = ToInt(value);
|
||||
break;
|
||||
case "max_sub_tokens" or "max_subscription_tokens":
|
||||
var tokens = ToInt(value);
|
||||
if (tokens > 256)
|
||||
errors.Add("max_sub_tokens cannot exceed 256");
|
||||
else
|
||||
opts.MaxSubTokens = tokens;
|
||||
break;
|
||||
case "max_traced_msg_len":
|
||||
opts.MaxTracedMsgLen = ToInt(value);
|
||||
break;
|
||||
case "max_closed_clients":
|
||||
opts.MaxClosedClients = ToInt(value);
|
||||
break;
|
||||
case "disable_sublist_cache" or "no_sublist_cache":
|
||||
opts.DisableSublistCache = ToBool(value);
|
||||
break;
|
||||
case "write_deadline":
|
||||
opts.WriteDeadline = ParseDuration(value);
|
||||
break;
|
||||
|
||||
// Ping
|
||||
case "ping_interval":
|
||||
opts.PingInterval = ParseDuration(value);
|
||||
break;
|
||||
case "ping_max" or "ping_max_out":
|
||||
opts.MaxPingsOut = ToInt(value);
|
||||
break;
|
||||
|
||||
// Monitoring
|
||||
case "http_port" or "monitor_port":
|
||||
opts.MonitorPort = ToInt(value);
|
||||
break;
|
||||
case "https_port":
|
||||
opts.MonitorHttpsPort = ToInt(value);
|
||||
break;
|
||||
case "http":
|
||||
ParseMonitorListen(value, opts, isHttps: false);
|
||||
break;
|
||||
case "https":
|
||||
ParseMonitorListen(value, opts, isHttps: true);
|
||||
break;
|
||||
case "http_base_path":
|
||||
opts.MonitorBasePath = ToString(value);
|
||||
break;
|
||||
|
||||
// Lifecycle
|
||||
case "lame_duck_duration":
|
||||
opts.LameDuckDuration = ParseDuration(value);
|
||||
break;
|
||||
case "lame_duck_grace_period":
|
||||
opts.LameDuckGracePeriod = ParseDuration(value);
|
||||
break;
|
||||
|
||||
// Files
|
||||
case "pidfile" or "pid_file":
|
||||
opts.PidFile = ToString(value);
|
||||
break;
|
||||
case "ports_file_dir":
|
||||
opts.PortsFileDir = ToString(value);
|
||||
break;
|
||||
|
||||
// Auth
|
||||
case "authorization":
|
||||
if (value is Dictionary<string, object?> authDict)
|
||||
ParseAuthorization(authDict, opts, errors);
|
||||
break;
|
||||
case "no_auth_user":
|
||||
opts.NoAuthUser = ToString(value);
|
||||
break;
|
||||
|
||||
// TLS
|
||||
case "tls":
|
||||
if (value is Dictionary<string, object?> tlsDict)
|
||||
ParseTls(tlsDict, opts, errors);
|
||||
break;
|
||||
case "allow_non_tls":
|
||||
opts.AllowNonTls = ToBool(value);
|
||||
break;
|
||||
|
||||
// Tags
|
||||
case "server_tags":
|
||||
if (value is Dictionary<string, object?> tagsDict)
|
||||
ParseTags(tagsDict, opts);
|
||||
break;
|
||||
|
||||
// Profiling
|
||||
case "prof_port":
|
||||
opts.ProfPort = ToInt(value);
|
||||
break;
|
||||
|
||||
// System account
|
||||
case "system_account":
|
||||
opts.SystemAccount = ToString(value);
|
||||
break;
|
||||
case "no_system_account":
|
||||
opts.NoSystemAccount = ToBool(value);
|
||||
break;
|
||||
case "no_header_support":
|
||||
opts.NoHeaderSupport = ToBool(value);
|
||||
break;
|
||||
case "connect_error_reports":
|
||||
opts.ConnectErrorReports = ToInt(value);
|
||||
break;
|
||||
case "reconnect_error_reports":
|
||||
opts.ReconnectErrorReports = ToInt(value);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (cluster, jetstream, gateway, leafnode, etc.)
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Listen parsing ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses a "listen" value that can be:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>":4222"</c> — port only</item>
|
||||
/// <item><c>"0.0.0.0:4222"</c> — host + port</item>
|
||||
/// <item><c>"4222"</c> — bare number (port only)</item>
|
||||
/// <item><c>4222</c> — integer (port only)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static void ParseListen(object? value, NatsOptions opts)
|
||||
{
|
||||
var (host, port) = ParseHostPort(value);
|
||||
if (host is not null)
|
||||
opts.Host = host;
|
||||
if (port is not null)
|
||||
opts.Port = port.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a monitor listen value. For "http" the port goes to MonitorPort;
|
||||
/// for "https" the port goes to MonitorHttpsPort.
|
||||
/// </summary>
|
||||
private static void ParseMonitorListen(object? value, NatsOptions opts, bool isHttps)
|
||||
{
|
||||
var (host, port) = ParseHostPort(value);
|
||||
if (host is not null)
|
||||
opts.MonitorHost = host;
|
||||
if (port is not null)
|
||||
{
|
||||
if (isHttps)
|
||||
opts.MonitorHttpsPort = port.Value;
|
||||
else
|
||||
opts.MonitorPort = port.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared host:port parsing logic.
|
||||
/// </summary>
|
||||
private static (string? Host, int? Port) ParseHostPort(object? value)
|
||||
{
|
||||
if (value is long l)
|
||||
return (null, (int)l);
|
||||
|
||||
var str = ToString(value);
|
||||
|
||||
// Try bare integer
|
||||
if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var barePort))
|
||||
return (null, barePort);
|
||||
|
||||
// Check for host:port
|
||||
var colonIdx = str.LastIndexOf(':');
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
var hostPart = str[..colonIdx];
|
||||
var portPart = str[(colonIdx + 1)..];
|
||||
if (int.TryParse(portPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var p))
|
||||
{
|
||||
var host = hostPart.Length > 0 ? hostPart : null;
|
||||
return (host, p);
|
||||
}
|
||||
}
|
||||
|
||||
throw new FormatException($"Cannot parse listen value: '{str}'");
|
||||
}
|
||||
|
||||
// ─── Duration parsing ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses a duration value. Accepts:
|
||||
/// <list type="bullet">
|
||||
/// <item>A string with unit suffix: "30s", "2m", "1h", "500ms"</item>
|
||||
/// <item>A number (long/double) treated as seconds</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static TimeSpan ParseDuration(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
long seconds => TimeSpan.FromSeconds(seconds),
|
||||
double seconds => TimeSpan.FromSeconds(seconds),
|
||||
string s => ParseDurationString(s),
|
||||
_ => throw new FormatException($"Cannot parse duration from {value?.GetType().Name ?? "null"}"),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly Regex DurationPattern = new(
|
||||
@"^(-?\d+(?:\.\d+)?)\s*(ms|s|m|h)$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static TimeSpan ParseDurationString(string s)
|
||||
{
|
||||
var match = DurationPattern.Match(s);
|
||||
if (!match.Success)
|
||||
throw new FormatException($"Cannot parse duration: '{s}'");
|
||||
|
||||
var amount = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
var unit = match.Groups[2].Value.ToLowerInvariant();
|
||||
|
||||
return unit switch
|
||||
{
|
||||
"ms" => TimeSpan.FromMilliseconds(amount),
|
||||
"s" => TimeSpan.FromSeconds(amount),
|
||||
"m" => TimeSpan.FromMinutes(amount),
|
||||
"h" => TimeSpan.FromHours(amount),
|
||||
_ => throw new FormatException($"Unknown duration unit: '{unit}'"),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "user" or "username":
|
||||
opts.Username = ToString(value);
|
||||
break;
|
||||
case "pass" or "password":
|
||||
opts.Password = ToString(value);
|
||||
break;
|
||||
case "token":
|
||||
opts.Authorization = ToString(value);
|
||||
break;
|
||||
case "timeout":
|
||||
opts.AuthTimeout = value switch
|
||||
{
|
||||
long l => TimeSpan.FromSeconds(l),
|
||||
double d => TimeSpan.FromSeconds(d),
|
||||
string s => ParseDuration(s),
|
||||
_ => throw new FormatException($"Invalid auth timeout type: {value?.GetType().Name}"),
|
||||
};
|
||||
break;
|
||||
case "users":
|
||||
if (value is List<object?> userList)
|
||||
opts.Users = ParseUsers(userList, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown auth keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<User> ParseUsers(List<object?> list, List<string> errors)
|
||||
{
|
||||
var users = new List<User>();
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is not Dictionary<string, object?> userDict)
|
||||
{
|
||||
errors.Add("Expected user entry to be a map");
|
||||
continue;
|
||||
}
|
||||
|
||||
string? username = null;
|
||||
string? password = null;
|
||||
string? account = null;
|
||||
Permissions? permissions = null;
|
||||
|
||||
foreach (var (key, value) in userDict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "user" or "username":
|
||||
username = ToString(value);
|
||||
break;
|
||||
case "pass" or "password":
|
||||
password = ToString(value);
|
||||
break;
|
||||
case "account":
|
||||
account = ToString(value);
|
||||
break;
|
||||
case "permissions" or "permission":
|
||||
if (value is Dictionary<string, object?> permDict)
|
||||
permissions = ParsePermissions(permDict, errors);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (username is null)
|
||||
{
|
||||
errors.Add("User entry missing 'user' field");
|
||||
continue;
|
||||
}
|
||||
|
||||
users.Add(new User
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
private static Permissions ParsePermissions(Dictionary<string, object?> dict, List<string> errors)
|
||||
{
|
||||
SubjectPermission? publish = null;
|
||||
SubjectPermission? subscribe = null;
|
||||
ResponsePermission? response = null;
|
||||
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "publish" or "pub":
|
||||
publish = ParseSubjectPermission(value, errors);
|
||||
break;
|
||||
case "subscribe" or "sub":
|
||||
subscribe = ParseSubjectPermission(value, errors);
|
||||
break;
|
||||
case "resp" or "response":
|
||||
if (value is Dictionary<string, object?> respDict)
|
||||
response = ParseResponsePermission(respDict);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new Permissions
|
||||
{
|
||||
Publish = publish,
|
||||
Subscribe = subscribe,
|
||||
Response = response,
|
||||
};
|
||||
}
|
||||
|
||||
private static SubjectPermission? ParseSubjectPermission(object? value, List<string> errors)
|
||||
{
|
||||
// Can be a simple list of strings (treated as allow) or a dict with allow/deny
|
||||
if (value is Dictionary<string, object?> dict)
|
||||
{
|
||||
IReadOnlyList<string>? allow = null;
|
||||
IReadOnlyList<string>? deny = null;
|
||||
|
||||
foreach (var (key, v) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "allow":
|
||||
allow = ToStringList(v);
|
||||
break;
|
||||
case "deny":
|
||||
deny = ToStringList(v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new SubjectPermission { Allow = allow, Deny = deny };
|
||||
}
|
||||
|
||||
if (value is List<object?> list)
|
||||
{
|
||||
return new SubjectPermission { Allow = ToStringList(list) };
|
||||
}
|
||||
|
||||
if (value is string s)
|
||||
{
|
||||
return new SubjectPermission { Allow = [s] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ResponsePermission ParseResponsePermission(Dictionary<string, object?> dict)
|
||||
{
|
||||
var maxMsgs = 0;
|
||||
var expires = TimeSpan.Zero;
|
||||
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "max_msgs" or "max":
|
||||
maxMsgs = ToInt(value);
|
||||
break;
|
||||
case "expires" or "ttl":
|
||||
expires = ParseDuration(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires };
|
||||
}
|
||||
|
||||
// ─── TLS parsing ───────────────────────────────────────────────
|
||||
|
||||
private static void ParseTls(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "cert_file":
|
||||
opts.TlsCert = ToString(value);
|
||||
break;
|
||||
case "key_file":
|
||||
opts.TlsKey = ToString(value);
|
||||
break;
|
||||
case "ca_file":
|
||||
opts.TlsCaCert = ToString(value);
|
||||
break;
|
||||
case "verify":
|
||||
opts.TlsVerify = ToBool(value);
|
||||
break;
|
||||
case "verify_and_map":
|
||||
var map = ToBool(value);
|
||||
opts.TlsMap = map;
|
||||
if (map)
|
||||
opts.TlsVerify = true;
|
||||
break;
|
||||
case "timeout":
|
||||
opts.TlsTimeout = value switch
|
||||
{
|
||||
long l => TimeSpan.FromSeconds(l),
|
||||
double d => TimeSpan.FromSeconds(d),
|
||||
string s => ParseDuration(s),
|
||||
_ => throw new FormatException($"Invalid TLS timeout type: {value?.GetType().Name}"),
|
||||
};
|
||||
break;
|
||||
case "connection_rate_limit":
|
||||
opts.TlsRateLimit = ToLong(value);
|
||||
break;
|
||||
case "pinned_certs":
|
||||
if (value is List<object?> pinnedList)
|
||||
{
|
||||
var certs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in pinnedList)
|
||||
{
|
||||
if (item is string s)
|
||||
certs.Add(s.ToLowerInvariant());
|
||||
}
|
||||
|
||||
opts.TlsPinnedCerts = certs;
|
||||
}
|
||||
|
||||
break;
|
||||
case "handshake_first" or "first" or "immediate":
|
||||
opts.TlsHandshakeFirst = ToBool(value);
|
||||
break;
|
||||
case "handshake_first_fallback":
|
||||
opts.TlsHandshakeFirstFallback = ParseDuration(value);
|
||||
break;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags parsing ──────────────────────────────────────────────
|
||||
|
||||
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
|
||||
{
|
||||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
tags[key] = ToString(value);
|
||||
}
|
||||
|
||||
opts.Tags = tags;
|
||||
}
|
||||
|
||||
// ─── Type conversion helpers ───────────────────────────────────
|
||||
|
||||
private static int ToInt(object? value) => value switch
|
||||
{
|
||||
long l => (int)l,
|
||||
int i => i,
|
||||
double d => (int)d,
|
||||
string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i) => i,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to int"),
|
||||
};
|
||||
|
||||
private static long ToLong(object? value) => value switch
|
||||
{
|
||||
long l => l,
|
||||
int i => i,
|
||||
double d => (long)d,
|
||||
string s when long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => l,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to long"),
|
||||
};
|
||||
|
||||
private static bool ToBool(object? value) => value switch
|
||||
{
|
||||
bool b => b,
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to bool"),
|
||||
};
|
||||
|
||||
private static string ToString(object? value) => value switch
|
||||
{
|
||||
string s => s,
|
||||
long l => l.ToString(CultureInfo.InvariantCulture),
|
||||
_ => throw new FormatException($"Cannot convert {value?.GetType().Name ?? "null"} to string"),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<string> ToStringList(object? value)
|
||||
{
|
||||
if (value is List<object?> list)
|
||||
{
|
||||
var result = new List<string>(list.Count);
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (item is string s)
|
||||
result.Add(s);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (value is string str)
|
||||
return [str];
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when one or more configuration validation errors are detected.
|
||||
/// All errors are collected rather than failing on the first one.
|
||||
/// </summary>
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
}
|
||||
341
src/NATS.Server/Configuration/ConfigReloader.cs
Normal file
341
src/NATS.Server/Configuration/ConfigReloader.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
// Port of Go server/reload.go — config diffing, validation, and CLI override merging
|
||||
// for hot reload support. Reference: golang/nats-server/server/reload.go.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides static methods for comparing two <see cref="NatsOptions"/> instances,
|
||||
/// validating that detected changes are reloadable, and merging CLI overrides
|
||||
/// so that command-line flags always take precedence over config file values.
|
||||
/// </summary>
|
||||
public static class ConfigReloader
|
||||
{
|
||||
// Non-reloadable options (match Go server — Host, Port, ServerName require restart)
|
||||
private static readonly HashSet<string> NonReloadable = ["Host", "Port", "ServerName"];
|
||||
|
||||
// Logging-related options
|
||||
private static readonly HashSet<string> LoggingOptions =
|
||||
["Debug", "Trace", "TraceVerbose", "Logtime", "LogtimeUTC", "LogFile",
|
||||
"LogSizeLimit", "LogMaxFiles", "Syslog", "RemoteSyslog"];
|
||||
|
||||
// Auth-related options
|
||||
private static readonly HashSet<string> AuthOptions =
|
||||
["Username", "Password", "Authorization", "Users", "NKeys",
|
||||
"NoAuthUser", "AuthTimeout"];
|
||||
|
||||
// TLS-related options
|
||||
private static readonly HashSet<string> TlsOptions =
|
||||
["TlsCert", "TlsKey", "TlsCaCert", "TlsVerify", "TlsMap",
|
||||
"TlsTimeout", "TlsHandshakeFirst", "TlsHandshakeFirstFallback",
|
||||
"AllowNonTls", "TlsRateLimit", "TlsPinnedCerts"];
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="NatsOptions"/> instances property by property and returns
|
||||
/// a list of <see cref="IConfigChange"/> for every property that differs. Each change
|
||||
/// is tagged with the appropriate category flags.
|
||||
/// </summary>
|
||||
public static List<IConfigChange> Diff(NatsOptions oldOpts, NatsOptions newOpts)
|
||||
{
|
||||
var changes = new List<IConfigChange>();
|
||||
|
||||
// Non-reloadable
|
||||
CompareAndAdd(changes, "Host", oldOpts.Host, newOpts.Host);
|
||||
CompareAndAdd(changes, "Port", oldOpts.Port, newOpts.Port);
|
||||
CompareAndAdd(changes, "ServerName", oldOpts.ServerName, newOpts.ServerName);
|
||||
|
||||
// Logging
|
||||
CompareAndAdd(changes, "Debug", oldOpts.Debug, newOpts.Debug);
|
||||
CompareAndAdd(changes, "Trace", oldOpts.Trace, newOpts.Trace);
|
||||
CompareAndAdd(changes, "TraceVerbose", oldOpts.TraceVerbose, newOpts.TraceVerbose);
|
||||
CompareAndAdd(changes, "Logtime", oldOpts.Logtime, newOpts.Logtime);
|
||||
CompareAndAdd(changes, "LogtimeUTC", oldOpts.LogtimeUTC, newOpts.LogtimeUTC);
|
||||
CompareAndAdd(changes, "LogFile", oldOpts.LogFile, newOpts.LogFile);
|
||||
CompareAndAdd(changes, "LogSizeLimit", oldOpts.LogSizeLimit, newOpts.LogSizeLimit);
|
||||
CompareAndAdd(changes, "LogMaxFiles", oldOpts.LogMaxFiles, newOpts.LogMaxFiles);
|
||||
CompareAndAdd(changes, "Syslog", oldOpts.Syslog, newOpts.Syslog);
|
||||
CompareAndAdd(changes, "RemoteSyslog", oldOpts.RemoteSyslog, newOpts.RemoteSyslog);
|
||||
|
||||
// Auth
|
||||
CompareAndAdd(changes, "Username", oldOpts.Username, newOpts.Username);
|
||||
CompareAndAdd(changes, "Password", oldOpts.Password, newOpts.Password);
|
||||
CompareAndAdd(changes, "Authorization", oldOpts.Authorization, newOpts.Authorization);
|
||||
CompareCollectionAndAdd(changes, "Users", oldOpts.Users, newOpts.Users);
|
||||
CompareCollectionAndAdd(changes, "NKeys", oldOpts.NKeys, newOpts.NKeys);
|
||||
CompareAndAdd(changes, "NoAuthUser", oldOpts.NoAuthUser, newOpts.NoAuthUser);
|
||||
CompareAndAdd(changes, "AuthTimeout", oldOpts.AuthTimeout, newOpts.AuthTimeout);
|
||||
|
||||
// TLS
|
||||
CompareAndAdd(changes, "TlsCert", oldOpts.TlsCert, newOpts.TlsCert);
|
||||
CompareAndAdd(changes, "TlsKey", oldOpts.TlsKey, newOpts.TlsKey);
|
||||
CompareAndAdd(changes, "TlsCaCert", oldOpts.TlsCaCert, newOpts.TlsCaCert);
|
||||
CompareAndAdd(changes, "TlsVerify", oldOpts.TlsVerify, newOpts.TlsVerify);
|
||||
CompareAndAdd(changes, "TlsMap", oldOpts.TlsMap, newOpts.TlsMap);
|
||||
CompareAndAdd(changes, "TlsTimeout", oldOpts.TlsTimeout, newOpts.TlsTimeout);
|
||||
CompareAndAdd(changes, "TlsHandshakeFirst", oldOpts.TlsHandshakeFirst, newOpts.TlsHandshakeFirst);
|
||||
CompareAndAdd(changes, "TlsHandshakeFirstFallback", oldOpts.TlsHandshakeFirstFallback, newOpts.TlsHandshakeFirstFallback);
|
||||
CompareAndAdd(changes, "AllowNonTls", oldOpts.AllowNonTls, newOpts.AllowNonTls);
|
||||
CompareAndAdd(changes, "TlsRateLimit", oldOpts.TlsRateLimit, newOpts.TlsRateLimit);
|
||||
CompareCollectionAndAdd(changes, "TlsPinnedCerts", oldOpts.TlsPinnedCerts, newOpts.TlsPinnedCerts);
|
||||
|
||||
// Limits
|
||||
CompareAndAdd(changes, "MaxConnections", oldOpts.MaxConnections, newOpts.MaxConnections);
|
||||
CompareAndAdd(changes, "MaxPayload", oldOpts.MaxPayload, newOpts.MaxPayload);
|
||||
CompareAndAdd(changes, "MaxPending", oldOpts.MaxPending, newOpts.MaxPending);
|
||||
CompareAndAdd(changes, "WriteDeadline", oldOpts.WriteDeadline, newOpts.WriteDeadline);
|
||||
CompareAndAdd(changes, "PingInterval", oldOpts.PingInterval, newOpts.PingInterval);
|
||||
CompareAndAdd(changes, "MaxPingsOut", oldOpts.MaxPingsOut, newOpts.MaxPingsOut);
|
||||
CompareAndAdd(changes, "MaxControlLine", oldOpts.MaxControlLine, newOpts.MaxControlLine);
|
||||
CompareAndAdd(changes, "MaxSubs", oldOpts.MaxSubs, newOpts.MaxSubs);
|
||||
CompareAndAdd(changes, "MaxSubTokens", oldOpts.MaxSubTokens, newOpts.MaxSubTokens);
|
||||
CompareAndAdd(changes, "MaxTracedMsgLen", oldOpts.MaxTracedMsgLen, newOpts.MaxTracedMsgLen);
|
||||
CompareAndAdd(changes, "MaxClosedClients", oldOpts.MaxClosedClients, newOpts.MaxClosedClients);
|
||||
|
||||
// Misc
|
||||
CompareCollectionAndAdd(changes, "Tags", oldOpts.Tags, newOpts.Tags);
|
||||
CompareAndAdd(changes, "LameDuckDuration", oldOpts.LameDuckDuration, newOpts.LameDuckDuration);
|
||||
CompareAndAdd(changes, "LameDuckGracePeriod", oldOpts.LameDuckGracePeriod, newOpts.LameDuckGracePeriod);
|
||||
CompareAndAdd(changes, "ClientAdvertise", oldOpts.ClientAdvertise, newOpts.ClientAdvertise);
|
||||
CompareAndAdd(changes, "DisableSublistCache", oldOpts.DisableSublistCache, newOpts.DisableSublistCache);
|
||||
CompareAndAdd(changes, "ConnectErrorReports", oldOpts.ConnectErrorReports, newOpts.ConnectErrorReports);
|
||||
CompareAndAdd(changes, "ReconnectErrorReports", oldOpts.ReconnectErrorReports, newOpts.ReconnectErrorReports);
|
||||
CompareAndAdd(changes, "NoHeaderSupport", oldOpts.NoHeaderSupport, newOpts.NoHeaderSupport);
|
||||
CompareAndAdd(changes, "NoSystemAccount", oldOpts.NoSystemAccount, newOpts.NoSystemAccount);
|
||||
CompareAndAdd(changes, "SystemAccount", oldOpts.SystemAccount, newOpts.SystemAccount);
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a list of config changes and returns error messages for any
|
||||
/// non-reloadable changes (properties that require a server restart).
|
||||
/// </summary>
|
||||
public static List<string> Validate(List<IConfigChange> changes)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.IsNonReloadable)
|
||||
{
|
||||
errors.Add($"Config reload: '{change.Name}' cannot be changed at runtime (requires restart)");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges CLI overrides into a freshly-parsed config so that command-line flags
|
||||
/// always take precedence. Only properties whose names appear in <paramref name="cliFlags"/>
|
||||
/// are copied from <paramref name="cliValues"/> to <paramref name="fromConfig"/>.
|
||||
/// </summary>
|
||||
public static void MergeCliOverrides(NatsOptions fromConfig, NatsOptions cliValues, HashSet<string> cliFlags)
|
||||
{
|
||||
foreach (var flag in cliFlags)
|
||||
{
|
||||
switch (flag)
|
||||
{
|
||||
// Non-reloadable
|
||||
case "Host":
|
||||
fromConfig.Host = cliValues.Host;
|
||||
break;
|
||||
case "Port":
|
||||
fromConfig.Port = cliValues.Port;
|
||||
break;
|
||||
case "ServerName":
|
||||
fromConfig.ServerName = cliValues.ServerName;
|
||||
break;
|
||||
|
||||
// Logging
|
||||
case "Debug":
|
||||
fromConfig.Debug = cliValues.Debug;
|
||||
break;
|
||||
case "Trace":
|
||||
fromConfig.Trace = cliValues.Trace;
|
||||
break;
|
||||
case "TraceVerbose":
|
||||
fromConfig.TraceVerbose = cliValues.TraceVerbose;
|
||||
break;
|
||||
case "Logtime":
|
||||
fromConfig.Logtime = cliValues.Logtime;
|
||||
break;
|
||||
case "LogtimeUTC":
|
||||
fromConfig.LogtimeUTC = cliValues.LogtimeUTC;
|
||||
break;
|
||||
case "LogFile":
|
||||
fromConfig.LogFile = cliValues.LogFile;
|
||||
break;
|
||||
case "LogSizeLimit":
|
||||
fromConfig.LogSizeLimit = cliValues.LogSizeLimit;
|
||||
break;
|
||||
case "LogMaxFiles":
|
||||
fromConfig.LogMaxFiles = cliValues.LogMaxFiles;
|
||||
break;
|
||||
case "Syslog":
|
||||
fromConfig.Syslog = cliValues.Syslog;
|
||||
break;
|
||||
case "RemoteSyslog":
|
||||
fromConfig.RemoteSyslog = cliValues.RemoteSyslog;
|
||||
break;
|
||||
|
||||
// Auth
|
||||
case "Username":
|
||||
fromConfig.Username = cliValues.Username;
|
||||
break;
|
||||
case "Password":
|
||||
fromConfig.Password = cliValues.Password;
|
||||
break;
|
||||
case "Authorization":
|
||||
fromConfig.Authorization = cliValues.Authorization;
|
||||
break;
|
||||
case "Users":
|
||||
fromConfig.Users = cliValues.Users;
|
||||
break;
|
||||
case "NKeys":
|
||||
fromConfig.NKeys = cliValues.NKeys;
|
||||
break;
|
||||
case "NoAuthUser":
|
||||
fromConfig.NoAuthUser = cliValues.NoAuthUser;
|
||||
break;
|
||||
case "AuthTimeout":
|
||||
fromConfig.AuthTimeout = cliValues.AuthTimeout;
|
||||
break;
|
||||
|
||||
// TLS
|
||||
case "TlsCert":
|
||||
fromConfig.TlsCert = cliValues.TlsCert;
|
||||
break;
|
||||
case "TlsKey":
|
||||
fromConfig.TlsKey = cliValues.TlsKey;
|
||||
break;
|
||||
case "TlsCaCert":
|
||||
fromConfig.TlsCaCert = cliValues.TlsCaCert;
|
||||
break;
|
||||
case "TlsVerify":
|
||||
fromConfig.TlsVerify = cliValues.TlsVerify;
|
||||
break;
|
||||
case "TlsMap":
|
||||
fromConfig.TlsMap = cliValues.TlsMap;
|
||||
break;
|
||||
case "TlsTimeout":
|
||||
fromConfig.TlsTimeout = cliValues.TlsTimeout;
|
||||
break;
|
||||
case "TlsHandshakeFirst":
|
||||
fromConfig.TlsHandshakeFirst = cliValues.TlsHandshakeFirst;
|
||||
break;
|
||||
case "TlsHandshakeFirstFallback":
|
||||
fromConfig.TlsHandshakeFirstFallback = cliValues.TlsHandshakeFirstFallback;
|
||||
break;
|
||||
case "AllowNonTls":
|
||||
fromConfig.AllowNonTls = cliValues.AllowNonTls;
|
||||
break;
|
||||
case "TlsRateLimit":
|
||||
fromConfig.TlsRateLimit = cliValues.TlsRateLimit;
|
||||
break;
|
||||
case "TlsPinnedCerts":
|
||||
fromConfig.TlsPinnedCerts = cliValues.TlsPinnedCerts;
|
||||
break;
|
||||
|
||||
// Limits
|
||||
case "MaxConnections":
|
||||
fromConfig.MaxConnections = cliValues.MaxConnections;
|
||||
break;
|
||||
case "MaxPayload":
|
||||
fromConfig.MaxPayload = cliValues.MaxPayload;
|
||||
break;
|
||||
case "MaxPending":
|
||||
fromConfig.MaxPending = cliValues.MaxPending;
|
||||
break;
|
||||
case "WriteDeadline":
|
||||
fromConfig.WriteDeadline = cliValues.WriteDeadline;
|
||||
break;
|
||||
case "PingInterval":
|
||||
fromConfig.PingInterval = cliValues.PingInterval;
|
||||
break;
|
||||
case "MaxPingsOut":
|
||||
fromConfig.MaxPingsOut = cliValues.MaxPingsOut;
|
||||
break;
|
||||
case "MaxControlLine":
|
||||
fromConfig.MaxControlLine = cliValues.MaxControlLine;
|
||||
break;
|
||||
case "MaxSubs":
|
||||
fromConfig.MaxSubs = cliValues.MaxSubs;
|
||||
break;
|
||||
case "MaxSubTokens":
|
||||
fromConfig.MaxSubTokens = cliValues.MaxSubTokens;
|
||||
break;
|
||||
case "MaxTracedMsgLen":
|
||||
fromConfig.MaxTracedMsgLen = cliValues.MaxTracedMsgLen;
|
||||
break;
|
||||
case "MaxClosedClients":
|
||||
fromConfig.MaxClosedClients = cliValues.MaxClosedClients;
|
||||
break;
|
||||
|
||||
// Misc
|
||||
case "Tags":
|
||||
fromConfig.Tags = cliValues.Tags;
|
||||
break;
|
||||
case "LameDuckDuration":
|
||||
fromConfig.LameDuckDuration = cliValues.LameDuckDuration;
|
||||
break;
|
||||
case "LameDuckGracePeriod":
|
||||
fromConfig.LameDuckGracePeriod = cliValues.LameDuckGracePeriod;
|
||||
break;
|
||||
case "ClientAdvertise":
|
||||
fromConfig.ClientAdvertise = cliValues.ClientAdvertise;
|
||||
break;
|
||||
case "DisableSublistCache":
|
||||
fromConfig.DisableSublistCache = cliValues.DisableSublistCache;
|
||||
break;
|
||||
case "ConnectErrorReports":
|
||||
fromConfig.ConnectErrorReports = cliValues.ConnectErrorReports;
|
||||
break;
|
||||
case "ReconnectErrorReports":
|
||||
fromConfig.ReconnectErrorReports = cliValues.ReconnectErrorReports;
|
||||
break;
|
||||
case "NoHeaderSupport":
|
||||
fromConfig.NoHeaderSupport = cliValues.NoHeaderSupport;
|
||||
break;
|
||||
case "NoSystemAccount":
|
||||
fromConfig.NoSystemAccount = cliValues.NoSystemAccount;
|
||||
break;
|
||||
case "SystemAccount":
|
||||
fromConfig.SystemAccount = cliValues.SystemAccount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comparison helpers ─────────────────────────────────────────
|
||||
|
||||
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
||||
{
|
||||
if (!Equals(oldVal, newVal))
|
||||
{
|
||||
changes.Add(new ConfigChange(
|
||||
name,
|
||||
isLoggingChange: LoggingOptions.Contains(name),
|
||||
isAuthChange: AuthOptions.Contains(name),
|
||||
isTlsChange: TlsOptions.Contains(name),
|
||||
isNonReloadable: NonReloadable.Contains(name)));
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompareCollectionAndAdd<T>(List<IConfigChange> changes, string name, T? oldVal, T? newVal)
|
||||
where T : class
|
||||
{
|
||||
// For collections we compare by reference and null state.
|
||||
// A change from null to non-null (or vice versa), or a different reference, counts as changed.
|
||||
if (ReferenceEquals(oldVal, newVal))
|
||||
return;
|
||||
|
||||
if (oldVal is null || newVal is null || !ReferenceEquals(oldVal, newVal))
|
||||
{
|
||||
changes.Add(new ConfigChange(
|
||||
name,
|
||||
isLoggingChange: LoggingOptions.Contains(name),
|
||||
isAuthChange: AuthOptions.Contains(name),
|
||||
isTlsChange: TlsOptions.Contains(name),
|
||||
isNonReloadable: NonReloadable.Contains(name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/NATS.Server/Configuration/IConfigChange.cs
Normal file
54
src/NATS.Server/Configuration/IConfigChange.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
// Port of Go server/reload.go option interface — represents a single detected
|
||||
// configuration change with category flags for reload handling.
|
||||
// Reference: golang/nats-server/server/reload.go lines 42-74.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single detected configuration change during a hot reload.
|
||||
/// Category flags indicate what kind of reload action is needed.
|
||||
/// </summary>
|
||||
public interface IConfigChange
|
||||
{
|
||||
/// <summary>
|
||||
/// The property name that changed (matches NatsOptions property name).
|
||||
/// </summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading the logger.
|
||||
/// </summary>
|
||||
bool IsLoggingChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading authorization.
|
||||
/// </summary>
|
||||
bool IsAuthChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this change requires reloading TLS configuration.
|
||||
/// </summary>
|
||||
bool IsTlsChange { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this option cannot be changed at runtime (requires restart).
|
||||
/// </summary>
|
||||
bool IsNonReloadable { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IConfigChange"/> using a primary constructor.
|
||||
/// </summary>
|
||||
public sealed class ConfigChange(
|
||||
string name,
|
||||
bool isLoggingChange = false,
|
||||
bool isAuthChange = false,
|
||||
bool isTlsChange = false,
|
||||
bool isNonReloadable = false) : IConfigChange
|
||||
{
|
||||
public string Name => name;
|
||||
public bool IsLoggingChange => isLoggingChange;
|
||||
public bool IsAuthChange => isAuthChange;
|
||||
public bool IsTlsChange => isTlsChange;
|
||||
public bool IsNonReloadable => isNonReloadable;
|
||||
}
|
||||
1503
src/NATS.Server/Configuration/NatsConfLexer.cs
Normal file
1503
src/NATS.Server/Configuration/NatsConfLexer.cs
Normal file
File diff suppressed because it is too large
Load Diff
421
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
421
src/NATS.Server/Configuration/NatsConfParser.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
// Port of Go conf/parse.go — recursive-descent parser for NATS config files.
|
||||
// Reference: golang/nats-server/conf/parse.go
|
||||
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Parses NATS configuration data (tokenized by <see cref="NatsConfLexer"/>) into
|
||||
/// a <c>Dictionary<string, object?></c> tree. Supports nested maps, arrays,
|
||||
/// variable references (block-scoped + environment), include directives, bcrypt
|
||||
/// password literals, and integer suffix multipliers.
|
||||
/// </summary>
|
||||
public static class NatsConfParser
|
||||
{
|
||||
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
|
||||
// and emits a Variable token whose value begins with "2a$" or "2b$".
|
||||
private const string BcryptPrefix2A = "2a$";
|
||||
private const string BcryptPrefix2B = "2b$";
|
||||
|
||||
// Maximum nesting depth for include directives to prevent infinite recursion.
|
||||
private const int MaxIncludeDepth = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration string into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> Parse(string data)
|
||||
{
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||
ParseFile(filePath, includeDepth: 0);
|
||||
|
||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file and returns the parsed config plus a
|
||||
/// SHA-256 digest of the raw file content formatted as "sha256:<hex>".
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithDigest(string filePath)
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(filePath);
|
||||
var hashBytes = SHA256.HashData(rawBytes);
|
||||
var digest = "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
|
||||
var data = Encoding.UTF8.GetString(rawBytes);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth: 0);
|
||||
state.Run();
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: parse an environment variable value by wrapping it in a synthetic
|
||||
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> ParseEnvValue(string value, HashSet<string> envVarReferences, int includeDepth)
|
||||
{
|
||||
var synthetic = $"pk={value}";
|
||||
var tokens = NatsConfLexer.Tokenize(synthetic);
|
||||
var state = new ParserState(tokens, baseDir: string.Empty, envVarReferences, includeDepth);
|
||||
state.Run();
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates the mutable parsing state: context stack, key stack, token cursor.
|
||||
/// Mirrors the Go <c>parser</c> struct from conf/parse.go.
|
||||
/// </summary>
|
||||
private sealed class ParserState
|
||||
{
|
||||
private readonly IReadOnlyList<Token> _tokens;
|
||||
private readonly string _baseDir;
|
||||
private readonly HashSet<string> _envVarReferences;
|
||||
private readonly int _includeDepth;
|
||||
private int _pos;
|
||||
|
||||
// The context stack holds either Dictionary<string, object?> (map) or List<object?> (array).
|
||||
private readonly List<object> _ctxs = new(4);
|
||||
private object _ctx = null!;
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir)
|
||||
: this(tokens, baseDir, [], includeDepth: 0)
|
||||
{
|
||||
}
|
||||
|
||||
public ParserState(IReadOnlyList<Token> tokens, string baseDir, HashSet<string> envVarReferences, int includeDepth)
|
||||
{
|
||||
_tokens = tokens;
|
||||
_baseDir = baseDir;
|
||||
_envVarReferences = envVarReferences;
|
||||
_includeDepth = includeDepth;
|
||||
}
|
||||
|
||||
public void Run()
|
||||
{
|
||||
PushContext(Mapping);
|
||||
|
||||
Token prevToken = default;
|
||||
while (true)
|
||||
{
|
||||
var token = Next();
|
||||
if (token.Type == TokenType.Eof)
|
||||
{
|
||||
// Allow a trailing '}' (JSON-like configs) — mirror Go behavior.
|
||||
if (prevToken.Type == TokenType.Key && prevToken.Value != "}")
|
||||
{
|
||||
throw new FormatException($"Config is invalid at line {token.Line}:{token.Position}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
prevToken = token;
|
||||
ProcessItem(token);
|
||||
}
|
||||
}
|
||||
|
||||
private Token Next()
|
||||
{
|
||||
if (_pos >= _tokens.Count)
|
||||
{
|
||||
return new Token(TokenType.Eof, string.Empty, 0, 0);
|
||||
}
|
||||
|
||||
return _tokens[_pos++];
|
||||
}
|
||||
|
||||
private void PushContext(object ctx)
|
||||
{
|
||||
_ctxs.Add(ctx);
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
private object PopContext()
|
||||
{
|
||||
if (_ctxs.Count <= 1)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, context stack underflow");
|
||||
}
|
||||
|
||||
var last = _ctxs[^1];
|
||||
_ctxs.RemoveAt(_ctxs.Count - 1);
|
||||
_ctx = _ctxs[^1];
|
||||
return last;
|
||||
}
|
||||
|
||||
private void PushKey(string key) => _keys.Add(key);
|
||||
|
||||
private string PopKey()
|
||||
{
|
||||
if (_keys.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("BUG in parser, keys stack empty");
|
||||
}
|
||||
|
||||
var last = _keys[^1];
|
||||
_keys.RemoveAt(_keys.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void SetValue(object? val)
|
||||
{
|
||||
// Array context: append the value.
|
||||
if (_ctx is List<object?> array)
|
||||
{
|
||||
array.Add(val);
|
||||
return;
|
||||
}
|
||||
|
||||
// Map context: pop the pending key and assign.
|
||||
if (_ctx is Dictionary<string, object?> map)
|
||||
{
|
||||
var key = PopKey();
|
||||
map[key] = val;
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"BUG in parser, unexpected context type {_ctx?.GetType().Name ?? "null"}");
|
||||
}
|
||||
|
||||
private void ProcessItem(Token token)
|
||||
{
|
||||
switch (token.Type)
|
||||
{
|
||||
case TokenType.Error:
|
||||
throw new FormatException($"Parse error on line {token.Line}: '{token.Value}'");
|
||||
|
||||
case TokenType.Key:
|
||||
PushKey(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.String:
|
||||
SetValue(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Bool:
|
||||
SetValue(ParseBool(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Integer:
|
||||
SetValue(ParseInteger(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.Float:
|
||||
SetValue(ParseFloat(token.Value));
|
||||
break;
|
||||
|
||||
case TokenType.DateTime:
|
||||
SetValue(DateTimeOffset.Parse(token.Value, CultureInfo.InvariantCulture));
|
||||
break;
|
||||
|
||||
case TokenType.ArrayStart:
|
||||
PushContext(new List<object?>());
|
||||
break;
|
||||
|
||||
case TokenType.ArrayEnd:
|
||||
{
|
||||
var array = _ctx;
|
||||
PopContext();
|
||||
SetValue(array);
|
||||
break;
|
||||
}
|
||||
|
||||
case TokenType.MapStart:
|
||||
PushContext(new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase));
|
||||
break;
|
||||
|
||||
case TokenType.MapEnd:
|
||||
SetValue(PopContext());
|
||||
break;
|
||||
|
||||
case TokenType.Variable:
|
||||
ResolveVariable(token);
|
||||
break;
|
||||
|
||||
case TokenType.Include:
|
||||
ProcessInclude(token.Value);
|
||||
break;
|
||||
|
||||
case TokenType.Comment:
|
||||
// Skip comments entirely.
|
||||
break;
|
||||
|
||||
case TokenType.Eof:
|
||||
// Handled in the Run loop; should not reach here.
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new FormatException($"Unexpected token type {token.Type} on line {token.Line}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ParseBool(string value) =>
|
||||
value.ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "yes" or "on" => true,
|
||||
"false" or "no" or "off" => false,
|
||||
_ => throw new FormatException($"Expected boolean value, but got '{value}'"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses an integer token value, handling optional size suffixes
|
||||
/// (k, kb, m, mb, g, gb, t, tb, etc.) exactly as the Go reference does.
|
||||
/// </summary>
|
||||
private static long ParseInteger(string value)
|
||||
{
|
||||
// Find where digits end and potential suffix begins.
|
||||
var lastDigit = 0;
|
||||
foreach (var c in value)
|
||||
{
|
||||
if (!char.IsDigit(c) && c != '-')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
lastDigit++;
|
||||
}
|
||||
|
||||
var numStr = value[..lastDigit];
|
||||
if (!long.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num))
|
||||
{
|
||||
throw new FormatException($"Expected integer, but got '{value}'");
|
||||
}
|
||||
|
||||
var suffix = value[lastDigit..].Trim().ToLowerInvariant();
|
||||
return suffix switch
|
||||
{
|
||||
"" => num,
|
||||
"k" => num * 1000,
|
||||
"kb" or "ki" or "kib" => num * 1024,
|
||||
"m" => num * 1_000_000,
|
||||
"mb" or "mi" or "mib" => num * 1024 * 1024,
|
||||
"g" => num * 1_000_000_000,
|
||||
"gb" or "gi" or "gib" => num * 1024 * 1024 * 1024,
|
||||
"t" => num * 1_000_000_000_000,
|
||||
"tb" or "ti" or "tib" => num * 1024L * 1024 * 1024 * 1024,
|
||||
"p" => num * 1_000_000_000_000_000,
|
||||
"pb" or "pi" or "pib" => num * 1024L * 1024 * 1024 * 1024 * 1024,
|
||||
"e" => num * 1_000_000_000_000_000_000,
|
||||
"eb" or "ei" or "eib" => num * 1024L * 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
_ => throw new FormatException($"Unknown integer suffix '{suffix}' in '{value}'"),
|
||||
};
|
||||
}
|
||||
|
||||
private static double ParseFloat(string value)
|
||||
{
|
||||
if (!double.TryParse(value, NumberStyles.Float | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var result))
|
||||
{
|
||||
throw new FormatException($"Expected float, but got '{value}'");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a variable reference using block scoping: walks the context stack
|
||||
/// top-down looking in map contexts, then falls back to environment variables.
|
||||
/// Detects bcrypt password literals and reference cycles.
|
||||
/// </summary>
|
||||
private void ResolveVariable(Token token)
|
||||
{
|
||||
var varName = token.Value;
|
||||
|
||||
// Special case: raw bcrypt strings ($2a$... or $2b$...).
|
||||
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
|
||||
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
|
||||
{
|
||||
SetValue("$" + varName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk context stack from top (innermost scope) to bottom (outermost).
|
||||
for (var i = _ctxs.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_ctxs[i] is Dictionary<string, object?> map && map.TryGetValue(varName, out var found))
|
||||
{
|
||||
SetValue(found);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found in any context map. Check environment variables.
|
||||
// First, detect cycles.
|
||||
if (!_envVarReferences.Add(varName))
|
||||
{
|
||||
throw new FormatException($"Variable reference cycle for '{varName}'");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envValue = Environment.GetEnvironmentVariable(varName);
|
||||
if (envValue is not null)
|
||||
{
|
||||
// Parse the env value through the full parser to get correct typing
|
||||
// (e.g., "42" becomes long 42, "true" becomes bool, etc.).
|
||||
var subResult = ParseEnvValue(envValue, _envVarReferences, _includeDepth);
|
||||
if (subResult.TryGetValue("pk", out var parsedValue))
|
||||
{
|
||||
SetValue(parsedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_envVarReferences.Remove(varName);
|
||||
}
|
||||
|
||||
// Not found anywhere.
|
||||
throw new FormatException(
|
||||
$"Variable reference for '{varName}' on line {token.Line} can not be found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes an include directive by parsing the referenced file and merging
|
||||
/// all its top-level keys into the current context.
|
||||
/// </summary>
|
||||
private void ProcessInclude(string includePath)
|
||||
{
|
||||
if (_includeDepth >= MaxIncludeDepth)
|
||||
{
|
||||
throw new FormatException(
|
||||
$"Include depth limit of {MaxIncludeDepth} exceeded while processing '{includePath}'");
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(_baseDir, includePath);
|
||||
var includeResult = ParseFile(fullPath, _includeDepth + 1);
|
||||
|
||||
foreach (var (key, value) in includeResult)
|
||||
{
|
||||
PushKey(key);
|
||||
SetValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/NATS.Server/Configuration/NatsConfToken.cs
Normal file
24
src/NATS.Server/Configuration/NatsConfToken.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Port of Go conf/lex.go token types.
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public enum TokenType
|
||||
{
|
||||
Error,
|
||||
Eof,
|
||||
Key,
|
||||
String,
|
||||
Bool,
|
||||
Integer,
|
||||
Float,
|
||||
DateTime,
|
||||
ArrayStart,
|
||||
ArrayEnd,
|
||||
MapStart,
|
||||
MapEnd,
|
||||
Variable,
|
||||
Include,
|
||||
Comment,
|
||||
}
|
||||
|
||||
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
|
||||
12
src/NATS.Server/Events/EventJsonContext.cs
Normal file
12
src/NATS.Server/Events/EventJsonContext.cs
Normal 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;
|
||||
49
src/NATS.Server/Events/EventSubjects.cs
Normal file
49
src/NATS.Server/Events/EventSubjects.cs
Normal 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);
|
||||
270
src/NATS.Server/Events/EventTypes.cs
Normal file
270
src/NATS.Server/Events/EventTypes.cs
Normal 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;
|
||||
}
|
||||
333
src/NATS.Server/Events/InternalEventSystem.cs
Normal file
333
src/NATS.Server/Events/InternalEventSystem.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
19
src/NATS.Server/INatsClient.cs
Normal file
19
src/NATS.Server/INatsClient.cs
Normal 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);
|
||||
}
|
||||
25
src/NATS.Server/Imports/ExportAuth.cs
Normal file
25
src/NATS.Server/Imports/ExportAuth.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/NATS.Server/Imports/ExportMap.cs
Normal file
8
src/NATS.Server/Imports/ExportMap.cs
Normal 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);
|
||||
}
|
||||
18
src/NATS.Server/Imports/ImportMap.cs
Normal file
18
src/NATS.Server/Imports/ImportMap.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
47
src/NATS.Server/Imports/LatencyTracker.cs
Normal file
47
src/NATS.Server/Imports/LatencyTracker.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
64
src/NATS.Server/Imports/ResponseRouter.cs
Normal file
64
src/NATS.Server/Imports/ResponseRouter.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/NATS.Server/Imports/ServiceExport.cs
Normal file
13
src/NATS.Server/Imports/ServiceExport.cs
Normal 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; }
|
||||
}
|
||||
21
src/NATS.Server/Imports/ServiceImport.cs
Normal file
21
src/NATS.Server/Imports/ServiceImport.cs
Normal 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; }
|
||||
}
|
||||
7
src/NATS.Server/Imports/ServiceLatency.cs
Normal file
7
src/NATS.Server/Imports/ServiceLatency.cs
Normal 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;
|
||||
}
|
||||
8
src/NATS.Server/Imports/ServiceResponseType.cs
Normal file
8
src/NATS.Server/Imports/ServiceResponseType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NATS.Server.Imports;
|
||||
|
||||
public enum ServiceResponseType
|
||||
{
|
||||
Singleton,
|
||||
Streamed,
|
||||
Chunked,
|
||||
}
|
||||
6
src/NATS.Server/Imports/StreamExport.cs
Normal file
6
src/NATS.Server/Imports/StreamExport.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace NATS.Server.Imports;
|
||||
|
||||
public sealed class StreamExport
|
||||
{
|
||||
public ExportAuth Auth { get; init; } = new();
|
||||
}
|
||||
14
src/NATS.Server/Imports/StreamImport.cs
Normal file
14
src/NATS.Server/Imports/StreamImport.cs
Normal 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; }
|
||||
}
|
||||
59
src/NATS.Server/InternalClient.cs
Normal file
59
src/NATS.Server/InternalClient.cs
Normal 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;
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -72,6 +72,21 @@ public sealed class NatsOptions
|
||||
// Profiling (0 = disabled)
|
||||
public int ProfPort { get; set; }
|
||||
|
||||
// Extended options for Go parity
|
||||
public string? ClientAdvertise { get; set; }
|
||||
public bool TraceVerbose { get; set; }
|
||||
public int MaxTracedMsgLen { get; set; }
|
||||
public bool DisableSublistCache { get; set; }
|
||||
public int ConnectErrorReports { get; set; } = 3600;
|
||||
public int ReconnectErrorReports { get; set; } = 1;
|
||||
public bool NoHeaderSupport { get; set; }
|
||||
public int MaxClosedClients { get; set; } = 10_000;
|
||||
public bool NoSystemAccount { get; set; }
|
||||
public string? SystemAccount { get; set; }
|
||||
|
||||
// Tracks which fields were set via CLI flags (for reload precedence)
|
||||
public HashSet<string> InCmdLine { get; } = [];
|
||||
|
||||
// TLS
|
||||
public string? TlsCert { get; set; }
|
||||
public string? TlsKey { get; set; }
|
||||
|
||||
@@ -8,6 +8,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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
76
tests/NATS.Server.Tests/ConfigIntegrationTests.cs
Normal file
76
tests/NATS.Server.Tests/ConfigIntegrationTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
504
tests/NATS.Server.Tests/ConfigProcessorTests.cs
Normal file
504
tests/NATS.Server.Tests/ConfigProcessorTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
89
tests/NATS.Server.Tests/ConfigReloadTests.cs
Normal file
89
tests/NATS.Server.Tests/ConfigReloadTests.cs
Normal 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)
|
||||
}
|
||||
}
|
||||
121
tests/NATS.Server.Tests/EventSystemTests.cs
Normal file
121
tests/NATS.Server.Tests/EventSystemTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
338
tests/NATS.Server.Tests/ImportExportTests.cs
Normal file
338
tests/NATS.Server.Tests/ImportExportTests.cs
Normal 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) { }
|
||||
}
|
||||
}
|
||||
85
tests/NATS.Server.Tests/InternalClientTests.cs
Normal file
85
tests/NATS.Server.Tests/InternalClientTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
221
tests/NATS.Server.Tests/NatsConfLexerTests.cs
Normal file
221
tests/NATS.Server.Tests/NatsConfLexerTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal file
184
tests/NATS.Server.Tests/NatsConfParserTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
149
tests/NATS.Server.Tests/ResponseRoutingTests.cs
Normal file
149
tests/NATS.Server.Tests/ResponseRoutingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
133
tests/NATS.Server.Tests/SystemEventsTests.cs
Normal file
133
tests/NATS.Server.Tests/SystemEventsTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
170
tests/NATS.Server.Tests/SystemRequestReplyTests.cs
Normal file
170
tests/NATS.Server.Tests/SystemRequestReplyTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
tests/NATS.Server.Tests/TestData/auth.conf
Normal file
11
tests/NATS.Server.Tests/TestData/auth.conf
Normal 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"
|
||||
19
tests/NATS.Server.Tests/TestData/basic.conf
Normal file
19
tests/NATS.Server.Tests/TestData/basic.conf
Normal 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
|
||||
57
tests/NATS.Server.Tests/TestData/full.conf
Normal file
57
tests/NATS.Server.Tests/TestData/full.conf
Normal 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
|
||||
}
|
||||
12
tests/NATS.Server.Tests/TestData/tls.conf
Normal file
12
tests/NATS.Server.Tests/TestData/tls.conf
Normal 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
|
||||
Reference in New Issue
Block a user