Compare commits
124 Commits
a6e9bd1467
...
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 | ||
|
|
4e9c415fd2 | ||
|
|
8a2ded8e48 | ||
|
|
6fcc9d1fd5 | ||
|
|
d5a0274fc9 | ||
|
|
5219f77f9b | ||
|
|
afbbccab82 | ||
|
|
39a1383de2 | ||
|
|
9f66ef72c6 | ||
|
|
d69308600a | ||
|
|
d0af741eb8 | ||
|
|
a406832bfa | ||
|
|
ae043136a1 | ||
|
|
4836f7851e | ||
|
|
46116400d2 | ||
|
|
67a3881c7c | ||
|
|
dac641c52c | ||
|
|
7c324843ff | ||
|
|
cd87a48343 | ||
|
|
f952e6afab | ||
|
|
f316e6e86e | ||
|
|
c8b347cb96 | ||
|
|
9fff5709c4 | ||
|
|
9f88b034eb | ||
|
|
30ae67f613 | ||
|
|
f533bf0945 | ||
|
|
fadbbf463c | ||
|
|
65fac32a14 | ||
|
|
cc5ce63cb9 | ||
|
|
56de543713 | ||
|
|
42c7c9cb7a | ||
|
|
8878301c7f | ||
|
|
e31ba04fdb | ||
|
|
dab8004d6b | ||
|
|
f0b5edd7c6 | ||
|
|
1806ae607e | ||
|
|
1f13269447 | ||
|
|
7a897c1087 | ||
|
|
e9b6c7fdd3 | ||
|
|
1269ae8275 | ||
|
|
0347e8a28c | ||
|
|
6afe11ad4d | ||
|
|
345e7ca15c | ||
|
|
cc0fe04f3c | ||
|
|
cf75077bc4 | ||
|
|
4ad821394b | ||
|
|
b7c0e321d9 | ||
|
|
0ec5583422 | ||
|
|
cd4ae3cce6 | ||
|
|
eb25d52ed5 | ||
|
|
dddced444e | ||
|
|
e87d4c00d9 | ||
|
|
7cf6bb866e | ||
|
|
17a0a217dd | ||
|
|
573cd06bb1 | ||
|
|
a0f02d6641 | ||
|
|
5b383ada4b | ||
|
|
060e1ee23d | ||
|
|
f4efbcf09e | ||
|
|
f86ea57f43 | ||
|
|
3941c85e76 | ||
|
|
2baf8a85bf | ||
|
|
f5c0c4f906 | ||
|
|
5323c8bb30 | ||
|
|
2fb14821e0 | ||
|
|
04305447f9 | ||
|
|
df39ebdc58 | ||
|
|
bce793fd42 | ||
|
|
e57605f090 | ||
|
|
c522ce99f5 | ||
|
|
34067f2b9b | ||
|
|
b289041761 | ||
|
|
45de110a84 | ||
|
|
b68f898fa0 | ||
|
|
31660a4187 | ||
|
|
600c6f9e5a | ||
|
|
086b4f50e8 | ||
|
|
38eaaa8b83 | ||
|
|
ad6a02b9a2 | ||
|
|
9ae75207fc | ||
|
|
61c6b832e5 | ||
|
|
d0aa6a5fdd | ||
|
|
1a916a3f36 | ||
|
|
8bbfa54058 | ||
|
|
149c852510 | ||
|
|
c2dc503e2e | ||
|
|
4d89661e79 | ||
|
|
3b6bd08248 | ||
|
|
19f35e6463 | ||
|
|
9eb108b1df | ||
|
|
87746168ba | ||
|
|
818bc0ba1f | ||
|
|
63198ef83b | ||
|
|
a52db677e2 | ||
|
|
0409acc745 | ||
|
|
f2badc3488 | ||
|
|
f6b38df291 | ||
|
|
045c12cce7 | ||
|
|
b2f7b1b2a0 | ||
|
|
a26c1359de | ||
|
|
1a777e09c9 | ||
|
|
ceaafc48d4 | ||
|
|
d08ce7f6fb |
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "slopwatch analyze -d . --hook",
|
||||
"timeout": 60000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
10
.slopwatch/config.json.example
Normal file
10
.slopwatch/config.json.example
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"suppressions": [
|
||||
{
|
||||
"ruleId": "SW002",
|
||||
"pattern": "**/Generated/**",
|
||||
"justification": "Generated code from protobuf/gRPC compiler - cannot be modified"
|
||||
}
|
||||
],
|
||||
"globalSuppressions": []
|
||||
}
|
||||
@@ -8,11 +8,16 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.SyslogMessages" Version="3.0.1" />
|
||||
|
||||
<!-- Authentication -->
|
||||
<PackageVersion Include="NATS.NKeys" Version="1.0.0-preview.3" />
|
||||
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
|
||||
<!-- Windows Service -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
|
||||
|
||||
<!-- Testing -->
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
|
||||
416
differences.md
Normal file
416
differences.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# Go vs .NET NATS Server: Functionality Differences
|
||||
|
||||
> Excludes clustering/routes, gateways, leaf nodes, and JetStream.
|
||||
> Generated 2026-02-22 by comparing `golang/nats-server/server/` against `src/NATS.Server/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Server Lifecycle
|
||||
|
||||
### Server Initialization
|
||||
| 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 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 |
|
||||
|
||||
### Accept Loop
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Exponential backoff on accept errors | Y | Y | .NET backs off from 10ms to 1s on repeated failures |
|
||||
| Config reload lock during client creation | Y | N | Go holds `reloadMu` around `createClient` |
|
||||
| Goroutine/task tracking (WaitGroup) | Y | Y | `Interlocked` counter + drain with 10s timeout on shutdown |
|
||||
| Callback-based error handling | Y | N | Go uses `errFunc` callback pattern |
|
||||
| Random/ephemeral port (port=0) | Y | Y | Port resolved after `Bind`+`Listen`, stored in `_options.Port` |
|
||||
|
||||
### Shutdown
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Graceful shutdown with `WaitForShutdown()` | Y | Y | Idempotent CAS-guarded `ShutdownAsync()` + blocking `WaitForShutdown()` |
|
||||
| Close reason tracking per connection | Y | Y | 37-value `ClosedState` enum, CAS-based first-writer-wins `MarkClosed()` |
|
||||
| Lame duck mode (stop new, drain existing) | Y | Y | `LameDuckShutdownAsync()` with grace period + stagger-close with jitter |
|
||||
| Wait for accept loop completion | Y | Y | `TaskCompletionSource` signaled in accept loop `finally` |
|
||||
| Flush pending data before close | Y | Y | `FlushAndCloseAsync()` with best-effort flush, skip-flush for error conditions |
|
||||
|
||||
### Signal Handling
|
||||
| Signal | Go | .NET | Notes |
|
||||
|--------|:--:|:----:|-------|
|
||||
| SIGINT (Ctrl+C) | Y | Y | Both handle graceful shutdown |
|
||||
| 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 | Y | Re-parses config, diffs options, applies reloadable subset; CLI flags preserved |
|
||||
| Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Client / Connection Handling
|
||||
|
||||
### Concurrency Model
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Separate read + write loops | Y | Y | Channel-based `RunWriteLoopAsync` with `QueueOutbound()` |
|
||||
| Write coalescing / batch flush | Y | Y | Write loop drains all channel items before single `FlushAsync` |
|
||||
| Dynamic buffer sizing (512B-64KB) | Y | N | .NET delegates to `System.IO.Pipelines` |
|
||||
| Output buffer pooling (3-tier) | Y | N | Go pools at 512B, 4KB, 64KB |
|
||||
|
||||
### Connection Types
|
||||
| Type | Go | .NET | Notes |
|
||||
|------|:--:|:----:|-------|
|
||||
| CLIENT | Y | Y | |
|
||||
| ROUTER | Y | N | Excluded per scope |
|
||||
| GATEWAY | Y | N | Excluded per scope |
|
||||
| LEAF | Y | N | Excluded per scope |
|
||||
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
|
||||
| JETSTREAM (internal) | Y | N | |
|
||||
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
|
||||
| WebSocket clients | Y | N | |
|
||||
| MQTT clients | Y | N | |
|
||||
|
||||
### Client Features
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Echo suppression (`echo: false`) | Y | Y | .NET checks echo in delivery path (NatsServer.cs:234,253) |
|
||||
| Verbose mode (`+OK` responses) | Y | Y | Sends `+OK` after CONNECT, SUB, UNSUB, PUB when `verbose:true` |
|
||||
| No-responders validation | Y | Y | CONNECT rejects `no_responders` without `headers`; 503 HMSG on no match |
|
||||
| Slow consumer detection | Y | Y | Pending bytes threshold (64MB) + write deadline timeout (10s) |
|
||||
| Write deadline / timeout policies | Y | Y | `WriteDeadline` option with `CancellationTokenSource.CancelAfter` on flush |
|
||||
| RTT measurement | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
|
||||
| Per-client trace mode | Y | Y | `SetTraceMode()` toggles parser logger dynamically via `ClientFlags.TraceMode` |
|
||||
| Detailed close reason tracking | Y | Y | 37-value `ClosedState` enum with CAS-based `MarkClosed()` |
|
||||
| Connection state flags (16 flags) | Y | Y | 7-flag `ClientFlagHolder` with `Interlocked.Or`/`And` |
|
||||
|
||||
### Slow Consumer Handling
|
||||
Go implements a sophisticated slow consumer detection system:
|
||||
- Tracks `pendingBytes` per client output buffer
|
||||
- If pending exceeds `maxPending`, enters stall mode (2-5ms waits)
|
||||
- Total stall capped at 10ms per read cycle
|
||||
- Closes with `SlowConsumerPendingBytes` or `SlowConsumerWriteDeadline`
|
||||
- Sets `isSlowConsumer` flag for monitoring
|
||||
|
||||
.NET now implements pending bytes tracking and write deadline enforcement via `Channel<ReadOnlyMemory<byte>>`. Key differences from Go: no stall/retry mode (immediate close on threshold exceeded), write deadline via `CancellationTokenSource.CancelAfter` instead of `SetWriteDeadline`. `IsSlowConsumer` flag and server-level `SlowConsumerCount` stats are tracked for monitoring.
|
||||
|
||||
### Stats Tracking
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Per-connection atomic stats | Y | Y | .NET uses `Interlocked` for stats access |
|
||||
| Per-read-cycle stat batching | Y | Y | Local accumulators flushed via `Interlocked.Add` per read cycle |
|
||||
| Per-account stats | Y | Y | `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes per `Account` |
|
||||
| Slow consumer counters | Y | Y | `SlowConsumers` and `SlowConsumerClients` incremented on detection |
|
||||
|
||||
---
|
||||
|
||||
## 3. Protocol Parsing
|
||||
|
||||
### Parser Architecture
|
||||
| Aspect | Go | .NET |
|
||||
|--------|-----|------|
|
||||
| Approach | Byte-by-byte state machine (74 states) | Two-phase: line extraction + command dispatch |
|
||||
| Case handling | Per-state character checks | Bit-mask lowercase normalization (`\| 0x20`) |
|
||||
| Buffer strategy | Jump-ahead optimization for payloads | Direct size-based reads via Pipe |
|
||||
| Split-buffer handling | argBuf accumulation with scratch buffer | State variables (`_awaitingPayload`, etc.) |
|
||||
| Error model | Inline error sending + error return | Exception-based (`ProtocolViolationException`) |
|
||||
| CRLF in payload | Included in message buffer | Excluded by design |
|
||||
|
||||
### Protocol Operations
|
||||
| Operation | Go | .NET | Notes |
|
||||
|-----------|:--:|:----:|-------|
|
||||
| PUB | Y | Y | |
|
||||
| HPUB (headers) | Y | Y | |
|
||||
| SUB | Y | Y | |
|
||||
| UNSUB | Y | Y | |
|
||||
| CONNECT | Y | Y | |
|
||||
| INFO | Y | Y | |
|
||||
| PING / PONG | Y | Y | |
|
||||
| MSG / HMSG | Y | Y | |
|
||||
| +OK / -ERR | Y | Y | |
|
||||
| RS+/RS-/RMSG (routes) | Y | N | Excluded per scope |
|
||||
| A+/A- (accounts) | Y | N | Excluded per scope |
|
||||
| LS+/LS-/LMSG (leaf) | Y | N | Excluded per scope |
|
||||
|
||||
### Protocol Parsing Gaps
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
|
||||
| Protocol tracing in parser | Y | Y | `TraceInOp()` logs `<<- OP arg` at `LogLevel.Trace` via optional `ILogger` |
|
||||
| Subject mapping (input→output) | Y | Y | Compiled `SubjectTransform` engine with 9 function tokens; wired into `ProcessMessage` |
|
||||
| MIME header parsing | Y | Y | `NatsHeaderParser.Parse()` — status line + key-value headers from `ReadOnlySpan<byte>` |
|
||||
| Message trace event initialization | Y | N | |
|
||||
|
||||
### Protocol Writing
|
||||
| Aspect | Go | .NET | Notes |
|
||||
|--------|:--:|:----:|-------|
|
||||
| INFO serialization | Once at startup | Once at startup | Cached at startup; nonce connections serialize per-connection |
|
||||
| MSG/HMSG construction | Direct buffer write | Span-based buffer write | `int.TryFormat` + `CopyTo` into rented buffer, no string allocations |
|
||||
| Pre-encoded constants | Y | Y | Both pre-encode PING/PONG/OK |
|
||||
|
||||
---
|
||||
|
||||
## 4. Subscriptions & Subject Matching
|
||||
|
||||
### Trie Implementation
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Basic trie with `*`/`>` wildcards | Y | Y | Core matching identical |
|
||||
| Queue group support | Y | Y | |
|
||||
| Result caching (1024 max) | Y | Y | Same limits |
|
||||
| `plist` optimization (>256 subs) | Y | N | Go converts high-fanout nodes to array |
|
||||
| Async cache sweep (background) | Y | N | .NET sweeps inline under write lock |
|
||||
| Atomic generation ID for invalidation | Y | Y | `Interlocked.Increment` on insert/remove; cached results store generation |
|
||||
| Cache eviction strategy | Random | First-N | Semantic difference minimal |
|
||||
|
||||
### SubList Features
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| `Stats()` — comprehensive statistics | Y | Y | Matches, cache hits, inserts, removes tracked via `Interlocked` |
|
||||
| `HasInterest()` — fast bool check | Y | Y | Walks trie without allocating result list |
|
||||
| `NumInterest()` — fast count | Y | Y | Counts plain + queue subs without allocation |
|
||||
| `ReverseMatch()` — pattern→literal query | Y | Y | Finds subscriptions whose wildcards match a literal subject |
|
||||
| `RemoveBatch()` — efficient bulk removal | Y | Y | Single generation increment for batch; increments `_removes` per sub |
|
||||
| `All()` — enumerate all subscriptions | Y | Y | Recursive trie walk returning all subscriptions |
|
||||
| Notification system (interest changes) | Y | N | |
|
||||
| Local/remote subscription filtering | Y | N | |
|
||||
| Queue weight expansion (remote subs) | Y | N | |
|
||||
| `MatchBytes()` — zero-copy byte API | Y | N | |
|
||||
|
||||
### Subject Validation
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Basic validation (empty tokens, wildcards) | Y | Y | |
|
||||
| Literal subject check | Y | Y | |
|
||||
| UTF-8/null rune validation | Y | Y | `IsValidSubject(string, bool checkRunes)` rejects null bytes |
|
||||
| Collision detection (`SubjectsCollide`) | Y | Y | Token-by-token wildcard comparison; O(n) via upfront `Split` |
|
||||
| Token utilities (`tokenAt`, `numTokens`) | Y | Y | `TokenAt` returns `ReadOnlySpan<char>`; `NumTokens` counts separators |
|
||||
| Stack-allocated token buffer | Y | N | Go uses `[32]string{}` on stack |
|
||||
|
||||
### Subscription Lifecycle
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Per-account subscription limit | Y | Y | `Account.IncrementSubscriptions()` returns false when `MaxSubscriptions` exceeded |
|
||||
| Auto-unsubscribe on max messages | Y | Y | Enforced at delivery; sub removed from trie + client dict when exhausted |
|
||||
| Subscription routing propagation | Y | N | For clusters |
|
||||
| Queue weight (`qw`) field | Y | N | For remote queue load balancing |
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication & Authorization
|
||||
|
||||
### Auth Mechanisms
|
||||
| Mechanism | Go | .NET | Notes |
|
||||
|-----------|:--:|:----:|-------|
|
||||
| Username/password | Y | Y | |
|
||||
| Token | Y | Y | |
|
||||
| NKeys (Ed25519) | Y | Y | .NET has framework but integration is basic |
|
||||
| JWT validation | Y | Y | `NatsJwt` decode/verify, `JwtAuthenticator` with account resolution + revocation |
|
||||
| Bcrypt password hashing | Y | Y | .NET supports bcrypt (`$2*` prefix) with constant-time fallback |
|
||||
| TLS certificate mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
|
||||
| Custom auth interface | Y | N | |
|
||||
| External auth callout | Y | N | |
|
||||
| Proxy authentication | Y | N | |
|
||||
| Bearer tokens | Y | Y | `UserClaims.BearerToken` skips nonce signature verification |
|
||||
| User revocation tracking | Y | Y | Per-account `ConcurrentDictionary` with wildcard (`*`) revocation support |
|
||||
|
||||
### Account System
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| 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 | 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 |
|
||||
|
||||
### Permissions
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Publish allow list | Y | Y | |
|
||||
| Subscribe allow list | Y | Y | |
|
||||
| Publish deny list | Y | Y | Full enforcement with LRU-cached results |
|
||||
| Subscribe deny list | Y | Y | Queue-aware deny checking in `IsSubscribeAllowed` |
|
||||
| Message-level deny filtering | Y | Y | `IsDeliveryAllowed()` checked before MSG send; auto-unsub cleanup on deny |
|
||||
| Permission caching (128 entries) | Y | Y | `PermissionLruCache` — Dictionary+LinkedList LRU, matching Go's `maxPermCacheSize` |
|
||||
| Response permissions (reply tracking) | Y | Y | `ResponseTracker` with configurable TTL + max messages; not LRU-cached |
|
||||
| Auth expiry enforcement | Y | Y | `Task.Delay` timer closes client when JWT/auth expires |
|
||||
| Permission templates (JWT) | Y | Y | `PermissionTemplates.Expand()` — 6 functions with cartesian product for multi-value tags |
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration
|
||||
|
||||
### CLI Flags
|
||||
| Flag | Go | .NET | Notes |
|
||||
|------|:--:|:----:|-------|
|
||||
| `-p/--port` | Y | Y | |
|
||||
| `-a/--addr` | Y | Y | |
|
||||
| `-n/--name` (ServerName) | Y | Y | |
|
||||
| `-m/--http_port` (monitoring) | Y | Y | |
|
||||
| `-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 | |
|
||||
| `--http_base_path` | Y | Y | |
|
||||
| `--https_port` | Y | Y | |
|
||||
|
||||
### Configuration System
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| 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
|
||||
- ~~Advanced limits (MaxSubs, MaxSubTokens, MaxPending, WriteDeadline)~~ — `MaxSubs`, `MaxSubTokens` implemented; MaxPending/WriteDeadline already existed
|
||||
- ~~Tags/metadata~~ — `Tags` dictionary implemented in `NatsOptions`
|
||||
- ~~OCSP configuration~~ — `OcspConfig` with 4 modes (Auto/Always/Must/Never), peer verification, and stapling
|
||||
- WebSocket/MQTT options
|
||||
- ~~Operator mode / account resolver~~ — `JwtAuthenticator` + `IAccountResolver` + `MemAccountResolver` with trusted keys
|
||||
|
||||
---
|
||||
|
||||
## 7. Monitoring
|
||||
|
||||
### HTTP Endpoints
|
||||
| Endpoint | Go | .NET | Notes |
|
||||
|----------|:--:|:----:|-------|
|
||||
| `/healthz` | Y | Y | |
|
||||
| `/varz` | Y | Y | |
|
||||
| `/connz` | Y | Y | |
|
||||
| `/` (root listing) | Y | Y | |
|
||||
| `/routez` | Y | Stub | Returns empty response |
|
||||
| `/gatewayz` | Y | Stub | Returns empty response |
|
||||
| `/leafz` | Y | Stub | Returns empty response |
|
||||
| `/subz` / `/subscriptionsz` | Y | Y | Account filtering, test subject filtering, pagination, and subscription details |
|
||||
| `/accountz` | Y | Stub | Returns empty response |
|
||||
| `/accstatz` | Y | Stub | Returns empty response |
|
||||
| `/jsz` | Y | Stub | Returns empty response |
|
||||
|
||||
### Varz Response
|
||||
| Field Category | Go | .NET | Notes |
|
||||
|----------------|:--:|:----:|-------|
|
||||
| Identity (ID, Name, Version) | Y | Y | |
|
||||
| Network (Host, Port, URLs) | Y | Y | |
|
||||
| Security (AuthRequired, TLS) | Y | Y | |
|
||||
| Limits (MaxConn, MaxPayload) | Y | Y | |
|
||||
| Timing (Start, Now, Uptime) | Y | Y | |
|
||||
| Runtime (Mem, CPU, Cores) | Y | Y | |
|
||||
| Connections (current, total) | Y | Y | |
|
||||
| Messages (in/out msgs/bytes) | Y | Y | |
|
||||
| SlowConsumer breakdown | Y | N | Go tracks per connection type |
|
||||
| Cluster/Gateway/Leaf blocks | Y | N | Excluded per scope |
|
||||
| JetStream block | Y | N | Excluded per scope |
|
||||
| TLS cert expiry info | Y | Y | `TlsCertNotAfter` loaded via `X509CertificateLoader` in `/varz` |
|
||||
|
||||
### Connz Response
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Filtering by CID, user, account | Y | Partial | |
|
||||
| Sorting (11 options) | Y | Y | All options including ByStop, ByReason, ByRtt |
|
||||
| State filtering (open/closed/all) | Y | Y | `state=open|closed|all` query parameter |
|
||||
| Closed connection tracking | Y | Y | `ConcurrentQueue<ClosedClient>` capped at 10,000 entries |
|
||||
| Pagination (offset, limit) | Y | Y | |
|
||||
| Subscription detail mode | Y | N | |
|
||||
| TLS peer certificate info | Y | N | |
|
||||
| JWT/IssuerKey/Tags fields | Y | N | |
|
||||
| MQTT client ID filtering | Y | N | |
|
||||
| Proxy info | Y | N | |
|
||||
|
||||
---
|
||||
|
||||
## 8. TLS
|
||||
|
||||
### TLS Modes
|
||||
| Mode | Go | .NET | Notes |
|
||||
|------|:--:|:----:|-------|
|
||||
| No TLS | Y | Y | |
|
||||
| INFO-first (default NATS) | Y | Y | |
|
||||
| TLS-first (before INFO) | Y | Y | |
|
||||
| Mixed/Fallback | Y | Y | |
|
||||
| TLS-required | Y | Y | |
|
||||
|
||||
### TLS Features
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| PEM cert/key loading | Y | Y | |
|
||||
| CA chain validation | Y | Y | |
|
||||
| Mutual TLS (client certs) | Y | Y | |
|
||||
| Certificate pinning (SHA256 SPKI) | Y | Y | |
|
||||
| TLS handshake timeout | Y | Y | |
|
||||
| TLS rate limiting | Y | Y | Rate enforcement with refill; unit tests cover rate limiting and refill |
|
||||
| First-byte peeking (0x16 detection) | Y | Y | |
|
||||
| Cert subject→user mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
|
||||
| OCSP stapling | Y | Y | `SslStreamCertificateContext.Create` with `offline:false` for runtime OCSP fetch |
|
||||
| Min TLS version control | Y | Y | |
|
||||
|
||||
---
|
||||
|
||||
## 9. Logging
|
||||
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Structured logging | Partial | Y | .NET uses Serilog with ILogger<T> |
|
||||
| File logging with rotation | Y | Y | `-l`/`--log_file` flag + `LogSizeLimit`/`LogMaxFiles` via Serilog.Sinks.File |
|
||||
| Syslog (local and remote) | Y | Y | `--syslog` and `--remote_syslog` flags via Serilog.Sinks.SyslogMessages |
|
||||
| Log reopening (SIGUSR1) | Y | Y | SIGUSR1 handler calls ReOpenLogFile callback |
|
||||
| Trace mode (protocol-level) | Y | Y | `-V`/`-T`/`--trace` flags; parser `TraceInOp()` logs at Trace level |
|
||||
| Debug mode | Y | Y | `-D`/`--debug` flag lowers Serilog minimum to Debug |
|
||||
| Per-subsystem log control | Y | Y | `--log_level_override ns=level` CLI flag with Serilog `MinimumLevel.Override` |
|
||||
| Color output on TTY | Y | Y | Auto-detected via `Console.IsOutputRedirected`, uses `AnsiConsoleTheme.Code` |
|
||||
| Timestamp format control | Y | Y | `--logtime` and `--logtime_utc` flags |
|
||||
|
||||
---
|
||||
|
||||
## 10. Ping/Pong & Keepalive
|
||||
|
||||
| Feature | Go | .NET | Notes |
|
||||
|---------|:--:|:----:|-------|
|
||||
| Server-initiated PING | Y | Y | |
|
||||
| Configurable interval | Y | Y | PingInterval option |
|
||||
| Max pings out | Y | Y | MaxPingsOut option |
|
||||
| Stale connection close | Y | Y | |
|
||||
| RTT-based first PING delay | Y | Y | Skips PING until FirstPongSent or 2s elapsed |
|
||||
| RTT tracking | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
|
||||
| Stale connection stats | Y | Y | `StaleConnectionStats` model, exposed in `/varz` |
|
||||
|
||||
---
|
||||
|
||||
## Summary: Critical Gaps for Production Use
|
||||
|
||||
### Resolved Since Initial Audit
|
||||
The following items from the original gap list have been implemented:
|
||||
- **Slow consumer detection** — pending bytes threshold (64MB) with write deadline enforcement
|
||||
- **Write coalescing / batch flush** — channel-based write loop drains all items before single flush
|
||||
- **Verbose mode** — `+OK` responses for CONNECT, SUB, UNSUB, PUB when `verbose:true`
|
||||
- **Permission deny enforcement at delivery** — `IsDeliveryAllowed` + auto-unsub cleanup
|
||||
- **No-responders validation** — CONNECT rejects `no_responders` without `headers`; 503 HMSG on no match
|
||||
- **File logging with rotation** — Serilog.Sinks.File with rolling file support
|
||||
- **TLS certificate mapping** — X500DistinguishedName with full DN match and CN fallback
|
||||
- **Protocol tracing** — `-V`/`-T` flag enables trace-level logging; `-D` for debug
|
||||
- **Subscription statistics** — `Stats()`, `HasInterest()`, `NumInterest()`, etc.
|
||||
- **Per-account limits** — connection + subscription limits via `AccountConfig`
|
||||
- **Reply subject tracking** — `ResponseTracker` with TTL + max messages
|
||||
- **JWT authentication** — `JwtAuthenticator` with decode/verify, account resolution, revocation, permission templates
|
||||
- **OCSP support** — peer verification via `X509RevocationMode.Online`, stapling via `SslStreamCertificateContext`
|
||||
- **Subject mapping** — compiled `SubjectTransform` engine with 9 function tokens, wired into message delivery
|
||||
- **Windows Service integration** — `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices`
|
||||
- **Per-subsystem log control** — `--log_level_override` CLI flag with Serilog overrides
|
||||
- **Per-client trace mode** — `SetTraceMode()` with dynamic parser logger toggling
|
||||
- **Per-account stats** — `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes
|
||||
- **TLS cert expiry in /varz** — `TlsCertNotAfter` populated via `X509CertificateLoader`
|
||||
- **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
|
||||
- **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
|
||||
1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections
|
||||
139
docs/plans/2026-02-22-core-lifecycle-design.md
Normal file
139
docs/plans/2026-02-22-core-lifecycle-design.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Core Server Lifecycle — Design
|
||||
|
||||
Implements all gaps from section 1 of `differences.md` (Core Server Lifecycle).
|
||||
|
||||
Reference: `golang/nats-server/server/server.go`, `client.go`, `signal.go`
|
||||
|
||||
## Components
|
||||
|
||||
### 1. ClosedState Enum & Close Reason Tracking
|
||||
|
||||
New file `src/NATS.Server/ClosedState.cs` — full Go enum (37 values from `client.go:188-228`).
|
||||
|
||||
- `NatsClient` gets `CloseReason` property, `MarkClosed(ClosedState)` method
|
||||
- Close reason set in `RunAsync` finally blocks based on exception type
|
||||
- Error-related reasons (ReadError, WriteError, TLSHandshakeError) skip flush on close
|
||||
- `NatsServer.RemoveClient` logs close reason via structured logging
|
||||
|
||||
### 2. Accept Loop Exponential Backoff
|
||||
|
||||
Port Go's `acceptError` pattern from `server.go:4607-4627`.
|
||||
|
||||
- Constants: `AcceptMinSleep = 10ms`, `AcceptMaxSleep = 1s`
|
||||
- On `SocketException`: sleep `tmpDelay`, double it, cap at 1s
|
||||
- On success: reset to 10ms
|
||||
- During sleep: check `_quitCts` to abort if shutting down
|
||||
- Non-temporary errors break the loop
|
||||
|
||||
### 3. Ephemeral Port (port=0)
|
||||
|
||||
After `_listener.Bind()` + `Listen()`, resolve actual port:
|
||||
|
||||
```csharp
|
||||
if (_options.Port == 0)
|
||||
{
|
||||
var actualPort = ((IPEndPoint)_listener.LocalEndPoint!).Port;
|
||||
_options.Port = actualPort;
|
||||
_serverInfo.Port = actualPort;
|
||||
}
|
||||
```
|
||||
|
||||
Add public `Port` property on `NatsServer` exposing the resolved port.
|
||||
|
||||
### 4. Graceful Shutdown with WaitForShutdown
|
||||
|
||||
New fields on `NatsServer`:
|
||||
- `_shutdown` (volatile bool)
|
||||
- `_shutdownComplete` (TaskCompletionSource)
|
||||
- `_quitCts` (CancellationTokenSource) — internal shutdown signal
|
||||
|
||||
`ShutdownAsync()` sequence:
|
||||
1. Guard: if already shutting down, return
|
||||
2. Set `_shutdown = true`, cancel `_quitCts`
|
||||
3. Close `_listener` (stops accept loop)
|
||||
4. Close all client connections with `ServerShutdown` reason
|
||||
5. Wait for active client tasks to drain
|
||||
6. Stop monitor server
|
||||
7. Signal `_shutdownComplete`
|
||||
|
||||
`WaitForShutdown()`: blocks on `_shutdownComplete.Task`.
|
||||
|
||||
`Dispose()`: calls `ShutdownAsync` synchronously if not already shut down.
|
||||
|
||||
### 5. Task Tracking
|
||||
|
||||
Track active client tasks for clean shutdown:
|
||||
- `_activeClientCount` (int, Interlocked)
|
||||
- `_allClientsExited` (TaskCompletionSource, signaled when count hits 0 during shutdown)
|
||||
- Increment in `AcceptClientAsync`, decrement in `RunClientAsync` finally block
|
||||
- `ShutdownAsync` waits on `_allClientsExited` with timeout
|
||||
|
||||
### 6. Flush Pending Data Before Close
|
||||
|
||||
`NatsClient.FlushAndCloseAsync(bool minimalFlush)`:
|
||||
- If not skip-flush reason: flush stream with 100ms write deadline
|
||||
- Close socket
|
||||
|
||||
`MarkClosed(ClosedState)` sets skip-flush flag for: ReadError, WriteError, SlowConsumerPendingBytes, SlowConsumerWriteDeadline, TLSHandshakeError.
|
||||
|
||||
### 7. Lame Duck Mode
|
||||
|
||||
New options: `LameDuckDuration` (default 2min), `LameDuckGracePeriod` (default 10s).
|
||||
|
||||
`LameDuckShutdownAsync()`:
|
||||
1. Set `_lameDuckMode = true`
|
||||
2. Close listener (stop new connections)
|
||||
3. Wait `LameDuckGracePeriod` (10s default) for clients to drain naturally
|
||||
4. Stagger-close remaining clients over `LameDuckDuration - GracePeriod`
|
||||
- Sleep interval = remaining duration / client count (min 1ms, max 1s)
|
||||
- Randomize slightly to avoid reconnect storms
|
||||
5. Call `ShutdownAsync()` for final cleanup
|
||||
|
||||
Accept loop: on error, if `_lameDuckMode`, exit cleanly.
|
||||
|
||||
### 8. PID File & Ports File
|
||||
|
||||
New options: `PidFile` (string?), `PortsFileDir` (string?).
|
||||
|
||||
PID file: `File.WriteAllText(pidFile, Process.GetCurrentProcess().Id.ToString())`
|
||||
Ports file: JSON with `{ "client": port, "monitor": monitorPort }` written to `{dir}/{exe}_{pid}.ports`
|
||||
|
||||
Written at startup, deleted at shutdown.
|
||||
|
||||
### 9. Signal Handling
|
||||
|
||||
In `Program.cs`, use `PosixSignalRegistration` (.NET 6+):
|
||||
|
||||
- `SIGTERM` → `server.ShutdownAsync()` then exit
|
||||
- `SIGUSR2` → `server.LameDuckShutdownAsync()`
|
||||
- `SIGUSR1` → log "log reopen not yet supported"
|
||||
- `SIGHUP` → log "config reload not yet supported"
|
||||
|
||||
Keep existing Ctrl+C handler (SIGINT).
|
||||
|
||||
### 10. Server Identity NKey (Stub)
|
||||
|
||||
Generate Ed25519 key pair at construction. Store as `ServerNKey` (public) and `_serverSeed` (private). Not used in protocol yet — placeholder for future cluster identity.
|
||||
|
||||
### 11. System Account (Stub)
|
||||
|
||||
Create `$SYS` account in `_accounts` at construction. Expose as `SystemAccount` property. No internal subscriptions yet.
|
||||
|
||||
### 12. Config File & Profiling (Stubs)
|
||||
|
||||
- `NatsOptions.ConfigFile` — if set, log warning "config file parsing not yet supported"
|
||||
- `NatsOptions.ProfPort` — if set, log warning "profiling endpoint not yet supported"
|
||||
- `Program.cs`: add `-c` CLI flag
|
||||
|
||||
## Testing
|
||||
|
||||
- Accept loop backoff: mock socket that throws N times, verify delays
|
||||
- Ephemeral port: start server with port=0, verify resolved port > 0
|
||||
- Graceful shutdown: start server, connect clients, call ShutdownAsync, verify all disconnected
|
||||
- WaitForShutdown: verify it blocks until shutdown completes
|
||||
- Close reason tracking: verify correct ClosedState for auth timeout, max connections, stale connection
|
||||
- Lame duck mode: start server, connect clients, trigger lame duck, verify staggered closure
|
||||
- PID file: start server with PidFile option, verify file contents, verify deleted on shutdown
|
||||
- Ports file: start server with PortsFileDir, verify JSON contents
|
||||
- Flush before close: verify data is flushed before socket close during shutdown
|
||||
- System account: verify $SYS account exists after construction
|
||||
1631
docs/plans/2026-02-22-core-lifecycle-plan.md
Normal file
1631
docs/plans/2026-02-22-core-lifecycle-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
18
docs/plans/2026-02-22-core-lifecycle-plan.md.tasks.json
Normal file
18
docs/plans/2026-02-22-core-lifecycle-plan.md.tasks.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-22-core-lifecycle-plan.md",
|
||||
"tasks": [
|
||||
{"id": 5, "subject": "Task 0: Create ClosedState enum", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 1: Add close reason tracking to NatsClient", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 2: Add NatsOptions for lifecycle features", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 3: Ephemeral port support", "status": "pending"},
|
||||
{"id": 9, "subject": "Task 4: Graceful shutdown infrastructure", "status": "pending", "blockedBy": [5, 6, 7, 8]},
|
||||
{"id": 10, "subject": "Task 5: Flush pending data before close", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 6: Lame duck mode", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 12, "subject": "Task 7: PID file and ports file", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 13, "subject": "Task 8: System account and NKey identity stubs", "status": "pending"},
|
||||
{"id": 14, "subject": "Task 9: Signal handling and CLI stubs", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 15, "subject": "Task 10: Update differences.md", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 11: Final verification", "status": "pending", "blockedBy": [15]}
|
||||
],
|
||||
"lastUpdated": "2026-02-22T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
# Section 2: Client/Connection Handling — Design
|
||||
|
||||
> Implements all in-scope gaps from differences.md Section 2.
|
||||
|
||||
## Scope
|
||||
|
||||
8 features, all single-server client-facing (no clustering/routes/gateways/leaf):
|
||||
|
||||
1. Close reason tracking (ClosedState enum)
|
||||
2. Connection state flags (bitfield replacing `_connectReceived`)
|
||||
3. Channel-based write loop with batch flush
|
||||
4. Slow consumer detection (pending bytes + write deadline)
|
||||
5. Write deadline / timeout
|
||||
6. Verbose mode (`+OK` responses)
|
||||
7. No-responders validation and notification
|
||||
8. Per-read-cycle stat batching
|
||||
|
||||
## A. Close Reasons
|
||||
|
||||
New `ClientClosedReason` enum with 16 values scoped to single-server:
|
||||
|
||||
```
|
||||
ClientClosed, AuthenticationTimeout, AuthenticationViolation, TLSHandshakeError,
|
||||
SlowConsumerPendingBytes, SlowConsumerWriteDeadline, WriteError, ReadError,
|
||||
ParseError, StaleConnection, ProtocolViolation, MaxPayloadExceeded,
|
||||
MaxSubscriptionsExceeded, ServerShutdown, MsgHeaderViolation, NoRespondersRequiresHeaders
|
||||
```
|
||||
|
||||
Go has 37 values; excluded: route/gateway/leaf/JWT/operator-mode values.
|
||||
|
||||
Per-client `CloseReason` property set before closing. Available in monitoring (`/connz`).
|
||||
|
||||
## B. Connection State Flags
|
||||
|
||||
`ClientFlags` bitfield enum backed by `int`, manipulated via `Interlocked.Or`/`Interlocked.And`:
|
||||
|
||||
```
|
||||
ConnectReceived = 1,
|
||||
FirstPongSent = 2,
|
||||
HandshakeComplete = 4,
|
||||
CloseConnection = 8,
|
||||
WriteLoopStarted = 16,
|
||||
IsSlowConsumer = 32,
|
||||
ConnectProcessFinished = 64
|
||||
```
|
||||
|
||||
Replaces current `_connectReceived` (int with Volatile.Read/Write).
|
||||
|
||||
Helper methods: `SetFlag(flag)`, `ClearFlag(flag)`, `HasFlag(flag)`.
|
||||
|
||||
## C. Channel-based Write Loop
|
||||
|
||||
### Architecture
|
||||
|
||||
Replace inline `_writeLock` + direct stream writes:
|
||||
|
||||
```
|
||||
Producer threads → QueueOutbound(bytes) → Channel<ReadOnlyMemory<byte>> → WriteLoop → Stream
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
- `Channel<ReadOnlyMemory<byte>>` — bounded (capacity derived from MaxPending / avg message size, or 8192 items)
|
||||
- `_pendingBytes` (long) — tracks queued but unflushed bytes via `Interlocked.Add`
|
||||
- `RunWriteLoopAsync` — background task: `WaitToReadAsync` → drain all via `TryRead` → single `FlushAsync`
|
||||
- `QueueOutbound(ReadOnlyMemory<byte>)` — enqueue, update pending bytes, check slow consumer
|
||||
|
||||
### Coalescing
|
||||
|
||||
The write loop drains all available items from the channel before flushing:
|
||||
|
||||
```
|
||||
while (await reader.WaitToReadAsync(ct))
|
||||
{
|
||||
while (reader.TryRead(out var data))
|
||||
await stream.WriteAsync(data, ct); // buffered writes, no flush yet
|
||||
await stream.FlushAsync(ct); // single flush after batch
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
All existing write paths refactored:
|
||||
- `SendMessageAsync` → serialize MSG/HMSG to byte array → `QueueOutbound`
|
||||
- `WriteAsync` → serialize protocol message → `QueueOutbound`
|
||||
- Remove `_writeLock` SemaphoreSlim
|
||||
|
||||
## D. Slow Consumer Detection
|
||||
|
||||
### Pending Bytes (Hard Limit)
|
||||
|
||||
In `QueueOutbound`, before writing to channel:
|
||||
|
||||
```
|
||||
if (_pendingBytes + data.Length > _maxPending)
|
||||
{
|
||||
SetFlag(IsSlowConsumer);
|
||||
CloseWithReason(SlowConsumerPendingBytes);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- `MaxPending` default: 64MB (matching Go's `MAX_PENDING_SIZE`)
|
||||
- New option in `NatsOptions`
|
||||
|
||||
### Write Deadline (Timeout)
|
||||
|
||||
In write loop flush:
|
||||
|
||||
```
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_writeDeadline);
|
||||
await stream.FlushAsync(cts.Token);
|
||||
```
|
||||
|
||||
On timeout → close with `SlowConsumerWriteDeadline`.
|
||||
|
||||
- `WriteDeadline` default: 10 seconds
|
||||
- New option in `NatsOptions`
|
||||
|
||||
### Monitoring
|
||||
|
||||
- `IsSlowConsumer` flag readable for `/connz`
|
||||
- Server-level `SlowConsumerCount` stat incremented
|
||||
|
||||
## E. Verbose Mode
|
||||
|
||||
After successful command processing (CONNECT, SUB, UNSUB, PUB), check `ClientOpts?.Verbose`:
|
||||
|
||||
```
|
||||
if (ClientOpts?.Verbose == true)
|
||||
QueueOutbound(OkBytes);
|
||||
```
|
||||
|
||||
`OkBytes` = pre-encoded `+OK\r\n` static byte array in `NatsProtocol`.
|
||||
|
||||
## F. No-Responders
|
||||
|
||||
### CONNECT Validation
|
||||
|
||||
```
|
||||
if (clientOpts.NoResponders && !clientOpts.Headers)
|
||||
{
|
||||
CloseWithReason(NoRespondersRequiresHeaders);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
### Publish-time Notification
|
||||
|
||||
In `NatsServer` message delivery, after `Match()` returns zero subscribers:
|
||||
|
||||
```
|
||||
if (!delivered && reply.Length > 0 && publisher.ClientOpts?.NoResponders == true)
|
||||
{
|
||||
// Send HMSG with NATS/1.0 503 status back to publisher
|
||||
var header = $"NATS/1.0 503\r\nNats-Subject: {subject}\r\n\r\n";
|
||||
publisher.SendNoRespondersAsync(reply, sid, header);
|
||||
}
|
||||
```
|
||||
|
||||
## G. Stat Batching
|
||||
|
||||
In read loop, accumulate locally:
|
||||
|
||||
```
|
||||
long localInMsgs = 0, localInBytes = 0;
|
||||
// ... per message: localInMsgs++; localInBytes += size;
|
||||
// End of read cycle:
|
||||
Interlocked.Add(ref _inMsgs, localInMsgs);
|
||||
Interlocked.Add(ref _inBytes, localInBytes);
|
||||
// Same for server stats
|
||||
```
|
||||
|
||||
Reduces atomic operations from per-message to per-read-cycle.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change | Size |
|
||||
|------|--------|------|
|
||||
| `ClientClosedReason.cs` | New | Small |
|
||||
| `ClientFlags.cs` | New | Small |
|
||||
| `NatsClient.cs` | Major rewrite of write path | Large |
|
||||
| `NatsServer.cs` | No-responders, close reason | Medium |
|
||||
| `NatsOptions.cs` | MaxPending, WriteDeadline | Small |
|
||||
| `NatsProtocol.cs` | +OK bytes, NoResponders | Small |
|
||||
| `ClientTests.cs` | Verbose, close reasons, flags | Medium |
|
||||
| `ServerTests.cs` | No-responders, slow consumer | Medium |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- **Verbose mode**: Connect with `verbose:true`, send SUB/PUB, verify `+OK` responses
|
||||
- **Close reasons**: Trigger each close path, verify reason is set
|
||||
- **State flags**: Set/clear/check flags concurrently
|
||||
- **Slow consumer (pending bytes)**: Queue more than MaxPending, verify close
|
||||
- **Slow consumer (write deadline)**: Use a slow/blocked stream, verify timeout close
|
||||
- **No-responders**: Publish to empty subject with reply, verify 503 HMSG
|
||||
- **Write coalescing**: Send multiple messages rapidly, verify batched flush
|
||||
- **Stat batching**: Send N messages, verify stats match after read cycle
|
||||
1541
docs/plans/2026-02-22-section2-client-connection-handling-plan.md
Normal file
1541
docs/plans/2026-02-22-section2-client-connection-handling-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-22-section2-client-connection-handling-plan.md",
|
||||
"tasks": [
|
||||
{"id": 4, "subject": "Task 1: Add ClientClosedReason enum", "status": "pending"},
|
||||
{"id": 5, "subject": "Task 2: Add ClientFlags bitfield", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 3: Add MaxPending and WriteDeadline to NatsOptions", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 4: Integrate ClientFlags into NatsClient", "status": "pending", "blockedBy": [4, 5, 6]},
|
||||
{"id": 8, "subject": "Task 5: Implement channel-based write loop", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 6: Write tests for write loop and slow consumer", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 10, "subject": "Task 7: Update NatsServer for SendMessage + no-responders", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 11, "subject": "Task 8: Implement verbose mode", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 9: Implement no-responders CONNECT validation", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 13, "subject": "Task 10: Implement stat batching in read loop", "status": "pending", "blockedBy": [8]},
|
||||
{"id": 14, "subject": "Task 11: Update ConnzHandler for close reason + pending bytes", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 15, "subject": "Task 12: Fix existing tests for new write model", "status": "pending", "blockedBy": [13]},
|
||||
{"id": 16, "subject": "Task 13: Final verification and differences.md update", "status": "pending", "blockedBy": [14, 15]}
|
||||
],
|
||||
"lastUpdated": "2026-02-22T00:00:00Z"
|
||||
}
|
||||
184
docs/plans/2026-02-23-config-parsing-design.md
Normal file
184
docs/plans/2026-02-23-config-parsing-design.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Config File Parsing & Hot Reload Design
|
||||
|
||||
> Resolves the two remaining high-priority gaps in differences.md.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Port the Go NATS config file parser (`conf/lex.go` + `conf/parse.go`) to .NET
|
||||
2. Map parsed config to existing + new `NatsOptions` fields (single-server scope)
|
||||
3. Implement SIGHUP hot reload matching Go's reloadable option set
|
||||
4. Add unit tests for lexer, parser, config processor, and reload
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Config File
|
||||
→ NatsConfLexer (state-machine tokenizer)
|
||||
→ NatsConfParser (builds Dictionary<string, object?>)
|
||||
→ ConfigProcessor.Apply(dict, NatsOptions)
|
||||
→ NatsOptions populated
|
||||
|
||||
SIGHUP
|
||||
→ NatsServer.ReloadConfig()
|
||||
→ Re-parse config file
|
||||
→ Merge with CLI flag snapshot
|
||||
→ ConfigReloader.Diff(old, new) → IConfigChange[]
|
||||
→ Validate (reject non-reloadable)
|
||||
→ Apply each change to running server
|
||||
```
|
||||
|
||||
## Component 1: Lexer (`NatsConfLexer.cs`)
|
||||
|
||||
Direct port of Go `conf/lex.go` (~1320 lines Go → ~400 lines C#).
|
||||
|
||||
State-machine tokenizer producing typed tokens:
|
||||
- `Key`, `String`, `Bool`, `Integer`, `Float`, `DateTime`
|
||||
- `ArrayStart`/`ArrayEnd`, `MapStart`/`MapEnd`
|
||||
- `Variable`, `Include`, `Comment`, `EOF`, `Error`
|
||||
|
||||
Supported syntax:
|
||||
- Key separators: `=`, `:`, or whitespace
|
||||
- Comments: `#` and `//`
|
||||
- Strings: `"double"`, `'single'`, raw (unquoted)
|
||||
- Booleans: `true/false`, `yes/no`, `on/off`
|
||||
- Integers with size suffixes: `1k`, `2mb`, `1gb`
|
||||
- Floats, ISO8601 datetimes (`2006-01-02T15:04:05Z`)
|
||||
- Block strings: `(` multi-line raw text `)`
|
||||
- Hex escapes: `\x##`, plus `\t`, `\n`, `\r`, `\"`, `\\`
|
||||
|
||||
## Component 2: Parser (`NatsConfParser.cs`)
|
||||
|
||||
Direct port of Go `conf/parse.go` (~529 lines Go → ~300 lines C#).
|
||||
|
||||
Consumes token stream, produces `Dictionary<string, object?>`:
|
||||
- Stack-based context tracking for nested maps/arrays
|
||||
- Variable resolution: `$VAR` searches current context stack, then environment
|
||||
- Cycle detection for variable references
|
||||
- `include "path"` resolves relative to current config file
|
||||
- Pedantic mode with line/column tracking for error messages
|
||||
- SHA256 digest of parsed content for reload change detection
|
||||
|
||||
## Component 3: Config Processor (`ConfigProcessor.cs`)
|
||||
|
||||
Maps parsed dictionary keys to `NatsOptions` fields. Port of Go `processConfigFileLine` in `opts.go`.
|
||||
|
||||
Key categories handled:
|
||||
- **Network**: `listen`, `port`, `host`/`net`, `client_advertise`, `max_connections`/`max_conn`
|
||||
- **Logging**: `debug`, `trace`, `trace_verbose`, `logtime`, `logtime_utc`, `logfile`/`log_file`, `log_size_limit`, `log_max_num`, `syslog`, `remote_syslog`
|
||||
- **Auth**: `authorization { ... }` block (username, password, token, users, nkeys, timeout), `no_auth_user`
|
||||
- **Accounts**: `accounts { ... }` block, `system_account`, `no_system_account`
|
||||
- **TLS**: `tls { ... }` block (cert_file, key_file, ca_file, verify, verify_and_map, timeout, pinned_certs, handshake_first, handshake_first_fallback), `allow_non_tls`
|
||||
- **Monitoring**: `http_port`/`monitor_port`, `https_port`, `http`/`https` (combined), `http_base_path`
|
||||
- **Limits**: `max_payload`, `max_control_line`, `max_pending`, `max_subs`, `max_sub_tokens`, `max_traced_msg_len`, `write_deadline`
|
||||
- **Ping**: `ping_interval`, `ping_max`/`ping_max_out`
|
||||
- **Lifecycle**: `lame_duck_duration`, `lame_duck_grace_period`
|
||||
- **Files**: `pidfile`/`pid_file`, `ports_file_dir`
|
||||
- **Misc**: `server_name`, `server_tags`, `disable_sublist_cache`, `max_closed_clients`, `prof_port`
|
||||
|
||||
Error handling: accumulate all errors, report together (not fail-fast). Unknown keys silently ignored (allows cluster/JetStream configs to coexist).
|
||||
|
||||
## Component 4: Hot Reload (`ConfigReloader.cs`)
|
||||
|
||||
### Reloadable Options (matching Go)
|
||||
|
||||
- **Logging**: Debug, Trace, TraceVerbose, Logtime, LogtimeUTC, LogFile, LogSizeLimit, LogMaxFiles, Syslog, RemoteSyslog
|
||||
- **Auth**: Username, Password, Authorization, Users, NKeys, NoAuthUser, AuthTimeout
|
||||
- **Limits**: MaxConnections, MaxPayload, MaxPending, WriteDeadline, PingInterval, MaxPingsOut, MaxControlLine, MaxSubs, MaxSubTokens, MaxTracedMsgLen
|
||||
- **TLS**: cert/key/CA file paths (reload certs without restart)
|
||||
- **Misc**: Tags, LameDuckDuration, LameDuckGracePeriod, ClientAdvertise, MaxClosedClients
|
||||
|
||||
### Non-Reloadable (error if changed)
|
||||
|
||||
- Host, Port, ServerName
|
||||
|
||||
### IConfigChange Interface
|
||||
|
||||
```csharp
|
||||
interface IConfigChange
|
||||
{
|
||||
string Name { get; }
|
||||
void Apply(NatsServer server);
|
||||
bool IsLoggingChange { get; }
|
||||
bool IsAuthChange { get; }
|
||||
bool IsTlsChange { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### Reload Flow
|
||||
|
||||
1. SIGHUP → `NatsServer.ReloadConfig()`
|
||||
2. Re-parse config file via `ConfigProcessor.ProcessConfigFile()`
|
||||
3. Merge with CLI flag snapshot (CLI always wins)
|
||||
4. `ConfigReloader.Diff(oldOpts, newOpts)` → list of `IConfigChange`
|
||||
5. Validate: reject if non-reloadable options changed
|
||||
6. Apply each change to running server (logging, auth, limits, TLS grouped)
|
||||
7. Log applied changes at Information level, errors at Warning
|
||||
|
||||
## New NatsOptions Fields
|
||||
|
||||
Added for single-server parity with Go:
|
||||
|
||||
| Field | Type | Default | Go equivalent |
|
||||
|-------|------|---------|---------------|
|
||||
| `ClientAdvertise` | string? | null | `client_advertise` |
|
||||
| `TraceVerbose` | bool | false | `trace_verbose` |
|
||||
| `MaxTracedMsgLen` | int | 0 | `max_traced_msg_len` |
|
||||
| `DisableSublistCache` | bool | false | `disable_sublist_cache` |
|
||||
| `ConnectErrorReports` | int | 3600 | `connect_error_reports` |
|
||||
| `ReconnectErrorReports` | int | 1 | `reconnect_error_reports` |
|
||||
| `NoHeaderSupport` | bool | false | `no_header_support` |
|
||||
| `MaxClosedClients` | int | 10000 | `max_closed_clients` |
|
||||
| `NoSystemAccount` | bool | false | `no_system_account` |
|
||||
| `SystemAccount` | string? | null | `system_account` |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### NatsServer.cs
|
||||
|
||||
- Constructor: if `ConfigFile` set, parse before startup
|
||||
- SIGHUP handler: call `ReloadConfig()` instead of warning log
|
||||
- New `ReloadConfig()` method for reload orchestration
|
||||
- Store CLI flag snapshot (`HashSet<string> InCmdLine`)
|
||||
|
||||
### Program.cs
|
||||
|
||||
- Parse config file after defaults, before CLI args
|
||||
- Track CLI-set options in `InCmdLine`
|
||||
- Rebuild Serilog config on logging reload
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
src/NATS.Server/Configuration/
|
||||
NatsConfLexer.cs (~400 lines)
|
||||
NatsConfParser.cs (~300 lines)
|
||||
NatsConfToken.cs (~30 lines)
|
||||
ConfigProcessor.cs (~350 lines)
|
||||
ConfigReloader.cs (~250 lines)
|
||||
IConfigChange.cs (~15 lines)
|
||||
```
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Test Files
|
||||
|
||||
- `NatsConfLexerTests.cs` — all token types, comments, escapes, edge cases
|
||||
- `NatsConfParserTests.cs` — nested blocks, arrays, variables, includes, errors
|
||||
- `ConfigProcessorTests.cs` — all option key mappings, type coercion, error collection
|
||||
- `ConfigReloadTests.cs` — reload flow, reloadable vs non-reloadable, CLI precedence
|
||||
|
||||
### Test Data
|
||||
|
||||
```
|
||||
tests/NATS.Server.Tests/TestData/
|
||||
basic.conf — minimal server config
|
||||
auth.conf — authorization block with users/nkeys
|
||||
tls.conf — TLS configuration
|
||||
full.conf — all supported options
|
||||
includes/ — include directive tests
|
||||
invalid.conf — error case configs
|
||||
```
|
||||
|
||||
## Task Reference
|
||||
|
||||
Implementation tasks will be created via the writing-plans skill.
|
||||
1507
docs/plans/2026-02-23-config-parsing-plan.md
Normal file
1507
docs/plans/2026-02-23-config-parsing-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
14
docs/plans/2026-02-23-config-parsing-plan.md.tasks.json
Normal file
14
docs/plans/2026-02-23-config-parsing-plan.md.tasks.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-config-parsing-plan.md",
|
||||
"tasks": [
|
||||
{"id": 6, "subject": "Task 1: Token Types and Lexer Infrastructure", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 2: Config Parser", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 3: New NatsOptions Fields", "status": "pending"},
|
||||
{"id": 9, "subject": "Task 4: Config Processor", "status": "pending", "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 5: Hot Reload System", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 6: Server Integration", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 12, "subject": "Task 7: Update differences.md", "status": "pending", "blockedBy": [11]},
|
||||
{"id": 13, "subject": "Task 8: Full Test Suite Verification", "status": "pending", "blockedBy": [12]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
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.
|
||||
160
docs/plans/2026-02-23-remaining-gaps-design.md
Normal file
160
docs/plans/2026-02-23-remaining-gaps-design.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Remaining Lower-Priority Gaps — Design Document
|
||||
|
||||
**Goal:** Resolve all remaining lower-priority gaps from differences.md with full Go parity, bringing the .NET NATS server to feature completeness for single-server (non-clustered) deployments.
|
||||
|
||||
**Approach:** Full Go parity — implement all listed gaps including JWT authentication, subject mapping, OCSP, and quick wins.
|
||||
|
||||
---
|
||||
|
||||
## 1. JWT Authentication
|
||||
|
||||
The largest component. NATS uses standard JWT encoding (base64url header.payload.signature) with Ed25519 signing via the `nats-io/jwt/v2` Go library.
|
||||
|
||||
### New Files
|
||||
|
||||
- **`Auth/Jwt/NatsJwt.cs`** — JWT decode/encode utilities. Base64url parsing, header extraction, Ed25519 signature verification via NATS.NKeys. Detects JWT by `"eyJ"` prefix.
|
||||
- **`Auth/Jwt/UserClaims.cs`** — User claim record: `Subject` (nkey), `Issuer` (account nkey), `IssuerAccount` (scoped signer), `Name`, `Tags`, `BearerToken`, `Permissions` (pub/sub allow/deny), `ResponsePermission`, `Src` (allowed CIDRs), `Times` (time-based access), `AllowedConnectionTypes`, `IssuedAt`, `Expires`.
|
||||
- **`Auth/Jwt/AccountClaims.cs`** — Account claim record: `Subject`, `Issuer` (operator nkey), `SigningKeys`, `Limits`, `Exports`, `Imports`.
|
||||
- **`Auth/Jwt/PermissionTemplates.cs`** — Template expansion for all 6 mustache-style templates:
|
||||
- `{{name()}}` → user's Name
|
||||
- `{{subject()}}` → user's Subject (nkey)
|
||||
- `{{tag(name)}}` → user tags matching `name:` prefix
|
||||
- `{{account-name()}}` → account display name
|
||||
- `{{account-subject()}}` → account nkey
|
||||
- `{{account-tag(name)}}` → account tags matching prefix
|
||||
- Cartesian product applied for multi-value tags.
|
||||
- **`Auth/Jwt/AccountResolver.cs`** — `IAccountResolver` interface (`FetchAsync`, `StoreAsync`, `IsReadOnly`) + `MemAccountResolver` in-memory implementation.
|
||||
- **`Auth/JwtAuthenticator.cs`** — Implements `IAuthenticator`. Flow: decode user JWT → resolve account → verify Ed25519 signature against nonce → expand permission templates → build `NkeyUser` → validate source IP + time restrictions → check user revocation.
|
||||
|
||||
### Modified Files
|
||||
|
||||
- **`Auth/Account.cs`** — Add `Nkey`, `Issuer`, `SigningKeys` (Dictionary), `RevokedUsers` (ConcurrentDictionary<string, long>).
|
||||
- **`NatsOptions.cs`** — Add `TrustedKeys` (string[]), `AccountResolver` (IAccountResolver).
|
||||
- **`NatsClient.cs`** — Pass JWT + signature from CONNECT opts to authenticator.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- No external JWT NuGet — NATS JWTs are simple enough to parse inline (base64url + System.Text.Json + Ed25519 via NATS.NKeys).
|
||||
- `MemAccountResolver` only — URL/directory resolvers are deployment infrastructure.
|
||||
- User revocation: `ConcurrentDictionary<string, long>` on Account (nkey → issuedAt; JWTs issued before revocation time are rejected).
|
||||
- Source IP validation via `System.Net.IPNetwork.Contains()` (.NET 8+).
|
||||
|
||||
---
|
||||
|
||||
## 2. Subject Mapping / Transforms
|
||||
|
||||
Port Go's `subject_transform.go`. Configurable source pattern → destination template with function tokens.
|
||||
|
||||
### New File
|
||||
|
||||
- **`Subscriptions/SubjectTransform.cs`** — A transform has a source pattern (with wildcards) and a destination template with function tokens. On match, captured wildcard values are substituted into the destination.
|
||||
|
||||
### Transform Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `{{wildcard(n)}}` / `$n` | Replace with nth captured wildcard token (1-based) |
|
||||
| `{{partition(num,tokens...)}}` | FNV-1a hash of captured tokens mod `num` |
|
||||
| `{{split(token,delim)}}` | Split captured token by delimiter into subject tokens |
|
||||
| `{{splitFromLeft(token,pos)}}` | Split token into two at position from left |
|
||||
| `{{splitFromRight(token,pos)}}` | Split token into two at position from right |
|
||||
| `{{sliceFromLeft(token,size)}}` | Slice token into fixed-size chunks from left |
|
||||
| `{{sliceFromRight(token,size)}}` | Slice token into fixed-size chunks from right |
|
||||
| `{{left(token,len)}}` | Take first `len` chars |
|
||||
| `{{right(token,len)}}` | Take last `len` chars |
|
||||
|
||||
### Integration
|
||||
|
||||
- `NatsOptions.SubjectMappings` — `Dictionary<string, string>` of source→destination rules.
|
||||
- Transforms compiled at config time into token operation lists (no runtime regex).
|
||||
- Applied in `NatsServer.DeliverMessage` before subject matching.
|
||||
- Account-level mappings for import/export rewriting.
|
||||
|
||||
---
|
||||
|
||||
## 3. OCSP Support
|
||||
|
||||
Two dimensions: peer verification (client cert revocation checking) and stapling (server cert status).
|
||||
|
||||
### New File
|
||||
|
||||
- **`Tls/OcspConfig.cs`** — `OcspMode` enum (`Auto`, `Always`, `Must`, `Never`) + `OcspConfig` record with `Mode` and `OverrideUrls`.
|
||||
|
||||
### Peer Verification
|
||||
|
||||
- Modify `TlsConnectionWrapper` to set `X509RevocationMode.Online` when `OcspPeerVerify` is true.
|
||||
- Checks CRL/OCSP during TLS handshake for client certificates.
|
||||
|
||||
### OCSP Stapling
|
||||
|
||||
- Build `SslStreamCertificateContext.Create(cert, chain, offline: false)` at startup — .NET fetches OCSP response automatically.
|
||||
- Pass to `SslServerAuthenticationOptions.ServerCertificateContext`.
|
||||
- `Must` mode: verify OCSP response obtained; fail startup if not.
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `NatsOptions.cs` — `OcspConfig` and `OcspPeerVerify` properties.
|
||||
- `TlsConnectionWrapper.cs` — Peer verification in cert validation callback.
|
||||
- `NatsServer.cs` — `SslStreamCertificateContext` with OCSP at startup.
|
||||
- `VarzHandler.cs` — Populate `TlsOcspPeerVerify` field.
|
||||
|
||||
---
|
||||
|
||||
## 4. Quick Wins
|
||||
|
||||
### A. Windows Service Integration
|
||||
|
||||
- Add `Microsoft.Extensions.Hosting.WindowsServices` NuGet.
|
||||
- Detect `--service` flag in `Program.cs`, call `UseWindowsService()`.
|
||||
- .NET generic host handles service lifecycle automatically.
|
||||
|
||||
### B. Per-Subsystem Log Control
|
||||
|
||||
- `NatsOptions.LogOverrides` — `Dictionary<string, string>` mapping namespace→level.
|
||||
- CLI: `--log_level_override "NATS.Server.Protocol=Trace"` (repeatable).
|
||||
- Serilog: `MinimumLevel.Override(namespace, level)` per entry.
|
||||
|
||||
### C. Per-Client Trace Mode
|
||||
|
||||
- `TraceMode` flag in `ClientFlagHolder`.
|
||||
- When set, parser receives logger regardless of global `options.Trace`.
|
||||
- Connz response includes `trace` boolean per connection.
|
||||
|
||||
### D. Per-Account Stats
|
||||
|
||||
- `long _inMsgs, _outMsgs, _inBytes, _outBytes` on `Account` with `Interlocked`.
|
||||
- `IncrementInbound(long bytes)` / `IncrementOutbound(long bytes)`.
|
||||
- Called from `DeliverMessage` (outbound) and message processing (inbound).
|
||||
- `/accountz` endpoint returns per-account stats.
|
||||
|
||||
### E. TLS Certificate Expiry in /varz
|
||||
|
||||
- In `VarzHandler`, read server TLS cert `NotAfter`.
|
||||
- Populate existing `TlsCertNotAfter` field.
|
||||
|
||||
### F. differences.md Update
|
||||
|
||||
- Mark all resolved features as Y with notes.
|
||||
- Update summary section.
|
||||
- Correct any stale entries.
|
||||
|
||||
---
|
||||
|
||||
## Task Dependencies
|
||||
|
||||
```
|
||||
Independent (parallelizable):
|
||||
- JWT Authentication (#23)
|
||||
- Subject Mapping (#24)
|
||||
- OCSP Support (#25)
|
||||
- Windows Service (#26)
|
||||
- Per-Subsystem Logging (#27)
|
||||
- Per-Client Trace (#28)
|
||||
- Per-Account Stats (#29)
|
||||
- TLS Cert Expiry (#30)
|
||||
|
||||
Dependent:
|
||||
- Update differences.md (#31) — blocked by all above
|
||||
```
|
||||
|
||||
Most tasks can run in parallel since they touch different files. JWT and Subject Mapping are the two largest. The quick wins (26-30) are all independent of each other and of the larger tasks.
|
||||
1806
docs/plans/2026-02-23-remaining-gaps-plan.md
Normal file
1806
docs/plans/2026-02-23-remaining-gaps-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
20
docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
Normal file
20
docs/plans/2026-02-23-remaining-gaps-plan.md.tasks.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-remaining-gaps-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: JWT Core — Decode/Verify + Claim Structs", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Permission Templates", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Account Resolver", "status": "pending"},
|
||||
{"id": 4, "subject": "Task 4: JwtAuthenticator — Wire JWT into Auth", "status": "pending", "blockedBy": [1, 2, 3]},
|
||||
{"id": 5, "subject": "Task 5: Subject Transform — Core Engine", "status": "pending"},
|
||||
{"id": 6, "subject": "Task 6: Wire Subject Transforms into Delivery", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 7, "subject": "Task 7: OCSP Config and Peer Verification", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 8: OCSP Stapling", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 9: Windows Service Integration", "status": "pending"},
|
||||
{"id": 10, "subject": "Task 10: Per-Subsystem Log Control", "status": "pending"},
|
||||
{"id": 11, "subject": "Task 11: Per-Client Trace Mode", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 12: Per-Account Stats", "status": "pending"},
|
||||
{"id": 13, "subject": "Task 13: TLS Cert Expiry in /varz", "status": "pending"},
|
||||
{"id": 14, "subject": "Task 14: Update differences.md", "status": "pending", "blockedBy": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
226
docs/plans/2026-02-23-sections-7-10-gaps-design.md
Normal file
226
docs/plans/2026-02-23-sections-7-10-gaps-design.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Sections 7-10 Gaps Design: Monitoring, TLS, Logging, Ping/Pong
|
||||
|
||||
**Date:** 2026-02-23
|
||||
**Scope:** Implement remaining gaps in differences.md sections 7 (Monitoring), 8 (TLS), 9 (Logging), 10 (Ping/Pong)
|
||||
**Goal:** Go parity for all features within scope
|
||||
|
||||
---
|
||||
|
||||
## Section 7: Monitoring
|
||||
|
||||
### 7a. `/subz` Endpoint
|
||||
|
||||
Replace the empty stub with a full `SubszHandler`.
|
||||
|
||||
**Models:**
|
||||
- `Subsz` — response envelope: `Id`, `Now`, `SublistStats`, `Total`, `Offset`, `Limit`, `Subs[]`
|
||||
- `SubszOptions` — `Offset`, `Limit`, `Subscriptions` (bool for detail), `Account` (filter), `Test` (literal subject filter)
|
||||
- Reuse existing `SubDetail` from Connz
|
||||
|
||||
**Algorithm:**
|
||||
1. Iterate all accounts (or filter by `Account` param)
|
||||
2. Collect all subscriptions from each account's SubList
|
||||
3. If `Test` subject provided, filter using `SubjectMatch.MatchLiteral()` to only return subs that would receive that message
|
||||
4. Apply pagination (offset/limit)
|
||||
5. If `Subscriptions` is true, include `SubDetail[]` array
|
||||
|
||||
**SubList stats** — add a `Stats()` method to `SubList` returning `SublistStats` (count, cache size, inserts, removes, matches, cache hits).
|
||||
|
||||
**Files:** New `Monitoring/SubszHandler.cs`, `Monitoring/Subsz.cs`. Modify `MonitorServer.cs`, `SubList.cs`.
|
||||
|
||||
### 7b. Connz `ByStop` / `ByReason` Sorting
|
||||
|
||||
Add two missing sort options for closed connection queries.
|
||||
|
||||
- Add `ByStop` and `ByReason` to `SortOpt` enum
|
||||
- Parse `sort=stop` and `sort=reason` in query params
|
||||
- Validate: these sorts only work with `state=closed` — return error if used with open connections
|
||||
|
||||
### 7c. Connz State Filtering & Closed Connections
|
||||
|
||||
Track closed connections and support state-based filtering.
|
||||
|
||||
**Closed connection tracking:**
|
||||
- `ClosedClient` record: `Cid`, `Ip`, `Port`, `Start`, `Stop`, `Reason`, `Name`, `Lang`, `Version`, `InMsgs`, `OutMsgs`, `InBytes`, `OutBytes`, `NumSubs`, `Rtt`, `TlsVersion`, `TlsCipherSuite`
|
||||
- `ConcurrentQueue<ClosedClient>` on `NatsServer` (capped at 10,000 entries)
|
||||
- Populate in `RemoveClient()` from client state before disposal
|
||||
|
||||
**State filter:**
|
||||
- Parse `state=open|closed|all` query param
|
||||
- `open` (default): current live connections only
|
||||
- `closed`: only from closed connections list
|
||||
- `all`: merge both
|
||||
|
||||
**Files:** Modify `NatsServer.cs`, `ConnzHandler.cs`, new `Monitoring/ClosedClient.cs`.
|
||||
|
||||
### 7d. Varz Slow Consumer Stats
|
||||
|
||||
Already at parity. `SlowConsumersStats` is populated from `ServerStats` counters. No changes needed.
|
||||
|
||||
---
|
||||
|
||||
## Section 8: TLS
|
||||
|
||||
### 8a. TLS Rate Limiting
|
||||
|
||||
Already implemented via `TlsRateLimiter` (semaphore + periodic refill timer). Wired into `AcceptClientAsync`. Only a unit test needed.
|
||||
|
||||
### 8b. TLS Cert-to-User Mapping (TlsMap)
|
||||
|
||||
Full DN parsing using .NET built-in `X500DistinguishedName`.
|
||||
|
||||
**New `TlsMapAuthenticator`:**
|
||||
- Implements `IAuthenticator`
|
||||
- Receives the list of configured `User` objects
|
||||
- On `Authenticate()`:
|
||||
1. Extract `X509Certificate2` from auth context (passed from `TlsConnectionState`)
|
||||
2. Parse subject DN via `cert.SubjectName` (`X500DistinguishedName`)
|
||||
3. Build normalized DN string from RDN components
|
||||
4. Try exact DN match against user map (key = DN string)
|
||||
5. If no exact match, try CN-only match
|
||||
6. Return `AuthResult` with matched user's permissions
|
||||
|
||||
**Auth context extension:**
|
||||
- Add `X509Certificate2? ClientCertificate` to `ClientAuthContext`
|
||||
- Pass certificate from `TlsConnectionState` in `ProcessConnectAsync`
|
||||
|
||||
**AuthService integration:**
|
||||
- When `options.TlsMap && options.TlsVerify`, add `TlsMapAuthenticator` to authenticator chain
|
||||
- TlsMap auth runs before other authenticators (cert-based auth takes priority)
|
||||
|
||||
**Files:** New `Auth/TlsMapAuthenticator.cs`. Modify `Auth/AuthService.cs`, `Auth/ClientAuthContext.cs`, `NatsClient.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Section 9: Logging
|
||||
|
||||
### 9a. File Logging with Rotation
|
||||
|
||||
**New options on `NatsOptions`:**
|
||||
- `LogFile` (string?) — path to log file
|
||||
- `LogSizeLimit` (long) — file size in bytes before rotation (0 = unlimited)
|
||||
- `LogMaxFiles` (int) — max retained rotated files (0 = unlimited)
|
||||
|
||||
**CLI flags:** `--log_file`, `--log_size_limit`, `--log_max_files`
|
||||
|
||||
**Serilog config:** Add `WriteTo.File()` with `fileSizeLimitBytes` and `retainedFileCountLimit` when `LogFile` is set.
|
||||
|
||||
### 9b. Debug/Trace Modes
|
||||
|
||||
**New options on `NatsOptions`:**
|
||||
- `Debug` (bool) — enable debug-level logging
|
||||
- `Trace` (bool) — enable trace/verbose-level logging
|
||||
|
||||
**CLI flags:** `-D` (debug), `-V` or `-T` (trace), `-DV` (both)
|
||||
|
||||
**Serilog config:**
|
||||
- Default: `MinimumLevel.Information()`
|
||||
- `-D`: `MinimumLevel.Debug()`
|
||||
- `-V`/`-T`: `MinimumLevel.Verbose()`
|
||||
|
||||
### 9c. Color Output
|
||||
|
||||
Auto-detect TTY via `Console.IsOutputRedirected`.
|
||||
- TTY: use `Serilog.Sinks.Console` with `AnsiConsoleTheme.Code`
|
||||
- Non-TTY: use `ConsoleTheme.None`
|
||||
|
||||
Matches Go's behavior of disabling color when stderr is not a terminal.
|
||||
|
||||
### 9d. Timestamp Format Control
|
||||
|
||||
**New options on `NatsOptions`:**
|
||||
- `Logtime` (bool, default true) — include timestamps
|
||||
- `LogtimeUTC` (bool, default false) — use UTC format
|
||||
|
||||
**CLI flags:** `--logtime` (true/false), `--logtime_utc`
|
||||
|
||||
**Output template adjustment:**
|
||||
- With timestamps: `[{Timestamp:yyyy/MM/dd HH:mm:ss.ffffff} {Level:u3}] {Message:lj}{NewLine}{Exception}`
|
||||
- Without timestamps: `[{Level:u3}] {Message:lj}{NewLine}{Exception}`
|
||||
- UTC: set `Serilog.Formatting` culture to UTC
|
||||
|
||||
### 9e. Log Reopening (SIGUSR1)
|
||||
|
||||
When file logging is configured:
|
||||
- SIGUSR1 handler calls `ReOpenLogFile()` on the server
|
||||
- `ReOpenLogFile()` flushes and closes current Serilog logger, creates new one with same config
|
||||
- This enables external log rotation tools (logrotate)
|
||||
|
||||
**Files:** Modify `NatsOptions.cs`, `Program.cs`, `NatsServer.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Section 10: Ping/Pong
|
||||
|
||||
### 10a. RTT Tracking
|
||||
|
||||
**New fields on `NatsClient`:**
|
||||
- `_rttStartTicks` (long) — UTC ticks when PING sent
|
||||
- `_rtt` (long) — computed RTT in ticks
|
||||
- `Rtt` property (TimeSpan) — computed from `_rtt`
|
||||
|
||||
**Logic:**
|
||||
- In `RunPingTimerAsync`, before writing PING: `_rttStartTicks = DateTime.UtcNow.Ticks`
|
||||
- In `DispatchCommandAsync` PONG handler: compute `_rtt = DateTime.UtcNow.Ticks - _rttStartTicks` (min 1 tick)
|
||||
- `computeRTT()` helper ensures minimum 1 tick (handles clock granularity on Windows)
|
||||
|
||||
**Monitoring exposure:**
|
||||
- Populate `ConnInfo.Rtt` as formatted string (e.g., `"1.234ms"`)
|
||||
- Add `ByRtt` sort option to Connz
|
||||
|
||||
### 10b. RTT-Based First PING Delay
|
||||
|
||||
**New state on `NatsClient`:**
|
||||
- `_firstPongSent` flag in `ClientFlags`
|
||||
|
||||
**Logic in `RunPingTimerAsync`:**
|
||||
- Before first PING, check: `_firstPongSent || timeSinceStart > 2 seconds`
|
||||
- If neither condition met, skip this PING cycle
|
||||
- Set `_firstPongSent` on first PONG after CONNECT (in PONG handler)
|
||||
|
||||
This prevents the server from sending PING (for RTT) before the client has had a chance to respond to the initial INFO with CONNECT+PING.
|
||||
|
||||
### 10c. Stale Connection Stats
|
||||
|
||||
**New model:**
|
||||
- `StaleConnectionStats` — `Clients`, `Routes`, `Gateways`, `Leafs` (matching Go)
|
||||
|
||||
**ServerStats extension:**
|
||||
- Add `StaleConnectionClients`, `StaleConnectionRoutes`, etc. fields
|
||||
- Increment in `MarkClosed(StaleConnection)` based on connection kind
|
||||
|
||||
**Varz exposure:**
|
||||
- Add `StaleConnectionStats` field to `Varz`
|
||||
- Populate from `ServerStats` counters
|
||||
|
||||
**Files:** Modify `NatsClient.cs`, `ServerStats.cs`, `Varz.cs`, `VarzHandler.cs`, `Connz.cs`, `ConnzHandler.cs`.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Each section includes unit tests:
|
||||
|
||||
| Feature | Test File | Tests |
|
||||
|---------|-----------|-------|
|
||||
| Subz endpoint | SubszHandlerTests.cs | Empty response, with subs, account filter, test subject filter, pagination |
|
||||
| Connz closed state | ConnzHandlerTests.cs | State=closed, ByStop sort, ByReason sort, validation errors |
|
||||
| TLS rate limiter | TlsRateLimiterTests.cs | Rate enforcement, refill behavior |
|
||||
| TlsMap auth | TlsMapAuthenticatorTests.cs | DN matching, CN fallback, no match |
|
||||
| File logging | LoggingTests.cs | File creation, rotation on size limit |
|
||||
| RTT tracking | ClientTests.cs | RTT computed on PONG, exposed in connz, ByRtt sort |
|
||||
| First PING delay | ClientTests.cs | PING delayed until first PONG or 2s |
|
||||
| Stale stats | ServerTests.cs | Stale counters incremented, exposed in varz |
|
||||
|
||||
---
|
||||
|
||||
## Parallelization Strategy
|
||||
|
||||
These work streams are independent and can be developed by parallel subagents:
|
||||
|
||||
1. **Monitoring stream** (7a, 7b, 7c): SubszHandler + Connz closed connections + state filter
|
||||
2. **TLS stream** (8b): TlsMapAuthenticator
|
||||
3. **Logging stream** (9a-9e): All logging improvements
|
||||
4. **Ping/Pong stream** (10a-10c): RTT tracking + first PING delay + stale stats
|
||||
|
||||
Streams 1-4 touch different files with minimal overlap. The only shared touch point is `NatsOptions.cs` (new options for logging and ping/pong), which can be handled by one stream first and the others will build on it.
|
||||
1911
docs/plans/2026-02-23-sections-7-10-gaps-plan.md
Normal file
1911
docs/plans/2026-02-23-sections-7-10-gaps-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
19
docs/plans/2026-02-23-sections-7-10-gaps-plan.md.tasks.json
Normal file
19
docs/plans/2026-02-23-sections-7-10-gaps-plan.md.tasks.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-sections-7-10-gaps-plan.md",
|
||||
"tasks": [
|
||||
{"id": 6, "subject": "Task 0: Add NuGet dependencies for logging sinks", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 1: Add logging and ping/pong options to NatsOptions", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 2: Add CLI flag parsing for logging and debug/trace", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 3: Implement log reopening on SIGUSR1", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 10, "subject": "Task 4: Add RTT tracking to NatsClient", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 11, "subject": "Task 5: Add stale connection stats and expose in varz", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 12, "subject": "Task 6: Add closed connection tracking and connz state filtering", "status": "pending", "blockedBy": [10]},
|
||||
{"id": 13, "subject": "Task 7: Implement /subz endpoint", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 14, "subject": "Task 8: Implement TLS cert-to-user mapping (TlsMap)", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 15, "subject": "Task 9: Add TLS rate limiter test", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 16, "subject": "Task 10: File logging tests", "status": "pending", "blockedBy": [6, 7, 8]},
|
||||
{"id": 17, "subject": "Task 11: Run full test suite and verify", "status": "pending", "blockedBy": [8, 9, 10, 11, 12, 13, 14, 15, 16]},
|
||||
{"id": 18, "subject": "Task 12: Update differences.md", "status": "pending", "blockedBy": [17]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
190
docs/plans/2026-02-23-sections3-6-gaps-design.md
Normal file
190
docs/plans/2026-02-23-sections3-6-gaps-design.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Sections 3-6 Gaps Implementation Design
|
||||
|
||||
> Approved 2026-02-23. Implements all remaining gaps in Protocol Parsing, Subscriptions & Subject Matching, Authentication & Authorization, and Configuration.
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Protocol Parsing
|
||||
|
||||
### 3a. INFO Serialization Caching
|
||||
|
||||
Add `byte[] _infoJsonCache` on `NatsServer`. Build once in `StartAsync()` after `ServerInfo` is populated. Rebuild only on config reload.
|
||||
|
||||
`NatsClient.SendInfo()` uses cached bytes for non-nonce connections. For NKey connections (which need a per-connection nonce), clone `ServerInfo`, set nonce, serialize fresh.
|
||||
|
||||
**Files:** `NatsServer.cs`, `NatsClient.cs`
|
||||
|
||||
### 3b. Protocol Tracing
|
||||
|
||||
Add `ILogger` to `NatsParser`. Add `TraceInOp(ReadOnlySpan<byte> op, ReadOnlySpan<byte> arg)` that logs at `LogLevel.Trace` after each command dispatch in `TryParse()`.
|
||||
|
||||
Controlled by `NatsOptions.Trace` (which sets log level filter).
|
||||
|
||||
**Files:** `NatsParser.cs`, `NatsOptions.cs`
|
||||
|
||||
### 3c. MIME Header Parsing
|
||||
|
||||
New `NatsHeaderParser` static class in `Protocol/`. Parses `NATS/1.0 <status> <description>\r\n` status line + `Key: Value\r\n` pairs.
|
||||
|
||||
Returns `NatsHeaders` readonly struct with `Status` (int), `Description` (string), and key-value `Dictionary<string, string[]>` lookup.
|
||||
|
||||
Used in `ProcessMessage()` for header inspection (no-responders status, future message tracing).
|
||||
|
||||
**Files:** New `Protocol/NatsHeaderParser.cs`, `NatsServer.cs`
|
||||
|
||||
### 3d. MSG/HMSG Construction Optimization
|
||||
|
||||
Pre-allocate buffers for MSG/HMSG prefix using `Span<byte>` and `Utf8Formatter` instead of string interpolation + `Encoding.UTF8.GetBytes()`. Reduces per-message allocations.
|
||||
|
||||
**Files:** `NatsClient.cs`
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Subscriptions & Subject Matching
|
||||
|
||||
### 4a. Atomic Generation ID for Cache Invalidation
|
||||
|
||||
Add `long _generation` to `SubList`. Increment via `Interlocked.Increment` on every `Insert()` and `Remove()`. Cache entries store generation at computation time. On `Match()`, stale generation = cache miss.
|
||||
|
||||
Replaces current explicit per-key cache removal. O(1) invalidation.
|
||||
|
||||
**Files:** `SubList.cs`
|
||||
|
||||
### 4b. Async Background Cache Sweep
|
||||
|
||||
Replace inline sweep (runs under write lock) with `PeriodicTimer`-based background task. Acquires write lock briefly to snapshot + evict stale entries. Triggered when cache count > 1024, sweeps to 256.
|
||||
|
||||
**Files:** `SubList.cs`
|
||||
|
||||
### 4c. `plist` Optimization for High-Fanout Nodes
|
||||
|
||||
On `TrieNode`, when `PlainSubs.Count > 256`, convert `HashSet<Subscription>` to `Subscription[]` flat array in `PList` field. `Match()` iterates `PList` when present. On count < 128, convert back to HashSet.
|
||||
|
||||
**Files:** `SubList.cs`
|
||||
|
||||
### 4d. SubList Utility Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `Stats()` | Returns `SubListStats` record with NumSubs, NumCache, NumInserts, NumRemoves, NumMatches, CacheHitRate, MaxFanout. Track via `Interlocked` counters. |
|
||||
| `HasInterest(string)` | Walk trie without building result, return true on first hit. |
|
||||
| `NumInterest(string)` | Walk trie, count without allocating result arrays. |
|
||||
| `ReverseMatch(string)` | Walk trie with literal tokens, collect all subscription patterns matching the literal. |
|
||||
| `RemoveBatch(IEnumerable<Subscription>)` | Single write-lock, batch removes, single generation bump. |
|
||||
| `All()` | Depth-first trie walk, yield all subscriptions. Returns `IReadOnlyList<Subscription>`. |
|
||||
| `MatchBytes(ReadOnlySpan<byte>)` | Zero-copy match using byte-based `TokenEnumerator`. |
|
||||
|
||||
**Files:** `SubList.cs`, new `SubListStats.cs`
|
||||
|
||||
### 4e. SubjectMatch Utilities
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `SubjectsCollide(string, string)` | Token-by-token comparison handling `*` and `>`. Two patterns collide if any literal could match both. |
|
||||
| `TokenAt(string, int)` | Return nth dot-delimited token as `ReadOnlySpan<char>`. |
|
||||
| `NumTokens(string)` | Count dots + 1. |
|
||||
| UTF-8/null validation | Add `checkRunes` parameter to `IsValidSubject()`. When true, scan for `\0` bytes and validate UTF-8. |
|
||||
|
||||
**Files:** `SubjectMatch.cs`
|
||||
|
||||
### 4f. Per-Account Subscription Limits
|
||||
|
||||
Add `MaxSubscriptions` to `Account`. Track `SubscriptionCount` via `Interlocked`. Enforce in `ProcessSub()`. Close with `ClientClosedReason.MaxSubscriptionsExceeded`.
|
||||
|
||||
**Files:** `Account.cs`, `NatsClient.cs`
|
||||
|
||||
---
|
||||
|
||||
## Section 5 — Authentication & Authorization
|
||||
|
||||
### 5a. Deny List Enforcement at Delivery Time
|
||||
|
||||
In `NatsServer.DeliverMessage()`, before sending MSG, check `subscriber.Client.Permissions?.IsDeliveryAllowed(subject)`. New method checks publish deny list for the receiving client ("msg delivery filter"). Cache results in pub cache.
|
||||
|
||||
**Files:** `NatsServer.cs`, `ClientPermissions.cs`
|
||||
|
||||
### 5b. Permission Caching with 128-Entry LRU
|
||||
|
||||
Replace `ConcurrentDictionary<string, bool>` with custom `PermissionLruCache` (128 entries). `Dictionary<string, LinkedListNode>` + `LinkedList` for LRU ordering. Lock-protected (per-client, low contention).
|
||||
|
||||
**Files:** `ClientPermissions.cs`, new `Auth/PermissionLruCache.cs`
|
||||
|
||||
### 5c. Subscribe Deny Queue-Group Checking
|
||||
|
||||
`IsSubscribeAllowed(subject, queue)` checks queue group against subscribe deny list (currently ignores queue parameter).
|
||||
|
||||
**Files:** `ClientPermissions.cs`
|
||||
|
||||
### 5d. Response Permissions (Reply Tracking)
|
||||
|
||||
New `ResponseTracker` class on `NatsClient`. Created when `Permissions.Response` is non-null. Tracks reply subjects with TTL (`ResponsePermission.Expires`) and max count (`ResponsePermission.MaxMsgs`). `IsPublishAllowed()` consults tracker for reply subjects not in static allow list. Expired entries cleaned lazily + in ping timer.
|
||||
|
||||
**Files:** New `Auth/ResponseTracker.cs`, `ClientPermissions.cs`, `NatsClient.cs`
|
||||
|
||||
### 5e. Per-Account Connection Limits
|
||||
|
||||
Add `MaxConnections` to `Account`. Enforce in `ProcessConnectAsync()` after account assignment. Reject with `-ERR maximum connections for account exceeded`.
|
||||
|
||||
**Files:** `Account.cs`, `NatsClient.cs`
|
||||
|
||||
### 5f. Multi-Account User Resolution
|
||||
|
||||
Add `NatsOptions.Accounts` as `Dictionary<string, AccountConfig>` with per-account MaxConnections, MaxSubscriptions, DefaultPermissions. `AuthService` resolves account name to configured `Account` with limits.
|
||||
|
||||
**Files:** `NatsOptions.cs`, new `Auth/AccountConfig.cs`, `AuthService.cs`, `NatsServer.cs`
|
||||
|
||||
### 5g. Auth Expiry Enforcement
|
||||
|
||||
In `ProcessConnectAsync()`, if `AuthResult.Expiry` is set, start `CancellationTokenSource.CancelAfter(expiry - now)`. Link to client lifetime. On fire, close with `ClientClosedReason.AuthenticationExpired`.
|
||||
|
||||
**Files:** `NatsClient.cs`
|
||||
|
||||
### 5h. Auto-Unsub Cleanup
|
||||
|
||||
In `DeliverMessage()`, when `sub.MessageCount >= sub.MaxMessages`, call `sub.Client.RemoveSubscription(sub.Sid)` and `subList.Remove(sub)` to clean up both tracking dict and trie. Currently only skips delivery.
|
||||
|
||||
**Files:** `NatsServer.cs`, `NatsClient.cs`
|
||||
|
||||
---
|
||||
|
||||
## Section 6 — Configuration
|
||||
|
||||
### 6a. Debug/Trace CLI Flags
|
||||
|
||||
Add `-D`/`--debug`, `-V`/`--trace`, `-DV` to `Program.cs`. Set `NatsOptions.Debug` and `NatsOptions.Trace`. Wire into Serilog minimum level.
|
||||
|
||||
**Files:** `Program.cs`
|
||||
|
||||
### 6b. New NatsOptions Fields
|
||||
|
||||
| Field | Type | Default | Purpose |
|
||||
|-------|------|---------|---------|
|
||||
| `MaxSubs` | int | 0 (unlimited) | Per-connection subscription limit |
|
||||
| `MaxSubTokens` | int | 0 (unlimited) | Max tokens in a subject |
|
||||
| `Debug` | bool | false | Enable debug-level logging |
|
||||
| `Trace` | bool | false | Enable trace-level protocol logging |
|
||||
| `LogFile` | string? | null | Log to file instead of console |
|
||||
| `LogSizeLimit` | long | 0 | Max log file size before rotation |
|
||||
| `Tags` | Dictionary<string, string>? | null | Server metadata tags |
|
||||
|
||||
**Files:** `NatsOptions.cs`
|
||||
|
||||
### 6c. Logging Options Wiring
|
||||
|
||||
In `Program.cs`, if `LogFile` is set, add Serilog `File` sink with `LogSizeLimit`. If `Debug`/`Trace`, override Serilog minimum level.
|
||||
|
||||
**Files:** `Program.cs`
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
Execute in a git worktree. Parallelize where files don't overlap:
|
||||
|
||||
- **Parallel batch 1:** SubjectMatch utilities (4e) | NatsOptions + CLI flags (6a, 6b) | NatsHeaderParser (3c) | PermissionLruCache (5b) | SubListStats (4d stats class)
|
||||
- **Parallel batch 2:** SubList overhaul (4a, 4b, 4c, 4d methods) | Account limits + config (4f, 5e, 5f, 5g) | Response tracker (5d)
|
||||
- **Parallel batch 3:** Protocol tracing (3b) | INFO caching (3a) | MSG optimization (3d)
|
||||
- **Sequential:** Delivery-time enforcement (5a, 5c, 5h) — touches NatsServer.cs + ClientPermissions.cs, must be coordinated
|
||||
- **Final:** Logging wiring (6c) | differences.md update
|
||||
|
||||
Tests added alongside each feature in the appropriate test file.
|
||||
2384
docs/plans/2026-02-23-sections3-6-gaps-plan.md
Normal file
2384
docs/plans/2026-02-23-sections3-6-gaps-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
20
docs/plans/2026-02-23-sections3-6-gaps-plan.md.tasks.json
Normal file
20
docs/plans/2026-02-23-sections3-6-gaps-plan.md.tasks.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-sections3-6-gaps-plan.md",
|
||||
"tasks": [
|
||||
{"id": 6, "subject": "Task 1: NatsOptions — Add New Configuration Fields", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 2: CLI Flags — Add -D/-V/-DV and Logging Options", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 3: SubjectMatch Utilities", "status": "pending"},
|
||||
{"id": 9, "subject": "Task 4: SubList — Generation ID, Stats, and Utility Methods", "status": "pending"},
|
||||
{"id": 10, "subject": "Task 5: NatsHeaderParser — MIME Header Parsing", "status": "pending"},
|
||||
{"id": 11, "subject": "Task 6: PermissionLruCache — 128-Entry LRU", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 7: Account Limits and AccountConfig", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 13, "subject": "Task 8: MaxSubs Enforcement, Subscribe Deny Queue, Delivery-Time Deny", "status": "pending", "blockedBy": [11, 12]},
|
||||
{"id": 14, "subject": "Task 9: Response Permissions (Reply Tracking)", "status": "pending", "blockedBy": [11, 13]},
|
||||
{"id": 15, "subject": "Task 10: Auth Expiry Enforcement", "status": "pending"},
|
||||
{"id": 16, "subject": "Task 11: INFO Serialization Caching", "status": "pending"},
|
||||
{"id": 17, "subject": "Task 12: Protocol Tracing", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 18, "subject": "Task 13: MSG/HMSG Construction Optimization", "status": "pending"},
|
||||
{"id": 19, "subject": "Task 14: Verify, Run Full Test Suite, Update differences.md", "status": "pending", "blockedBy": [13, 14, 15, 16, 17, 18]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
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"
|
||||
}
|
||||
322
docs/plans/2026-02-23-websocket-design.md
Normal file
322
docs/plans/2026-02-23-websocket-design.md
Normal file
@@ -0,0 +1,322 @@
|
||||
# WebSocket Support Design
|
||||
|
||||
## Overview
|
||||
|
||||
Port WebSocket connection support from the Go NATS server (`golang/nats-server/server/websocket.go`, ~1,550 lines) to the .NET solution. Full feature parity: client/leaf/MQTT paths, HTTP upgrade handshake, custom frame parser with masking, permessage-deflate compression, browser compatibility, origin checking, cookie-based auth, and close frame handling.
|
||||
|
||||
## Approach
|
||||
|
||||
**Raw socket with manual HTTP upgrade** and **custom frame parser** — no ASP.NET Core WebSocket middleware, no `System.Net.WebSockets`. Direct port of Go's frame-level implementation for full control over masking negotiation, compression, fragmentation, and browser quirks.
|
||||
|
||||
**Self-contained WebSocket module** under `src/NATS.Server/WebSocket/` with a `WsConnection` Stream wrapper that integrates transparently with the existing `NatsClient` read/write loops.
|
||||
|
||||
## Go Reference
|
||||
|
||||
- `server/websocket.go` — Main implementation (1,550 lines)
|
||||
- `server/websocket_test.go` — Tests (4,982 lines)
|
||||
- `server/opts.go` lines 518-610 — `WebsocketOpts` struct
|
||||
- `server/client.go` — Integration points (`c.ws` field, `wsRead`, `wsCollapsePtoNB`)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/NATS.Server/WebSocket/
|
||||
WsConstants.cs — Opcodes, frame limits, close codes, compression magic bytes
|
||||
WsReadInfo.cs — Per-connection frame reader state machine
|
||||
WsFrameWriter.cs — Frame construction, masking, compression, fragmentation
|
||||
WsUpgrade.cs — HTTP upgrade handshake validation and 101 response
|
||||
WsConnection.cs — Stream wrapper bridging WS frames <-> NatsClient read/write
|
||||
WsOriginChecker.cs — Same-origin and allowed-origins validation
|
||||
WsCompression.cs — permessage-deflate via DeflateStream
|
||||
|
||||
tests/NATS.Server.Tests/WebSocket/
|
||||
WsUpgradeTests.cs
|
||||
WsFrameTests.cs
|
||||
WsCompressionTests.cs
|
||||
WsOriginCheckerTests.cs
|
||||
WsIntegrationTests.cs
|
||||
```
|
||||
|
||||
Modified existing files:
|
||||
- `NatsOptions.cs` — Add `WebSocketOptions` class
|
||||
- `NatsServer.cs` — Second accept loop for WebSocket port
|
||||
- `NatsClient.cs` — `IsWebSocket` flag, `WsInfo` metadata property
|
||||
|
||||
## Constants (WsConstants.cs)
|
||||
|
||||
Direct port of Go constants:
|
||||
|
||||
| Constant | Value | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `WsTextMessage` | 1 | Text frame opcode |
|
||||
| `WsBinaryMessage` | 2 | Binary frame opcode |
|
||||
| `WsCloseMessage` | 8 | Close frame opcode |
|
||||
| `WsPingMessage` | 9 | Ping frame opcode |
|
||||
| `WsPongMessage` | 10 | Pong frame opcode |
|
||||
| `WsFinalBit` | 0x80 | FIN bit in byte 0 |
|
||||
| `WsRsv1Bit` | 0x40 | RSV1 (compression) in byte 0 |
|
||||
| `WsMaskBit` | 0x80 | Mask bit in byte 1 |
|
||||
| `WsMaxFrameHeaderSize` | 14 | Max frame header bytes |
|
||||
| `WsMaxControlPayloadSize` | 125 | Max control frame payload |
|
||||
| `WsFrameSizeForBrowsers` | 4096 | Browser fragmentation limit |
|
||||
| `WsCompressThreshold` | 64 | Min payload size to compress |
|
||||
| Close codes | 1000-1015 | RFC 6455 Section 11.7 |
|
||||
| Paths | `/`, `/leafnode`, `/mqtt` | Client type routing |
|
||||
|
||||
## HTTP Upgrade Handshake (WsUpgrade.cs)
|
||||
|
||||
### Input
|
||||
Raw `Stream` (TCP or TLS) after socket accept.
|
||||
|
||||
### Validation (RFC 6455 Section 4.2.1)
|
||||
1. Parse HTTP request line — must be `GET <path> HTTP/1.1`
|
||||
2. Parse headers into dictionary
|
||||
3. Host header required
|
||||
4. `Upgrade` header must contain `"websocket"` (case-insensitive)
|
||||
5. `Connection` header must contain `"Upgrade"` (case-insensitive)
|
||||
6. `Sec-WebSocket-Version` must be `"13"`
|
||||
7. `Sec-WebSocket-Key` must be present
|
||||
8. Path routing: `/` -> Client, `/leafnode` -> Leaf, `/mqtt` -> Mqtt
|
||||
9. Origin checking via `WsOriginChecker` if configured
|
||||
10. Compression: parse `Sec-WebSocket-Extensions` for `permessage-deflate`
|
||||
11. No-masking: check `Nats-No-Masking` header (for leaf nodes)
|
||||
12. Browser detection: `User-Agent` contains `"Mozilla/"`, Safari for `nocompfrag`
|
||||
13. Cookie extraction: map configured cookie names to values
|
||||
14. X-Forwarded-For: extract client IP
|
||||
|
||||
### Response
|
||||
```
|
||||
HTTP/1.1 101 Switching Protocols\r\n
|
||||
Upgrade: websocket\r\n
|
||||
Connection: Upgrade\r\n
|
||||
Sec-WebSocket-Accept: <base64(SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))>\r\n
|
||||
[Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n]
|
||||
[Nats-No-Masking: true\r\n]
|
||||
[Custom headers\r\n]
|
||||
\r\n
|
||||
```
|
||||
|
||||
### Result Type
|
||||
```csharp
|
||||
public readonly record struct WsUpgradeResult(
|
||||
bool Success,
|
||||
bool Compress,
|
||||
bool Browser,
|
||||
bool NoCompFrag,
|
||||
bool MaskRead,
|
||||
bool MaskWrite,
|
||||
string? CookieJwt,
|
||||
string? CookieUsername,
|
||||
string? CookiePassword,
|
||||
string? CookieToken,
|
||||
string? ClientIp,
|
||||
WsClientKind Kind);
|
||||
|
||||
public enum WsClientKind { Client, Leaf, Mqtt }
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Return standard HTTP error responses (400, 403, etc.) with body text for invalid requests.
|
||||
|
||||
## Frame Reading (WsReadInfo.cs)
|
||||
|
||||
### State Machine
|
||||
```csharp
|
||||
public struct WsReadInfo
|
||||
{
|
||||
public int Remaining; // Bytes left in current frame payload
|
||||
public bool FrameStart; // Reading new frame header
|
||||
public bool FirstFrame; // First frame of fragmented message
|
||||
public bool FrameCompressed; // Message is compressed (RSV1)
|
||||
public bool ExpectMask; // Client frames should be masked
|
||||
public byte MaskKeyPos; // Position in 4-byte mask key
|
||||
public byte[] MaskKey; // 4-byte XOR mask
|
||||
public List<byte[]>? CompressedBuffers;
|
||||
public int CompressedOffset;
|
||||
}
|
||||
```
|
||||
|
||||
### Flow
|
||||
1. Parse frame header: FIN, RSV1, opcode, mask, length, mask key
|
||||
2. Handle control frames in-band (PING -> PONG, CLOSE -> close response)
|
||||
3. Unmask payload bytes (XOR with cycling 4-byte key, optimized for 8-byte chunks)
|
||||
4. If compressed: collect payloads across frames, decompress on final frame
|
||||
5. Return unframed NATS protocol bytes
|
||||
|
||||
### Decompression
|
||||
- Append magic trailer `[0x00, 0x00, 0xff, 0xff]` before decompressing
|
||||
- Use `DeflateStream` for decompression
|
||||
- Validate decompressed size against `MaxPayload`
|
||||
|
||||
## Frame Writing (WsFrameWriter.cs)
|
||||
|
||||
### Per-Connection State
|
||||
```csharp
|
||||
public sealed class WsFrameWriter
|
||||
{
|
||||
private readonly bool _compress;
|
||||
private readonly bool _maskWrite;
|
||||
private readonly bool _browser;
|
||||
private readonly bool _noCompFrag;
|
||||
private DeflateStream? _compressor;
|
||||
}
|
||||
```
|
||||
|
||||
### Flow
|
||||
1. If compression enabled and payload > 64 bytes: compress via `DeflateStream`
|
||||
2. If browser client and payload > 4096 bytes: fragment into chunks
|
||||
3. Build frame headers: FIN | RSV1 | opcode | MASK | length | mask key
|
||||
4. If masking: generate random 4-byte key, XOR payload
|
||||
5. Control frame helpers: `EnqueuePing`, `EnqueuePong`, `EnqueueClose`
|
||||
|
||||
### Close Frame
|
||||
- 2-byte status code (big-endian) + optional UTF-8 reason
|
||||
- Reason truncated to 125 bytes with `"..."` suffix
|
||||
- Status code mapping from `ClientClosedReason`:
|
||||
- ClientClosed -> 1000 (Normal)
|
||||
- Auth failure -> 1008 (Policy Violation)
|
||||
- Parse error -> 1002 (Protocol Error)
|
||||
- Payload too big -> 1009 (Message Too Big)
|
||||
- Other -> 1001 (Going Away) or 1011 (Server Error)
|
||||
|
||||
## WsConnection Stream Wrapper (WsConnection.cs)
|
||||
|
||||
Extends `Stream` to transparently wrap WebSocket framing around raw I/O:
|
||||
|
||||
- `ReadAsync`: Calls `WsRead` to decode frames, buffers decoded payloads, returns NATS bytes to caller
|
||||
- `WriteAsync`: Wraps payload in WS frames via `WsFrameWriter`, writes to inner stream
|
||||
- `EnqueueControlMessage`: Called by read loop for PING responses and close frames
|
||||
|
||||
NatsClient's `FillPipeAsync` and `RunWriteLoopAsync` work unchanged because `WsConnection` is a `Stream`.
|
||||
|
||||
## NatsServer Integration
|
||||
|
||||
### Second Accept Loop
|
||||
```
|
||||
StartAsync:
|
||||
if WebSocket.Port > 0:
|
||||
create _wsListener socket
|
||||
bind to WebSocket.Host:WebSocket.Port
|
||||
start RunWebSocketAcceptLoopAsync
|
||||
```
|
||||
|
||||
### WebSocket Accept Flow
|
||||
```
|
||||
Accept socket
|
||||
-> TLS negotiation (reuse TlsConnectionWrapper)
|
||||
-> WsUpgrade.TryUpgradeAsync (HTTP upgrade)
|
||||
-> Create WsConnection wrapping stream
|
||||
-> Create NatsClient with WsConnection as stream
|
||||
-> Set IsWebSocket = true, attach WsUpgradeResult
|
||||
-> RunClientAsync (same as TCP from here)
|
||||
```
|
||||
|
||||
### NatsClient Changes
|
||||
- `public bool IsWebSocket { get; set; }` flag
|
||||
- `public WsUpgradeResult? WsInfo { get; set; }` metadata
|
||||
- No changes to read/write loops — `WsConnection` handles framing transparently
|
||||
|
||||
### Shutdown
|
||||
- `ShutdownAsync` also closes `_wsListener`
|
||||
- WebSocket clients receive close frames before TCP disconnect
|
||||
- `LameDuckShutdownAsync` includes WS clients in stagger-close
|
||||
|
||||
## Configuration (WebSocketOptions)
|
||||
|
||||
```csharp
|
||||
public sealed class WebSocketOptions
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } // 0 = disabled
|
||||
public string? Advertise { get; set; }
|
||||
public string? NoAuthUser { get; set; }
|
||||
public string? JwtCookie { get; set; }
|
||||
public string? UsernameCookie { get; set; }
|
||||
public string? PasswordCookie { get; set; }
|
||||
public string? TokenCookie { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? Token { get; set; }
|
||||
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public bool NoTls { get; set; }
|
||||
public string? TlsCert { get; set; }
|
||||
public string? TlsKey { get; set; }
|
||||
public bool SameOrigin { get; set; }
|
||||
public List<string>? AllowedOrigins { get; set; }
|
||||
public bool Compression { get; set; }
|
||||
public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public TimeSpan? PingInterval { get; set; }
|
||||
public Dictionary<string, string>? Headers { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Rules
|
||||
- Port 0 = disabled, skip remaining validation
|
||||
- TLS required unless `NoTls = true`
|
||||
- AllowedOrigins must be valid URLs
|
||||
- Custom headers must not use reserved names
|
||||
- NoAuthUser must match existing user if specified
|
||||
|
||||
## Origin Checking (WsOriginChecker.cs)
|
||||
|
||||
1. No Origin header -> accept (per RFC for non-browser clients)
|
||||
2. If `SameOrigin`: compare origin host:port with request Host header
|
||||
3. If `AllowedOrigins`: check host against list, match scheme and port
|
||||
|
||||
Parsed from config URLs, stored as `Dictionary<string, (string scheme, int port)>`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**WsUpgradeTests.cs**
|
||||
- Valid upgrade -> 101
|
||||
- Missing/invalid headers -> error codes
|
||||
- Origin checking (same-origin, allowed, blocked)
|
||||
- Compression negotiation
|
||||
- No-masking header
|
||||
- Browser/Safari detection
|
||||
- Cookie extraction
|
||||
- Path routing
|
||||
- Handshake timeout
|
||||
- Custom/reserved headers
|
||||
|
||||
**WsFrameTests.cs**
|
||||
- Read uncompressed frames (various sizes)
|
||||
- Length encoding (7-bit, 16-bit, 64-bit)
|
||||
- Masking/unmasking round-trip
|
||||
- Control frames (PING, PONG, CLOSE)
|
||||
- Close frame status code and reason
|
||||
- Invalid frames (missing FIN on control, oversized control)
|
||||
- Fragmented messages
|
||||
- Compressed frame round-trip
|
||||
- Browser fragmentation at 4096
|
||||
- Safari no-compressed-fragmentation
|
||||
|
||||
**WsCompressionTests.cs**
|
||||
- Compress/decompress round-trip
|
||||
- Below threshold not compressed
|
||||
- Large payload compression
|
||||
- MaxPayload limit on decompression
|
||||
|
||||
**WsOriginCheckerTests.cs**
|
||||
- No origin -> accepted
|
||||
- Same-origin match/mismatch
|
||||
- Allowed origins match/mismatch
|
||||
- Scheme and port matching
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**WsIntegrationTests.cs**
|
||||
- Connect via raw WebSocket, CONNECT/INFO exchange
|
||||
- PUB/SUB over WebSocket
|
||||
- Multiple WS clients
|
||||
- Mixed TCP + WS clients interoperating
|
||||
- WS with compression
|
||||
- Graceful close (close frame exchange)
|
||||
- Server shutdown sends close frames
|
||||
|
||||
## Post-Implementation
|
||||
|
||||
- Update `differences.md` to mark WebSocket support as implemented
|
||||
- Update ports file to include WebSocket port
|
||||
2792
docs/plans/2026-02-23-websocket-plan.md
Normal file
2792
docs/plans/2026-02-23-websocket-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
17
docs/plans/2026-02-23-websocket-plan.md.tasks.json
Normal file
17
docs/plans/2026-02-23-websocket-plan.md.tasks.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-02-23-websocket-plan.md",
|
||||
"tasks": [
|
||||
{"id": 6, "subject": "Task 0: Add WebSocketOptions configuration", "status": "pending"},
|
||||
{"id": 7, "subject": "Task 1: Add WsConstants", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 2: Add WsOriginChecker", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 3: Add WsFrameWriter", "status": "pending", "blockedBy": [7, 8]},
|
||||
{"id": 10, "subject": "Task 4: Add WsReadInfo frame reader state machine", "status": "pending", "blockedBy": [7, 8, 9]},
|
||||
{"id": 11, "subject": "Task 5: Add WsCompression (permessage-deflate)", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 12, "subject": "Task 6: Add WsUpgrade HTTP handshake", "status": "pending", "blockedBy": [7, 8, 11]},
|
||||
{"id": 13, "subject": "Task 7: Add WsConnection Stream wrapper", "status": "pending", "blockedBy": [7, 9, 10, 11]},
|
||||
{"id": 14, "subject": "Task 8: Integrate WebSocket into NatsServer and NatsClient", "status": "pending", "blockedBy": [6, 7, 12, 13]},
|
||||
{"id": 15, "subject": "Task 9: Update differences.md", "status": "pending", "blockedBy": [14]},
|
||||
{"id": 16, "subject": "Task 10: Run full test suite and verify", "status": "pending", "blockedBy": [14, 15]}
|
||||
],
|
||||
"lastUpdated": "2026-02-23T00:00:00Z"
|
||||
}
|
||||
@@ -9,8 +9,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Serilog.Sinks.SyslogMessages" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,53 +1,240 @@
|
||||
using NATS.Server;
|
||||
using NATS.Server.Configuration;
|
||||
using Serilog;
|
||||
using Serilog.Sinks.SystemConsole.Themes;
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||
.CreateLogger();
|
||||
// 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 options = new NatsOptions();
|
||||
var windowsService = false;
|
||||
|
||||
// Simple CLI argument parsing
|
||||
// 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:
|
||||
// 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;
|
||||
break;
|
||||
case "--log_level_override" when i + 1 < args.Length:
|
||||
var parts = args[++i].Split('=', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
options.LogOverrides ??= new();
|
||||
options.LogOverrides[parts[0]] = parts[1];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
|
||||
var server = new NatsServer(options, loggerFactory);
|
||||
// Build Serilog configuration from options
|
||||
var logConfig = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext();
|
||||
|
||||
var cts = new CancellationTokenSource();
|
||||
// Set minimum level based on flags
|
||||
if (options.Trace)
|
||||
logConfig.MinimumLevel.Verbose();
|
||||
else if (options.Debug)
|
||||
logConfig.MinimumLevel.Debug();
|
||||
else
|
||||
logConfig.MinimumLevel.Information();
|
||||
|
||||
// Build output template
|
||||
var timestampFormat = options.LogtimeUTC
|
||||
? "{Timestamp:yyyy/MM/dd HH:mm:ss.ffffff} "
|
||||
: "{Timestamp:HH:mm:ss} ";
|
||||
var template = options.Logtime
|
||||
? $"[{timestampFormat}{{Level:u3}}] {{Message:lj}}{{NewLine}}{{Exception}}"
|
||||
: "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
// Console sink with color auto-detection
|
||||
if (!Console.IsOutputRedirected)
|
||||
logConfig.WriteTo.Console(outputTemplate: template, theme: AnsiConsoleTheme.Code);
|
||||
else
|
||||
logConfig.WriteTo.Console(outputTemplate: template);
|
||||
|
||||
// File sink with rotation
|
||||
if (!string.IsNullOrEmpty(options.LogFile))
|
||||
{
|
||||
logConfig.WriteTo.File(
|
||||
options.LogFile,
|
||||
fileSizeLimitBytes: options.LogSizeLimit > 0 ? options.LogSizeLimit : null,
|
||||
retainedFileCountLimit: options.LogMaxFiles > 0 ? options.LogMaxFiles : null,
|
||||
rollOnFileSizeLimit: options.LogSizeLimit > 0,
|
||||
outputTemplate: template);
|
||||
}
|
||||
|
||||
// Syslog sink
|
||||
if (!string.IsNullOrEmpty(options.RemoteSyslog))
|
||||
{
|
||||
logConfig.WriteTo.UdpSyslog(options.RemoteSyslog);
|
||||
}
|
||||
else if (options.Syslog)
|
||||
{
|
||||
logConfig.WriteTo.LocalSyslog("nats-server");
|
||||
}
|
||||
|
||||
// Apply per-subsystem log level overrides
|
||||
if (options.LogOverrides is not null)
|
||||
{
|
||||
foreach (var (ns, level) in options.LogOverrides)
|
||||
{
|
||||
if (Enum.TryParse<Serilog.Events.LogEventLevel>(level, true, out var serilogLevel))
|
||||
logConfig.MinimumLevel.Override(ns, serilogLevel);
|
||||
}
|
||||
}
|
||||
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
|
||||
if (windowsService)
|
||||
{
|
||||
Log.Information("Windows Service mode requested");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
server.ReOpenLogFile = () =>
|
||||
{
|
||||
Log.Information("Reopening log file");
|
||||
Log.CloseAndFlush();
|
||||
Log.Logger = logConfig.CreateLogger();
|
||||
Log.Information("File log re-opened");
|
||||
};
|
||||
|
||||
// Ctrl+C triggers graceful shutdown
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
Log.Information("Trapped SIGINT signal");
|
||||
_ = Task.Run(async () => await server.ShutdownAsync());
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await server.StartAsync(cts.Token);
|
||||
_ = server.StartAsync(CancellationToken.None);
|
||||
await server.WaitForReadyAsync();
|
||||
server.WaitForShutdown();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using NATS.Server.Imports;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
@@ -10,8 +11,31 @@ public sealed class Account : IDisposable
|
||||
public string Name { get; }
|
||||
public SubList SubList { get; } = new();
|
||||
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; }
|
||||
public string? Issuer { get; set; }
|
||||
public Dictionary<string, object>? SigningKeys { get; set; }
|
||||
private readonly ConcurrentDictionary<string, long> _revokedUsers = new(StringComparer.Ordinal);
|
||||
|
||||
public void RevokeUser(string userNkey, long issuedAt) => _revokedUsers[userNkey] = issuedAt;
|
||||
|
||||
public bool IsUserRevoked(string userNkey, long issuedAt)
|
||||
{
|
||||
if (_revokedUsers.TryGetValue(userNkey, out var revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
// Check "*" wildcard for all-user revocation
|
||||
if (_revokedUsers.TryGetValue("*", out revokedAt))
|
||||
return issuedAt <= revokedAt;
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, byte> _clients = new();
|
||||
private int _subscriptionCount;
|
||||
|
||||
public Account(string name)
|
||||
{
|
||||
@@ -19,10 +43,126 @@ public sealed class Account : IDisposable
|
||||
}
|
||||
|
||||
public int ClientCount => _clients.Count;
|
||||
public int SubscriptionCount => Volatile.Read(ref _subscriptionCount);
|
||||
|
||||
public void AddClient(ulong clientId) => _clients[clientId] = 0;
|
||||
/// <summary>Returns false if max connections exceeded.</summary>
|
||||
public bool AddClient(ulong clientId)
|
||||
{
|
||||
if (MaxConnections > 0 && _clients.Count >= MaxConnections)
|
||||
return false;
|
||||
_clients[clientId] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _);
|
||||
|
||||
public bool IncrementSubscriptions()
|
||||
{
|
||||
if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions)
|
||||
return false;
|
||||
Interlocked.Increment(ref _subscriptionCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void DecrementSubscriptions()
|
||||
{
|
||||
Interlocked.Decrement(ref _subscriptionCount);
|
||||
}
|
||||
|
||||
// Per-account message/byte stats
|
||||
private long _inMsgs;
|
||||
private long _outMsgs;
|
||||
private long _inBytes;
|
||||
private long _outBytes;
|
||||
|
||||
public long InMsgs => Interlocked.Read(ref _inMsgs);
|
||||
public long OutMsgs => Interlocked.Read(ref _outMsgs);
|
||||
public long InBytes => Interlocked.Read(ref _inBytes);
|
||||
public long OutBytes => Interlocked.Read(ref _outBytes);
|
||||
|
||||
public void IncrementInbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _inMsgs, msgs);
|
||||
Interlocked.Add(ref _inBytes, bytes);
|
||||
}
|
||||
|
||||
public void IncrementOutbound(long msgs, long bytes)
|
||||
{
|
||||
Interlocked.Add(ref _outMsgs, msgs);
|
||||
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();
|
||||
}
|
||||
|
||||
8
src/NATS.Server/Auth/AccountConfig.cs
Normal file
8
src/NATS.Server/Auth/AccountConfig.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class AccountConfig
|
||||
{
|
||||
public int MaxConnections { get; init; } // 0 = unlimited
|
||||
public int MaxSubscriptions { get; init; } // 0 = unlimited
|
||||
public Permissions? DefaultPermissions { get; init; }
|
||||
}
|
||||
@@ -34,6 +34,21 @@ public sealed class AuthService
|
||||
var nonceRequired = false;
|
||||
Dictionary<string, User>? usersMap = null;
|
||||
|
||||
// TLS certificate mapping (highest priority when enabled)
|
||||
if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
|
||||
{
|
||||
authenticators.Add(new TlsMapAuthenticator(options.Users));
|
||||
authRequired = true;
|
||||
}
|
||||
|
||||
// JWT / Operator mode (highest priority after TLS)
|
||||
if (options.TrustedKeys is { Length: > 0 } && options.AccountResolver is not null)
|
||||
{
|
||||
authenticators.Add(new JwtAuthenticator(options.TrustedKeys, options.AccountResolver));
|
||||
authRequired = true;
|
||||
nonceRequired = true;
|
||||
}
|
||||
|
||||
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
||||
|
||||
if (options.NKeys is { Count: > 0 })
|
||||
@@ -92,7 +107,8 @@ public sealed class AuthService
|
||||
&& string.IsNullOrEmpty(opts.Password)
|
||||
&& string.IsNullOrEmpty(opts.Token)
|
||||
&& string.IsNullOrEmpty(opts.Nkey)
|
||||
&& string.IsNullOrEmpty(opts.Sig);
|
||||
&& string.IsNullOrEmpty(opts.Sig)
|
||||
&& string.IsNullOrEmpty(opts.JWT);
|
||||
}
|
||||
|
||||
private AuthResult? ResolveNoAuthUser()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Collections.Concurrent;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
@@ -7,12 +6,14 @@ public sealed class ClientPermissions : IDisposable
|
||||
{
|
||||
private readonly PermissionSet? _publish;
|
||||
private readonly PermissionSet? _subscribe;
|
||||
private readonly ConcurrentDictionary<string, bool> _pubCache = new(StringComparer.Ordinal);
|
||||
private readonly ResponseTracker? _responseTracker;
|
||||
private readonly PermissionLruCache _pubCache = new(128);
|
||||
|
||||
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe)
|
||||
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe, ResponseTracker? responseTracker)
|
||||
{
|
||||
_publish = publish;
|
||||
_subscribe = subscribe;
|
||||
_responseTracker = responseTracker;
|
||||
}
|
||||
|
||||
public static ClientPermissions? Build(Permissions? permissions)
|
||||
@@ -22,27 +23,55 @@ public sealed class ClientPermissions : IDisposable
|
||||
|
||||
var pub = PermissionSet.Build(permissions.Publish);
|
||||
var sub = PermissionSet.Build(permissions.Subscribe);
|
||||
ResponseTracker? responseTracker = null;
|
||||
if (permissions.Response != null)
|
||||
responseTracker = new ResponseTracker(permissions.Response.MaxMsgs, permissions.Response.Expires);
|
||||
|
||||
if (pub == null && sub == null)
|
||||
if (pub == null && sub == null && responseTracker == null)
|
||||
return null;
|
||||
|
||||
return new ClientPermissions(pub, sub);
|
||||
return new ClientPermissions(pub, sub, responseTracker);
|
||||
}
|
||||
|
||||
public ResponseTracker? ResponseTracker => _responseTracker;
|
||||
|
||||
public bool IsPublishAllowed(string subject)
|
||||
{
|
||||
if (_publish == null)
|
||||
return true;
|
||||
|
||||
return _pubCache.GetOrAdd(subject, _publish.IsAllowed);
|
||||
if (_pubCache.TryGet(subject, out var cached))
|
||||
return cached;
|
||||
|
||||
var allowed = _publish.IsAllowed(subject);
|
||||
|
||||
// If denied but response tracking is enabled, check reply table
|
||||
if (!allowed && _responseTracker != null)
|
||||
{
|
||||
if (_responseTracker.IsReplyAllowed(subject))
|
||||
return true; // Don't cache dynamic reply permissions
|
||||
}
|
||||
|
||||
_pubCache.Set(subject, allowed);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
public bool IsSubscribeAllowed(string subject, string? queue = null)
|
||||
{
|
||||
if (_subscribe == null)
|
||||
return true;
|
||||
if (!_subscribe.IsAllowed(subject))
|
||||
return false;
|
||||
if (queue != null && _subscribe.IsDenied(queue))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return _subscribe.IsAllowed(subject);
|
||||
public bool IsDeliveryAllowed(string subject)
|
||||
{
|
||||
if (_subscribe == null)
|
||||
return true;
|
||||
return _subscribe.IsDeliveryAllowed(subject);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -113,6 +142,21 @@ public sealed class PermissionSet : IDisposable
|
||||
return allowed;
|
||||
}
|
||||
|
||||
public bool IsDenied(string subject)
|
||||
{
|
||||
if (_deny == null) return false;
|
||||
var result = _deny.Match(subject);
|
||||
return result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
|
||||
}
|
||||
|
||||
public bool IsDeliveryAllowed(string subject)
|
||||
{
|
||||
if (_deny == null)
|
||||
return true;
|
||||
var result = _deny.Match(subject);
|
||||
return result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_allow?.Dispose();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
@@ -11,4 +12,5 @@ public sealed class ClientAuthContext
|
||||
{
|
||||
public required ClientOptions Opts { get; init; }
|
||||
public required byte[] Nonce { get; init; }
|
||||
public X509Certificate2? ClientCertificate { get; init; }
|
||||
}
|
||||
|
||||
94
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
94
src/NATS.Server/Auth/Jwt/AccountClaims.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS account JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with account limits, signing keys, and revocations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — AccountClaims, Account, OperatorLimits types
|
||||
/// </remarks>
|
||||
public sealed class AccountClaims
|
||||
{
|
||||
/// <summary>Subject — the account's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the operator or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the account.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific account claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public AccountNats? Nats { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of account JWT claims.
|
||||
/// Contains limits, signing keys, and user revocations.
|
||||
/// </summary>
|
||||
public sealed class AccountNats
|
||||
{
|
||||
/// <summary>Account resource limits.</summary>
|
||||
[JsonPropertyName("limits")]
|
||||
public AccountLimits? Limits { get; set; }
|
||||
|
||||
/// <summary>NKey public keys authorized to sign user JWTs for this account.</summary>
|
||||
[JsonPropertyName("signing_keys")]
|
||||
public string[]? SigningKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Map of revoked user NKey public keys to the Unix epoch time of revocation.
|
||||
/// Any user JWT issued before the revocation time is considered revoked.
|
||||
/// </summary>
|
||||
[JsonPropertyName("revocations")]
|
||||
public Dictionary<string, long>? Revocations { get; set; }
|
||||
|
||||
/// <summary>Tags associated with this account.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "account").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource limits for a NATS account. A value of -1 means unlimited.
|
||||
/// </summary>
|
||||
public sealed class AccountLimits
|
||||
{
|
||||
/// <summary>Maximum number of connections. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("conn")]
|
||||
public long MaxConnections { get; set; }
|
||||
|
||||
/// <summary>Maximum number of subscriptions. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("subs")]
|
||||
public long MaxSubscriptions { get; set; }
|
||||
|
||||
/// <summary>Maximum payload size in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("payload")]
|
||||
public long MaxPayload { get; set; }
|
||||
|
||||
/// <summary>Maximum data transfer in bytes. -1 means unlimited.</summary>
|
||||
[JsonPropertyName("data")]
|
||||
public long MaxData { get; set; }
|
||||
}
|
||||
65
src/NATS.Server/Auth/Jwt/AccountResolver.cs
Normal file
65
src/NATS.Server/Auth/Jwt/AccountResolver.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves account JWTs by account NKey public key. The server calls
|
||||
/// <see cref="FetchAsync"/> during client authentication to obtain the
|
||||
/// account JWT that was previously published by an account operator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/accounts.go:4035+ — AccountResolver interface
|
||||
/// and MemAccResolver implementation.
|
||||
/// </remarks>
|
||||
public interface IAccountResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches the JWT for the given account NKey. Returns <c>null</c> when
|
||||
/// the NKey is not known to this resolver.
|
||||
/// </summary>
|
||||
Task<string?> FetchAsync(string accountNkey);
|
||||
|
||||
/// <summary>
|
||||
/// Stores (or replaces) the JWT for the given account NKey. Callers that
|
||||
/// target a read-only resolver should check <see cref="IsReadOnly"/> first.
|
||||
/// </summary>
|
||||
Task StoreAsync(string accountNkey, string jwt);
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <see cref="StoreAsync"/> is not supported and will
|
||||
/// throw <see cref="NotSupportedException"/>. Directory and URL resolvers
|
||||
/// may be read-only; in-memory resolvers are not.
|
||||
/// </summary>
|
||||
bool IsReadOnly { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
/// Suitable for tests and simple single-operator deployments where account JWTs
|
||||
/// are provided at startup via <see cref="StoreAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/accounts.go — MemAccResolver
|
||||
/// </remarks>
|
||||
public sealed class MemAccountResolver : IAccountResolver
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _accounts =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReadOnly => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string?> FetchAsync(string accountNkey)
|
||||
{
|
||||
_accounts.TryGetValue(accountNkey, out var jwt);
|
||||
return Task.FromResult(jwt);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task StoreAsync(string accountNkey, string jwt)
|
||||
{
|
||||
_accounts[accountNkey] = jwt;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
221
src/NATS.Server/Auth/Jwt/NatsJwt.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.NKeys;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Provides NATS JWT decode, verify, and claim extraction.
|
||||
/// NATS JWTs are standard JWT format (base64url header.payload.signature) with Ed25519 signing.
|
||||
/// All NATS JWTs start with "eyJ" (base64url for '{"').
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: golang/nats-server/server/jwt.go and github.com/nats-io/jwt/v2
|
||||
/// </remarks>
|
||||
public static class NatsJwt
|
||||
{
|
||||
private const string JwtPrefix = "eyJ";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the string appears to be a JWT (starts with "eyJ").
|
||||
/// </summary>
|
||||
public static bool IsJwt(string token)
|
||||
{
|
||||
return !string.IsNullOrEmpty(token) && token.StartsWith(JwtPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token into its constituent parts without verifying the signature.
|
||||
/// Returns null if the token is structurally invalid.
|
||||
/// </summary>
|
||||
public static JwtToken? Decode(string token)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return null;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var headerBytes = Base64UrlDecode(parts[0]);
|
||||
var payloadBytes = Base64UrlDecode(parts[1]);
|
||||
var signatureBytes = Base64UrlDecode(parts[2]);
|
||||
|
||||
var header = JsonSerializer.Deserialize<JwtHeader>(headerBytes);
|
||||
if (header is null)
|
||||
return null;
|
||||
|
||||
var payloadJson = Encoding.UTF8.GetString(payloadBytes);
|
||||
var signingInput = $"{parts[0]}.{parts[1]}";
|
||||
|
||||
return new JwtToken
|
||||
{
|
||||
Header = header,
|
||||
PayloadJson = payloadJson,
|
||||
Signature = signatureBytes,
|
||||
SigningInput = signingInput,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="UserClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static UserClaims? DecodeUserClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<UserClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a JWT token and deserializes the payload as <see cref="AccountClaims"/>.
|
||||
/// Returns null if the token is structurally invalid or cannot be deserialized.
|
||||
/// </summary>
|
||||
public static AccountClaims? DecodeAccountClaims(string token)
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<AccountClaims>(jwt.PayloadJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Ed25519 signature on a JWT token against the given NKey public key.
|
||||
/// </summary>
|
||||
public static bool Verify(string token, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var jwt = Decode(token);
|
||||
if (jwt is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
var signingInputBytes = Encoding.UTF8.GetBytes(jwt.SigningInput);
|
||||
return kp.Verify(signingInputBytes, jwt.Signature);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a nonce signature against the given NKey public key.
|
||||
/// Tries base64url decoding first, then falls back to standard base64 (Go compatibility).
|
||||
/// </summary>
|
||||
public static bool VerifyNonce(byte[] nonce, string signature, string publicNkey)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sigBytes = TryDecodeSignature(signature);
|
||||
if (sigBytes is null)
|
||||
return false;
|
||||
|
||||
var kp = KeyPair.FromPublicKey(publicNkey);
|
||||
return kp.Verify(nonce, sigBytes);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a base64url-encoded byte array.
|
||||
/// Replaces URL-safe characters and adds padding as needed.
|
||||
/// </summary>
|
||||
internal static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to decode a signature string. Tries base64url first, then standard base64.
|
||||
/// Returns null if neither encoding works.
|
||||
/// </summary>
|
||||
private static byte[]? TryDecodeSignature(string signature)
|
||||
{
|
||||
// Try base64url first
|
||||
try
|
||||
{
|
||||
return Base64UrlDecode(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// Fall through to standard base64
|
||||
}
|
||||
|
||||
// Try standard base64
|
||||
try
|
||||
{
|
||||
return Convert.FromBase64String(signature);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a decoded JWT token with its constituent parts.
|
||||
/// </summary>
|
||||
public sealed class JwtToken
|
||||
{
|
||||
/// <summary>The decoded JWT header.</summary>
|
||||
public required JwtHeader Header { get; init; }
|
||||
|
||||
/// <summary>The raw JSON string of the payload.</summary>
|
||||
public required string PayloadJson { get; init; }
|
||||
|
||||
/// <summary>The raw signature bytes.</summary>
|
||||
public required byte[] Signature { get; init; }
|
||||
|
||||
/// <summary>The signing input (header.payload in base64url) used for signature verification.</summary>
|
||||
public required string SigningInput { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS JWT header. Algorithm is "ed25519-nkey" for NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtHeader
|
||||
{
|
||||
[System.Text.Json.Serialization.JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("typ")]
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
123
src/NATS.Server/Auth/Jwt/PermissionTemplates.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Expands mustache-style template strings in NATS JWT permission subjects.
|
||||
/// When a user connects with a JWT, template strings in their permissions are
|
||||
/// expanded using claim values from the user and account JWTs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: Go auth.go:424-520 — processUserPermissionsTemplate()
|
||||
///
|
||||
/// Supported template functions:
|
||||
/// {{name()}} — user's Name claim
|
||||
/// {{subject()}} — user's Subject (NKey public key)
|
||||
/// {{tag(tagname)}} — user tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
/// {{account-name()}} — account display name
|
||||
/// {{account-subject()}} — account NKey public key
|
||||
/// {{account-tag(tagname)}} — account tags matching "tagname:" prefix (multi-value → cartesian product)
|
||||
///
|
||||
/// When a template resolves to multiple values (e.g. a user with two "dept:" tags),
|
||||
/// the cartesian product of all expanded subjects is returned. If any template
|
||||
/// resolves to zero values, the entire pattern is dropped (returns empty list).
|
||||
/// </remarks>
|
||||
public static partial class PermissionTemplates
|
||||
{
|
||||
[GeneratedRegex(@"\{\{([^}]+)\}\}")]
|
||||
private static partial Regex TemplateRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Expands a single permission pattern containing zero or more template expressions.
|
||||
/// Returns the list of concrete subjects after substitution.
|
||||
/// Returns an empty list if any template resolves to no values (tag not found).
|
||||
/// Returns a single-element list containing the original pattern if no templates are present.
|
||||
/// </summary>
|
||||
public static List<string> Expand(
|
||||
string pattern,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var matches = TemplateRegex().Matches(pattern);
|
||||
if (matches.Count == 0)
|
||||
return [pattern];
|
||||
|
||||
var replacements = new List<(string Placeholder, string[] Values)>();
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var expr = match.Groups[1].Value.Trim();
|
||||
var values = ResolveTemplate(expr, name, subject, accountName, accountSubject, userTags, accountTags);
|
||||
if (values.Length == 0)
|
||||
return [];
|
||||
replacements.Add((match.Value, values));
|
||||
}
|
||||
|
||||
// Compute cartesian product across all multi-value replacements.
|
||||
// Start with the full pattern and iteratively replace each placeholder.
|
||||
var results = new List<string> { pattern };
|
||||
foreach (var (placeholder, values) in replacements)
|
||||
{
|
||||
var next = new List<string>();
|
||||
foreach (var current in results)
|
||||
foreach (var value in values)
|
||||
next.Add(current.Replace(placeholder, value));
|
||||
results = next;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands all patterns in a permission list, flattening multi-value expansions
|
||||
/// into the result. Patterns that resolve to no values are omitted entirely.
|
||||
/// </summary>
|
||||
public static List<string> ExpandAll(
|
||||
IEnumerable<string> patterns,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var pattern in patterns)
|
||||
result.AddRange(Expand(pattern, name, subject, accountName, accountSubject, userTags, accountTags));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string[] ResolveTemplate(
|
||||
string expr,
|
||||
string name, string subject,
|
||||
string accountName, string accountSubject,
|
||||
string[] userTags, string[] accountTags)
|
||||
{
|
||||
return expr.ToLowerInvariant() switch
|
||||
{
|
||||
"name()" => [name],
|
||||
"subject()" => [subject],
|
||||
"account-name()" => [accountName],
|
||||
"account-subject()" => [accountSubject],
|
||||
_ when expr.StartsWith("tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, userTags),
|
||||
_ when expr.StartsWith("account-tag(", StringComparison.OrdinalIgnoreCase) => ResolveTags(expr, accountTags),
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the tag name from a tag() or account-tag() expression and returns
|
||||
/// all matching tag values from the provided tags array.
|
||||
/// Tags are stored in "key:value" format; this method returns the value portion.
|
||||
/// </summary>
|
||||
private static string[] ResolveTags(string expr, string[] tags)
|
||||
{
|
||||
var openParen = expr.IndexOf('(');
|
||||
var closeParen = expr.IndexOf(')');
|
||||
if (openParen < 0 || closeParen < 0)
|
||||
return [];
|
||||
|
||||
var tagName = expr[(openParen + 1)..closeParen].Trim();
|
||||
var prefix = tagName + ":";
|
||||
return tags
|
||||
.Where(t => t.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(t => t[prefix.Length..])
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
173
src/NATS.Server/Auth/Jwt/UserClaims.cs
Normal file
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Auth.Jwt;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the claims in a NATS user JWT.
|
||||
/// Contains standard JWT fields (sub, iss, iat, exp) and a NATS-specific nested object
|
||||
/// with user permissions, bearer token flags, and connection restrictions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reference: github.com/nats-io/jwt/v2 — UserClaims, User, Permission types
|
||||
/// </remarks>
|
||||
public sealed class UserClaims
|
||||
{
|
||||
/// <summary>Subject — the user's NKey public key.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
/// <summary>Issuer — the account or signing key that issued this JWT.</summary>
|
||||
[JsonPropertyName("iss")]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
/// <summary>Issued-at time as Unix epoch seconds.</summary>
|
||||
[JsonPropertyName("iat")]
|
||||
public long IssuedAt { get; set; }
|
||||
|
||||
/// <summary>Expiration time as Unix epoch seconds. 0 means no expiry.</summary>
|
||||
[JsonPropertyName("exp")]
|
||||
public long Expires { get; set; }
|
||||
|
||||
/// <summary>Human-readable name for the user.</summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>NATS-specific user claims.</summary>
|
||||
[JsonPropertyName("nats")]
|
||||
public UserNats? Nats { get; set; }
|
||||
|
||||
// =========================================================================
|
||||
// Convenience properties that delegate to the Nats sub-object
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>Whether this is a bearer token (no client nonce signature required).</summary>
|
||||
[JsonIgnore]
|
||||
public bool BearerToken => Nats?.BearerToken ?? false;
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonIgnore]
|
||||
public string? IssuerAccount => Nats?.IssuerAccount;
|
||||
|
||||
// =========================================================================
|
||||
// Expiry helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the JWT has expired. A zero Expires value means no expiry.
|
||||
/// </summary>
|
||||
public bool IsExpired()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return false;
|
||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() > Expires;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the expiry as a <see cref="DateTimeOffset"/>, or null if there is no expiry (Expires == 0).
|
||||
/// </summary>
|
||||
public DateTimeOffset? GetExpiry()
|
||||
{
|
||||
if (Expires == 0)
|
||||
return null;
|
||||
return DateTimeOffset.FromUnixTimeSeconds(Expires);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// NATS-specific portion of user JWT claims.
|
||||
/// Contains permissions, bearer token flag, connection restrictions, and more.
|
||||
/// </summary>
|
||||
public sealed class UserNats
|
||||
{
|
||||
/// <summary>Publish permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("pub")]
|
||||
public JwtSubjectPermission? Pub { get; set; }
|
||||
|
||||
/// <summary>Subscribe permission with allow/deny subject lists.</summary>
|
||||
[JsonPropertyName("sub")]
|
||||
public JwtSubjectPermission? Sub { get; set; }
|
||||
|
||||
/// <summary>Response permission controlling request-reply behavior.</summary>
|
||||
[JsonPropertyName("resp")]
|
||||
public JwtResponsePermission? Resp { get; set; }
|
||||
|
||||
/// <summary>Whether this is a bearer token (no nonce signature required).</summary>
|
||||
[JsonPropertyName("bearer_token")]
|
||||
public bool BearerToken { get; set; }
|
||||
|
||||
/// <summary>The account NKey public key that issued this user JWT.</summary>
|
||||
[JsonPropertyName("issuer_account")]
|
||||
public string? IssuerAccount { get; set; }
|
||||
|
||||
/// <summary>Tags associated with this user.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
/// <summary>Allowed source CIDRs for this user's connections.</summary>
|
||||
[JsonPropertyName("src")]
|
||||
public string[]? Src { get; set; }
|
||||
|
||||
/// <summary>Allowed connection types (e.g., "STANDARD", "WEBSOCKET", "LEAFNODE").</summary>
|
||||
[JsonPropertyName("allowed_connection_types")]
|
||||
public string[]? AllowedConnectionTypes { get; set; }
|
||||
|
||||
/// <summary>Time-of-day restrictions for when the user may connect.</summary>
|
||||
[JsonPropertyName("times")]
|
||||
public JwtTimeRange[]? Times { get; set; }
|
||||
|
||||
/// <summary>Claim type (e.g., "user").</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>Claim version.</summary>
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subject permission with allow and deny lists, as used in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtSubjectPermission
|
||||
{
|
||||
/// <summary>Subjects the user is allowed to publish/subscribe to.</summary>
|
||||
[JsonPropertyName("allow")]
|
||||
public string[]? Allow { get; set; }
|
||||
|
||||
/// <summary>Subjects the user is denied from publishing/subscribing to.</summary>
|
||||
[JsonPropertyName("deny")]
|
||||
public string[]? Deny { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response permission controlling request-reply behavior in NATS JWTs.
|
||||
/// </summary>
|
||||
public sealed class JwtResponsePermission
|
||||
{
|
||||
/// <summary>Maximum number of response messages allowed.</summary>
|
||||
[JsonPropertyName("max")]
|
||||
public int MaxMsgs { get; set; }
|
||||
|
||||
/// <summary>Time-to-live for the response permission, in nanoseconds.</summary>
|
||||
[JsonPropertyName("ttl")]
|
||||
public long TtlNanos { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Convenience property: converts <see cref="TtlNanos"/> to a <see cref="TimeSpan"/>.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public TimeSpan Ttl => TimeSpan.FromTicks(TtlNanos / 100); // 1 tick = 100 nanoseconds
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A time-of-day range for connection restrictions.
|
||||
/// </summary>
|
||||
public sealed class JwtTimeRange
|
||||
{
|
||||
/// <summary>Start time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("start")]
|
||||
public string? Start { get; set; }
|
||||
|
||||
/// <summary>End time in HH:mm:ss format.</summary>
|
||||
[JsonPropertyName("end")]
|
||||
public string? End { get; set; }
|
||||
}
|
||||
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
160
src/NATS.Server/Auth/JwtAuthenticator.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticator for JWT-based client connections.
|
||||
/// Decodes user JWT, resolves account, verifies signature, checks revocation.
|
||||
/// Reference: Go auth.go:588+ processClientOrLeafAuthentication.
|
||||
/// </summary>
|
||||
public sealed class JwtAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly string[] _trustedKeys;
|
||||
private readonly IAccountResolver _resolver;
|
||||
|
||||
public JwtAuthenticator(string[] trustedKeys, IAccountResolver resolver)
|
||||
{
|
||||
_trustedKeys = trustedKeys;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var jwt = context.Opts.JWT;
|
||||
if (string.IsNullOrEmpty(jwt) || !NatsJwt.IsJwt(jwt))
|
||||
return null;
|
||||
|
||||
// 1. Decode user claims
|
||||
var userClaims = NatsJwt.DecodeUserClaims(jwt);
|
||||
if (userClaims is null)
|
||||
return null;
|
||||
|
||||
// 2. Check expiry
|
||||
if (userClaims.IsExpired())
|
||||
return null;
|
||||
|
||||
// 3. Resolve issuing account
|
||||
var issuerAccount = !string.IsNullOrEmpty(userClaims.IssuerAccount)
|
||||
? userClaims.IssuerAccount
|
||||
: userClaims.Issuer;
|
||||
|
||||
if (string.IsNullOrEmpty(issuerAccount))
|
||||
return null;
|
||||
|
||||
var accountJwt = _resolver.FetchAsync(issuerAccount).GetAwaiter().GetResult();
|
||||
if (accountJwt is null)
|
||||
return null;
|
||||
|
||||
var accountClaims = NatsJwt.DecodeAccountClaims(accountJwt);
|
||||
if (accountClaims is null)
|
||||
return null;
|
||||
|
||||
// 4. Verify account issuer is trusted
|
||||
if (!IsTrusted(accountClaims.Issuer))
|
||||
return null;
|
||||
|
||||
// 5. Verify user JWT issuer is the account or a signing key
|
||||
var userIssuer = userClaims.Issuer;
|
||||
if (userIssuer != accountClaims.Subject)
|
||||
{
|
||||
// Check if issuer is a signing key of the account
|
||||
var signingKeys = accountClaims.Nats?.SigningKeys;
|
||||
if (signingKeys is null || !signingKeys.Contains(userIssuer))
|
||||
return null;
|
||||
}
|
||||
|
||||
// 6. Verify nonce signature (unless bearer token)
|
||||
if (!userClaims.BearerToken)
|
||||
{
|
||||
if (context.Nonce is null || string.IsNullOrEmpty(context.Opts.Sig))
|
||||
return null;
|
||||
|
||||
var userNkey = userClaims.Subject ?? context.Opts.Nkey;
|
||||
if (string.IsNullOrEmpty(userNkey))
|
||||
return null;
|
||||
|
||||
if (!NatsJwt.VerifyNonce(context.Nonce, context.Opts.Sig, userNkey))
|
||||
return null;
|
||||
}
|
||||
|
||||
// 7. Check user revocation
|
||||
var revocations = accountClaims.Nats?.Revocations;
|
||||
if (revocations is not null && userClaims.Subject is not null)
|
||||
{
|
||||
if (revocations.TryGetValue(userClaims.Subject, out var revokedAt))
|
||||
{
|
||||
if (userClaims.IssuedAt <= revokedAt)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check wildcard revocation
|
||||
if (revocations.TryGetValue("*", out revokedAt))
|
||||
{
|
||||
if (userClaims.IssuedAt <= revokedAt)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Build permissions from JWT claims
|
||||
Permissions? permissions = null;
|
||||
var nats = userClaims.Nats;
|
||||
if (nats is not null)
|
||||
{
|
||||
var pubAllow = nats.Pub?.Allow;
|
||||
var pubDeny = nats.Pub?.Deny;
|
||||
var subAllow = nats.Sub?.Allow;
|
||||
var subDeny = nats.Sub?.Deny;
|
||||
|
||||
// Expand permission templates
|
||||
var name = userClaims.Name ?? "";
|
||||
var subject = userClaims.Subject ?? "";
|
||||
var acctName = accountClaims.Name ?? "";
|
||||
var acctSubject = accountClaims.Subject ?? "";
|
||||
var userTags = nats.Tags ?? [];
|
||||
var acctTags = accountClaims.Nats?.Tags ?? [];
|
||||
|
||||
if (pubAllow is { Length: > 0 })
|
||||
pubAllow = PermissionTemplates.ExpandAll(pubAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (pubDeny is { Length: > 0 })
|
||||
pubDeny = PermissionTemplates.ExpandAll(pubDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (subAllow is { Length: > 0 })
|
||||
subAllow = PermissionTemplates.ExpandAll(subAllow, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
if (subDeny is { Length: > 0 })
|
||||
subDeny = PermissionTemplates.ExpandAll(subDeny, name, subject, acctName, acctSubject, userTags, acctTags).ToArray();
|
||||
|
||||
if (pubAllow is not null || pubDeny is not null || subAllow is not null || subDeny is not null)
|
||||
{
|
||||
permissions = new Permissions
|
||||
{
|
||||
Publish = (pubAllow is not null || pubDeny is not null)
|
||||
? new SubjectPermission { Allow = pubAllow, Deny = pubDeny }
|
||||
: null,
|
||||
Subscribe = (subAllow is not null || subDeny is not null)
|
||||
? new SubjectPermission { Allow = subAllow, Deny = subDeny }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Build result
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = userClaims.Subject ?? "",
|
||||
AccountName = issuerAccount,
|
||||
Permissions = permissions,
|
||||
Expiry = userClaims.GetExpiry(),
|
||||
};
|
||||
}
|
||||
|
||||
private bool IsTrusted(string? issuer)
|
||||
{
|
||||
if (string.IsNullOrEmpty(issuer)) return false;
|
||||
foreach (var key in _trustedKeys)
|
||||
{
|
||||
if (key == issuer)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
73
src/NATS.Server/Auth/PermissionLruCache.cs
Normal file
73
src/NATS.Server/Auth/PermissionLruCache.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-capacity LRU cache for permission results.
|
||||
/// Lock-protected (per-client, low contention).
|
||||
/// Reference: Go client.go maxPermCacheSize=128.
|
||||
/// </summary>
|
||||
public sealed class PermissionLruCache
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly Dictionary<string, LinkedListNode<(string Key, bool Value)>> _map;
|
||||
private readonly LinkedList<(string Key, bool Value)> _list = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public PermissionLruCache(int capacity = 128)
|
||||
{
|
||||
_capacity = capacity;
|
||||
_map = new Dictionary<string, LinkedListNode<(string Key, bool Value)>>(capacity, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public bool TryGet(string key, out bool value)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var node))
|
||||
{
|
||||
value = node.Value.Value;
|
||||
_list.Remove(node);
|
||||
_list.AddFirst(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _map.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Set(string key, bool value)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_map.TryGetValue(key, out var existing))
|
||||
{
|
||||
_list.Remove(existing);
|
||||
existing.Value = (key, value);
|
||||
_list.AddFirst(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_map.Count >= _capacity)
|
||||
{
|
||||
var last = _list.Last!;
|
||||
_map.Remove(last.Value.Key);
|
||||
_list.RemoveLast();
|
||||
}
|
||||
|
||||
var node = new LinkedListNode<(string Key, bool Value)>((key, value));
|
||||
_list.AddFirst(node);
|
||||
_map[key] = node;
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/NATS.Server/Auth/ResponseTracker.cs
Normal file
78
src/NATS.Server/Auth/ResponseTracker.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks reply subjects that a client is temporarily allowed to publish to.
|
||||
/// Reference: Go client.go resp struct, setResponsePermissionIfNeeded.
|
||||
/// </summary>
|
||||
public sealed class ResponseTracker
|
||||
{
|
||||
private readonly int _maxMsgs; // 0 = unlimited
|
||||
private readonly TimeSpan _expires; // TimeSpan.Zero = no TTL
|
||||
private readonly Dictionary<string, (DateTime RegisteredAt, int Count)> _replies = new(StringComparer.Ordinal);
|
||||
private readonly object _lock = new();
|
||||
|
||||
public ResponseTracker(int maxMsgs, TimeSpan expires)
|
||||
{
|
||||
_maxMsgs = maxMsgs;
|
||||
_expires = expires;
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get { lock (_lock) return _replies.Count; }
|
||||
}
|
||||
|
||||
public void RegisterReply(string replySubject)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_replies[replySubject] = (DateTime.UtcNow, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReplyAllowed(string subject)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_replies.TryGetValue(subject, out var entry))
|
||||
return false;
|
||||
|
||||
if (_expires > TimeSpan.Zero && DateTime.UtcNow - entry.RegisteredAt > _expires)
|
||||
{
|
||||
_replies.Remove(subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
var newCount = entry.Count + 1;
|
||||
if (_maxMsgs > 0 && newCount > _maxMsgs)
|
||||
{
|
||||
_replies.Remove(subject);
|
||||
return false;
|
||||
}
|
||||
|
||||
_replies[subject] = (entry.RegisteredAt, newCount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Prune()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_expires <= TimeSpan.Zero && _maxMsgs <= 0)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var toRemove = new List<string>();
|
||||
foreach (var (key, entry) in _replies)
|
||||
{
|
||||
if (_expires > TimeSpan.Zero && now - entry.RegisteredAt > _expires)
|
||||
toRemove.Add(key);
|
||||
else if (_maxMsgs > 0 && entry.Count >= _maxMsgs)
|
||||
toRemove.Add(key);
|
||||
}
|
||||
foreach (var key in toRemove)
|
||||
_replies.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/NATS.Server/Auth/TlsMapAuthenticator.cs
Normal file
67
src/NATS.Server/Auth/TlsMapAuthenticator.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates clients by mapping TLS certificate subject DN to configured users.
|
||||
/// Corresponds to Go server/auth.go checkClientTLSCertSubject.
|
||||
/// </summary>
|
||||
public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
{
|
||||
private readonly Dictionary<string, User> _usersByDn;
|
||||
private readonly Dictionary<string, User> _usersByCn;
|
||||
|
||||
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
||||
{
|
||||
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||
_usersByCn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var user in users)
|
||||
{
|
||||
_usersByDn[user.Username] = user;
|
||||
_usersByCn[user.Username] = user;
|
||||
}
|
||||
}
|
||||
|
||||
public AuthResult? Authenticate(ClientAuthContext context)
|
||||
{
|
||||
var cert = context.ClientCertificate;
|
||||
if (cert == null)
|
||||
return null;
|
||||
|
||||
var dn = cert.SubjectName;
|
||||
var dnString = dn.Name; // RFC 2253 format
|
||||
|
||||
// Try exact DN match first
|
||||
if (_usersByDn.TryGetValue(dnString, out var user))
|
||||
return BuildResult(user);
|
||||
|
||||
// Try CN extraction
|
||||
var cn = ExtractCn(dn);
|
||||
if (cn != null && _usersByCn.TryGetValue(cn, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractCn(X500DistinguishedName dn)
|
||||
{
|
||||
var dnString = dn.Name;
|
||||
foreach (var rdn in dnString.Split(',', StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||||
return rdn[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AuthResult BuildResult(User user)
|
||||
{
|
||||
return new AuthResult
|
||||
{
|
||||
Identity = user.Username,
|
||||
AccountName = user.Account,
|
||||
Permissions = user.Permissions,
|
||||
Expiry = user.ConnectionDeadline,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
src/NATS.Server/ClientClosedReason.cs
Normal file
53
src/NATS.Server/ClientClosedReason.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace NATS.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Reason a client connection was closed.
|
||||
/// Corresponds to Go server/client.go ClosedState (subset for single-server scope).
|
||||
/// </summary>
|
||||
public enum ClientClosedReason
|
||||
{
|
||||
None = 0,
|
||||
ClientClosed,
|
||||
AuthenticationTimeout,
|
||||
AuthenticationViolation,
|
||||
TlsHandshakeError,
|
||||
SlowConsumerPendingBytes,
|
||||
SlowConsumerWriteDeadline,
|
||||
WriteError,
|
||||
ReadError,
|
||||
ParseError,
|
||||
StaleConnection,
|
||||
ProtocolViolation,
|
||||
MaxPayloadExceeded,
|
||||
MaxSubscriptionsExceeded,
|
||||
ServerShutdown,
|
||||
MsgHeaderViolation,
|
||||
NoRespondersRequiresHeaders,
|
||||
AuthenticationExpired,
|
||||
}
|
||||
|
||||
public static class ClientClosedReasonExtensions
|
||||
{
|
||||
public static string ToReasonString(this ClientClosedReason reason) => reason switch
|
||||
{
|
||||
ClientClosedReason.None => "",
|
||||
ClientClosedReason.ClientClosed => "Client Closed",
|
||||
ClientClosedReason.AuthenticationTimeout => "Authentication Timeout",
|
||||
ClientClosedReason.AuthenticationViolation => "Authorization Violation",
|
||||
ClientClosedReason.TlsHandshakeError => "TLS Handshake Error",
|
||||
ClientClosedReason.SlowConsumerPendingBytes => "Slow Consumer (Pending Bytes)",
|
||||
ClientClosedReason.SlowConsumerWriteDeadline => "Slow Consumer (Write Deadline)",
|
||||
ClientClosedReason.WriteError => "Write Error",
|
||||
ClientClosedReason.ReadError => "Read Error",
|
||||
ClientClosedReason.ParseError => "Parse Error",
|
||||
ClientClosedReason.StaleConnection => "Stale Connection",
|
||||
ClientClosedReason.ProtocolViolation => "Protocol Violation",
|
||||
ClientClosedReason.MaxPayloadExceeded => "Maximum Payload Exceeded",
|
||||
ClientClosedReason.MaxSubscriptionsExceeded => "Maximum Subscriptions Exceeded",
|
||||
ClientClosedReason.ServerShutdown => "Server Shutdown",
|
||||
ClientClosedReason.MsgHeaderViolation => "Message Header Violation",
|
||||
ClientClosedReason.NoRespondersRequiresHeaders => "No Responders Requires Headers",
|
||||
ClientClosedReason.AuthenticationExpired => "Authentication Expired",
|
||||
_ => reason.ToString(),
|
||||
};
|
||||
}
|
||||
42
src/NATS.Server/ClientFlags.cs
Normal file
42
src/NATS.Server/ClientFlags.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
namespace NATS.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Connection state flags tracked per client.
|
||||
/// Corresponds to Go server/client.go clientFlag bitfield.
|
||||
/// Thread-safe via Interlocked operations on the backing int.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ClientFlags
|
||||
{
|
||||
ConnectReceived = 1 << 0,
|
||||
FirstPongSent = 1 << 1,
|
||||
HandshakeComplete = 1 << 2,
|
||||
CloseConnection = 1 << 3,
|
||||
WriteLoopStarted = 1 << 4,
|
||||
IsSlowConsumer = 1 << 5,
|
||||
ConnectProcessFinished = 1 << 6,
|
||||
TraceMode = 1 << 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe holder for client flags using Interlocked operations.
|
||||
/// </summary>
|
||||
public sealed class ClientFlagHolder
|
||||
{
|
||||
private int _flags;
|
||||
|
||||
public void SetFlag(ClientFlags flag)
|
||||
{
|
||||
Interlocked.Or(ref _flags, (int)flag);
|
||||
}
|
||||
|
||||
public void ClearFlag(ClientFlags flag)
|
||||
{
|
||||
Interlocked.And(ref _flags, ~(int)flag);
|
||||
}
|
||||
|
||||
public bool HasFlag(ClientFlags flag)
|
||||
{
|
||||
return (Volatile.Read(ref _flags) & (int)flag) != 0;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
52
src/NATS.Server/ClosedState.cs
Normal file
52
src/NATS.Server/ClosedState.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
// Ported from Go: server/client.go:188-228
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Reason a client connection was closed. Stored in connection info for monitoring
|
||||
/// and passed to close handlers during connection teardown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Values start at 1 (matching Go's <c>iota + 1</c>) so that the default zero value
|
||||
/// is distinct from any valid close reason.
|
||||
/// </remarks>
|
||||
public enum ClosedState
|
||||
{
|
||||
ClientClosed = 1,
|
||||
AuthenticationTimeout,
|
||||
AuthenticationViolation,
|
||||
TLSHandshakeError,
|
||||
SlowConsumerPendingBytes,
|
||||
SlowConsumerWriteDeadline,
|
||||
WriteError,
|
||||
ReadError,
|
||||
ParseError,
|
||||
StaleConnection,
|
||||
ProtocolViolation,
|
||||
BadClientProtocolVersion,
|
||||
WrongPort,
|
||||
MaxAccountConnectionsExceeded,
|
||||
MaxConnectionsExceeded,
|
||||
MaxPayloadExceeded,
|
||||
MaxControlLineExceeded,
|
||||
MaxSubscriptionsExceeded,
|
||||
DuplicateRoute,
|
||||
RouteRemoved,
|
||||
ServerShutdown,
|
||||
AuthenticationExpired,
|
||||
WrongGateway,
|
||||
MissingAccount,
|
||||
Revocation,
|
||||
InternalClient,
|
||||
MsgHeaderViolation,
|
||||
NoRespondersRequiresHeaders,
|
||||
ClusterNameConflict,
|
||||
DuplicateRemoteLeafnodeConnection,
|
||||
DuplicateClientID,
|
||||
DuplicateServerName,
|
||||
MinimumVersionRequired,
|
||||
ClusterNamesIdentical,
|
||||
Kicked,
|
||||
ProxyNotTrusted,
|
||||
ProxyRequired,
|
||||
}
|
||||
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;
|
||||
}
|
||||
25
src/NATS.Server/Monitoring/ClosedClient.cs
Normal file
25
src/NATS.Server/Monitoring/ClosedClient.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a closed client connection for /connz reporting.
|
||||
/// </summary>
|
||||
public sealed record ClosedClient
|
||||
{
|
||||
public required ulong Cid { get; init; }
|
||||
public string Ip { get; init; } = "";
|
||||
public int Port { get; init; }
|
||||
public DateTime Start { get; init; }
|
||||
public DateTime Stop { get; init; }
|
||||
public string Reason { get; init; } = "";
|
||||
public string Name { get; init; } = "";
|
||||
public string Lang { get; init; } = "";
|
||||
public string Version { get; init; } = "";
|
||||
public long InMsgs { get; init; }
|
||||
public long OutMsgs { get; init; }
|
||||
public long InBytes { get; init; }
|
||||
public long OutBytes { get; init; }
|
||||
public uint NumSubs { get; init; }
|
||||
public TimeSpan Rtt { get; init; }
|
||||
public string TlsVersion { get; init; } = "";
|
||||
public string TlsCipherSuite { get; init; } = "";
|
||||
}
|
||||
210
src/NATS.Server/Monitoring/Connz.cs
Normal file
210
src/NATS.Server/Monitoring/Connz.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Connection information response. Corresponds to Go server/monitor.go Connz struct.
|
||||
/// </summary>
|
||||
public sealed class Connz
|
||||
{
|
||||
[JsonPropertyName("server_id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("now")]
|
||||
public DateTime Now { get; set; }
|
||||
|
||||
[JsonPropertyName("num_connections")]
|
||||
public int NumConns { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
|
||||
[JsonPropertyName("connections")]
|
||||
public ConnInfo[] Conns { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detailed information on a per-connection basis.
|
||||
/// Corresponds to Go server/monitor.go ConnInfo struct.
|
||||
/// </summary>
|
||||
public sealed class ConnInfo
|
||||
{
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ip")]
|
||||
public string Ip { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime Start { get; set; }
|
||||
|
||||
[JsonPropertyName("last_activity")]
|
||||
public DateTime LastActivity { get; set; }
|
||||
|
||||
[JsonPropertyName("stop")]
|
||||
public DateTime? Stop { get; set; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("rtt")]
|
||||
public string Rtt { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("uptime")]
|
||||
public string Uptime { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("idle")]
|
||||
public string Idle { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("pending_bytes")]
|
||||
public int Pending { 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("subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions_list")]
|
||||
public string[] Subs { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("subscriptions_list_detail")]
|
||||
public SubDetail[] SubsDetail { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("lang")]
|
||||
public string Lang { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("authorized_user")]
|
||||
public string AuthorizedUser { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("account")]
|
||||
public string Account { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tls_version")]
|
||||
public string TlsVersion { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tls_cipher_suite")]
|
||||
public string TlsCipherSuite { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tls_first")]
|
||||
public bool TlsFirst { get; set; }
|
||||
|
||||
[JsonPropertyName("mqtt_client")]
|
||||
public string MqttClient { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscription detail information.
|
||||
/// Corresponds to Go server/monitor.go SubDetail struct.
|
||||
/// </summary>
|
||||
public sealed class SubDetail
|
||||
{
|
||||
[JsonPropertyName("account")]
|
||||
public string Account { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("qgroup")]
|
||||
public string Queue { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("sid")]
|
||||
public string Sid { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("msgs")]
|
||||
public long Msgs { get; set; }
|
||||
|
||||
[JsonPropertyName("max")]
|
||||
public long Max { get; set; }
|
||||
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong Cid { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sort options for connection listing.
|
||||
/// Corresponds to Go server/monitor_sort_opts.go SortOpt type.
|
||||
/// </summary>
|
||||
public enum SortOpt
|
||||
{
|
||||
ByCid,
|
||||
ByStart,
|
||||
BySubs,
|
||||
ByPending,
|
||||
ByMsgsTo,
|
||||
ByMsgsFrom,
|
||||
ByBytesTo,
|
||||
ByBytesFrom,
|
||||
ByLast,
|
||||
ByIdle,
|
||||
ByUptime,
|
||||
ByRtt,
|
||||
ByStop,
|
||||
ByReason,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection state filter.
|
||||
/// Corresponds to Go server/monitor.go ConnState type.
|
||||
/// </summary>
|
||||
public enum ConnState
|
||||
{
|
||||
Open,
|
||||
Closed,
|
||||
All,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options passed to Connz() for filtering and sorting.
|
||||
/// Corresponds to Go server/monitor.go ConnzOptions struct.
|
||||
/// </summary>
|
||||
public sealed class ConnzOptions
|
||||
{
|
||||
public SortOpt Sort { get; set; } = SortOpt.ByCid;
|
||||
|
||||
public bool Subscriptions { get; set; }
|
||||
|
||||
public bool SubscriptionsDetail { get; set; }
|
||||
|
||||
public ConnState State { get; set; } = ConnState.Open;
|
||||
|
||||
public string User { get; set; } = "";
|
||||
|
||||
public string Account { get; set; } = "";
|
||||
|
||||
public string FilterSubject { get; set; } = "";
|
||||
|
||||
public int Offset { get; set; }
|
||||
|
||||
public int Limit { get; set; } = 1024;
|
||||
}
|
||||
223
src/NATS.Server/Monitoring/ConnzHandler.cs
Normal file
223
src/NATS.Server/Monitoring/ConnzHandler.cs
Normal file
@@ -0,0 +1,223 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Handles /connz endpoint requests, returning detailed connection information.
|
||||
/// Corresponds to Go server/monitor.go handleConnz function.
|
||||
/// </summary>
|
||||
public sealed class ConnzHandler(NatsServer server)
|
||||
{
|
||||
public Connz HandleConnz(HttpContext ctx)
|
||||
{
|
||||
var opts = ParseQueryParams(ctx);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var connInfos = new List<ConnInfo>();
|
||||
|
||||
// Collect open connections
|
||||
if (opts.State is ConnState.Open or ConnState.All)
|
||||
{
|
||||
var clients = server.GetClients().ToArray();
|
||||
connInfos.AddRange(clients.Select(c => BuildConnInfo(c, now, opts)));
|
||||
}
|
||||
|
||||
// Collect closed connections
|
||||
if (opts.State is ConnState.Closed or ConnState.All)
|
||||
{
|
||||
connInfos.AddRange(server.GetClosedClients().Select(c => BuildClosedConnInfo(c, now, opts)));
|
||||
}
|
||||
|
||||
// Validate sort options that require closed state
|
||||
if (opts.Sort is SortOpt.ByStop or SortOpt.ByReason && opts.State == ConnState.Open)
|
||||
opts.Sort = SortOpt.ByCid; // Fallback
|
||||
|
||||
// Sort
|
||||
connInfos = opts.Sort switch
|
||||
{
|
||||
SortOpt.ByCid => connInfos.OrderBy(c => c.Cid).ToList(),
|
||||
SortOpt.ByStart => connInfos.OrderBy(c => c.Start).ToList(),
|
||||
SortOpt.BySubs => connInfos.OrderByDescending(c => c.NumSubs).ToList(),
|
||||
SortOpt.ByPending => connInfos.OrderByDescending(c => c.Pending).ToList(),
|
||||
SortOpt.ByMsgsTo => connInfos.OrderByDescending(c => c.OutMsgs).ToList(),
|
||||
SortOpt.ByMsgsFrom => connInfos.OrderByDescending(c => c.InMsgs).ToList(),
|
||||
SortOpt.ByBytesTo => connInfos.OrderByDescending(c => c.OutBytes).ToList(),
|
||||
SortOpt.ByBytesFrom => connInfos.OrderByDescending(c => c.InBytes).ToList(),
|
||||
SortOpt.ByLast => connInfos.OrderByDescending(c => c.LastActivity).ToList(),
|
||||
SortOpt.ByIdle => connInfos.OrderByDescending(c => now - c.LastActivity).ToList(),
|
||||
SortOpt.ByUptime => connInfos.OrderByDescending(c => now - c.Start).ToList(),
|
||||
SortOpt.ByStop => connInfos.OrderByDescending(c => c.Stop ?? DateTime.MinValue).ToList(),
|
||||
SortOpt.ByReason => connInfos.OrderBy(c => c.Reason).ToList(),
|
||||
SortOpt.ByRtt => connInfos.OrderBy(c => c.Rtt).ToList(),
|
||||
_ => connInfos.OrderBy(c => c.Cid).ToList(),
|
||||
};
|
||||
|
||||
var total = connInfos.Count;
|
||||
var paged = connInfos.Skip(opts.Offset).Take(opts.Limit).ToArray();
|
||||
|
||||
return new Connz
|
||||
{
|
||||
Id = server.ServerId,
|
||||
Now = now,
|
||||
NumConns = paged.Length,
|
||||
Total = total,
|
||||
Offset = opts.Offset,
|
||||
Limit = opts.Limit,
|
||||
Conns = paged,
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnInfo BuildConnInfo(NatsClient client, DateTime now, ConnzOptions opts)
|
||||
{
|
||||
var info = new ConnInfo
|
||||
{
|
||||
Cid = client.Id,
|
||||
Kind = "Client",
|
||||
Type = "Client",
|
||||
Ip = client.RemoteIp ?? "",
|
||||
Port = client.RemotePort,
|
||||
Start = client.StartTime,
|
||||
LastActivity = client.LastActivity,
|
||||
Uptime = FormatDuration(now - client.StartTime),
|
||||
Idle = FormatDuration(now - client.LastActivity),
|
||||
InMsgs = Interlocked.Read(ref client.InMsgs),
|
||||
OutMsgs = Interlocked.Read(ref client.OutMsgs),
|
||||
InBytes = Interlocked.Read(ref client.InBytes),
|
||||
OutBytes = Interlocked.Read(ref client.OutBytes),
|
||||
NumSubs = (uint)client.Subscriptions.Count,
|
||||
Name = client.ClientOpts?.Name ?? "",
|
||||
Lang = client.ClientOpts?.Lang ?? "",
|
||||
Version = client.ClientOpts?.Version ?? "",
|
||||
Pending = (int)client.PendingBytes,
|
||||
Reason = client.CloseReason.ToReasonString(),
|
||||
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
||||
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
||||
Rtt = FormatRtt(client.Rtt),
|
||||
};
|
||||
|
||||
if (opts.Subscriptions)
|
||||
{
|
||||
info.Subs = client.Subscriptions.Values.Select(s => s.Subject).ToArray();
|
||||
}
|
||||
|
||||
if (opts.SubscriptionsDetail)
|
||||
{
|
||||
info.SubsDetail = client.Subscriptions.Values.Select(s => new SubDetail
|
||||
{
|
||||
Subject = s.Subject,
|
||||
Queue = s.Queue ?? "",
|
||||
Sid = s.Sid,
|
||||
Msgs = Interlocked.Read(ref s.MessageCount),
|
||||
Max = s.MaxMessages,
|
||||
Cid = client.Id,
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts)
|
||||
{
|
||||
return new ConnInfo
|
||||
{
|
||||
Cid = closed.Cid,
|
||||
Kind = "Client",
|
||||
Type = "Client",
|
||||
Ip = closed.Ip,
|
||||
Port = closed.Port,
|
||||
Start = closed.Start,
|
||||
Stop = closed.Stop,
|
||||
LastActivity = closed.Stop,
|
||||
Uptime = FormatDuration(closed.Stop - closed.Start),
|
||||
Idle = FormatDuration(now - closed.Stop),
|
||||
InMsgs = closed.InMsgs,
|
||||
OutMsgs = closed.OutMsgs,
|
||||
InBytes = closed.InBytes,
|
||||
OutBytes = closed.OutBytes,
|
||||
NumSubs = closed.NumSubs,
|
||||
Name = closed.Name,
|
||||
Lang = closed.Lang,
|
||||
Version = closed.Version,
|
||||
Reason = closed.Reason,
|
||||
Rtt = FormatRtt(closed.Rtt),
|
||||
TlsVersion = closed.TlsVersion,
|
||||
TlsCipherSuite = closed.TlsCipherSuite,
|
||||
};
|
||||
}
|
||||
|
||||
private static ConnzOptions ParseQueryParams(HttpContext ctx)
|
||||
{
|
||||
var q = ctx.Request.Query;
|
||||
var opts = new ConnzOptions();
|
||||
|
||||
if (q.TryGetValue("sort", out var sort))
|
||||
{
|
||||
opts.Sort = sort.ToString().ToLowerInvariant() switch
|
||||
{
|
||||
"cid" => SortOpt.ByCid,
|
||||
"start" => SortOpt.ByStart,
|
||||
"subs" => SortOpt.BySubs,
|
||||
"pending" => SortOpt.ByPending,
|
||||
"msgs_to" => SortOpt.ByMsgsTo,
|
||||
"msgs_from" => SortOpt.ByMsgsFrom,
|
||||
"bytes_to" => SortOpt.ByBytesTo,
|
||||
"bytes_from" => SortOpt.ByBytesFrom,
|
||||
"last" => SortOpt.ByLast,
|
||||
"idle" => SortOpt.ByIdle,
|
||||
"uptime" => SortOpt.ByUptime,
|
||||
"rtt" => SortOpt.ByRtt,
|
||||
"stop" => SortOpt.ByStop,
|
||||
"reason" => SortOpt.ByReason,
|
||||
_ => SortOpt.ByCid,
|
||||
};
|
||||
}
|
||||
|
||||
if (q.TryGetValue("subs", out var subs))
|
||||
{
|
||||
if (subs == "detail")
|
||||
opts.SubscriptionsDetail = true;
|
||||
else
|
||||
opts.Subscriptions = true;
|
||||
}
|
||||
|
||||
if (q.TryGetValue("state", out var state))
|
||||
{
|
||||
opts.State = state.ToString().ToLowerInvariant() switch
|
||||
{
|
||||
"open" => ConnState.Open,
|
||||
"closed" => ConnState.Closed,
|
||||
"all" => ConnState.All,
|
||||
_ => ConnState.Open,
|
||||
};
|
||||
}
|
||||
|
||||
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
|
||||
opts.Offset = o;
|
||||
|
||||
if (q.TryGetValue("limit", out var limit) && int.TryParse(limit, out var l))
|
||||
opts.Limit = l;
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
private static string FormatRtt(TimeSpan rtt)
|
||||
{
|
||||
if (rtt == TimeSpan.Zero) return "";
|
||||
if (rtt.TotalMilliseconds < 1)
|
||||
return $"{rtt.TotalMicroseconds:F3}\u00b5s";
|
||||
if (rtt.TotalSeconds < 1)
|
||||
return $"{rtt.TotalMilliseconds:F3}ms";
|
||||
return $"{rtt.TotalSeconds:F3}s";
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1)
|
||||
return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
if (ts.TotalHours >= 1)
|
||||
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
if (ts.TotalMinutes >= 1)
|
||||
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
|
||||
return $"{(int)ts.TotalSeconds}s";
|
||||
}
|
||||
}
|
||||
119
src/NATS.Server/Monitoring/MonitorServer.cs
Normal file
119
src/NATS.Server/Monitoring/MonitorServer.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP monitoring server providing /healthz, /varz, and other monitoring endpoints.
|
||||
/// Corresponds to Go server/monitor.go HTTP server setup.
|
||||
/// </summary>
|
||||
public sealed class MonitorServer : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication _app;
|
||||
private readonly ILogger<MonitorServer> _logger;
|
||||
private readonly VarzHandler _varzHandler;
|
||||
private readonly ConnzHandler _connzHandler;
|
||||
private readonly SubszHandler _subszHandler;
|
||||
|
||||
public MonitorServer(NatsServer server, NatsOptions options, ServerStats stats, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_logger = loggerFactory.CreateLogger<MonitorServer>();
|
||||
|
||||
var builder = WebApplication.CreateSlimBuilder();
|
||||
builder.WebHost.UseUrls($"http://{options.MonitorHost}:{options.MonitorPort}");
|
||||
builder.Logging.ClearProviders();
|
||||
|
||||
_app = builder.Build();
|
||||
var basePath = options.MonitorBasePath ?? "";
|
||||
|
||||
_varzHandler = new VarzHandler(server, options);
|
||||
_connzHandler = new ConnzHandler(server);
|
||||
_subszHandler = new SubszHandler(server);
|
||||
|
||||
_app.MapGet(basePath + "/", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new
|
||||
{
|
||||
endpoints = new[]
|
||||
{
|
||||
"/varz", "/connz", "/healthz", "/routez",
|
||||
"/gatewayz", "/leafz", "/subz", "/accountz", "/jsz",
|
||||
},
|
||||
});
|
||||
});
|
||||
_app.MapGet(basePath + "/healthz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/healthz", 1, (_, v) => v + 1);
|
||||
return Results.Ok("ok");
|
||||
});
|
||||
_app.MapGet(basePath + "/varz", async (HttpContext ctx) =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/varz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(await _varzHandler.HandleVarzAsync(ctx.RequestAborted));
|
||||
});
|
||||
|
||||
_app.MapGet(basePath + "/connz", (HttpContext ctx) =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/connz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(_connzHandler.HandleConnz(ctx));
|
||||
});
|
||||
|
||||
// Stubs for unimplemented endpoints
|
||||
_app.MapGet(basePath + "/routez", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/routez", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
_app.MapGet(basePath + "/gatewayz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/gatewayz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
_app.MapGet(basePath + "/leafz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/leafz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
_app.MapGet(basePath + "/subz", (HttpContext ctx) =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/subz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(_subszHandler.HandleSubsz(ctx));
|
||||
});
|
||||
_app.MapGet(basePath + "/subscriptionsz", (HttpContext ctx) =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/subscriptionsz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(_subszHandler.HandleSubsz(ctx));
|
||||
});
|
||||
_app.MapGet(basePath + "/accountz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/accountz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
_app.MapGet(basePath + "/accstatz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/accstatz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
_app.MapGet(basePath + "/jsz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/jsz", 1, (_, v) => v + 1);
|
||||
return Results.Ok(new { });
|
||||
});
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
await _app.StartAsync(ct);
|
||||
_logger.LogInformation("Monitoring listening on {Urls}", string.Join(", ", _app.Urls));
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
_varzHandler.Dispose();
|
||||
}
|
||||
}
|
||||
45
src/NATS.Server/Monitoring/Subsz.cs
Normal file
45
src/NATS.Server/Monitoring/Subsz.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Subscription information response. Corresponds to Go server/monitor.go Subsz struct.
|
||||
/// </summary>
|
||||
public sealed class Subsz
|
||||
{
|
||||
[JsonPropertyName("server_id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("now")]
|
||||
public DateTime Now { get; set; }
|
||||
|
||||
[JsonPropertyName("num_subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
[JsonPropertyName("num_cache")]
|
||||
public int NumCache { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; set; }
|
||||
|
||||
[JsonPropertyName("offset")]
|
||||
public int Offset { get; set; }
|
||||
|
||||
[JsonPropertyName("limit")]
|
||||
public int Limit { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public SubDetail[] Subs { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options passed to Subsz() for filtering.
|
||||
/// </summary>
|
||||
public sealed class SubszOptions
|
||||
{
|
||||
public int Offset { get; set; }
|
||||
public int Limit { get; set; } = 1024;
|
||||
public bool Subscriptions { get; set; }
|
||||
public string Account { get; set; } = "";
|
||||
public string Test { get; set; } = "";
|
||||
}
|
||||
97
src/NATS.Server/Monitoring/SubszHandler.cs
Normal file
97
src/NATS.Server/Monitoring/SubszHandler.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Handles /subz endpoint requests, returning subscription information.
|
||||
/// Corresponds to Go server/monitor.go handleSubsz.
|
||||
/// </summary>
|
||||
public sealed class SubszHandler(NatsServer server)
|
||||
{
|
||||
public Subsz HandleSubsz(HttpContext ctx)
|
||||
{
|
||||
var opts = ParseQueryParams(ctx);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
// Filter by test subject if provided
|
||||
if (!string.IsNullOrEmpty(opts.Test))
|
||||
{
|
||||
allSubs = allSubs.Where(s => SubjectMatch.MatchLiteral(opts.Test, s.Subject)).ToList();
|
||||
}
|
||||
|
||||
var total = allSubs.Count;
|
||||
var numSubs = server.GetAccounts()
|
||||
.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 != "$SYS") || a.Name == opts.Account)
|
||||
.Sum(a => a.SubList.CacheCount);
|
||||
|
||||
SubDetail[] details = [];
|
||||
if (opts.Subscriptions)
|
||||
{
|
||||
details = allSubs
|
||||
.Skip(opts.Offset)
|
||||
.Take(opts.Limit)
|
||||
.Select(s => new SubDetail
|
||||
{
|
||||
Subject = s.Subject,
|
||||
Queue = s.Queue ?? "",
|
||||
Sid = s.Sid,
|
||||
Msgs = Interlocked.Read(ref s.MessageCount),
|
||||
Max = s.MaxMessages,
|
||||
Cid = s.Client?.Id ?? 0,
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new Subsz
|
||||
{
|
||||
Id = server.ServerId,
|
||||
Now = now,
|
||||
NumSubs = numSubs,
|
||||
NumCache = numCache,
|
||||
Total = total,
|
||||
Offset = opts.Offset,
|
||||
Limit = opts.Limit,
|
||||
Subs = details,
|
||||
};
|
||||
}
|
||||
|
||||
private static SubszOptions ParseQueryParams(HttpContext ctx)
|
||||
{
|
||||
var q = ctx.Request.Query;
|
||||
var opts = new SubszOptions();
|
||||
|
||||
if (q.TryGetValue("subs", out var subs))
|
||||
opts.Subscriptions = subs == "true" || subs == "1" || subs == "detail";
|
||||
|
||||
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
|
||||
opts.Offset = o;
|
||||
|
||||
if (q.TryGetValue("limit", out var limit) && int.TryParse(limit, out var l))
|
||||
opts.Limit = l;
|
||||
|
||||
if (q.TryGetValue("acc", out var acc))
|
||||
opts.Account = acc.ToString();
|
||||
|
||||
if (q.TryGetValue("test", out var test))
|
||||
opts.Test = test.ToString();
|
||||
|
||||
return opts;
|
||||
}
|
||||
}
|
||||
440
src/NATS.Server/Monitoring/Varz.cs
Normal file
440
src/NATS.Server/Monitoring/Varz.cs
Normal file
@@ -0,0 +1,440 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Server general information. Corresponds to Go server/monitor.go Varz struct.
|
||||
/// </summary>
|
||||
public sealed class Varz
|
||||
{
|
||||
// Identity
|
||||
[JsonPropertyName("server_id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("server_name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string Version { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("proto")]
|
||||
public int Proto { get; set; }
|
||||
|
||||
[JsonPropertyName("git_commit")]
|
||||
public string GitCommit { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("go")]
|
||||
public string GoVersion { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
// Network
|
||||
[JsonPropertyName("ip")]
|
||||
public string Ip { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("connect_urls")]
|
||||
public string[] ConnectUrls { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("ws_connect_urls")]
|
||||
public string[] WsConnectUrls { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("http_host")]
|
||||
public string HttpHost { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("http_port")]
|
||||
public int HttpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("http_base_path")]
|
||||
public string HttpBasePath { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("https_port")]
|
||||
public int HttpsPort { get; set; }
|
||||
|
||||
// Security
|
||||
[JsonPropertyName("auth_required")]
|
||||
public bool AuthRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_required")]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_verify")]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_ocsp_peer_verify")]
|
||||
public bool TlsOcspPeerVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
|
||||
// Limits
|
||||
[JsonPropertyName("max_connections")]
|
||||
public int MaxConnections { get; set; }
|
||||
|
||||
[JsonPropertyName("max_subscriptions")]
|
||||
public int MaxSubscriptions { get; set; }
|
||||
|
||||
[JsonPropertyName("max_payload")]
|
||||
public int MaxPayload { get; set; }
|
||||
|
||||
[JsonPropertyName("max_pending")]
|
||||
public long MaxPending { get; set; }
|
||||
|
||||
[JsonPropertyName("max_control_line")]
|
||||
public int MaxControlLine { get; set; }
|
||||
|
||||
[JsonPropertyName("ping_max")]
|
||||
public int MaxPingsOut { get; set; }
|
||||
|
||||
// Timing
|
||||
[JsonPropertyName("ping_interval")]
|
||||
public long PingInterval { get; set; }
|
||||
|
||||
[JsonPropertyName("write_deadline")]
|
||||
public long WriteDeadline { get; set; }
|
||||
|
||||
[JsonPropertyName("start")]
|
||||
public DateTime Start { get; set; }
|
||||
|
||||
[JsonPropertyName("now")]
|
||||
public DateTime Now { get; set; }
|
||||
|
||||
[JsonPropertyName("uptime")]
|
||||
public string Uptime { get; set; } = "";
|
||||
|
||||
// Runtime
|
||||
[JsonPropertyName("mem")]
|
||||
public long Mem { get; set; }
|
||||
|
||||
[JsonPropertyName("cpu")]
|
||||
public double Cpu { get; set; }
|
||||
|
||||
[JsonPropertyName("cores")]
|
||||
public int Cores { get; set; }
|
||||
|
||||
[JsonPropertyName("gomaxprocs")]
|
||||
public int MaxProcs { get; set; }
|
||||
|
||||
// Connections
|
||||
[JsonPropertyName("connections")]
|
||||
public int Connections { get; set; }
|
||||
|
||||
[JsonPropertyName("total_connections")]
|
||||
public ulong TotalConnections { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
public int Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("remotes")]
|
||||
public int Remotes { get; set; }
|
||||
|
||||
[JsonPropertyName("leafnodes")]
|
||||
public int Leafnodes { get; set; }
|
||||
|
||||
// Messages
|
||||
[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; }
|
||||
|
||||
// Health
|
||||
[JsonPropertyName("slow_consumers")]
|
||||
public long SlowConsumers { get; set; }
|
||||
|
||||
[JsonPropertyName("slow_consumer_stats")]
|
||||
public SlowConsumersStats SlowConsumerStats { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("stale_connections")]
|
||||
public long StaleConnections { get; set; }
|
||||
|
||||
[JsonPropertyName("stale_connection_stats")]
|
||||
public StaleConnectionStats StaleConnectionStatsDetail { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public uint Subscriptions { get; set; }
|
||||
|
||||
// Config
|
||||
[JsonPropertyName("config_load_time")]
|
||||
public DateTime ConfigLoadTime { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public string[] Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("system_account")]
|
||||
public string SystemAccount { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("pinned_account_fails")]
|
||||
public ulong PinnedAccountFail { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_cert_not_after")]
|
||||
public DateTime TlsCertNotAfter { get; set; }
|
||||
|
||||
// HTTP
|
||||
[JsonPropertyName("http_req_stats")]
|
||||
public Dictionary<string, ulong> HttpReqStats { get; set; } = new();
|
||||
|
||||
// Subsystems
|
||||
[JsonPropertyName("cluster")]
|
||||
public ClusterOptsVarz Cluster { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("gateway")]
|
||||
public GatewayOptsVarz Gateway { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("leaf")]
|
||||
public LeafNodeOptsVarz Leaf { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("mqtt")]
|
||||
public MqttOptsVarz Mqtt { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("websocket")]
|
||||
public WebsocketOptsVarz Websocket { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("jetstream")]
|
||||
public JetStreamVarz JetStream { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about slow consumers by connection type.
|
||||
/// Corresponds to Go server/monitor.go SlowConsumersStats struct.
|
||||
/// </summary>
|
||||
public sealed class SlowConsumersStats
|
||||
{
|
||||
[JsonPropertyName("clients")]
|
||||
public ulong Clients { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
public ulong Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
public ulong Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("leafs")]
|
||||
public ulong Leafs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about stale connections by connection type.
|
||||
/// Corresponds to Go server/monitor.go StaleConnectionStats struct.
|
||||
/// </summary>
|
||||
public sealed class StaleConnectionStats
|
||||
{
|
||||
[JsonPropertyName("clients")]
|
||||
public ulong Clients { get; set; }
|
||||
|
||||
[JsonPropertyName("routes")]
|
||||
public ulong Routes { get; set; }
|
||||
|
||||
[JsonPropertyName("gateways")]
|
||||
public ulong Gateways { get; set; }
|
||||
|
||||
[JsonPropertyName("leafs")]
|
||||
public ulong Leafs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cluster configuration monitoring information.
|
||||
/// Corresponds to Go server/monitor.go ClusterOptsVarz struct.
|
||||
/// </summary>
|
||||
public sealed class ClusterOptsVarz
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("addr")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("cluster_port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_required")]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_verify")]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("pool_size")]
|
||||
public int PoolSize { get; set; }
|
||||
|
||||
[JsonPropertyName("urls")]
|
||||
public string[] Urls { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gateway configuration monitoring information.
|
||||
/// Corresponds to Go server/monitor.go GatewayOptsVarz struct.
|
||||
/// </summary>
|
||||
public sealed class GatewayOptsVarz
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_required")]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_verify")]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("advertise")]
|
||||
public string Advertise { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("connect_retries")]
|
||||
public int ConnectRetries { get; set; }
|
||||
|
||||
[JsonPropertyName("reject_unknown")]
|
||||
public bool RejectUnknown { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Leaf node configuration monitoring information.
|
||||
/// Corresponds to Go server/monitor.go LeafNodeOptsVarz struct.
|
||||
/// </summary>
|
||||
public sealed class LeafNodeOptsVarz
|
||||
{
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("auth_timeout")]
|
||||
public double AuthTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_required")]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_verify")]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_ocsp_peer_verify")]
|
||||
public bool TlsOcspPeerVerify { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT configuration monitoring information.
|
||||
/// Corresponds to Go server/monitor.go MQTTOptsVarz struct.
|
||||
/// </summary>
|
||||
public sealed class MqttOptsVarz
|
||||
{
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Websocket configuration monitoring information.
|
||||
/// Corresponds to Go server/monitor.go WebsocketOptsVarz struct.
|
||||
/// </summary>
|
||||
public sealed class WebsocketOptsVarz
|
||||
{
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("port")]
|
||||
public int Port { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_timeout")]
|
||||
public double TlsTimeout { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JetStream runtime information.
|
||||
/// Corresponds to Go server/monitor.go JetStreamVarz struct.
|
||||
/// </summary>
|
||||
public sealed class JetStreamVarz
|
||||
{
|
||||
[JsonPropertyName("config")]
|
||||
public JetStreamConfig Config { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("stats")]
|
||||
public JetStreamStats Stats { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JetStream configuration.
|
||||
/// Corresponds to Go server/jetstream.go JetStreamConfig struct.
|
||||
/// </summary>
|
||||
public sealed class JetStreamConfig
|
||||
{
|
||||
[JsonPropertyName("max_memory")]
|
||||
public long MaxMemory { get; set; }
|
||||
|
||||
[JsonPropertyName("max_storage")]
|
||||
public long MaxStorage { get; set; }
|
||||
|
||||
[JsonPropertyName("store_dir")]
|
||||
public string StoreDir { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JetStream statistics.
|
||||
/// Corresponds to Go server/jetstream.go JetStreamStats struct.
|
||||
/// </summary>
|
||||
public sealed class JetStreamStats
|
||||
{
|
||||
[JsonPropertyName("memory")]
|
||||
public ulong Memory { get; set; }
|
||||
|
||||
[JsonPropertyName("storage")]
|
||||
public ulong Storage { get; set; }
|
||||
|
||||
[JsonPropertyName("accounts")]
|
||||
public int Accounts { get; set; }
|
||||
|
||||
[JsonPropertyName("ha_assets")]
|
||||
public int HaAssets { get; set; }
|
||||
|
||||
[JsonPropertyName("api")]
|
||||
public JetStreamApiStats Api { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JetStream API statistics.
|
||||
/// Corresponds to Go server/jetstream.go JetStreamAPIStats struct.
|
||||
/// </summary>
|
||||
public sealed class JetStreamApiStats
|
||||
{
|
||||
[JsonPropertyName("total")]
|
||||
public ulong Total { get; set; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public ulong Errors { get; set; }
|
||||
}
|
||||
150
src/NATS.Server/Monitoring/VarzHandler.cs
Normal file
150
src/NATS.Server/Monitoring/VarzHandler.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Handles building the Varz response from server state and process metrics.
|
||||
/// Corresponds to Go server/monitor.go handleVarz function.
|
||||
/// </summary>
|
||||
public sealed class VarzHandler : IDisposable
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly NatsOptions _options;
|
||||
private readonly SemaphoreSlim _varzMu = new(1, 1);
|
||||
private DateTime _lastCpuSampleTime;
|
||||
private TimeSpan _lastCpuUsage;
|
||||
private double _cachedCpuPercent;
|
||||
|
||||
public VarzHandler(NatsServer server, NatsOptions options)
|
||||
{
|
||||
_server = server;
|
||||
_options = options;
|
||||
using var proc = Process.GetCurrentProcess();
|
||||
_lastCpuSampleTime = DateTime.UtcNow;
|
||||
_lastCpuUsage = proc.TotalProcessorTime;
|
||||
}
|
||||
|
||||
public async Task<Varz> HandleVarzAsync(CancellationToken ct = default)
|
||||
{
|
||||
await _varzMu.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
using var proc = Process.GetCurrentProcess();
|
||||
var now = DateTime.UtcNow;
|
||||
var uptime = now - _server.StartTime;
|
||||
var stats = _server.Stats;
|
||||
|
||||
// CPU sampling with 1-second cache to avoid excessive sampling
|
||||
if ((now - _lastCpuSampleTime).TotalSeconds >= 1.0)
|
||||
{
|
||||
var currentCpu = proc.TotalProcessorTime;
|
||||
var elapsed = now - _lastCpuSampleTime;
|
||||
_cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds
|
||||
/ elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0;
|
||||
_lastCpuSampleTime = now;
|
||||
_lastCpuUsage = currentCpu;
|
||||
}
|
||||
|
||||
// Load the TLS certificate to report its expiry date in /varz.
|
||||
// Corresponds to Go server/monitor.go handleVarz populating TLSCertExpiry.
|
||||
DateTime? tlsCertExpiry = null;
|
||||
if (_options.HasTls && !string.IsNullOrEmpty(_options.TlsCert))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cert = X509CertificateLoader.LoadCertificateFromFile(_options.TlsCert);
|
||||
tlsCertExpiry = cert.NotAfter;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// cert load failure — leave field as default
|
||||
}
|
||||
}
|
||||
|
||||
return new Varz
|
||||
{
|
||||
Id = _server.ServerId,
|
||||
Name = _server.ServerName,
|
||||
Version = NatsProtocol.Version,
|
||||
Proto = NatsProtocol.ProtoVersion,
|
||||
GoVersion = $"dotnet {RuntimeInformation.FrameworkDescription}",
|
||||
Host = _options.Host,
|
||||
Port = _options.Port,
|
||||
HttpHost = _options.MonitorHost,
|
||||
HttpPort = _options.MonitorPort,
|
||||
HttpBasePath = _options.MonitorBasePath ?? "",
|
||||
HttpsPort = _options.MonitorHttpsPort,
|
||||
TlsRequired = _options.HasTls && !_options.AllowNonTls,
|
||||
TlsVerify = _options.HasTls && _options.TlsVerify,
|
||||
TlsTimeout = _options.HasTls ? _options.TlsTimeout.TotalSeconds : 0,
|
||||
TlsCertNotAfter = tlsCertExpiry ?? default,
|
||||
TlsOcspPeerVerify = _options.OcspPeerVerify,
|
||||
MaxConnections = _options.MaxConnections,
|
||||
MaxPayload = _options.MaxPayload,
|
||||
MaxControlLine = _options.MaxControlLine,
|
||||
MaxPending = _options.MaxPending,
|
||||
WriteDeadline = (long)_options.WriteDeadline.TotalNanoseconds,
|
||||
MaxPingsOut = _options.MaxPingsOut,
|
||||
PingInterval = (long)_options.PingInterval.TotalNanoseconds,
|
||||
Start = _server.StartTime,
|
||||
Now = now,
|
||||
Uptime = FormatUptime(uptime),
|
||||
Mem = proc.WorkingSet64,
|
||||
Cpu = Math.Round(_cachedCpuPercent, 2),
|
||||
Cores = Environment.ProcessorCount,
|
||||
MaxProcs = ThreadPool.ThreadCount,
|
||||
Connections = _server.ClientCount,
|
||||
TotalConnections = (ulong)Interlocked.Read(ref stats.TotalConnections),
|
||||
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),
|
||||
SlowConsumerStats = new SlowConsumersStats
|
||||
{
|
||||
Clients = (ulong)Interlocked.Read(ref stats.SlowConsumerClients),
|
||||
Routes = (ulong)Interlocked.Read(ref stats.SlowConsumerRoutes),
|
||||
Gateways = (ulong)Interlocked.Read(ref stats.SlowConsumerGateways),
|
||||
Leafs = (ulong)Interlocked.Read(ref stats.SlowConsumerLeafs),
|
||||
},
|
||||
StaleConnections = Interlocked.Read(ref stats.StaleConnections),
|
||||
StaleConnectionStatsDetail = new StaleConnectionStats
|
||||
{
|
||||
Clients = (ulong)Interlocked.Read(ref stats.StaleConnectionClients),
|
||||
Routes = (ulong)Interlocked.Read(ref stats.StaleConnectionRoutes),
|
||||
Gateways = (ulong)Interlocked.Read(ref stats.StaleConnectionGateways),
|
||||
Leafs = (ulong)Interlocked.Read(ref stats.StaleConnectionLeafs),
|
||||
},
|
||||
Subscriptions = _server.SubList.Count,
|
||||
ConfigLoadTime = _server.StartTime,
|
||||
HttpReqStats = stats.HttpReqStats.ToDictionary(kv => kv.Key, kv => (ulong)kv.Value),
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
_varzMu.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_varzMu.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a TimeSpan as a human-readable uptime string matching Go server format.
|
||||
/// </summary>
|
||||
private static string FormatUptime(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1)
|
||||
return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
if (ts.TotalHours >= 1)
|
||||
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
|
||||
if (ts.TotalMinutes >= 1)
|
||||
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
|
||||
return $"{(int)ts.TotalSeconds}s";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<InternalsVisibleTo Include="NATS.Server.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="NATS.NKeys" />
|
||||
<PackageReference Include="BCrypt.Net-Next" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
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.Protocol;
|
||||
using NATS.Server.Subscriptions;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
@@ -16,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
|
||||
@@ -23,29 +28,54 @@ public interface ISubListAccess
|
||||
SubList SubList { get; }
|
||||
}
|
||||
|
||||
public sealed class NatsClient : IDisposable
|
||||
public sealed class NatsClient : INatsClient, IDisposable
|
||||
{
|
||||
private readonly Socket _socket;
|
||||
private readonly NetworkStream _stream;
|
||||
private readonly Stream _stream;
|
||||
private readonly NatsOptions _options;
|
||||
private readonly ServerInfo _serverInfo;
|
||||
private readonly AuthService _authService;
|
||||
private readonly byte[]? _nonce;
|
||||
private readonly NatsParser _parser;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly Channel<ReadOnlyMemory<byte>> _outbound = Channel.CreateBounded<ReadOnlyMemory<byte>>(
|
||||
new BoundedChannelOptions(8192) { SingleReader = true, FullMode = BoundedChannelFullMode.Wait });
|
||||
private long _pendingBytes;
|
||||
private CancellationTokenSource? _clientCts;
|
||||
private readonly Dictionary<string, Subscription> _subs = new();
|
||||
private readonly ILogger _logger;
|
||||
private ClientPermissions? _permissions;
|
||||
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; }
|
||||
public ClientPermissions? Permissions => _permissions;
|
||||
|
||||
// Thread-safe: read from auth timeout task on threadpool, written from command pipeline
|
||||
private int _connectReceived;
|
||||
public bool ConnectReceived => Volatile.Read(ref _connectReceived) != 0;
|
||||
private readonly ClientFlagHolder _flags = new();
|
||||
public bool ConnectReceived => _flags.HasFlag(ClientFlags.ConnectReceived);
|
||||
public ClientClosedReason CloseReason { get; private set; }
|
||||
|
||||
public void SetTraceMode(bool enabled)
|
||||
{
|
||||
if (enabled)
|
||||
{
|
||||
_flags.SetFlag(ClientFlags.TraceMode);
|
||||
_parser.Logger = _logger;
|
||||
}
|
||||
else
|
||||
{
|
||||
_flags.ClearFlag(ClientFlags.TraceMode);
|
||||
_parser.Logger = _options.Trace ? _logger : null;
|
||||
}
|
||||
}
|
||||
|
||||
public DateTime StartTime { get; }
|
||||
private long _lastActivityTicks;
|
||||
public DateTime LastActivity => new(Interlocked.Read(ref _lastActivityTicks), DateTimeKind.Utc);
|
||||
public string? RemoteIp { get; }
|
||||
public int RemotePort { get; }
|
||||
|
||||
// Stats
|
||||
public long InMsgs;
|
||||
@@ -53,26 +83,77 @@ public sealed class NatsClient : IDisposable
|
||||
public long InBytes;
|
||||
public long OutBytes;
|
||||
|
||||
// Close reason tracking
|
||||
private int _skipFlushOnClose;
|
||||
public bool ShouldSkipFlush => Volatile.Read(ref _skipFlushOnClose) != 0;
|
||||
|
||||
// PING keepalive state
|
||||
private int _pingsOut;
|
||||
private long _lastIn;
|
||||
|
||||
// RTT tracking
|
||||
private long _rttStartTicks;
|
||||
private long _rtt;
|
||||
public TimeSpan Rtt => new(Interlocked.Read(ref _rtt));
|
||||
|
||||
public TlsConnectionState? TlsState { get; set; }
|
||||
public bool InfoAlreadySent { get; set; }
|
||||
|
||||
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
|
||||
|
||||
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo,
|
||||
AuthService authService, byte[]? nonce, ILogger logger)
|
||||
public NatsClient(ulong id, Stream stream, Socket socket, NatsOptions options, ServerInfo serverInfo,
|
||||
AuthService authService, byte[]? nonce, ILogger logger, ServerStats serverStats)
|
||||
{
|
||||
Id = id;
|
||||
_socket = socket;
|
||||
_stream = new NetworkStream(socket, ownsSocket: false);
|
||||
_stream = stream;
|
||||
_options = options;
|
||||
_serverInfo = serverInfo;
|
||||
_authService = authService;
|
||||
_nonce = nonce;
|
||||
_logger = logger;
|
||||
_parser = new NatsParser(options.MaxPayload);
|
||||
_serverStats = serverStats;
|
||||
_parser = new NatsParser(options.MaxPayload, options.Trace ? logger : null);
|
||||
StartTime = DateTime.UtcNow;
|
||||
_lastActivityTicks = StartTime.Ticks;
|
||||
if (socket.RemoteEndPoint is IPEndPoint ep)
|
||||
{
|
||||
RemoteIp = ep.Address.ToString();
|
||||
RemotePort = ep.Port;
|
||||
}
|
||||
}
|
||||
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
if (_flags.HasFlag(ClientFlags.CloseConnection))
|
||||
return false;
|
||||
|
||||
var pending = Interlocked.Add(ref _pendingBytes, data.Length);
|
||||
if (pending > _options.MaxPending)
|
||||
{
|
||||
Interlocked.Add(ref _pendingBytes, -data.Length);
|
||||
_flags.SetFlag(ClientFlags.IsSlowConsumer);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumers);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
|
||||
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_outbound.Writer.TryWrite(data))
|
||||
{
|
||||
Interlocked.Add(ref _pendingBytes, -data.Length);
|
||||
_flags.SetFlag(ClientFlags.IsSlowConsumer);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumers);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
|
||||
_ = CloseWithReasonAsync(ClientClosedReason.SlowConsumerPendingBytes, NatsProtocol.ErrSlowConsumer);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public long PendingBytes => Interlocked.Read(ref _pendingBytes);
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
_clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
@@ -80,8 +161,9 @@ public sealed class NatsClient : IDisposable
|
||||
var pipe = new Pipe();
|
||||
try
|
||||
{
|
||||
// Send INFO
|
||||
await SendInfoAsync(_clientCts.Token);
|
||||
// Send INFO (skip if already sent during TLS negotiation)
|
||||
if (!InfoAlreadySent)
|
||||
SendInfo();
|
||||
|
||||
// Start auth timeout if auth is required
|
||||
Task? authTimeoutTask = null;
|
||||
@@ -95,36 +177,49 @@ public sealed class NatsClient : IDisposable
|
||||
if (!ConnectReceived)
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} auth timeout", Id);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrAuthTimeout);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrAuthTimeout, ClientClosedReason.AuthenticationTimeout);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal — client connected or was cancelled
|
||||
// Normal -- client connected or was cancelled
|
||||
}
|
||||
}, _clientCts.Token);
|
||||
}
|
||||
|
||||
// Start read pump, command processing, and ping timer in parallel
|
||||
// Start read pump, command processing, write loop, and ping timer in parallel
|
||||
var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
|
||||
var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
|
||||
var pingTask = RunPingTimerAsync(_clientCts.Token);
|
||||
var writeTask = RunWriteLoopAsync(_clientCts.Token);
|
||||
|
||||
if (authTimeoutTask != null)
|
||||
await Task.WhenAny(fillTask, processTask, pingTask, authTimeoutTask);
|
||||
await Task.WhenAny(fillTask, processTask, pingTask, writeTask, authTimeoutTask);
|
||||
else
|
||||
await Task.WhenAny(fillTask, processTask, pingTask);
|
||||
await Task.WhenAny(fillTask, processTask, pingTask, writeTask);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} operation cancelled", Id);
|
||||
MarkClosed(ClientClosedReason.ServerShutdown);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
MarkClosed(ClientClosedReason.ReadError);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
MarkClosed(ClientClosedReason.ReadError);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
|
||||
MarkClosed(ClientClosedReason.ReadError);
|
||||
}
|
||||
finally
|
||||
{
|
||||
MarkClosed(ClientClosedReason.ClientClosed);
|
||||
_outbound.Writer.TryComplete();
|
||||
try { _socket.Shutdown(SocketShutdown.Both); }
|
||||
catch (SocketException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
@@ -164,10 +259,39 @@ public sealed class NatsClient : IDisposable
|
||||
var result = await reader.ReadAsync(ct);
|
||||
var buffer = result.Buffer;
|
||||
|
||||
long localInMsgs = 0;
|
||||
long localInBytes = 0;
|
||||
|
||||
while (_parser.TryParse(ref buffer, out var cmd))
|
||||
{
|
||||
Interlocked.Exchange(ref _lastIn, Environment.TickCount64);
|
||||
await DispatchCommandAsync(cmd, ct);
|
||||
|
||||
// Handle Pub/HPub inline to allow ref parameter passing for stat batching.
|
||||
// DispatchCommandAsync is async and cannot accept ref parameters.
|
||||
if (cmd.Type is CommandType.Pub or CommandType.HPub
|
||||
&& (!_authService.IsAuthRequired || ConnectReceived))
|
||||
{
|
||||
Interlocked.Exchange(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
|
||||
ProcessPub(cmd, ref localInMsgs, ref localInBytes);
|
||||
if (ClientOpts?.Verbose == true)
|
||||
WriteProtocol(NatsProtocol.OkBytes);
|
||||
}
|
||||
else
|
||||
{
|
||||
await DispatchCommandAsync(cmd, ct);
|
||||
}
|
||||
}
|
||||
|
||||
if (localInMsgs > 0)
|
||||
{
|
||||
Interlocked.Add(ref InMsgs, localInMsgs);
|
||||
Interlocked.Add(ref _serverStats.InMsgs, localInMsgs);
|
||||
}
|
||||
|
||||
if (localInBytes > 0)
|
||||
{
|
||||
Interlocked.Add(ref InBytes, localInBytes);
|
||||
Interlocked.Add(ref _serverStats.InBytes, localInBytes);
|
||||
}
|
||||
|
||||
reader.AdvanceTo(buffer.Start, buffer.End);
|
||||
@@ -184,6 +308,8 @@ public sealed class NatsClient : IDisposable
|
||||
|
||||
private async ValueTask DispatchCommandAsync(ParsedCommand cmd, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
|
||||
|
||||
// If auth is required and CONNECT hasn't been received yet,
|
||||
// only allow CONNECT and PING commands
|
||||
if (_authService.IsAuthRequired && !ConnectReceived)
|
||||
@@ -194,7 +320,7 @@ public sealed class NatsClient : IDisposable
|
||||
await ProcessConnectAsync(cmd);
|
||||
return;
|
||||
case CommandType.Ping:
|
||||
await WriteAsync(NatsProtocol.PongBytes, ct);
|
||||
WriteProtocol(NatsProtocol.PongBytes);
|
||||
return;
|
||||
default:
|
||||
// Ignore all other commands until authenticated
|
||||
@@ -206,27 +332,43 @@ public sealed class NatsClient : IDisposable
|
||||
{
|
||||
case CommandType.Connect:
|
||||
await ProcessConnectAsync(cmd);
|
||||
if (ClientOpts?.Verbose == true)
|
||||
WriteProtocol(NatsProtocol.OkBytes);
|
||||
break;
|
||||
|
||||
case CommandType.Ping:
|
||||
await WriteAsync(NatsProtocol.PongBytes, ct);
|
||||
WriteProtocol(NatsProtocol.PongBytes);
|
||||
if (ClientOpts?.Verbose == true)
|
||||
WriteProtocol(NatsProtocol.OkBytes);
|
||||
break;
|
||||
|
||||
case CommandType.Pong:
|
||||
Interlocked.Exchange(ref _pingsOut, 0);
|
||||
var rttStart = Interlocked.Read(ref _rttStartTicks);
|
||||
if (rttStart > 0)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow.Ticks - rttStart;
|
||||
if (elapsed <= 0) elapsed = 1; // min 1 tick for Windows granularity
|
||||
Interlocked.Exchange(ref _rtt, elapsed);
|
||||
}
|
||||
_flags.SetFlag(ClientFlags.FirstPongSent);
|
||||
break;
|
||||
|
||||
case CommandType.Sub:
|
||||
await ProcessSubAsync(cmd);
|
||||
ProcessSub(cmd);
|
||||
if (ClientOpts?.Verbose == true)
|
||||
WriteProtocol(NatsProtocol.OkBytes);
|
||||
break;
|
||||
|
||||
case CommandType.Unsub:
|
||||
ProcessUnsub(cmd);
|
||||
if (ClientOpts?.Verbose == true)
|
||||
WriteProtocol(NatsProtocol.OkBytes);
|
||||
break;
|
||||
|
||||
case CommandType.Pub:
|
||||
case CommandType.HPub:
|
||||
await ProcessPubAsync(cmd);
|
||||
// Pub/HPub is handled inline in ProcessCommandsAsync for stat batching
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -237,36 +379,44 @@ public sealed class NatsClient : IDisposable
|
||||
?? new ClientOptions();
|
||||
|
||||
// Authenticate if auth is required
|
||||
AuthResult? authResult = null;
|
||||
if (_authService.IsAuthRequired)
|
||||
{
|
||||
var context = new ClientAuthContext
|
||||
{
|
||||
Opts = ClientOpts,
|
||||
Nonce = _nonce ?? [],
|
||||
ClientCertificate = TlsState?.PeerCert,
|
||||
};
|
||||
|
||||
var result = _authService.Authenticate(context);
|
||||
if (result == null)
|
||||
authResult = _authService.Authenticate(context);
|
||||
if (authResult == null)
|
||||
{
|
||||
_logger.LogWarning("Client {ClientId} authentication failed", Id);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrAuthorizationViolation, ClientClosedReason.AuthenticationViolation);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build permissions from auth result
|
||||
_permissions = ClientPermissions.Build(result.Permissions);
|
||||
_permissions = ClientPermissions.Build(authResult.Permissions);
|
||||
|
||||
// Resolve account
|
||||
if (Router is NatsServer server)
|
||||
{
|
||||
var accountName = result.AccountName ?? Account.GlobalAccountName;
|
||||
var accountName = authResult.AccountName ?? Account.GlobalAccountName;
|
||||
Account = server.GetOrCreateAccount(accountName);
|
||||
Account.AddClient(Id);
|
||||
if (!Account.AddClient(Id))
|
||||
{
|
||||
Account = null;
|
||||
await SendErrAndCloseAsync("maximum connections for account exceeded",
|
||||
ClientClosedReason.AuthenticationViolation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Client {ClientId} authenticated as {Identity}", Id, result.Identity);
|
||||
_logger.LogDebug("Client {ClientId} authenticated as {Identity}", Id, authResult.Identity);
|
||||
|
||||
// Clear nonce after use — defense-in-depth against memory dumps
|
||||
// Clear nonce after use -- defense-in-depth against memory dumps
|
||||
if (_nonce != null)
|
||||
CryptographicOperations.ZeroMemory(_nonce);
|
||||
}
|
||||
@@ -275,20 +425,83 @@ public sealed class NatsClient : IDisposable
|
||||
if (Account == null && Router is NatsServer server2)
|
||||
{
|
||||
Account = server2.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
Account.AddClient(Id);
|
||||
if (!Account.AddClient(Id))
|
||||
{
|
||||
Account = null;
|
||||
await SendErrAndCloseAsync("maximum connections for account exceeded",
|
||||
ClientClosedReason.AuthenticationViolation);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Volatile.Write(ref _connectReceived, 1);
|
||||
// Validate no_responders requires headers
|
||||
if (ClientOpts.NoResponders && !ClientOpts.Headers)
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} no_responders requires headers", Id);
|
||||
await CloseWithReasonAsync(ClientClosedReason.NoRespondersRequiresHeaders,
|
||||
NatsProtocol.ErrNoRespondersRequiresHeaders);
|
||||
return;
|
||||
}
|
||||
|
||||
_flags.SetFlag(ClientFlags.ConnectReceived);
|
||||
_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)
|
||||
{
|
||||
var remaining = expiry - DateTimeOffset.UtcNow;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(remaining, _clientCts!.Token);
|
||||
_logger.LogDebug("Client {ClientId} authentication expired", Id);
|
||||
await SendErrAndCloseAsync("Authentication Expired",
|
||||
ClientClosedReason.AuthenticationExpired);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}, _clientCts!.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendErrAndCloseAsync("Authentication Expired",
|
||||
ClientClosedReason.AuthenticationExpired);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ProcessSubAsync(ParsedCommand cmd)
|
||||
private void ProcessSub(ParsedCommand cmd)
|
||||
{
|
||||
// Permission check for subscribe
|
||||
if (_permissions != null && !_permissions.IsSubscribeAllowed(cmd.Subject!, cmd.Queue))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} subscribe permission denied for {Subject}", Id, cmd.Subject);
|
||||
await SendErrAsync(NatsProtocol.ErrPermissionsSubscribe);
|
||||
SendErr(NatsProtocol.ErrPermissionsSubscribe);
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-connection subscription limit
|
||||
if (_options.MaxSubs > 0 && _subs.Count >= _options.MaxSubs)
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} max subscriptions exceeded", Id);
|
||||
_ = SendErrAndCloseAsync(NatsProtocol.ErrMaxSubscriptionsExceeded,
|
||||
ClientClosedReason.MaxSubscriptionsExceeded);
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-account subscription limit
|
||||
if (Account != null && !Account.IncrementSubscriptions())
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} account subscription limit exceeded", Id);
|
||||
_ = SendErrAndCloseAsync(NatsProtocol.ErrMaxSubscriptionsExceeded,
|
||||
ClientClosedReason.MaxSubscriptionsExceeded);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,21 +535,22 @@ public sealed class NatsClient : IDisposable
|
||||
}
|
||||
|
||||
_subs.Remove(cmd.Sid!);
|
||||
Account?.DecrementSubscriptions();
|
||||
|
||||
Account?.SubList.Remove(sub);
|
||||
}
|
||||
|
||||
private async ValueTask ProcessPubAsync(ParsedCommand cmd)
|
||||
private void ProcessPub(ParsedCommand cmd, ref long localInMsgs, ref long localInBytes)
|
||||
{
|
||||
Interlocked.Increment(ref InMsgs);
|
||||
Interlocked.Add(ref InBytes, cmd.Payload.Length);
|
||||
localInMsgs++;
|
||||
localInBytes += cmd.Payload.Length;
|
||||
|
||||
// Max payload validation (always, hard close)
|
||||
if (cmd.Payload.Length > _options.MaxPayload)
|
||||
{
|
||||
_logger.LogWarning("Client {ClientId} exceeded max payload: {Size} > {MaxPayload}",
|
||||
Id, cmd.Payload.Length, _options.MaxPayload);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrMaxPayloadViolation);
|
||||
_ = SendErrAndCloseAsync(NatsProtocol.ErrMaxPayloadViolation, ClientClosedReason.MaxPayloadExceeded);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -344,7 +558,7 @@ public sealed class NatsClient : IDisposable
|
||||
if (ClientOpts?.Pedantic == true && !SubjectMatch.IsValidPublishSubject(cmd.Subject!))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} invalid publish subject: {Subject}", Id, cmd.Subject);
|
||||
await SendErrAsync(NatsProtocol.ErrInvalidPublishSubject);
|
||||
SendErr(NatsProtocol.ErrInvalidPublishSubject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -352,7 +566,7 @@ public sealed class NatsClient : IDisposable
|
||||
if (_permissions != null && !_permissions.IsPublishAllowed(cmd.Subject!))
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} publish permission denied for {Subject}", Id, cmd.Subject);
|
||||
await SendErrAsync(NatsProtocol.ErrPermissionsPublish);
|
||||
SendErr(NatsProtocol.ErrPermissionsPublish);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -368,85 +582,178 @@ public sealed class NatsClient : IDisposable
|
||||
Router?.ProcessMessage(cmd.Subject!, cmd.ReplyTo, headers, payload, this);
|
||||
}
|
||||
|
||||
private async Task SendInfoAsync(CancellationToken ct)
|
||||
private void SendInfo()
|
||||
{
|
||||
var infoJson = JsonSerializer.Serialize(_serverInfo);
|
||||
var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
|
||||
await WriteAsync(infoLine, ct);
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref OutMsgs);
|
||||
Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
|
||||
|
||||
byte[] line;
|
||||
if (headers.Length > 0)
|
||||
// Use the cached INFO bytes from the server when there is no per-connection
|
||||
// nonce (i.e. NKey auth is not active for this connection). When a nonce is
|
||||
// present the _serverInfo was already cloned with the nonce embedded, so we
|
||||
// must serialise it individually.
|
||||
if (_nonce == null && Router is NatsServer server)
|
||||
{
|
||||
int totalSize = headers.Length + payload.Length;
|
||||
line = Encoding.ASCII.GetBytes($"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
|
||||
QueueOutbound(server.CachedInfoLine);
|
||||
}
|
||||
else
|
||||
{
|
||||
line = Encoding.ASCII.GetBytes($"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
|
||||
}
|
||||
|
||||
await _writeLock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(line, ct);
|
||||
if (headers.Length > 0)
|
||||
await _stream.WriteAsync(headers, ct);
|
||||
if (payload.Length > 0)
|
||||
await _stream.WriteAsync(payload, ct);
|
||||
await _stream.WriteAsync(NatsProtocol.CrLf, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
var infoJson = JsonSerializer.Serialize(_serverInfo);
|
||||
var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
|
||||
QueueOutbound(infoLine);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteAsync(byte[] data, CancellationToken ct)
|
||||
public void SendMessage(string subject, string sid, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
await _writeLock.WaitAsync(ct);
|
||||
try
|
||||
Interlocked.Increment(ref OutMsgs);
|
||||
Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
|
||||
Interlocked.Increment(ref _serverStats.OutMsgs);
|
||||
Interlocked.Add(ref _serverStats.OutBytes, payload.Length + headers.Length);
|
||||
|
||||
// Estimate control line size
|
||||
var estimatedLineSize = 5 + subject.Length + 1 + sid.Length + 1
|
||||
+ (replyTo != null ? replyTo.Length + 1 : 0) + 20 + 2;
|
||||
|
||||
var totalPayloadLen = headers.Length + payload.Length;
|
||||
var totalLen = estimatedLineSize + totalPayloadLen + 2;
|
||||
var buffer = new byte[totalLen];
|
||||
var span = buffer.AsSpan();
|
||||
int pos = 0;
|
||||
|
||||
// Write prefix
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
await _stream.WriteAsync(data, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
"HMSG "u8.CopyTo(span);
|
||||
pos = 5;
|
||||
}
|
||||
finally
|
||||
else
|
||||
{
|
||||
_writeLock.Release();
|
||||
"MSG "u8.CopyTo(span);
|
||||
pos = 4;
|
||||
}
|
||||
|
||||
// Subject
|
||||
pos += Encoding.ASCII.GetBytes(subject, span[pos..]);
|
||||
span[pos++] = (byte)' ';
|
||||
|
||||
// SID
|
||||
pos += Encoding.ASCII.GetBytes(sid, span[pos..]);
|
||||
span[pos++] = (byte)' ';
|
||||
|
||||
// Reply-to
|
||||
if (replyTo != null)
|
||||
{
|
||||
pos += Encoding.ASCII.GetBytes(replyTo, span[pos..]);
|
||||
span[pos++] = (byte)' ';
|
||||
}
|
||||
|
||||
// Sizes
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
int totalSize = headers.Length + payload.Length;
|
||||
headers.Length.TryFormat(span[pos..], out int written);
|
||||
pos += written;
|
||||
span[pos++] = (byte)' ';
|
||||
totalSize.TryFormat(span[pos..], out written);
|
||||
pos += written;
|
||||
}
|
||||
else
|
||||
{
|
||||
payload.Length.TryFormat(span[pos..], out int written);
|
||||
pos += written;
|
||||
}
|
||||
|
||||
// CRLF
|
||||
span[pos++] = (byte)'\r';
|
||||
span[pos++] = (byte)'\n';
|
||||
|
||||
// Headers + payload + trailing CRLF
|
||||
if (headers.Length > 0)
|
||||
{
|
||||
headers.Span.CopyTo(span[pos..]);
|
||||
pos += headers.Length;
|
||||
}
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
payload.Span.CopyTo(span[pos..]);
|
||||
pos += payload.Length;
|
||||
}
|
||||
span[pos++] = (byte)'\r';
|
||||
span[pos++] = (byte)'\n';
|
||||
|
||||
QueueOutbound(buffer.AsMemory(0, pos));
|
||||
}
|
||||
|
||||
public async Task SendErrAsync(string message)
|
||||
private void WriteProtocol(byte[] data)
|
||||
{
|
||||
QueueOutbound(data);
|
||||
}
|
||||
|
||||
public void SendErr(string message)
|
||||
{
|
||||
var errLine = Encoding.ASCII.GetBytes($"-ERR '{message}'\r\n");
|
||||
QueueOutbound(errLine);
|
||||
}
|
||||
|
||||
private async Task RunWriteLoopAsync(CancellationToken ct)
|
||||
{
|
||||
_flags.SetFlag(ClientFlags.WriteLoopStarted);
|
||||
var reader = _outbound.Reader;
|
||||
try
|
||||
{
|
||||
await WriteAsync(errLine, _clientCts?.Token ?? CancellationToken.None);
|
||||
while (await reader.WaitToReadAsync(ct))
|
||||
{
|
||||
long batchBytes = 0;
|
||||
while (reader.TryRead(out var data))
|
||||
{
|
||||
await _stream.WriteAsync(data, ct);
|
||||
batchBytes += data.Length;
|
||||
}
|
||||
|
||||
using var flushCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
flushCts.CancelAfter(_options.WriteDeadline);
|
||||
try
|
||||
{
|
||||
await _stream.FlushAsync(flushCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
_flags.SetFlag(ClientFlags.IsSlowConsumer);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumers);
|
||||
Interlocked.Increment(ref _serverStats.SlowConsumerClients);
|
||||
await CloseWithReasonAsync(ClientClosedReason.SlowConsumerWriteDeadline, NatsProtocol.ErrSlowConsumer);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Add(ref _pendingBytes, -batchBytes);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected during shutdown
|
||||
// Normal shutdown
|
||||
}
|
||||
catch (IOException ex)
|
||||
catch (IOException)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} failed to send -ERR", Id);
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} failed to send -ERR (disposed)", Id);
|
||||
await CloseWithReasonAsync(ClientClosedReason.WriteError);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendErrAndCloseAsync(string message)
|
||||
public async Task SendErrAndCloseAsync(string message, ClientClosedReason reason = ClientClosedReason.ProtocolViolation)
|
||||
{
|
||||
await SendErrAsync(message);
|
||||
await CloseWithReasonAsync(reason, message);
|
||||
}
|
||||
|
||||
private async Task CloseWithReasonAsync(ClientClosedReason reason, string? errMessage = null)
|
||||
{
|
||||
CloseReason = reason;
|
||||
if (errMessage != null)
|
||||
SendErr(errMessage);
|
||||
_flags.SetFlag(ClientFlags.CloseConnection);
|
||||
|
||||
// Complete the outbound channel so the write loop drains remaining data
|
||||
_outbound.Writer.TryComplete();
|
||||
|
||||
// Give the write loop a short window to flush the final batch before canceling
|
||||
await Task.Delay(50);
|
||||
|
||||
if (_clientCts is { } cts)
|
||||
await cts.CancelAsync();
|
||||
else
|
||||
@@ -460,6 +767,13 @@ public sealed class NatsClient : IDisposable
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(ct))
|
||||
{
|
||||
// Delay first PING until client has responded with PONG or 2 seconds elapsed
|
||||
if (!_flags.HasFlag(ClientFlags.FirstPongSent)
|
||||
&& (DateTime.UtcNow - StartTime).TotalSeconds < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var elapsed = Environment.TickCount64 - Interlocked.Read(ref _lastIn);
|
||||
if (elapsed < (long)_options.PingInterval.TotalMilliseconds)
|
||||
{
|
||||
@@ -470,23 +784,18 @@ public sealed class NatsClient : IDisposable
|
||||
|
||||
if (Volatile.Read(ref _pingsOut) + 1 > _options.MaxPingsOut)
|
||||
{
|
||||
_logger.LogDebug("Client {ClientId} stale connection — closing", Id);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrStaleConnection);
|
||||
_logger.LogDebug("Client {ClientId} stale connection -- closing", Id);
|
||||
Interlocked.Increment(ref _serverStats.StaleConnections);
|
||||
Interlocked.Increment(ref _serverStats.StaleConnectionClients);
|
||||
await SendErrAndCloseAsync(NatsProtocol.ErrStaleConnection, ClientClosedReason.StaleConnection);
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPingsOut = Interlocked.Increment(ref _pingsOut);
|
||||
_logger.LogDebug("Client {ClientId} sending PING ({PingsOut}/{MaxPingsOut})",
|
||||
Id, currentPingsOut, _options.MaxPingsOut);
|
||||
try
|
||||
{
|
||||
await WriteAsync(NatsProtocol.PingBytes, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Client {ClientId} failed to send PING", Id);
|
||||
return;
|
||||
}
|
||||
Interlocked.Exchange(ref _rttStartTicks, DateTime.UtcNow.Ticks);
|
||||
WriteProtocol(NatsProtocol.PingBytes);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -495,6 +804,63 @@ public sealed class NatsClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks this connection as closed with the given reason.
|
||||
/// Sets skip-flush flag for error-related reasons.
|
||||
/// Only the first call sets the reason (subsequent calls are no-ops).
|
||||
/// </summary>
|
||||
public void MarkClosed(ClientClosedReason reason)
|
||||
{
|
||||
if (CloseReason != ClientClosedReason.None)
|
||||
return;
|
||||
|
||||
CloseReason = reason;
|
||||
|
||||
switch (reason)
|
||||
{
|
||||
case ClientClosedReason.ReadError:
|
||||
case ClientClosedReason.WriteError:
|
||||
case ClientClosedReason.SlowConsumerPendingBytes:
|
||||
case ClientClosedReason.SlowConsumerWriteDeadline:
|
||||
case ClientClosedReason.TlsHandshakeError:
|
||||
Volatile.Write(ref _skipFlushOnClose, 1);
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Client {ClientId} connection closed: {CloseReason}", Id, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes pending data (unless skip-flush is set) and closes the connection.
|
||||
/// </summary>
|
||||
public async Task FlushAndCloseAsync(bool minimalFlush = false)
|
||||
{
|
||||
if (!ShouldSkipFlush)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var flushCts = new CancellationTokenSource(minimalFlush
|
||||
? TimeSpan.FromMilliseconds(100)
|
||||
: TimeSpan.FromSeconds(1));
|
||||
await _stream.FlushAsync(flushCts.Token);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best effort flush — don't let it prevent close
|
||||
}
|
||||
}
|
||||
|
||||
try { _socket.Shutdown(SocketShutdown.Both); }
|
||||
catch (SocketException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
public void RemoveSubscription(string sid)
|
||||
{
|
||||
if (_subs.Remove(sid))
|
||||
Account?.DecrementSubscriptions();
|
||||
}
|
||||
|
||||
public void RemoveAllSubscriptions(SubList subList)
|
||||
{
|
||||
foreach (var sub in _subs.Values)
|
||||
@@ -505,9 +871,9 @@ public sealed class NatsClient : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_permissions?.Dispose();
|
||||
_outbound.Writer.TryComplete();
|
||||
_clientCts?.Dispose();
|
||||
_stream.Dispose();
|
||||
_socket.Dispose();
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Security.Authentication;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
@@ -7,12 +9,24 @@ public sealed class NatsOptions
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } = 4222;
|
||||
public string? ServerName { get; set; }
|
||||
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
|
||||
public int MaxPayload { get; set; } = 1024 * 1024;
|
||||
public int MaxControlLine { get; set; } = 4096;
|
||||
public int MaxConnections { get; set; } = 65536;
|
||||
public long MaxPending { get; set; } = 64 * 1024 * 1024; // 64MB, matching Go MAX_PENDING_SIZE
|
||||
public TimeSpan WriteDeadline { get; set; } = TimeSpan.FromSeconds(10);
|
||||
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public int MaxPingsOut { get; set; } = 2;
|
||||
|
||||
// Subscription limits
|
||||
public int MaxSubs { get; set; } // 0 = unlimited (per-connection)
|
||||
public int MaxSubTokens { get; set; } // 0 = unlimited
|
||||
|
||||
// Server tags (exposed via /varz)
|
||||
public Dictionary<string, string>? Tags { get; set; }
|
||||
|
||||
// Account configuration
|
||||
public Dictionary<string, AccountConfig>? Accounts { get; set; }
|
||||
|
||||
// Simple auth (single user)
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
@@ -27,4 +41,79 @@ public sealed class NatsOptions
|
||||
|
||||
// Auth timing
|
||||
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
// Monitoring (0 = disabled; standard port is 8222)
|
||||
public int MonitorPort { get; set; }
|
||||
public string MonitorHost { get; set; } = "0.0.0.0";
|
||||
public string? MonitorBasePath { get; set; }
|
||||
// 0 = disabled
|
||||
public int MonitorHttpsPort { get; set; }
|
||||
|
||||
// Lifecycle / lame-duck mode
|
||||
public TimeSpan LameDuckDuration { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan LameDuckGracePeriod { get; set; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
// File paths
|
||||
public string? PidFile { get; set; }
|
||||
public string? PortsFileDir { get; set; }
|
||||
public string? ConfigFile { get; set; }
|
||||
|
||||
// Logging
|
||||
public string? LogFile { get; set; }
|
||||
public long LogSizeLimit { get; set; }
|
||||
public int LogMaxFiles { get; set; }
|
||||
public bool Debug { get; set; }
|
||||
public bool Trace { get; set; }
|
||||
public bool Logtime { get; set; } = true;
|
||||
public bool LogtimeUTC { get; set; }
|
||||
public bool Syslog { get; set; }
|
||||
public string? RemoteSyslog { get; set; }
|
||||
|
||||
// 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; }
|
||||
public string? TlsCaCert { get; set; }
|
||||
public bool TlsVerify { get; set; }
|
||||
public bool TlsMap { get; set; }
|
||||
public TimeSpan TlsTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public bool TlsHandshakeFirst { get; set; }
|
||||
public TimeSpan TlsHandshakeFirstFallback { get; set; } = TimeSpan.FromMilliseconds(50);
|
||||
public bool AllowNonTls { get; set; }
|
||||
public long TlsRateLimit { get; set; }
|
||||
public HashSet<string>? TlsPinnedCerts { get; set; }
|
||||
public SslProtocols TlsMinVersion { get; set; } = SslProtocols.Tls12;
|
||||
|
||||
// OCSP stapling and peer verification
|
||||
public OcspConfig? OcspConfig { get; set; }
|
||||
public bool OcspPeerVerify { get; set; }
|
||||
|
||||
// JWT / Operator mode
|
||||
public string[]? TrustedKeys { get; set; }
|
||||
public Auth.Jwt.IAccountResolver? AccountResolver { get; set; }
|
||||
|
||||
// Per-subsystem log level overrides (namespace -> level)
|
||||
public Dictionary<string, string>? LogOverrides { get; set; }
|
||||
|
||||
// Subject mapping / transforms (source pattern -> destination template)
|
||||
public Dictionary<string, string>? SubjectMappings { get; set; }
|
||||
|
||||
public bool HasTls => TlsCert != null && TlsKey != null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
108
src/NATS.Server/Protocol/NatsHeaderParser.cs
Normal file
108
src/NATS.Server/Protocol/NatsHeaderParser.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Protocol;
|
||||
|
||||
public readonly struct NatsHeaders()
|
||||
{
|
||||
public int Status { get; init; }
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public IReadOnlyDictionary<string, string[]> Headers { get; init; } = ReadOnlyDictionary<string, string[]>.Empty;
|
||||
|
||||
public static readonly NatsHeaders Invalid = new()
|
||||
{
|
||||
Status = -1,
|
||||
Description = string.Empty,
|
||||
Headers = ReadOnlyDictionary<string, string[]>.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public static class NatsHeaderParser
|
||||
{
|
||||
private static ReadOnlySpan<byte> CrLf => "\r\n"u8;
|
||||
private static ReadOnlySpan<byte> Prefix => "NATS/1.0"u8;
|
||||
|
||||
public static NatsHeaders Parse(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length < Prefix.Length)
|
||||
return NatsHeaders.Invalid;
|
||||
|
||||
if (!data[..Prefix.Length].SequenceEqual(Prefix))
|
||||
return NatsHeaders.Invalid;
|
||||
|
||||
int pos = Prefix.Length;
|
||||
int status = 0;
|
||||
string description = string.Empty;
|
||||
|
||||
// Parse status line: NATS/1.0[ status[ description]]\r\n
|
||||
int lineEnd = data[pos..].IndexOf(CrLf);
|
||||
if (lineEnd < 0)
|
||||
return NatsHeaders.Invalid;
|
||||
|
||||
var statusLine = data[pos..(pos + lineEnd)];
|
||||
pos += lineEnd + 2; // skip \r\n
|
||||
|
||||
if (statusLine.Length > 0)
|
||||
{
|
||||
int si = 0;
|
||||
while (si < statusLine.Length && statusLine[si] == (byte)' ')
|
||||
si++;
|
||||
|
||||
int numStart = si;
|
||||
while (si < statusLine.Length && statusLine[si] >= (byte)'0' && statusLine[si] <= (byte)'9')
|
||||
si++;
|
||||
|
||||
if (si > numStart && si - numStart <= 5) // max 5 digits to avoid overflow
|
||||
{
|
||||
for (int idx = numStart; idx < si; idx++)
|
||||
status = status * 10 + (statusLine[idx] - '0');
|
||||
|
||||
while (si < statusLine.Length && statusLine[si] == (byte)' ')
|
||||
si++;
|
||||
if (si < statusLine.Length)
|
||||
description = Encoding.ASCII.GetString(statusLine[si..]);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse key-value headers until empty line
|
||||
var headers = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
while (pos < data.Length)
|
||||
{
|
||||
var remaining = data[pos..];
|
||||
if (remaining.Length >= 2 && remaining[0] == (byte)'\r' && remaining[1] == (byte)'\n')
|
||||
break;
|
||||
|
||||
lineEnd = remaining.IndexOf(CrLf);
|
||||
if (lineEnd < 0)
|
||||
break;
|
||||
|
||||
var headerLine = remaining[..lineEnd];
|
||||
pos += lineEnd + 2;
|
||||
|
||||
int colon = headerLine.IndexOf((byte)':');
|
||||
if (colon < 0)
|
||||
continue;
|
||||
|
||||
var key = Encoding.ASCII.GetString(headerLine[..colon]).Trim();
|
||||
var value = Encoding.ASCII.GetString(headerLine[(colon + 1)..]).Trim();
|
||||
|
||||
if (!headers.TryGetValue(key, out var values))
|
||||
{
|
||||
values = [];
|
||||
headers[key] = values;
|
||||
}
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string[]>(headers.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (k, v) in headers)
|
||||
result[k] = v.ToArray();
|
||||
|
||||
return new NatsHeaders
|
||||
{
|
||||
Status = status,
|
||||
Description = description,
|
||||
Headers = result,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NATS.Server.Protocol;
|
||||
|
||||
@@ -35,6 +36,8 @@ public sealed class NatsParser
|
||||
{
|
||||
private static readonly byte[] CrLfBytes = "\r\n"u8.ToArray();
|
||||
private readonly int _maxPayload;
|
||||
private ILogger? _logger;
|
||||
public ILogger? Logger { set => _logger = value; }
|
||||
|
||||
// State for split-packet payload reading
|
||||
private bool _awaitingPayload;
|
||||
@@ -44,9 +47,20 @@ public sealed class NatsParser
|
||||
private int _pendingHeaderSize;
|
||||
private CommandType _pendingType;
|
||||
|
||||
public NatsParser(int maxPayload = NatsProtocol.MaxPayloadSize)
|
||||
public NatsParser(int maxPayload = NatsProtocol.MaxPayloadSize, ILogger? logger = null)
|
||||
{
|
||||
_maxPayload = maxPayload;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private void TraceInOp(string op, ReadOnlySpan<byte> arg = default)
|
||||
{
|
||||
if (_logger == null || !_logger.IsEnabled(LogLevel.Trace))
|
||||
return;
|
||||
if (arg.IsEmpty)
|
||||
_logger.LogTrace("<<- {Op}", op);
|
||||
else
|
||||
_logger.LogTrace("<<- {Op} {Arg}", op, Encoding.ASCII.GetString(arg));
|
||||
}
|
||||
|
||||
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
|
||||
@@ -91,6 +105,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParsedCommand.Simple(CommandType.Ping);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("PING");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -98,6 +113,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParsedCommand.Simple(CommandType.Pong);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("PONG");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -121,6 +137,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParseSub(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("SUB", lineSpan[4..]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -131,6 +148,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParseUnsub(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("UNSUB", lineSpan[6..]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -141,6 +159,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParseConnect(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("CONNECT");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -151,6 +170,7 @@ public sealed class NatsParser
|
||||
{
|
||||
command = ParseInfo(lineSpan);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("INFO");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -159,11 +179,13 @@ public sealed class NatsParser
|
||||
case (byte)'+': // +OK
|
||||
command = ParsedCommand.Simple(CommandType.Ok);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("+OK");
|
||||
return true;
|
||||
|
||||
case (byte)'-': // -ERR
|
||||
command = ParsedCommand.Simple(CommandType.Err);
|
||||
buffer = buffer.Slice(reader.Position);
|
||||
TraceInOp("-ERR");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -215,6 +237,7 @@ public sealed class NatsParser
|
||||
_pendingHeaderSize = -1;
|
||||
_pendingType = CommandType.Pub;
|
||||
|
||||
TraceInOp("PUB", argsSpan);
|
||||
return TryReadPayload(ref buffer, out command);
|
||||
}
|
||||
|
||||
@@ -264,6 +287,7 @@ public sealed class NatsParser
|
||||
_pendingHeaderSize = hdrSize;
|
||||
_pendingType = CommandType.HPub;
|
||||
|
||||
TraceInOp("HPUB", argsSpan);
|
||||
return TryReadPayload(ref buffer, out command);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ public static class NatsProtocol
|
||||
{
|
||||
public const int MaxControlLineSize = 4096;
|
||||
public const int MaxPayloadSize = 1024 * 1024; // 1MB
|
||||
public const long MaxPendingSize = 64 * 1024 * 1024; // 64MB default max pending
|
||||
public const int DefaultPort = 4222;
|
||||
public const string Version = "0.1.0";
|
||||
public const int ProtoVersion = 1;
|
||||
@@ -30,6 +31,9 @@ public static class NatsProtocol
|
||||
public const string ErrAuthTimeout = "Authentication Timeout";
|
||||
public const string ErrPermissionsPublish = "Permissions Violation for Publish";
|
||||
public const string ErrPermissionsSubscribe = "Permissions Violation for Subscription";
|
||||
public const string ErrSlowConsumer = "Slow Consumer";
|
||||
public const string ErrNoRespondersRequiresHeaders = "No Responders Requires Headers Support";
|
||||
public const string ErrMaxSubscriptionsExceeded = "Maximum Subscriptions Exceeded";
|
||||
}
|
||||
|
||||
public sealed class ServerInfo
|
||||
@@ -73,6 +77,18 @@ public sealed class ServerInfo
|
||||
[JsonPropertyName("nonce")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Nonce { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_required")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsRequired { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_verify")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsVerify { get; set; }
|
||||
|
||||
[JsonPropertyName("tls_available")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public bool TlsAvailable { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClientOptions
|
||||
@@ -118,4 +134,7 @@ public sealed class ClientOptions
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Sig { get; set; }
|
||||
|
||||
[JsonPropertyName("jwt")]
|
||||
public string? JWT { get; set; }
|
||||
}
|
||||
|
||||
24
src/NATS.Server/ServerStats.cs
Normal file
24
src/NATS.Server/ServerStats.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
public sealed class ServerStats
|
||||
{
|
||||
public long InMsgs;
|
||||
public long OutMsgs;
|
||||
public long InBytes;
|
||||
public long OutBytes;
|
||||
public long TotalConnections;
|
||||
public long SlowConsumers;
|
||||
public long StaleConnections;
|
||||
public long Stalls;
|
||||
public long SlowConsumerClients;
|
||||
public long SlowConsumerRoutes;
|
||||
public long SlowConsumerLeafs;
|
||||
public long SlowConsumerGateways;
|
||||
public long StaleConnectionClients;
|
||||
public long StaleConnectionRoutes;
|
||||
public long StaleConnectionLeafs;
|
||||
public long StaleConnectionGateways;
|
||||
public readonly ConcurrentDictionary<string, long> HttpReqStats = new();
|
||||
}
|
||||
@@ -13,9 +13,16 @@ public sealed class SubList : IDisposable
|
||||
|
||||
private readonly ReaderWriterLockSlim _lock = new();
|
||||
private readonly TrieLevel _root = new();
|
||||
private Dictionary<string, SubListResult>? _cache = new(StringComparer.Ordinal);
|
||||
private Dictionary<string, CachedResult>? _cache = new(StringComparer.Ordinal);
|
||||
private uint _count;
|
||||
private volatile bool _disposed;
|
||||
private long _generation;
|
||||
private ulong _matches;
|
||||
private ulong _cacheHits;
|
||||
private ulong _inserts;
|
||||
private ulong _removes;
|
||||
|
||||
private readonly record struct CachedResult(SubListResult Result, long Generation);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -33,6 +40,62 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all subscriptions in the trie. For monitoring only.
|
||||
/// </summary>
|
||||
public List<Subscription> GetAllSubscriptions()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var result = new List<Subscription>();
|
||||
CollectAll(_root, result);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectAll(TrieLevel level, List<Subscription> result)
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
foreach (var sub in node.PlainSubs) result.Add(sub);
|
||||
foreach (var (_, qset) in node.QueueSubs)
|
||||
foreach (var sub in qset) result.Add(sub);
|
||||
if (node.Next != null) CollectAll(node.Next, result);
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
foreach (var sub in level.Pwc.PlainSubs) result.Add(sub);
|
||||
foreach (var (_, qset) in level.Pwc.QueueSubs)
|
||||
foreach (var sub in qset) result.Add(sub);
|
||||
if (level.Pwc.Next != null) CollectAll(level.Pwc.Next, result);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
foreach (var sub in level.Fwc.PlainSubs) result.Add(sub);
|
||||
foreach (var (_, qset) in level.Fwc.QueueSubs)
|
||||
foreach (var sub in qset) result.Add(sub);
|
||||
if (level.Fwc.Next != null) CollectAll(level.Fwc.Next, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current number of entries in the cache.
|
||||
/// </summary>
|
||||
public int CacheCount
|
||||
{
|
||||
get
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try { return _cache?.Count ?? 0; }
|
||||
finally { _lock.ExitReadLock(); }
|
||||
}
|
||||
}
|
||||
|
||||
public void Insert(Subscription sub)
|
||||
{
|
||||
var subject = sub.Subject;
|
||||
@@ -90,7 +153,8 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
|
||||
_count++;
|
||||
AddToCache(subject, sub);
|
||||
_inserts++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -104,78 +168,10 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var level = _root;
|
||||
TrieNode? node = null;
|
||||
bool sawFwc = false;
|
||||
|
||||
var pathList = new List<(TrieLevel level, TrieNode node, string token, bool isPwc, bool isFwc)>();
|
||||
|
||||
foreach (var token in new TokenEnumerator(sub.Subject))
|
||||
if (RemoveInternal(sub))
|
||||
{
|
||||
if (token.Length == 0 || sawFwc)
|
||||
return;
|
||||
|
||||
bool isPwc = token.Length == 1 && token[0] == SubjectMatch.Pwc;
|
||||
bool isFwc = token.Length == 1 && token[0] == SubjectMatch.Fwc;
|
||||
|
||||
if (isPwc)
|
||||
{
|
||||
node = level.Pwc;
|
||||
}
|
||||
else if (isFwc)
|
||||
{
|
||||
node = level.Fwc;
|
||||
sawFwc = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
level.Nodes.TryGetValue(token.ToString(), out node);
|
||||
}
|
||||
|
||||
if (node == null)
|
||||
return; // not found
|
||||
|
||||
var tokenStr = token.ToString();
|
||||
pathList.Add((level, node, tokenStr, isPwc, isFwc));
|
||||
if (node.Next == null)
|
||||
return; // corrupted trie state
|
||||
level = node.Next;
|
||||
}
|
||||
|
||||
if (node == null) return;
|
||||
|
||||
// Remove from node
|
||||
bool removed;
|
||||
if (sub.Queue == null)
|
||||
{
|
||||
removed = node.PlainSubs.Remove(sub);
|
||||
}
|
||||
else
|
||||
{
|
||||
removed = false;
|
||||
if (node.QueueSubs.TryGetValue(sub.Queue, out var qset))
|
||||
{
|
||||
removed = qset.Remove(sub);
|
||||
if (qset.Count == 0)
|
||||
node.QueueSubs.Remove(sub.Queue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) return;
|
||||
|
||||
_count--;
|
||||
RemoveFromCache(sub.Subject);
|
||||
|
||||
// Prune empty nodes (walk backwards)
|
||||
for (int i = pathList.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var (l, n, t, isPwc, isFwc) = pathList[i];
|
||||
if (n.IsEmpty)
|
||||
{
|
||||
if (isPwc) l.Pwc = null;
|
||||
else if (isFwc) l.Fwc = null;
|
||||
else l.Nodes.Remove(t);
|
||||
}
|
||||
_removes++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -184,22 +180,107 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core remove logic without lock acquisition or generation bumping.
|
||||
/// Assumes write lock is held. Returns true if a subscription was actually removed.
|
||||
/// </summary>
|
||||
private bool RemoveInternal(Subscription sub)
|
||||
{
|
||||
var level = _root;
|
||||
TrieNode? node = null;
|
||||
bool sawFwc = false;
|
||||
|
||||
var pathList = new List<(TrieLevel level, TrieNode node, string token, bool isPwc, bool isFwc)>();
|
||||
|
||||
foreach (var token in new TokenEnumerator(sub.Subject))
|
||||
{
|
||||
if (token.Length == 0 || sawFwc)
|
||||
return false;
|
||||
|
||||
bool isPwc = token.Length == 1 && token[0] == SubjectMatch.Pwc;
|
||||
bool isFwc = token.Length == 1 && token[0] == SubjectMatch.Fwc;
|
||||
|
||||
if (isPwc)
|
||||
{
|
||||
node = level.Pwc;
|
||||
}
|
||||
else if (isFwc)
|
||||
{
|
||||
node = level.Fwc;
|
||||
sawFwc = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
level.Nodes.TryGetValue(token.ToString(), out node);
|
||||
}
|
||||
|
||||
if (node == null)
|
||||
return false; // not found
|
||||
|
||||
var tokenStr = token.ToString();
|
||||
pathList.Add((level, node, tokenStr, isPwc, isFwc));
|
||||
if (node.Next == null)
|
||||
return false; // corrupted trie state
|
||||
level = node.Next;
|
||||
}
|
||||
|
||||
if (node == null) return false;
|
||||
|
||||
// Remove from node
|
||||
bool removed;
|
||||
if (sub.Queue == null)
|
||||
{
|
||||
removed = node.PlainSubs.Remove(sub);
|
||||
}
|
||||
else
|
||||
{
|
||||
removed = false;
|
||||
if (node.QueueSubs.TryGetValue(sub.Queue, out var qset))
|
||||
{
|
||||
removed = qset.Remove(sub);
|
||||
if (qset.Count == 0)
|
||||
node.QueueSubs.Remove(sub.Queue);
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) return false;
|
||||
|
||||
_count--;
|
||||
|
||||
// Prune empty nodes (walk backwards)
|
||||
for (int i = pathList.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var (l, n, t, isPwc, isFwc) = pathList[i];
|
||||
if (n.IsEmpty)
|
||||
{
|
||||
if (isPwc) l.Pwc = null;
|
||||
else if (isFwc) l.Fwc = null;
|
||||
else l.Nodes.Remove(t);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public SubListResult Match(string subject)
|
||||
{
|
||||
// Check cache under read lock first.
|
||||
Interlocked.Increment(ref _matches);
|
||||
var currentGen = Interlocked.Read(ref _generation);
|
||||
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached))
|
||||
return cached;
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
return cached.Result;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
|
||||
// Cache miss -- tokenize and match under write lock (needed for cache update).
|
||||
// Tokenize the subject.
|
||||
var tokens = Tokenize(subject);
|
||||
if (tokens == null)
|
||||
return SubListResult.Empty;
|
||||
@@ -207,13 +288,15 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
// Re-check cache after acquiring write lock.
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached))
|
||||
return cached;
|
||||
currentGen = Interlocked.Read(ref _generation);
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
return cached.Result;
|
||||
}
|
||||
|
||||
var plainSubs = new List<Subscription>();
|
||||
var queueSubs = new List<List<Subscription>>();
|
||||
|
||||
MatchLevel(_root, tokens, 0, plainSubs, queueSubs);
|
||||
|
||||
SubListResult result;
|
||||
@@ -226,19 +309,14 @@ public sealed class SubList : IDisposable
|
||||
var queueSubsArr = new Subscription[queueSubs.Count][];
|
||||
for (int i = 0; i < queueSubs.Count; i++)
|
||||
queueSubsArr[i] = queueSubs[i].ToArray();
|
||||
|
||||
result = new SubListResult(
|
||||
plainSubs.ToArray(),
|
||||
queueSubsArr);
|
||||
result = new SubListResult(plainSubs.ToArray(), queueSubsArr);
|
||||
}
|
||||
|
||||
if (_cache != null)
|
||||
{
|
||||
_cache[subject] = result;
|
||||
|
||||
_cache[subject] = new CachedResult(result, currentGen);
|
||||
if (_cache.Count > CacheMax)
|
||||
{
|
||||
// Sweep: remove entries until at CacheSweep count.
|
||||
var keys = _cache.Keys.Take(_cache.Count - CacheSweep).ToList();
|
||||
foreach (var key in keys)
|
||||
_cache.Remove(key);
|
||||
@@ -356,119 +434,355 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a subscription to matching cache entries.
|
||||
/// Assumes write lock is held.
|
||||
/// </summary>
|
||||
private void AddToCache(string subject, Subscription sub)
|
||||
public SubListStats Stats()
|
||||
{
|
||||
if (_cache == null)
|
||||
return;
|
||||
|
||||
// If literal subject, we can do a direct lookup.
|
||||
if (SubjectMatch.IsLiteral(subject))
|
||||
_lock.EnterReadLock();
|
||||
uint numSubs, numCache;
|
||||
ulong inserts, removes;
|
||||
try
|
||||
{
|
||||
if (_cache.TryGetValue(subject, out var r))
|
||||
{
|
||||
_cache[subject] = AddSubToResult(r, sub);
|
||||
}
|
||||
return;
|
||||
numSubs = _count;
|
||||
numCache = (uint)(_cache?.Count ?? 0);
|
||||
inserts = _inserts;
|
||||
removes = _removes;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
|
||||
// Wildcard subscription -- check all cached keys.
|
||||
var keysToUpdate = new List<(string key, SubListResult result)>();
|
||||
foreach (var (key, r) in _cache)
|
||||
var matches = Interlocked.Read(ref _matches);
|
||||
var cacheHits = Interlocked.Read(ref _cacheHits);
|
||||
var hitRate = matches > 0 ? (double)cacheHits / matches : 0.0;
|
||||
|
||||
uint maxFanout = 0;
|
||||
long totalFanout = 0;
|
||||
int cacheEntries = 0;
|
||||
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (SubjectMatch.MatchLiteral(key, subject))
|
||||
if (_cache != null)
|
||||
{
|
||||
keysToUpdate.Add((key, r));
|
||||
foreach (var (_, entry) in _cache)
|
||||
{
|
||||
var r = entry.Result;
|
||||
var f = r.PlainSubs.Length + r.QueueSubs.Length;
|
||||
totalFanout += f;
|
||||
if (f > maxFanout) maxFanout = (uint)f;
|
||||
cacheEntries++;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var (key, r) in keysToUpdate)
|
||||
finally
|
||||
{
|
||||
_cache[key] = AddSubToResult(r, sub);
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
|
||||
return new SubListStats
|
||||
{
|
||||
NumSubs = numSubs,
|
||||
NumCache = numCache,
|
||||
NumInserts = inserts,
|
||||
NumRemoves = removes,
|
||||
NumMatches = matches,
|
||||
CacheHitRate = hitRate,
|
||||
MaxFanout = maxFanout,
|
||||
AvgFanout = cacheEntries > 0 ? (double)totalFanout / cacheEntries : 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
public bool HasInterest(string subject)
|
||||
{
|
||||
var currentGen = Interlocked.Read(ref _generation);
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
if (_cache != null && _cache.TryGetValue(subject, out var cached) && cached.Generation == currentGen)
|
||||
{
|
||||
var r = cached.Result;
|
||||
return r.PlainSubs.Length > 0 || r.QueueSubs.Length > 0;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
|
||||
var tokens = Tokenize(subject);
|
||||
if (tokens == null) return false;
|
||||
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return HasInterestLevel(_root, tokens, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes cache entries that match the given subject.
|
||||
/// Assumes write lock is held.
|
||||
/// </summary>
|
||||
private void RemoveFromCache(string subject)
|
||||
public (int plainCount, int queueCount) NumInterest(string subject)
|
||||
{
|
||||
if (_cache == null)
|
||||
return;
|
||||
var tokens = Tokenize(subject);
|
||||
if (tokens == null) return (0, 0);
|
||||
|
||||
// If literal subject, we can do a direct removal.
|
||||
if (SubjectMatch.IsLiteral(subject))
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
_cache.Remove(subject);
|
||||
return;
|
||||
int np = 0, nq = 0;
|
||||
CountInterestLevel(_root, tokens, 0, ref np, ref nq);
|
||||
return (np, nq);
|
||||
}
|
||||
|
||||
// Wildcard subscription -- remove all matching cached keys.
|
||||
var keysToRemove = new List<string>();
|
||||
foreach (var key in _cache.Keys)
|
||||
finally
|
||||
{
|
||||
if (SubjectMatch.MatchLiteral(key, subject))
|
||||
{
|
||||
keysToRemove.Add(key);
|
||||
}
|
||||
}
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new result with the given subscription added.
|
||||
/// </summary>
|
||||
private static SubListResult AddSubToResult(SubListResult result, Subscription sub)
|
||||
public void RemoveBatch(IEnumerable<Subscription> subs)
|
||||
{
|
||||
if (sub.Queue == null)
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var newPlain = new Subscription[result.PlainSubs.Length + 1];
|
||||
result.PlainSubs.CopyTo(newPlain, 0);
|
||||
newPlain[^1] = sub;
|
||||
return new SubListResult(newPlain, result.QueueSubs);
|
||||
var wasEnabled = _cache != null;
|
||||
_cache = null;
|
||||
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (RemoveInternal(sub))
|
||||
_removes++;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _generation);
|
||||
|
||||
if (wasEnabled)
|
||||
_cache = new Dictionary<string, CachedResult>(StringComparer.Ordinal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<Subscription> All()
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
CollectAllSubs(_root, subs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
|
||||
public SubListResult ReverseMatch(string subject)
|
||||
{
|
||||
var tokens = Tokenize(subject);
|
||||
if (tokens == null)
|
||||
return SubListResult.Empty;
|
||||
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var plainSubs = new List<Subscription>();
|
||||
var queueSubs = new List<List<Subscription>>();
|
||||
ReverseMatchLevel(_root, tokens, 0, plainSubs, queueSubs);
|
||||
|
||||
if (plainSubs.Count == 0 && queueSubs.Count == 0)
|
||||
return SubListResult.Empty;
|
||||
|
||||
var queueSubsArr = new Subscription[queueSubs.Count][];
|
||||
for (int i = 0; i < queueSubs.Count; i++)
|
||||
queueSubsArr[i] = queueSubs[i].ToArray();
|
||||
return new SubListResult(plainSubs.ToArray(), queueSubsArr);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasInterestLevel(TrieLevel? level, string[] tokens, int tokenIndex)
|
||||
{
|
||||
TrieNode? pwc = null;
|
||||
TrieNode? node = null;
|
||||
|
||||
for (int i = tokenIndex; i < tokens.Length; i++)
|
||||
{
|
||||
if (level == null) return false;
|
||||
if (level.Fwc != null && NodeHasInterest(level.Fwc)) return true;
|
||||
|
||||
pwc = level.Pwc;
|
||||
if (pwc != null && HasInterestLevel(pwc.Next, tokens, i + 1)) return true;
|
||||
|
||||
node = null;
|
||||
if (level.Nodes.TryGetValue(tokens[i], out var found))
|
||||
{
|
||||
node = found;
|
||||
level = node.Next;
|
||||
}
|
||||
else
|
||||
{
|
||||
level = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (node != null && NodeHasInterest(node)) return true;
|
||||
if (pwc != null && NodeHasInterest(pwc)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool NodeHasInterest(TrieNode node)
|
||||
{
|
||||
return node.PlainSubs.Count > 0 || node.QueueSubs.Count > 0;
|
||||
}
|
||||
|
||||
private static void CountInterestLevel(TrieLevel? level, string[] tokens, int tokenIndex,
|
||||
ref int np, ref int nq)
|
||||
{
|
||||
TrieNode? pwc = null;
|
||||
TrieNode? node = null;
|
||||
|
||||
for (int i = tokenIndex; i < tokens.Length; i++)
|
||||
{
|
||||
if (level == null) return;
|
||||
if (level.Fwc != null) AddNodeCounts(level.Fwc, ref np, ref nq);
|
||||
|
||||
pwc = level.Pwc;
|
||||
if (pwc != null) CountInterestLevel(pwc.Next, tokens, i + 1, ref np, ref nq);
|
||||
|
||||
node = null;
|
||||
if (level.Nodes.TryGetValue(tokens[i], out var found))
|
||||
{
|
||||
node = found;
|
||||
level = node.Next;
|
||||
}
|
||||
else
|
||||
{
|
||||
level = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (node != null) AddNodeCounts(node, ref np, ref nq);
|
||||
if (pwc != null) AddNodeCounts(pwc, ref np, ref nq);
|
||||
}
|
||||
|
||||
private static void AddNodeCounts(TrieNode node, ref int np, ref int nq)
|
||||
{
|
||||
np += node.PlainSubs.Count;
|
||||
foreach (var (_, qset) in node.QueueSubs)
|
||||
nq += qset.Count;
|
||||
}
|
||||
|
||||
private static void CollectAllSubs(TrieLevel level, List<Subscription> subs)
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
foreach (var sub in node.PlainSubs)
|
||||
subs.Add(sub);
|
||||
foreach (var (_, qset) in node.QueueSubs)
|
||||
foreach (var sub in qset)
|
||||
subs.Add(sub);
|
||||
if (node.Next != null)
|
||||
CollectAllSubs(node.Next, subs);
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
foreach (var sub in level.Pwc.PlainSubs)
|
||||
subs.Add(sub);
|
||||
foreach (var (_, qset) in level.Pwc.QueueSubs)
|
||||
foreach (var sub in qset)
|
||||
subs.Add(sub);
|
||||
if (level.Pwc.Next != null)
|
||||
CollectAllSubs(level.Pwc.Next, subs);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
foreach (var sub in level.Fwc.PlainSubs)
|
||||
subs.Add(sub);
|
||||
foreach (var (_, qset) in level.Fwc.QueueSubs)
|
||||
foreach (var sub in qset)
|
||||
subs.Add(sub);
|
||||
if (level.Fwc.Next != null)
|
||||
CollectAllSubs(level.Fwc.Next, subs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReverseMatchLevel(TrieLevel? level, string[] tokens, int tokenIndex,
|
||||
List<Subscription> plainSubs, List<List<Subscription>> queueSubs)
|
||||
{
|
||||
if (level == null || tokenIndex >= tokens.Length)
|
||||
return;
|
||||
|
||||
var token = tokens[tokenIndex];
|
||||
bool isLast = tokenIndex == tokens.Length - 1;
|
||||
|
||||
if (token == ">")
|
||||
{
|
||||
CollectAllNodes(level, plainSubs, queueSubs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token == "*")
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
if (isLast)
|
||||
AddNodeToResults(node, plainSubs, queueSubs);
|
||||
else
|
||||
ReverseMatchLevel(node.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Find existing queue group
|
||||
var queueSubs = result.QueueSubs;
|
||||
int slot = -1;
|
||||
for (int i = 0; i < queueSubs.Length; i++)
|
||||
if (level.Nodes.TryGetValue(token, out var node))
|
||||
{
|
||||
if (queueSubs[i].Length > 0 && queueSubs[i][0].Queue == sub.Queue)
|
||||
{
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy queue subs
|
||||
var newQueueSubs = new Subscription[queueSubs.Length + (slot < 0 ? 1 : 0)][];
|
||||
for (int i = 0; i < queueSubs.Length; i++)
|
||||
{
|
||||
if (i == slot)
|
||||
{
|
||||
var newGroup = new Subscription[queueSubs[i].Length + 1];
|
||||
queueSubs[i].CopyTo(newGroup, 0);
|
||||
newGroup[^1] = sub;
|
||||
newQueueSubs[i] = newGroup;
|
||||
}
|
||||
if (isLast)
|
||||
AddNodeToResults(node, plainSubs, queueSubs);
|
||||
else
|
||||
{
|
||||
newQueueSubs[i] = (Subscription[])queueSubs[i].Clone();
|
||||
}
|
||||
}
|
||||
if (slot < 0)
|
||||
{
|
||||
newQueueSubs[^1] = [sub];
|
||||
ReverseMatchLevel(node.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
|
||||
}
|
||||
}
|
||||
|
||||
return new SubListResult(result.PlainSubs, newQueueSubs);
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
if (isLast)
|
||||
AddNodeToResults(level.Pwc, plainSubs, queueSubs);
|
||||
else
|
||||
ReverseMatchLevel(level.Pwc.Next, tokens, tokenIndex + 1, plainSubs, queueSubs);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
AddNodeToResults(level.Fwc, plainSubs, queueSubs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectAllNodes(TrieLevel level, List<Subscription> plainSubs,
|
||||
List<List<Subscription>> queueSubs)
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
AddNodeToResults(node, plainSubs, queueSubs);
|
||||
if (node.Next != null)
|
||||
CollectAllNodes(node.Next, plainSubs, queueSubs);
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
AddNodeToResults(level.Pwc, plainSubs, queueSubs);
|
||||
if (level.Pwc.Next != null)
|
||||
CollectAllNodes(level.Pwc.Next, plainSubs, queueSubs);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
AddNodeToResults(level.Fwc, plainSubs, queueSubs);
|
||||
if (level.Fwc.Next != null)
|
||||
CollectAllNodes(level.Fwc.Next, plainSubs, queueSubs);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/NATS.Server/Subscriptions/SubListStats.cs
Normal file
13
src/NATS.Server/Subscriptions/SubListStats.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace NATS.Server.Subscriptions;
|
||||
|
||||
public sealed class SubListStats
|
||||
{
|
||||
public uint NumSubs { get; init; }
|
||||
public uint NumCache { get; init; }
|
||||
public ulong NumInserts { get; init; }
|
||||
public ulong NumRemoves { get; init; }
|
||||
public ulong NumMatches { get; init; }
|
||||
public double CacheHitRate { get; init; }
|
||||
public uint MaxFanout { get; init; }
|
||||
public double AvgFanout { get; init; }
|
||||
}
|
||||
@@ -113,4 +113,112 @@ public static class SubjectMatch
|
||||
|
||||
return li >= literal.Length; // both exhausted
|
||||
}
|
||||
|
||||
/// <summary>Count dot-delimited tokens. Empty string returns 0.</summary>
|
||||
public static int NumTokens(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return 0;
|
||||
int count = 1;
|
||||
for (int i = 0; i < subject.Length; i++)
|
||||
{
|
||||
if (subject[i] == Sep)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>Return the 0-based nth token as a span. Returns empty if out of range.</summary>
|
||||
public static ReadOnlySpan<char> TokenAt(string subject, int index)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return default;
|
||||
|
||||
var span = subject.AsSpan();
|
||||
int current = 0;
|
||||
int start = 0;
|
||||
for (int i = 0; i < span.Length; i++)
|
||||
{
|
||||
if (span[i] == Sep)
|
||||
{
|
||||
if (current == index)
|
||||
return span[start..i];
|
||||
start = i + 1;
|
||||
current++;
|
||||
}
|
||||
}
|
||||
if (current == index)
|
||||
return span[start..];
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if two subject patterns (possibly containing wildcards) can both
|
||||
/// match the same literal subject. Reference: Go sublist.go SubjectsCollide.
|
||||
/// </summary>
|
||||
public static bool SubjectsCollide(string subj1, string subj2)
|
||||
{
|
||||
if (subj1 == subj2)
|
||||
return true;
|
||||
|
||||
bool lit1 = IsLiteral(subj1);
|
||||
bool lit2 = IsLiteral(subj2);
|
||||
|
||||
if (lit1 && lit2)
|
||||
return false;
|
||||
|
||||
if (lit1 && !lit2)
|
||||
return MatchLiteral(subj1, subj2);
|
||||
if (lit2 && !lit1)
|
||||
return MatchLiteral(subj2, subj1);
|
||||
|
||||
// Both have wildcards — split once to avoid O(n²) TokenAt calls
|
||||
var tokens1 = subj1.Split(Sep);
|
||||
var tokens2 = subj2.Split(Sep);
|
||||
int n1 = tokens1.Length;
|
||||
int n2 = tokens2.Length;
|
||||
bool hasFwc1 = tokens1[^1] == ">";
|
||||
bool hasFwc2 = tokens2[^1] == ">";
|
||||
|
||||
if (!hasFwc1 && !hasFwc2 && n1 != n2)
|
||||
return false;
|
||||
if (n1 < n2 && !hasFwc1)
|
||||
return false;
|
||||
if (n2 < n1 && !hasFwc2)
|
||||
return false;
|
||||
|
||||
int stop = Math.Min(n1, n2);
|
||||
for (int i = 0; i < stop; i++)
|
||||
{
|
||||
if (!TokensCanMatch(tokens1[i], tokens2[i]))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TokensCanMatch(ReadOnlySpan<char> t1, ReadOnlySpan<char> t2)
|
||||
{
|
||||
if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc))
|
||||
return true;
|
||||
if (t2.Length == 1 && (t2[0] == Pwc || t2[0] == Fwc))
|
||||
return true;
|
||||
return t1.SequenceEqual(t2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates subject. When checkRunes is true, also rejects null bytes.
|
||||
/// </summary>
|
||||
public static bool IsValidSubject(string subject, bool checkRunes)
|
||||
{
|
||||
if (!IsValidSubject(subject))
|
||||
return false;
|
||||
if (!checkRunes)
|
||||
return true;
|
||||
for (int i = 0; i < subject.Length; i++)
|
||||
{
|
||||
if (subject[i] == '\0')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
708
src/NATS.Server/Subscriptions/SubjectTransform.cs
Normal file
708
src/NATS.Server/Subscriptions/SubjectTransform.cs
Normal file
@@ -0,0 +1,708 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Subscriptions;
|
||||
|
||||
/// <summary>
|
||||
/// Compiled subject transform engine that maps subjects from a source pattern to a destination template.
|
||||
/// Reference: Go server/subject_transform.go
|
||||
/// </summary>
|
||||
public sealed partial class SubjectTransform
|
||||
{
|
||||
private readonly string _source;
|
||||
private readonly string _dest;
|
||||
private readonly string[] _sourceTokens;
|
||||
private readonly string[] _destTokens;
|
||||
private readonly TransformOp[] _ops;
|
||||
|
||||
private SubjectTransform(string source, string dest, string[] sourceTokens, string[] destTokens, TransformOp[] ops)
|
||||
{
|
||||
_source = source;
|
||||
_dest = dest;
|
||||
_sourceTokens = sourceTokens;
|
||||
_destTokens = destTokens;
|
||||
_ops = ops;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a subject transform from source pattern to destination template.
|
||||
/// Returns null if source is invalid or destination references out-of-range wildcards.
|
||||
/// </summary>
|
||||
public static SubjectTransform? Create(string source, string destination)
|
||||
{
|
||||
if (string.IsNullOrEmpty(destination))
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrEmpty(source))
|
||||
source = ">";
|
||||
|
||||
// Validate source and destination as subjects
|
||||
var (srcValid, srcTokens, srcPwcCount, srcHasFwc) = SubjectInfo(source);
|
||||
var (destValid, destTokens, destPwcCount, destHasFwc) = SubjectInfo(destination);
|
||||
|
||||
// Both must be valid, dest must have no pwcs, fwc must match
|
||||
if (!srcValid || !destValid || destPwcCount > 0 || srcHasFwc != destHasFwc)
|
||||
return null;
|
||||
|
||||
var ops = new TransformOp[destTokens.Length];
|
||||
|
||||
if (srcPwcCount > 0 || srcHasFwc)
|
||||
{
|
||||
// Build map from 1-based wildcard index to source token position
|
||||
var wildcardPositions = new Dictionary<int, int>();
|
||||
int wildcardNum = 0;
|
||||
for (int i = 0; i < srcTokens.Length; i++)
|
||||
{
|
||||
if (srcTokens[i] == "*")
|
||||
{
|
||||
wildcardNum++;
|
||||
wildcardPositions[wildcardNum] = i;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < destTokens.Length; i++)
|
||||
{
|
||||
var parsed = ParseDestToken(destTokens[i]);
|
||||
if (parsed == null)
|
||||
return null; // Parse error (bad function, etc.)
|
||||
|
||||
if (parsed.Type == TransformType.None)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.None);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve wildcard indexes to source token positions
|
||||
var srcPositions = new int[parsed.WildcardIndexes.Length];
|
||||
for (int j = 0; j < parsed.WildcardIndexes.Length; j++)
|
||||
{
|
||||
int wcIdx = parsed.WildcardIndexes[j];
|
||||
if (wcIdx > srcPwcCount)
|
||||
return null; // Out of range
|
||||
|
||||
// Match Go behavior: missing map key returns zero-value (0)
|
||||
// This happens for partition with index 0, which Go silently allows.
|
||||
if (!wildcardPositions.TryGetValue(wcIdx, out int pos))
|
||||
pos = 0;
|
||||
|
||||
srcPositions[j] = pos;
|
||||
}
|
||||
|
||||
ops[i] = new TransformOp(parsed.Type, srcPositions, parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No wildcards in source: only NoTransform, Partition, and Random allowed
|
||||
for (int i = 0; i < destTokens.Length; i++)
|
||||
{
|
||||
var parsed = ParseDestToken(destTokens[i]);
|
||||
if (parsed == null)
|
||||
return null;
|
||||
|
||||
if (parsed.Type == TransformType.None)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.None);
|
||||
}
|
||||
else if (parsed.Type == TransformType.Partition)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other functions not allowed without wildcards in source
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SubjectTransform(source, destination, srcTokens, destTokens, ops);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches subject against source pattern, captures wildcard values, evaluates destination template.
|
||||
/// Returns null if subject doesn't match source.
|
||||
/// </summary>
|
||||
public string? Apply(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return null;
|
||||
|
||||
// Special case: source is > (match everything) and dest is > (passthrough)
|
||||
if ((_source == ">" || _source == string.Empty) && (_dest == ">" || _dest == string.Empty))
|
||||
return subject;
|
||||
|
||||
var subjectTokens = subject.Split('.');
|
||||
|
||||
// Check if subject matches source pattern
|
||||
if (_source != ">" && !MatchTokens(subjectTokens, _sourceTokens))
|
||||
return null;
|
||||
|
||||
return TransformTokenized(subjectTokens);
|
||||
}
|
||||
|
||||
private string TransformTokenized(string[] tokens)
|
||||
{
|
||||
if (_ops.Length == 0)
|
||||
return _dest;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
int lastIndex = _ops.Length - 1;
|
||||
|
||||
for (int i = 0; i < _ops.Length; i++)
|
||||
{
|
||||
var op = _ops[i];
|
||||
|
||||
if (op.Type == TransformType.None)
|
||||
{
|
||||
// If this dest token is fwc, break out to handle trailing tokens
|
||||
if (_destTokens[i] == ">")
|
||||
break;
|
||||
|
||||
sb.Append(_destTokens[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (op.Type)
|
||||
{
|
||||
case TransformType.Wildcard:
|
||||
if (op.SourcePositions.Length > 0 && op.SourcePositions[0] < tokens.Length)
|
||||
sb.Append(tokens[op.SourcePositions[0]]);
|
||||
break;
|
||||
|
||||
case TransformType.Partition:
|
||||
sb.Append(ComputePartition(tokens, op));
|
||||
break;
|
||||
|
||||
case TransformType.Split:
|
||||
ApplySplit(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SplitFromLeft:
|
||||
ApplySplitFromLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SplitFromRight:
|
||||
ApplySplitFromRight(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SliceFromLeft:
|
||||
ApplySliceFromLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.SliceFromRight:
|
||||
ApplySliceFromRight(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.Left:
|
||||
ApplyLeft(sb, tokens, op);
|
||||
break;
|
||||
|
||||
case TransformType.Right:
|
||||
ApplyRight(sb, tokens, op);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (i < lastIndex)
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
// Handle trailing fwc: append remaining tokens from subject
|
||||
if (_destTokens[^1] == ">")
|
||||
{
|
||||
int srcFwcPos = _sourceTokens.Length - 1; // position of > in source
|
||||
for (int i = srcFwcPos; i < tokens.Length; i++)
|
||||
{
|
||||
sb.Append(tokens[i]);
|
||||
if (i < tokens.Length - 1)
|
||||
sb.Append('.');
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ComputePartition(string[] tokens, TransformOp op)
|
||||
{
|
||||
int numBuckets = op.IntArg;
|
||||
if (numBuckets == 0)
|
||||
return "0";
|
||||
|
||||
byte[] keyBytes;
|
||||
if (op.SourcePositions.Length > 0)
|
||||
{
|
||||
// Hash concatenation of specified source tokens
|
||||
var keyBuilder = new StringBuilder();
|
||||
foreach (int pos in op.SourcePositions)
|
||||
{
|
||||
if (pos < tokens.Length)
|
||||
keyBuilder.Append(tokens[pos]);
|
||||
}
|
||||
|
||||
keyBytes = Encoding.ASCII.GetBytes(keyBuilder.ToString());
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hash full subject (all tokens joined with .)
|
||||
keyBytes = Encoding.ASCII.GetBytes(string.Join(".", tokens));
|
||||
}
|
||||
|
||||
uint hash = Fnv1A32(keyBytes);
|
||||
return (hash % (uint)numBuckets).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619.
|
||||
/// </summary>
|
||||
private static uint Fnv1A32(byte[] data)
|
||||
{
|
||||
const uint offsetBasis = 2166136261;
|
||||
const uint prime = 16777619;
|
||||
|
||||
uint hash = offsetBasis;
|
||||
foreach (byte b in data)
|
||||
{
|
||||
hash ^= b;
|
||||
hash *= prime;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static void ApplySplit(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
if (op.SourcePositions.Length == 0)
|
||||
return;
|
||||
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
string delimiter = op.StringArg ?? string.Empty;
|
||||
|
||||
var splits = sourceToken.Split(delimiter);
|
||||
bool first = true;
|
||||
|
||||
for (int j = 0; j < splits.Length; j++)
|
||||
{
|
||||
string split = splits[j];
|
||||
if (split != string.Empty)
|
||||
{
|
||||
if (!first)
|
||||
sb.Append('.');
|
||||
sb.Append(split);
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySplitFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int position = op.IntArg;
|
||||
|
||||
if (position > 0 && position < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, position));
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(position));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySplitFromRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int position = op.IntArg;
|
||||
|
||||
if (position > 0 && position < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, sourceToken.Length - position));
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(sourceToken.Length - position));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySliceFromLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int sliceSize = op.IntArg;
|
||||
|
||||
if (sliceSize > 0 && sliceSize < sourceToken.Length)
|
||||
{
|
||||
for (int i = 0; i + sliceSize <= sourceToken.Length; i += sliceSize)
|
||||
{
|
||||
if (i != 0)
|
||||
sb.Append('.');
|
||||
|
||||
sb.Append(sourceToken.AsSpan(i, sliceSize));
|
||||
|
||||
// If there's a remainder that doesn't fill a full slice
|
||||
if (i + sliceSize != sourceToken.Length && i + sliceSize + sliceSize > sourceToken.Length)
|
||||
{
|
||||
sb.Append('.');
|
||||
sb.Append(sourceToken.AsSpan(i + sliceSize));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplySliceFromRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int sliceSize = op.IntArg;
|
||||
|
||||
if (sliceSize > 0 && sliceSize < sourceToken.Length)
|
||||
{
|
||||
int remainder = sourceToken.Length % sliceSize;
|
||||
if (remainder > 0)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, remainder));
|
||||
sb.Append('.');
|
||||
}
|
||||
|
||||
for (int i = remainder; i + sliceSize <= sourceToken.Length; i += sliceSize)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(i, sliceSize));
|
||||
if (i + sliceSize < sourceToken.Length)
|
||||
sb.Append('.');
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyLeft(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int length = op.IntArg;
|
||||
|
||||
if (length > 0 && length < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(0, length));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyRight(StringBuilder sb, string[] tokens, TransformOp op)
|
||||
{
|
||||
string sourceToken = tokens[op.SourcePositions[0]];
|
||||
int length = op.IntArg;
|
||||
|
||||
if (length > 0 && length < sourceToken.Length)
|
||||
{
|
||||
sb.Append(sourceToken.AsSpan(sourceToken.Length - length));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(sourceToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches literal subject tokens against a pattern with wildcards.
|
||||
/// Subject tokens must be literal (no wildcards).
|
||||
/// </summary>
|
||||
private static bool MatchTokens(string[] subjectTokens, string[] patternTokens)
|
||||
{
|
||||
for (int i = 0; i < patternTokens.Length; i++)
|
||||
{
|
||||
if (i >= subjectTokens.Length)
|
||||
return false;
|
||||
|
||||
string pt = patternTokens[i];
|
||||
|
||||
// Full wildcard matches all remaining
|
||||
if (pt == ">")
|
||||
return true;
|
||||
|
||||
// Partial wildcard matches any single token
|
||||
if (pt == "*")
|
||||
continue;
|
||||
|
||||
// Literal comparison
|
||||
if (subjectTokens[i] != pt)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both must be exhausted (unless pattern ended with >)
|
||||
return subjectTokens.Length == patternTokens.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a subject and returns (valid, tokens, pwcCount, hasFwc).
|
||||
/// Reference: Go subject_transform.go subjectInfo()
|
||||
/// </summary>
|
||||
private static (bool Valid, string[] Tokens, int PwcCount, bool HasFwc) SubjectInfo(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return (false, [], 0, false);
|
||||
|
||||
string[] tokens = subject.Split('.');
|
||||
int pwcCount = 0;
|
||||
bool hasFwc = false;
|
||||
|
||||
foreach (string t in tokens)
|
||||
{
|
||||
if (t.Length == 0 || hasFwc)
|
||||
return (false, [], 0, false);
|
||||
|
||||
if (t.Length == 1)
|
||||
{
|
||||
switch (t[0])
|
||||
{
|
||||
case '>':
|
||||
hasFwc = true;
|
||||
break;
|
||||
case '*':
|
||||
pwcCount++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (true, tokens, pwcCount, hasFwc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single destination token into a transform operation descriptor.
|
||||
/// Returns null on parse error.
|
||||
/// </summary>
|
||||
private static ParsedToken? ParseDestToken(string token)
|
||||
{
|
||||
if (token.Length <= 1)
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
|
||||
// $N shorthand for wildcard(N)
|
||||
if (token[0] == '$')
|
||||
{
|
||||
if (int.TryParse(token.AsSpan(1), out int idx))
|
||||
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
|
||||
|
||||
// Other things rely on tokens starting with $ so not an error
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
}
|
||||
|
||||
// Mustache-style {{function(args)}}
|
||||
if (token.Length > 4 && token[0] == '{' && token[1] == '{' && token[^2] == '}' && token[^1] == '}')
|
||||
{
|
||||
return ParseMustacheToken(token);
|
||||
}
|
||||
|
||||
return new ParsedToken(TransformType.None, [], -1, string.Empty);
|
||||
}
|
||||
|
||||
private static ParsedToken? ParseMustacheToken(string token)
|
||||
{
|
||||
// wildcard(n)
|
||||
var args = GetFunctionArgs(WildcardRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length == 1 && args[0] == string.Empty)
|
||||
return null; // Not enough args
|
||||
|
||||
if (args.Length == 1)
|
||||
{
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
return new ParsedToken(TransformType.Wildcard, [idx], -1, string.Empty);
|
||||
}
|
||||
|
||||
return null; // Too many args
|
||||
}
|
||||
|
||||
// partition(num, tokens...)
|
||||
args = GetFunctionArgs(PartitionRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length < 1)
|
||||
return null;
|
||||
|
||||
if (args.Length == 1)
|
||||
{
|
||||
if (!TryParseInt32(args[0].Trim(), out int numBuckets))
|
||||
return null;
|
||||
return new ParsedToken(TransformType.Partition, [], numBuckets, string.Empty);
|
||||
}
|
||||
|
||||
// partition(num, tok1, tok2, ...)
|
||||
if (!TryParseInt32(args[0].Trim(), out int buckets))
|
||||
return null;
|
||||
|
||||
var indexes = new int[args.Length - 1];
|
||||
for (int i = 1; i < args.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(args[i].Trim(), out indexes[i - 1]))
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty);
|
||||
}
|
||||
|
||||
// splitFromLeft(token, position)
|
||||
args = GetFunctionArgs(SplitFromLeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SplitFromLeft);
|
||||
|
||||
// splitFromRight(token, position)
|
||||
args = GetFunctionArgs(SplitFromRightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SplitFromRight);
|
||||
|
||||
// sliceFromLeft(token, size)
|
||||
args = GetFunctionArgs(SliceFromLeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SliceFromLeft);
|
||||
|
||||
// sliceFromRight(token, size)
|
||||
args = GetFunctionArgs(SliceFromRightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.SliceFromRight);
|
||||
|
||||
// right(token, length)
|
||||
args = GetFunctionArgs(RightRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.Right);
|
||||
|
||||
// left(token, length)
|
||||
args = GetFunctionArgs(LeftRegex(), token);
|
||||
if (args != null)
|
||||
return ParseIndexIntArgs(args, TransformType.Left);
|
||||
|
||||
// split(token, delimiter)
|
||||
args = GetFunctionArgs(SplitRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
return null;
|
||||
if (args.Length > 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
|
||||
string delimiter = args[1];
|
||||
if (delimiter.Contains(' ') || delimiter.Contains('.'))
|
||||
return null;
|
||||
|
||||
return new ParsedToken(TransformType.Split, [idx], -1, delimiter);
|
||||
}
|
||||
|
||||
// Unknown function
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ParsedToken? ParseIndexIntArgs(string[] args, TransformType type)
|
||||
{
|
||||
if (args.Length < 2)
|
||||
return null;
|
||||
if (args.Length > 2)
|
||||
return null;
|
||||
|
||||
if (!int.TryParse(args[0].Trim(), out int idx))
|
||||
return null;
|
||||
|
||||
if (!TryParseInt32(args[1].Trim(), out int intArg))
|
||||
return null;
|
||||
|
||||
return new ParsedToken(type, [idx], intArg, string.Empty);
|
||||
}
|
||||
|
||||
private static bool TryParseInt32(string s, out int result)
|
||||
{
|
||||
// Parse as long first to detect overflow
|
||||
if (long.TryParse(s, out long longVal) && longVal >= 0 && longVal <= int.MaxValue)
|
||||
{
|
||||
result = (int)longVal;
|
||||
return true;
|
||||
}
|
||||
|
||||
result = -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string[]? GetFunctionArgs(Regex regex, string token)
|
||||
{
|
||||
var match = regex.Match(token);
|
||||
if (match.Success && match.Groups.Count > 1)
|
||||
{
|
||||
string argsStr = match.Groups[1].Value;
|
||||
return CommaSeparatorRegex().Split(argsStr);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Regex patterns matching the Go reference implementation (case-insensitive function names)
|
||||
[GeneratedRegex(@"\{\{\s*[wW]ildcard\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex WildcardRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex PartitionRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitFromLeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitFromRightRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SliceFromLeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]lice[fF]rom[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SliceFromRightRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex LeftRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[rR]ight\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex RightRegex();
|
||||
|
||||
[GeneratedRegex(@",\s*")]
|
||||
private static partial Regex CommaSeparatorRegex();
|
||||
|
||||
private enum TransformType
|
||||
{
|
||||
None,
|
||||
Wildcard,
|
||||
Partition,
|
||||
Split,
|
||||
SplitFromLeft,
|
||||
SplitFromRight,
|
||||
SliceFromLeft,
|
||||
SliceFromRight,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
private sealed record ParsedToken(TransformType Type, int[] WildcardIndexes, int IntArg, string StringArg);
|
||||
|
||||
private readonly record struct TransformOp(
|
||||
TransformType Type,
|
||||
int[] SourcePositions,
|
||||
int IntArg,
|
||||
string? StringArg)
|
||||
{
|
||||
public TransformOp(TransformType type) : this(type, [], -1, null)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
20
src/NATS.Server/Tls/OcspConfig.cs
Normal file
20
src/NATS.Server/Tls/OcspConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
// OcspMode mirrors the OCSPMode constants from the Go reference implementation (ocsp.go).
|
||||
// Auto — staple only if the certificate contains the status_request TLS extension.
|
||||
// Always — always attempt stapling; warn but continue if the OCSP response cannot be obtained.
|
||||
// Must — stapling is mandatory; fail server startup if the OCSP response cannot be obtained.
|
||||
// Never — never attempt stapling regardless of certificate extensions.
|
||||
public enum OcspMode
|
||||
{
|
||||
Auto = 0,
|
||||
Always = 1,
|
||||
Must = 2,
|
||||
Never = 3,
|
||||
}
|
||||
|
||||
public sealed class OcspConfig
|
||||
{
|
||||
public OcspMode Mode { get; init; } = OcspMode.Auto;
|
||||
public string[] OverrideUrls { get; init; } = [];
|
||||
}
|
||||
71
src/NATS.Server/Tls/PeekableStream.cs
Normal file
71
src/NATS.Server/Tls/PeekableStream.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public sealed class PeekableStream : Stream
|
||||
{
|
||||
private readonly Stream _inner;
|
||||
private byte[]? _peekedBytes;
|
||||
private int _peekedOffset;
|
||||
private int _peekedCount;
|
||||
|
||||
public PeekableStream(Stream inner) => _inner = inner;
|
||||
|
||||
public async Task<byte[]> PeekAsync(int count, CancellationToken ct = default)
|
||||
{
|
||||
var buf = new byte[count];
|
||||
int read = await _inner.ReadAsync(buf.AsMemory(0, count), ct);
|
||||
if (read < count) Array.Resize(ref buf, read);
|
||||
_peekedBytes = buf;
|
||||
_peekedOffset = 0;
|
||||
_peekedCount = read;
|
||||
return buf;
|
||||
}
|
||||
|
||||
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default)
|
||||
{
|
||||
if (_peekedBytes != null && _peekedOffset < _peekedCount)
|
||||
{
|
||||
int available = _peekedCount - _peekedOffset;
|
||||
int toCopy = Math.Min(available, buffer.Length);
|
||||
_peekedBytes.AsMemory(_peekedOffset, toCopy).CopyTo(buffer);
|
||||
_peekedOffset += toCopy;
|
||||
if (_peekedOffset >= _peekedCount) _peekedBytes = null;
|
||||
return toCopy;
|
||||
}
|
||||
return await _inner.ReadAsync(buffer, ct);
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (_peekedBytes != null && _peekedOffset < _peekedCount)
|
||||
{
|
||||
int available = _peekedCount - _peekedOffset;
|
||||
int toCopy = Math.Min(available, count);
|
||||
Array.Copy(_peekedBytes, _peekedOffset, buffer, offset, toCopy);
|
||||
_peekedOffset += toCopy;
|
||||
if (_peekedOffset >= _peekedCount) _peekedBytes = null;
|
||||
return toCopy;
|
||||
}
|
||||
return _inner.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
|
||||
=> ReadAsync(buffer.AsMemory(offset, count), ct).AsTask();
|
||||
|
||||
// Write passthrough
|
||||
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) => _inner.WriteAsync(buffer, offset, count, ct);
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) => _inner.WriteAsync(buffer, ct);
|
||||
public override void Flush() => _inner.Flush();
|
||||
public override Task FlushAsync(CancellationToken ct) => _inner.FlushAsync(ct);
|
||||
|
||||
// Required Stream overrides
|
||||
public override bool CanRead => _inner.CanRead;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => _inner.CanWrite;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
protected override void Dispose(bool disposing) { if (disposing) _inner.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
9
src/NATS.Server/Tls/TlsConnectionState.cs
Normal file
9
src/NATS.Server/Tls/TlsConnectionState.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public sealed record TlsConnectionState(
|
||||
string? TlsVersion,
|
||||
string? CipherSuite,
|
||||
X509Certificate2? PeerCert
|
||||
);
|
||||
202
src/NATS.Server/Tls/TlsConnectionWrapper.cs
Normal file
202
src/NATS.Server/Tls/TlsConnectionWrapper.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public static class TlsConnectionWrapper
|
||||
{
|
||||
private const byte TlsRecordMarker = 0x16;
|
||||
|
||||
public static async Task<(Stream stream, bool infoAlreadySent)> NegotiateAsync(
|
||||
Socket socket,
|
||||
Stream networkStream,
|
||||
NatsOptions options,
|
||||
SslServerAuthenticationOptions? sslOptions,
|
||||
ServerInfo serverInfo,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Mode 1: No TLS
|
||||
if (sslOptions == null || !options.HasTls)
|
||||
return (networkStream, false);
|
||||
|
||||
// Clone to avoid mutating shared instance
|
||||
serverInfo = new ServerInfo
|
||||
{
|
||||
ServerId = serverInfo.ServerId,
|
||||
ServerName = serverInfo.ServerName,
|
||||
Version = serverInfo.Version,
|
||||
Proto = serverInfo.Proto,
|
||||
Host = serverInfo.Host,
|
||||
Port = serverInfo.Port,
|
||||
Headers = serverInfo.Headers,
|
||||
MaxPayload = serverInfo.MaxPayload,
|
||||
ClientId = serverInfo.ClientId,
|
||||
ClientIp = serverInfo.ClientIp,
|
||||
};
|
||||
|
||||
// Mode 3: TLS First
|
||||
if (options.TlsHandshakeFirst)
|
||||
return await NegotiateTlsFirstAsync(socket, networkStream, options, sslOptions, serverInfo, logger, ct);
|
||||
|
||||
// Mode 2 & 4: Send INFO first, then decide
|
||||
serverInfo.TlsRequired = !options.AllowNonTls;
|
||||
serverInfo.TlsAvailable = options.AllowNonTls;
|
||||
serverInfo.TlsVerify = options.TlsVerify;
|
||||
await SendInfoAsync(networkStream, serverInfo, ct);
|
||||
|
||||
// Peek first byte to detect TLS
|
||||
var peekable = new PeekableStream(networkStream);
|
||||
var peeked = await PeekWithTimeoutAsync(peekable, 1, options.TlsTimeout, ct);
|
||||
|
||||
if (peeked.Length == 0)
|
||||
{
|
||||
// Client disconnected or timed out
|
||||
return (peekable, true);
|
||||
}
|
||||
|
||||
if (peeked[0] == TlsRecordMarker)
|
||||
{
|
||||
// Client is starting TLS
|
||||
var sslStream = new SslStream(peekable, leaveInnerStreamOpen: false);
|
||||
try
|
||||
{
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
handshakeCts.CancelAfter(options.TlsTimeout);
|
||||
|
||||
await sslStream.AuthenticateAsServerAsync(sslOptions, handshakeCts.Token);
|
||||
logger.LogDebug("TLS handshake complete: {Protocol} {CipherSuite}",
|
||||
sslStream.SslProtocol, sslStream.NegotiatedCipherSuite);
|
||||
|
||||
// Validate pinned certs
|
||||
if (options.TlsPinnedCerts != null && sslStream.RemoteCertificate is X509Certificate2 remoteCert)
|
||||
{
|
||||
if (!TlsHelper.MatchesPinnedCert(remoteCert, options.TlsPinnedCerts))
|
||||
{
|
||||
logger.LogWarning("Certificate pinning check failed");
|
||||
throw new InvalidOperationException("Certificate pinning check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
sslStream.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return (sslStream, true);
|
||||
}
|
||||
|
||||
// Mode 4: Mixed — client chose plaintext
|
||||
if (options.AllowNonTls)
|
||||
{
|
||||
logger.LogDebug("Client connected without TLS (mixed mode)");
|
||||
return (peekable, true);
|
||||
}
|
||||
|
||||
// TLS required but client sent plaintext
|
||||
logger.LogWarning("TLS required but client sent plaintext data");
|
||||
throw new InvalidOperationException("TLS required");
|
||||
}
|
||||
|
||||
private static async Task<(Stream stream, bool infoAlreadySent)> NegotiateTlsFirstAsync(
|
||||
Socket socket,
|
||||
Stream networkStream,
|
||||
NatsOptions options,
|
||||
SslServerAuthenticationOptions sslOptions,
|
||||
ServerInfo serverInfo,
|
||||
ILogger logger,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Wait for data with fallback timeout
|
||||
var peekable = new PeekableStream(networkStream);
|
||||
var peeked = await PeekWithTimeoutAsync(peekable, 1, options.TlsHandshakeFirstFallback, ct);
|
||||
|
||||
if (peeked.Length > 0 && peeked[0] == TlsRecordMarker)
|
||||
{
|
||||
// Client started TLS immediately — handshake first, then send INFO
|
||||
var sslStream = new SslStream(peekable, leaveInnerStreamOpen: false);
|
||||
try
|
||||
{
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
handshakeCts.CancelAfter(options.TlsTimeout);
|
||||
|
||||
await sslStream.AuthenticateAsServerAsync(sslOptions, handshakeCts.Token);
|
||||
logger.LogDebug("TLS-first handshake complete: {Protocol} {CipherSuite}",
|
||||
sslStream.SslProtocol, sslStream.NegotiatedCipherSuite);
|
||||
|
||||
// Validate pinned certs
|
||||
if (options.TlsPinnedCerts != null && sslStream.RemoteCertificate is X509Certificate2 remoteCert)
|
||||
{
|
||||
if (!TlsHelper.MatchesPinnedCert(remoteCert, options.TlsPinnedCerts))
|
||||
{
|
||||
throw new InvalidOperationException("Certificate pinning check failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Now send INFO over encrypted stream
|
||||
serverInfo.TlsRequired = true;
|
||||
serverInfo.TlsVerify = options.TlsVerify;
|
||||
await SendInfoAsync(sslStream, serverInfo, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
sslStream.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return (sslStream, true);
|
||||
}
|
||||
|
||||
// Fallback: timeout expired or non-TLS data — send INFO and negotiate normally
|
||||
logger.LogDebug("TLS-first fallback: sending INFO");
|
||||
serverInfo.TlsRequired = !options.AllowNonTls;
|
||||
serverInfo.TlsAvailable = options.AllowNonTls;
|
||||
serverInfo.TlsVerify = options.TlsVerify;
|
||||
await SendInfoAsync(peekable, serverInfo, ct);
|
||||
|
||||
if (peeked.Length == 0)
|
||||
{
|
||||
// Timeout — INFO was sent, return stream for normal flow
|
||||
return (peekable, true);
|
||||
}
|
||||
|
||||
// Non-TLS data received during fallback window
|
||||
if (options.AllowNonTls)
|
||||
{
|
||||
return (peekable, true);
|
||||
}
|
||||
|
||||
// TLS required but got plaintext
|
||||
throw new InvalidOperationException("TLS required but client sent plaintext");
|
||||
}
|
||||
|
||||
private static async Task<byte[]> PeekWithTimeoutAsync(
|
||||
PeekableStream stream, int count, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(timeout);
|
||||
try
|
||||
{
|
||||
return await stream.PeekAsync(count, cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
// Timeout — not a cancellation of the outer token
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendInfoAsync(Stream stream, ServerInfo serverInfo, CancellationToken ct)
|
||||
{
|
||||
var infoJson = JsonSerializer.Serialize(serverInfo);
|
||||
var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
|
||||
await stream.WriteAsync(infoLine, ct);
|
||||
await stream.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
100
src/NATS.Server/Tls/TlsHelper.cs
Normal file
100
src/NATS.Server/Tls/TlsHelper.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public static class TlsHelper
|
||||
{
|
||||
public static X509Certificate2 LoadCertificate(string certPath, string? keyPath)
|
||||
{
|
||||
if (keyPath != null)
|
||||
return X509Certificate2.CreateFromPemFile(certPath, keyPath);
|
||||
return X509CertificateLoader.LoadCertificateFromFile(certPath);
|
||||
}
|
||||
|
||||
public static X509Certificate2Collection LoadCaCertificates(string caPath)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(caPath);
|
||||
return collection;
|
||||
}
|
||||
|
||||
public static SslServerAuthenticationOptions BuildServerAuthOptions(NatsOptions opts)
|
||||
{
|
||||
var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey);
|
||||
var authOpts = new SslServerAuthenticationOptions
|
||||
{
|
||||
ServerCertificate = cert,
|
||||
EnabledSslProtocols = opts.TlsMinVersion,
|
||||
ClientCertificateRequired = opts.TlsVerify,
|
||||
};
|
||||
|
||||
if (opts.TlsVerify && opts.TlsCaCert != null)
|
||||
{
|
||||
var revocationMode = opts.OcspPeerVerify
|
||||
? X509RevocationMode.Online
|
||||
: X509RevocationMode.NoCheck;
|
||||
|
||||
var caCerts = LoadCaCertificates(opts.TlsCaCert);
|
||||
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
using var chain2 = new X509Chain();
|
||||
chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
foreach (var ca in caCerts)
|
||||
chain2.ChainPolicy.CustomTrustStore.Add(ca);
|
||||
chain2.ChainPolicy.RevocationMode = revocationMode;
|
||||
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
|
||||
return chain2.Build(cert2);
|
||||
};
|
||||
}
|
||||
else if (opts.OcspPeerVerify)
|
||||
{
|
||||
// No custom CA — still enable online revocation checking against the system store
|
||||
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
using var chain2 = new X509Chain();
|
||||
chain2.ChainPolicy.RevocationMode = X509RevocationMode.Online;
|
||||
var cert2 = cert as X509Certificate2 ?? X509CertificateLoader.LoadCertificate(cert.GetRawCertData());
|
||||
return chain2.Build(cert2);
|
||||
};
|
||||
}
|
||||
|
||||
return authOpts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="SslStreamCertificateContext"/> for OCSP stapling.
|
||||
/// Returns null when TLS is not configured or OCSP mode is Never.
|
||||
/// When <paramref name="offline"/> is false the runtime will contact the
|
||||
/// certificate's OCSP responder to obtain a fresh stapled response.
|
||||
/// </summary>
|
||||
public static SslStreamCertificateContext? BuildCertificateContext(NatsOptions opts, bool offline = false)
|
||||
{
|
||||
if (!opts.HasTls) return null;
|
||||
if (opts.OcspConfig is null || opts.OcspConfig.Mode == OcspMode.Never) return null;
|
||||
|
||||
var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey);
|
||||
var chain = new X509Certificate2Collection();
|
||||
if (!string.IsNullOrEmpty(opts.TlsCaCert))
|
||||
chain.ImportFromPemFile(opts.TlsCaCert);
|
||||
|
||||
return SslStreamCertificateContext.Create(cert, chain, offline: offline);
|
||||
}
|
||||
|
||||
public static string GetCertificateHash(X509Certificate2 cert)
|
||||
{
|
||||
var spki = cert.PublicKey.ExportSubjectPublicKeyInfo();
|
||||
var hash = SHA256.HashData(spki);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
public static bool MatchesPinnedCert(X509Certificate2 cert, HashSet<string> pinned)
|
||||
{
|
||||
var hash = GetCertificateHash(cert);
|
||||
return pinned.Contains(hash);
|
||||
}
|
||||
}
|
||||
25
src/NATS.Server/Tls/TlsRateLimiter.cs
Normal file
25
src/NATS.Server/Tls/TlsRateLimiter.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public sealed class TlsRateLimiter : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim _semaphore;
|
||||
private readonly Timer _refillTimer;
|
||||
private readonly int _tokensPerSecond;
|
||||
|
||||
public TlsRateLimiter(long tokensPerSecond)
|
||||
{
|
||||
_tokensPerSecond = (int)Math.Max(1, tokensPerSecond);
|
||||
_semaphore = new SemaphoreSlim(_tokensPerSecond, _tokensPerSecond);
|
||||
_refillTimer = new Timer(Refill, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
private void Refill(object? state)
|
||||
{
|
||||
int toRelease = _tokensPerSecond - _semaphore.CurrentCount;
|
||||
if (toRelease > 0) _semaphore.Release(toRelease);
|
||||
}
|
||||
|
||||
public Task WaitAsync(CancellationToken ct) => _semaphore.WaitAsync(ct);
|
||||
|
||||
public void Dispose() { _refillTimer.Dispose(); _semaphore.Dispose(); }
|
||||
}
|
||||
68
tests/NATS.Server.Tests/AccountResolverTests.cs
Normal file
68
tests/NATS.Server.Tests/AccountResolverTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using NATS.Server.Auth.Jwt;
|
||||
|
||||
namespace NATS.Server.Tests;
|
||||
|
||||
public class AccountResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Store_and_fetch_roundtrip()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string jwt = "eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig";
|
||||
|
||||
await resolver.StoreAsync(nkey, jwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(jwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fetch_unknown_key_returns_null()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
|
||||
var result = await resolver.FetchAsync("UNKNOWN_NKEY");
|
||||
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Store_overwrites_existing_entry()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ";
|
||||
const string originalJwt = "original.jwt.token";
|
||||
const string updatedJwt = "updated.jwt.token";
|
||||
|
||||
await resolver.StoreAsync(nkey, originalJwt);
|
||||
await resolver.StoreAsync(nkey, updatedJwt);
|
||||
var fetched = await resolver.FetchAsync(nkey);
|
||||
|
||||
fetched.ShouldBe(updatedJwt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsReadOnly_returns_false()
|
||||
{
|
||||
IAccountResolver resolver = new MemAccountResolver();
|
||||
|
||||
resolver.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_accounts_are_stored_independently()
|
||||
{
|
||||
var resolver = new MemAccountResolver();
|
||||
const string nkey1 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ1";
|
||||
const string nkey2 = "AABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQ2";
|
||||
const string jwt1 = "jwt.for.account.one";
|
||||
const string jwt2 = "jwt.for.account.two";
|
||||
|
||||
await resolver.StoreAsync(nkey1, jwt1);
|
||||
await resolver.StoreAsync(nkey2, jwt2);
|
||||
|
||||
(await resolver.FetchAsync(nkey1)).ShouldBe(jwt1);
|
||||
(await resolver.FetchAsync(nkey2)).ShouldBe(jwt2);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user