Compare commits

...

81 Commits

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

Includes error collection (not fail-fast), duration parsing (ms/s/m/h
strings and numeric seconds), host:port listen parsing, and 56 tests
covering all config sections plus validation edge cases.
2026-02-23 04:47:54 -05:00
Joseph Doherty
6fcc9d1fd5 docs: update differences.md to reflect all remaining lower-priority gaps resolved
Mark JWT auth, OCSP, subject mapping, Windows Service, per-subsystem
log control, per-client trace, per-account stats, TLS cert expiry,
permission templates, bearer tokens, and user revocation as implemented.
2026-02-23 04:47:41 -05:00
Joseph Doherty
d5a0274fc9 feat: wire subject transforms into NatsServer message delivery path 2026-02-23 04:45:08 -05:00
Joseph Doherty
5219f77f9b fix: add include depth limit, fix PopContext guard, add SetValue fallback
- Add MaxIncludeDepth = 10 constant and thread _includeDepth through ParserState
  constructors, ProcessInclude, ParseFile (private overload), and ParseEnvValue
  to prevent StackOverflowException from recursive includes
- Fix PopContext to check _ctxs.Count <= 1 instead of == 0 so the root context
  is never popped, replacing silent crash with clear InvalidOperationException
- Add else throw in SetValue so unknown context types surface as bugs rather
  than silently dropping values
2026-02-23 04:42:37 -05:00
Joseph Doherty
afbbccab82 feat: add JwtAuthenticator with account resolution, revocation, and template expansion 2026-02-23 04:41:01 -05:00
Joseph Doherty
39a1383de2 feat: add OCSP peer verification and stapling support
Wire OcspPeerVerify into the client-cert validation callback in
TlsHelper so revocation is checked online when the flag is set.
Add TlsHelper.BuildCertificateContext to build an
SslStreamCertificateContext with offline:false, enabling the runtime
to fetch and staple OCSP responses during the TLS handshake.
NatsServer applies the context at startup when OcspConfig.Mode is not
Never. Ten unit tests cover the config defaults, mode ordinals, and
the null-return invariants of BuildCertificateContext.
2026-02-23 04:38:01 -05:00
Joseph Doherty
9f66ef72c6 feat: add NATS config file parser (port of Go conf/parse.go)
Implements NatsConfParser with Parse, ParseFile, and ParseFileWithDigest
methods. Supports nested maps/arrays, variable resolution with block
scoping and environment fallback, bcrypt password literals, integer
suffix multipliers, include directives, and cycle detection.
2026-02-23 04:35:46 -05:00
Joseph Doherty
d69308600a feat: add per-subsystem log control via --log_level_override CLI flag
Adds LogOverrides property to NatsOptions and a --log_level_override=namespace=level CLI flag that wires Serilog MinimumLevel.Override entries so operators can tune verbosity per .NET namespace without changing the global log level.
2026-02-23 04:34:01 -05:00
Joseph Doherty
d0af741eb8 feat: add JWT permission template expansion with cartesian product for multi-value tags 2026-02-23 04:33:45 -05:00
Joseph Doherty
a406832bfa feat: add per-account message/byte stats with Interlocked counters 2026-02-23 04:33:44 -05:00
Joseph Doherty
ae043136a1 fix: address lexer code review findings (newline handling, emit cleanup, null guard) 2026-02-23 04:30:36 -05:00
Joseph Doherty
4836f7851e feat: add JWT core decode/verify and claim structs for NATS auth
Implement NatsJwt static class with Ed25519 signature verification,
base64url decoding, and JWT parsing. Add UserClaims and AccountClaims
with all NATS-specific fields (permissions, bearer tokens, limits,
signing keys, revocations). Includes 44 tests covering decode, verify,
nonce verification, and full round-trip signing with real NKey keypairs.
2026-02-23 04:30:20 -05:00
Joseph Doherty
46116400d2 feat: add SubjectTransform compiled engine for subject mapping
Port Go server/subject_transform.go to .NET. Implements a compiled
transform engine that parses source patterns with wildcards and
destination templates with function tokens at Create() time, then
evaluates them efficiently at Apply() time without runtime regex.

Supports all 9 transform functions: wildcard/$N, partition (FNV-1a),
split, splitFromLeft, splitFromRight, sliceFromLeft, sliceFromRight,
left, and right. Used for stream mirroring, account imports/exports,
and subject routing.
2026-02-23 04:27:36 -05:00
Joseph Doherty
67a3881c7c feat: populate TLS certificate expiry and OCSP peer verify in /varz
Load the server TLS certificate from disk during each /varz request to
read its NotAfter date and expose it as tls_cert_not_after. Also wire
OcspPeerVerify from NatsOptions into the tls_ocsp_peer_verify field.
Both fields were already declared in the Varz model but left unpopulated.
2026-02-23 04:26:45 -05:00
Joseph Doherty
dac641c52c docs: add WebSocket implementation plan with 11 tasks
TDD-based plan covering constants, origin checker, frame writer,
frame reader, compression, HTTP upgrade, connection wrapper,
server/client integration, differences.md update, and verification.
2026-02-23 04:26:40 -05:00
Joseph Doherty
7c324843ff feat: add per-client trace mode flag with dynamic parser logger 2026-02-23 04:26:15 -05:00
Joseph Doherty
cd87a48343 feat: add Windows Service integration via --service flag
Adds Microsoft.Extensions.Hosting.WindowsServices package and a --service
CLI flag to Program.cs that logs service mode activation, enabling future
Windows Service lifecycle management.
2026-02-23 04:26:04 -05:00
Joseph Doherty
f952e6afab feat: add new NatsOptions fields for Go config parity
Adds 10 new fields to NatsOptions (ClientAdvertise, TraceVerbose, MaxTracedMsgLen,
DisableSublistCache, ConnectErrorReports, ReconnectErrorReports, NoHeaderSupport,
MaxClosedClients, NoSystemAccount, SystemAccount) plus InCmdLine tracking set.
Moves MaxClosedClients from a private constant in NatsServer to a configurable option.
2026-02-23 04:23:27 -05:00
Joseph Doherty
f316e6e86e feat: add OcspMode enum, OcspConfig class, and wire into NatsOptions
Introduces NATS.Server.Tls.OcspMode (Auto/Always/Must/Never matching
Go ocsp.go constants) and OcspConfig with Mode and OverrideUrls. Adds
OcspConfig? and OcspPeerVerify to NatsOptions for stapling configuration
and peer certificate revocation checking. Covered by 12 new unit tests.
2026-02-23 04:23:14 -05:00
Joseph Doherty
c8b347cb96 feat: implement IAccountResolver interface and MemAccountResolver
Adds the IAccountResolver interface (FetchAsync, StoreAsync, IsReadOnly)
and a MemAccountResolver backed by ConcurrentDictionary for in-memory
JWT storage in tests and simple operator deployments.

Reference: golang/nats-server/server/accounts.go:4035+
2026-02-23 04:22:36 -05:00
Joseph Doherty
9fff5709c4 feat: add NATS config file lexer (port of Go conf/lex.go)
Port the NATS configuration file lexer from Go's conf/lex.go to C#.
The lexer is a state-machine tokenizer that supports the NATS config
format: key-value pairs with =, :, or whitespace separators; nested
maps {}; arrays []; single and double quoted strings with escape
sequences; block strings (); variables $VAR; include directives;
comments (# and //); booleans; integers with size suffixes (kb, mb, gb);
floats; ISO8601 datetimes; and IP addresses.
2026-02-23 04:20:56 -05:00
Joseph Doherty
9f88b034eb docs: add implementation plan for remaining lower-priority gaps
14-task plan covering JWT auth (4 tasks), subject transforms (2 tasks),
OCSP support (2 tasks), and quick wins (5 tasks) + differences.md update.
Includes parallelization guide, TDD steps, and task persistence.
2026-02-23 04:20:24 -05:00
Joseph Doherty
30ae67f613 docs: add WebSocket support design document
Full port design for WebSocket connections from Go NATS server,
including HTTP upgrade handshake, custom frame parser, compression,
origin checking, and cookie-based auth.
2026-02-23 04:17:56 -05:00
Joseph Doherty
f533bf0945 docs: add design document for remaining lower-priority gaps
Covers JWT authentication, subject mapping/transforms, OCSP support,
Windows Service integration, per-subsystem logging, per-client trace,
per-account stats, and TLS cert expiry in /varz.
2026-02-23 04:12:45 -05:00
Joseph Doherty
fadbbf463c docs: add detailed implementation plan for config parsing and hot reload
8 tasks with TDD steps, complete test code, exact file paths,
and dependency chain from lexer through to verification.
2026-02-23 04:12:11 -05:00
Joseph Doherty
65fac32a14 docs: add config parsing and hot reload design document
Captures the design for resolving the two remaining high-priority gaps
in differences.md: config file parsing and SIGHUP hot reload.
2026-02-23 04:06:16 -05:00
Joseph Doherty
cc5ce63cb9 Merge branch 'feature/sections-7-10-gaps' into main 2026-02-23 03:34:00 -05:00
Joseph Doherty
56de543713 docs: update differences.md sections 7-10 to reflect implemented features 2026-02-23 01:08:34 -05:00
Joseph Doherty
42c7c9cb7a docs: update differences.md sections 3-6 and 9 to reflect implemented features
Update comparison tables for protocol parsing (tracing, MIME headers, INFO caching,
Span-based MSG), subscriptions (generation ID, Stats, SubjectsCollide, token utils,
account limits), auth (deny enforcement, LRU cache, response permissions, auth expiry),
configuration (CLI flags, MaxSubs, Tags, file logging), and logging (trace/debug modes,
file output). Mark 11 summary items as resolved.
2026-02-23 01:07:14 -05:00
Joseph Doherty
8878301c7f test: add file logging and rotation tests 2026-02-23 01:05:10 -05:00
Joseph Doherty
e31ba04fdb feat: add closed connection tracking, state filtering, ByStop/ByReason sorting 2026-02-23 01:01:56 -05:00
Joseph Doherty
dab8004d6b feat: cache INFO serialization — build once at startup instead of per-connection
Avoids re-serializing the same ServerInfo JSON on every new connection. The
cache is rebuilt when the ephemeral port is resolved. Connections that carry a
per-connection nonce (NKey auth) continue to serialize individually so the nonce
is included correctly.
2026-02-23 01:01:38 -05:00
Joseph Doherty
f0b5edd7c6 feat: add response permission tracking for dynamic reply subject authorization 2026-02-23 00:59:15 -05:00
Joseph Doherty
1806ae607e test: add TLS rate limiter unit tests 2026-02-23 00:57:14 -05:00
Joseph Doherty
1f13269447 feat: implement TLS cert-to-user mapping via X500 DN matching 2026-02-23 00:55:29 -05:00
Joseph Doherty
7a897c1087 feat: add MaxSubs enforcement, delivery-time deny filtering, auto-unsub cleanup 2026-02-23 00:53:15 -05:00
Joseph Doherty
e9b6c7fdd3 feat: add protocol tracing (<<- op arg) at LogLevel.Trace 2026-02-23 00:52:00 -05:00
Joseph Doherty
1269ae8275 feat: implement /subz endpoint with account filter, test subject, and pagination 2026-02-23 00:50:26 -05:00
Joseph Doherty
0347e8a28c fix: increment _removes counter in RemoveBatch for accurate stats 2026-02-23 00:48:53 -05:00
Joseph Doherty
6afe11ad4d feat: add per-account connection/subscription limits with AccountConfig 2026-02-23 00:46:16 -05:00
Joseph Doherty
345e7ca15c feat: implement log reopening on SIGUSR1 signal 2026-02-23 00:46:09 -05:00
Joseph Doherty
cc0fe04f3c feat: add generation-based cache, Stats, HasInterest, NumInterest, RemoveBatch, All, ReverseMatch to SubList 2026-02-23 00:45:28 -05:00
Joseph Doherty
cf75077bc4 feat: add CLI flags for debug/trace modes, file logging, syslog, color, timestamps 2026-02-23 00:43:27 -05:00
Joseph Doherty
4ad821394b feat: add -D/-V/-DV debug/trace CLI flags and file logging support 2026-02-23 00:41:49 -05:00
Joseph Doherty
b7c0e321d9 fix: move stale connection stat increments to detection site in RunPingTimerAsync 2026-02-23 00:41:12 -05:00
Joseph Doherty
0ec5583422 fix: address code quality review findings for batch 1
- SubjectsCollide: split tokens once upfront instead of O(n²) TokenAt calls
- NatsHeaderParser: manual digit accumulation avoids string allocation and overflow
- NatsHeaders: use IReadOnlyDictionary for Headers, immutable Invalid sentinel
- PermissionLruCache: add missing Count property
2026-02-23 00:40:14 -05:00
Joseph Doherty
cd4ae3cce6 feat: add stale connection stats tracking and varz exposure 2026-02-23 00:38:43 -05:00
Joseph Doherty
eb25d52ed5 feat: add RTT tracking and first-PING delay to NatsClient 2026-02-23 00:34:30 -05:00
Joseph Doherty
dddced444e feat: add NumTokens, TokenAt, SubjectsCollide, UTF-8 validation to SubjectMatch 2026-02-23 00:33:43 -05:00
Joseph Doherty
e87d4c00d9 feat: add NatsHeaderParser for MIME header parsing 2026-02-23 00:33:24 -05:00
Joseph Doherty
7cf6bb866e feat: add PermissionLruCache (128-entry LRU) and wire into ClientPermissions 2026-02-23 00:33:15 -05:00
Joseph Doherty
17a0a217dd feat: add MaxSubs, MaxSubTokens, Debug, Trace, LogFile, LogSizeLimit, Tags to NatsOptions 2026-02-23 00:32:12 -05:00
Joseph Doherty
573cd06bb1 feat: add logging and timestamp options to NatsOptions 2026-02-23 00:29:45 -05:00
Joseph Doherty
a0f02d6641 chore: add Serilog.Sinks.File and SyslogMessages packages 2026-02-23 00:28:32 -05:00
Joseph Doherty
5b383ada4b docs: add implementation plan for sections 3-6 gaps 2026-02-23 00:28:31 -05:00
Joseph Doherty
060e1ee23d docs: add implementation plan for sections 7-10 gaps 2026-02-23 00:25:04 -05:00
Joseph Doherty
f4efbcf09e docs: add design for sections 7-10 gaps implementation 2026-02-23 00:17:35 -05:00
Joseph Doherty
f86ea57f43 docs: add design for sections 3-6 gaps implementation 2026-02-23 00:17:24 -05:00
127 changed files with 28956 additions and 326 deletions

View File

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

View File

@@ -11,8 +11,8 @@
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| NKey generation (server identity) | Y | Y | Ed25519 key pair via NATS.NKeys at startup |
| System account setup | Y | Y | `$SYS` account created; no event publishing yet (stub) |
| Config file validation on startup | Y | Stub | `-c` flag parsed, `ConfigFile` stored, but no config parser |
| System account setup | Y | Y | `$SYS` account with InternalEventSystem, event publishing, request-reply services |
| Config file validation on startup | Y | Y | Full config parsing with error collection via `ConfigProcessor` |
| PID file writing | Y | Y | Written on startup, deleted on shutdown |
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Stub | `ProfPort` option exists but endpoint not implemented |
| Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup |
@@ -40,10 +40,10 @@
|--------|:--:|:----:|-------|
| SIGINT (Ctrl+C) | Y | Y | Both handle graceful shutdown |
| SIGTERM | Y | Y | `PosixSignalRegistration` triggers `ShutdownAsync()` |
| SIGUSR1 (reopen logs) | Y | Stub | Signal registered, handler logs "not yet implemented" |
| SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile |
| SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` |
| SIGHUP (config reload) | Y | Stub | Signal registered, handler logs "not yet implemented" |
| Windows Service integration | Y | N | |
| SIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset; CLI flags preserved |
| Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` |
---
@@ -64,9 +64,9 @@
| ROUTER | Y | N | Excluded per scope |
| GATEWAY | Y | N | Excluded per scope |
| LEAF | Y | N | Excluded per scope |
| SYSTEM (internal) | Y | N | |
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
| JETSTREAM (internal) | Y | N | |
| ACCOUNT (internal) | Y | N | |
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
| WebSocket clients | Y | N | |
| MQTT clients | Y | N | |
@@ -78,8 +78,8 @@
| 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 | N | Go tracks round-trip time per client |
| Per-client trace mode | Y | N | |
| 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` |
@@ -98,7 +98,7 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| 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 | N | |
| 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 |
---
@@ -135,16 +135,16 @@ Go implements a sophisticated slow consumer detection system:
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
| Protocol tracing in parser | Y | N | Go calls `traceInOp()` per operation |
| Subject mapping (input→output) | Y | N | Go transforms subjects via mapping rules |
| MIME header parsing | Y | N | .NET delegates header handling to client layer |
| 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 | Every send | .NET re-serializes JSON each time |
| MSG/HMSG construction | Direct buffer write | String interpolation → byte encode | More allocations in .NET |
| 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 |
---
@@ -159,18 +159,18 @@ Go implements a sophisticated slow consumer detection system:
| 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 | N | .NET clears cache explicitly |
| 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 |
### Missing SubList Features
### SubList Features
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| `Stats()` — comprehensive statistics | Y | N | Matches, cache hits, inserts, removes, fanout |
| `HasInterest()` — fast bool check | Y | N | |
| `NumInterest()` — fast count | Y | N | |
| `ReverseMatch()` — pattern→literal query | Y | N | |
| `RemoveBatch()` — efficient bulk removal | Y | N | |
| `All()` — enumerate all subscriptions | Y | N | |
| `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 | |
@@ -181,16 +181,16 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| Basic validation (empty tokens, wildcards) | Y | Y | |
| Literal subject check | Y | Y | |
| UTF-8/null rune validation | Y | N | Go has `checkRunes` parameter |
| Collision detection (`SubjectsCollide`) | Y | N | |
| Token utilities (`tokenAt`, `numTokens`) | Y | N | |
| 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 | N | |
| Auto-unsubscribe on max messages | Y | Y | .NET enforces at delivery time (NatsServer.cs:269-270) |
| 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 |
@@ -204,23 +204,23 @@ Go implements a sophisticated slow consumer detection system:
| Username/password | Y | Y | |
| Token | Y | Y | |
| NKeys (Ed25519) | Y | Y | .NET has framework but integration is basic |
| JWT validation | Y | N | |
| 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 | N | Property exists but no implementation |
| 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 | N | |
| User revocation tracking | 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 | N | .NET has basic account, no resolution |
| Account exports/imports | Y | N | |
| Per-account connection limits | Y | N | |
| Per-account subscription limits | Y | N | |
| 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
@@ -228,12 +228,13 @@ Go implements a sophisticated slow consumer detection system:
|---------|:--:|:----:|-------|
| Publish allow list | Y | Y | |
| Subscribe allow list | Y | Y | |
| Publish deny list | Y | Partial | .NET has deny in struct but limited enforcement |
| Subscribe deny list | Y | Partial | Same |
| Message-level deny filtering | Y | N | Go filters at delivery time |
| Permission caching (128 entries) | Y | N | |
| Response permissions (reply tracking) | Y | N | Dynamic reply subject authorization |
| Permission templates (JWT) | Y | N | e.g., `{{name()}}`, `{{account-tag(...)}}` |
| 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 |
---
@@ -246,8 +247,8 @@ Go implements a sophisticated slow consumer detection system:
| `-a/--addr` | Y | Y | |
| `-n/--name` (ServerName) | Y | Y | |
| `-m/--http_port` (monitoring) | Y | Y | |
| `-c` (config file) | Y | Stub | Flag parsed, stored in `ConfigFile`, no config parser |
| `-D/-V/-DV` (debug/trace) | Y | N | |
| `-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 | |
@@ -256,18 +257,18 @@ Go implements a sophisticated slow consumer detection system:
### Configuration System
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Config file parsing | Y | N | Go has custom `conf` parser with includes |
| Hot reload (SIGHUP) | Y | N | |
| Config change detection | Y | N | Go tracks `inConfig`/`inCmdLine` origins |
| ~450 option fields | Y | ~54 | .NET covers core options only |
| 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, rotation, syslog, trace levels)
- Advanced limits (MaxSubs, MaxSubTokens, MaxPending, WriteDeadline)
- Tags/metadata
- OCSP configuration
- ~~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
- ~~Operator mode / account resolver~~ — `JwtAuthenticator` + `IAccountResolver` + `MemAccountResolver` with trusted keys
---
@@ -283,7 +284,7 @@ Go implements a sophisticated slow consumer detection system:
| `/routez` | Y | Stub | Returns empty response |
| `/gatewayz` | Y | Stub | Returns empty response |
| `/leafz` | Y | Stub | Returns empty response |
| `/subz` / `/subscriptionsz` | 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 |
@@ -302,13 +303,15 @@ Go implements a sophisticated slow consumer detection system:
| 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 | N | |
| 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 | .NET missing ByStop, ByReason |
| 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 | |
@@ -337,10 +340,10 @@ Go implements a sophisticated slow consumer detection system:
| Mutual TLS (client certs) | Y | Y | |
| Certificate pinning (SHA256 SPKI) | Y | Y | |
| TLS handshake timeout | Y | Y | |
| TLS rate limiting | Y | Property only | .NET has the option but enforcement is partial |
| 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 | N | `TlsMap` property exists, no implementation |
| OCSP stapling | Y | N | |
| 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 | |
---
@@ -350,14 +353,14 @@ Go implements a sophisticated slow consumer detection system:
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Structured logging | Partial | Y | .NET uses Serilog with ILogger<T> |
| File logging with rotation | Y | N | |
| Syslog (local and remote) | Y | N | |
| Log reopening (SIGUSR1) | Y | N | |
| Trace mode (protocol-level) | Y | N | |
| Debug mode | Y | N | |
| Per-subsystem log control | Y | N | |
| Color output on TTY | Y | N | |
| Timestamp format control | Y | N | |
| 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 |
---
@@ -369,34 +372,45 @@ Go implements a sophisticated slow consumer detection system:
| Configurable interval | Y | Y | PingInterval option |
| Max pings out | Y | Y | MaxPingsOut option |
| Stale connection close | Y | Y | |
| RTT-based first PING delay | Y | N | Go delays first PING based on RTT |
| RTT tracking | Y | N | |
| Stale connection watcher | Y | N | Go has dedicated watcher goroutine |
| 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
### High Priority
1. **Slow consumer detection** — unbounded writes can exhaust memory (stat fields exist but no detection logic)
2. **Write coalescing / batch flush** — performance gap for high-throughput scenarios
### 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
### Medium Priority
3. **Verbose mode** — clients expect `+OK` when `verbose: true`
4. **Permission deny enforcement at delivery** — deny lists checked at SUB/PUB time but not during message delivery
5. **Config file parsing** — needed for production deployment (CLI stub exists)
6. **Hot reload** — needed for zero-downtime config changes (SIGHUP stub exists)
7. **File logging with rotation** — needed for production logging
8. **No-responders validation** — flag parsed but not enforced
### Lower Priority
9. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections
10. **JWT authentication** — needed for operator mode
11. **TLS certificate mapping** — property exists, not implemented
12. **OCSP support** — certificate revocation checking
13. **Subject mapping** — input→output subject transformation
14. **Protocol tracing** — no trace-level logging
15. **Subscription statistics** — SubList has no stats collection
16. **Per-account limits** — connections, subscriptions per account
17. **Reply subject tracking** — dynamic response permissions
18. **Windows Service integration** — needed for Windows deployment
### Remaining Lower Priority
1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections

View 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.

File diff suppressed because it is too large Load Diff

View 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"
}

View File

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

View File

@@ -0,0 +1,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.

File diff suppressed because it is too large Load Diff

View 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"
}

View 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.

File diff suppressed because it is too large Load Diff

View 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"
}

View 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.

File diff suppressed because it is too large Load Diff

View 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"
}

View File

@@ -0,0 +1,565 @@
# Design: SYSTEM and ACCOUNT Connection Types
**Date:** 2026-02-23
**Status:** Approved
**Approach:** Bottom-Up Layered Build (6 layers)
## Overview
Port the SYSTEM and ACCOUNT internal connection types from the Go NATS server to .NET. This includes:
- Client type differentiation (ClientKind enum)
- Internal client infrastructure (socketless clients with callback-based delivery)
- Full system event publishing ($SYS.ACCOUNT.*.CONNECT, DISCONNECT, STATSZ, etc.)
- System request-reply monitoring services ($SYS.REQ.SERVER.*.VARZ, CONNZ, etc.)
- Account service/stream imports and exports (cross-account message routing)
- Response routing for service imports with latency tracking
**Go reference files:**
- `golang/nats-server/server/client.go` — client type constants (lines 45-65), `isInternalClient()`, message delivery (lines 3789-3803)
- `golang/nats-server/server/server.go` — system account setup (lines 1822-1892), `createInternalClient()` (lines 1910-1936)
- `golang/nats-server/server/events.go``internal` struct (lines 124-147), event subjects (lines 41-97), send/receive loops (lines 474-668), event publishing, subscriptions (lines 1172-1495)
- `golang/nats-server/server/accounts.go``Account` struct (lines 52-119), import/export structs (lines 142-263), `addServiceImport()` (lines 1560-2112), `addServiceImportSub()` (lines 2156-2187), `internalClient()` (lines 2114-2122)
---
## Layer 1: ClientKind Enum + INatsClient Interface + InternalClient
### ClientKind Enum
**New file:** `src/NATS.Server/ClientKind.cs`
```csharp
public enum ClientKind
{
Client, // End user connection
Router, // Cluster peer (out of scope)
Gateway, // Inter-cluster bridge (out of scope)
Leaf, // Leaf node (out of scope)
System, // Internal system client
JetStream, // Internal JetStream client (out of scope)
Account, // Internal per-account client
}
public static class ClientKindExtensions
{
public static bool IsInternal(this ClientKind kind) =>
kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account;
}
```
### INatsClient Interface
Extract from `NatsClient` the surface used by `Subscription`, `DeliverMessage`, `ProcessMessage`:
```csharp
public interface INatsClient
{
ulong Id { get; }
ClientKind Kind { get; }
bool IsInternal { get; }
Account? Account { get; }
ClientOptions? ClientOpts { get; }
ClientPermissions? Permissions { get; }
void SendMessage(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload);
bool QueueOutbound(ReadOnlyMemory<byte> data);
}
```
### InternalClient Class
**New file:** `src/NATS.Server/InternalClient.cs`
Lightweight, socketless client for internal messaging:
- `ClientKind Kind` — System, Account, or JetStream
- `Account Account` — associated account
- `ulong Id` — unique client ID from server's ID counter
- Headers always enabled, echo always disabled
- `SendMessage` invokes internal callback delegate or pushes to Channel
- No socket, no read/write loops, no parser
- `QueueOutbound` is a no-op (internal clients don't write wire protocol)
### Subscription Change
`Subscription.Client` changes from `NatsClient?` to `INatsClient?`. This is the biggest refactoring step — all code referencing `sub.Client` as `NatsClient` needs updating.
`NatsClient` implements `INatsClient` with `Kind = ClientKind.Client`.
---
## Layer 2: System Event Infrastructure
### InternalEventSystem Class
**New file:** `src/NATS.Server/Events/InternalEventSystem.cs`
Core class managing the server's internal event system, mirroring Go's `internal` struct:
```csharp
public sealed class InternalEventSystem : IAsyncDisposable
{
// Core state
public Account SystemAccount { get; }
public InternalClient SystemClient { get; }
private ulong _sequence;
private int _subscriptionId;
private readonly string _serverHash;
private readonly string _inboxPrefix;
// Message queues (Channel<T>-based)
private readonly Channel<PublishMessage> _sendQueue;
private readonly Channel<InternalSystemMessage> _receiveQueue;
private readonly Channel<InternalSystemMessage> _receiveQueuePings;
// Background tasks
private Task? _sendLoop;
private Task? _receiveLoop;
private Task? _receiveLoopPings;
// Remote server tracking
private readonly ConcurrentDictionary<string, ServerUpdate> _remoteServers = new();
// Timers
private PeriodicTimer? _statszTimer; // 10s interval
private PeriodicTimer? _accountConnsTimer; // 30s interval
private PeriodicTimer? _orphanSweeper; // 90s interval
}
```
### Message Types
```csharp
public record PublishMessage(
InternalClient? Client, // Use specific client or default to system client
string Subject,
string? Reply,
ServerInfo? Info,
byte[]? Headers,
object? Body, // JSON-serializable
bool Echo = false,
bool IsLast = false);
public record InternalSystemMessage(
Subscription? Sub,
INatsClient? Client,
Account? Account,
string Subject,
string? Reply,
ReadOnlyMemory<byte> Headers,
ReadOnlyMemory<byte> Message,
Action<Subscription?, INatsClient?, Account?, string, string?, ReadOnlyMemory<byte>, ReadOnlyMemory<byte>> Callback);
```
### Lifecycle
- `StartAsync(NatsServer server)` — creates system client, starts 3 background Tasks
- `StopAsync()` — publishes shutdown event with `IsLast=true`, signals channels complete, awaits all tasks
### Send Loop
Consumes from `_sendQueue`:
1. Fills in ServerInfo metadata (name, host, ID, sequence, version, tags)
2. Serializes body to JSON using source-generated serializer
3. Calls `server.ProcessMessage()` on the system account to deliver locally
4. Handles compression if configured
### Receive Loop(s)
Two instances (general + pings) consuming from their respective channels:
- Pop messages, invoke callbacks
- Exit on cancellation
### APIs on NatsServer
```csharp
public void SendInternalMsg(string subject, string? reply, object? msg);
public void SendInternalAccountMsg(Account account, string subject, object? msg);
public Subscription SysSubscribe(string subject, SystemMessageHandler callback);
public Subscription SysSubscribeInternal(string subject, SystemMessageHandler callback);
```
### noInlineCallback Pattern
Wraps a `SystemMessageHandler` so that instead of executing inline during message delivery, it enqueues to `_receiveQueue` for async dispatch. This prevents system event handlers from blocking the publishing path.
---
## Layer 3: System Event Publishing
### Event Types (DTOs)
**New folder:** `src/NATS.Server/Events/`
All events embed a `TypedEvent` base:
```csharp
public record TypedEvent(string Type, string Id, DateTime Time);
```
| Event Class | Type String | Published On |
|-------------|-------------|-------------|
| `ConnectEventMsg` | `io.nats.server.advisory.v1.client_connect` | `$SYS.ACCOUNT.{acc}.CONNECT` |
| `DisconnectEventMsg` | `io.nats.server.advisory.v1.client_disconnect` | `$SYS.ACCOUNT.{acc}.DISCONNECT` |
| `AccountNumConns` | `io.nats.server.advisory.v1.account_connections` | `$SYS.ACCOUNT.{acc}.SERVER.CONNS` |
| `ServerStatsMsg` | (stats) | `$SYS.SERVER.{id}.STATSZ` |
| `ShutdownEventMsg` | (shutdown) | `$SYS.SERVER.{id}.SHUTDOWN` |
| `LameDuckEventMsg` | (lameduck) | `$SYS.SERVER.{id}.LAMEDUCK` |
| `AuthErrorEventMsg` | `io.nats.server.advisory.v1.client_auth` | `$SYS.SERVER.{id}.CLIENT.AUTH.ERR` |
### Integration Points
| Location | Event | Trigger |
|----------|-------|---------|
| `NatsServer.HandleClientAsync()` after auth | `ConnectEventMsg` | Client authenticated |
| `NatsServer.RemoveClient()` | `DisconnectEventMsg` | Client disconnected |
| `NatsServer.ShutdownAsync()` | `ShutdownEventMsg` | Server shutting down |
| `NatsServer.LameDuckShutdownAsync()` | `LameDuckEventMsg` | Lame duck mode |
| Auth failure in `NatsClient.ProcessConnect()` | `AuthErrorEventMsg` | Auth rejected |
| Periodic timer (10s) | `ServerStatsMsg` | Timer tick |
| Periodic timer (30s) | `AccountNumConns` | Timer tick, for each account with connections |
### JSON Serialization
`System.Text.Json` source generator context:
```csharp
[JsonSerializable(typeof(ConnectEventMsg))]
[JsonSerializable(typeof(DisconnectEventMsg))]
[JsonSerializable(typeof(ServerStatsMsg))]
// ... etc
internal partial class EventJsonContext : JsonSerializerContext { }
```
---
## Layer 4: System Request-Reply Services
### Subscriptions Created in initEventTracking()
Server-specific (only this server responds):
| Subject | Handler | Response |
|---------|---------|----------|
| `$SYS.REQ.SERVER.{id}.IDZ` | `IdzReq` | Server identity |
| `$SYS.REQ.SERVER.{id}.STATSZ` | `StatszReq` | Server stats (same as /varz stats) |
| `$SYS.REQ.SERVER.{id}.VARZ` | `VarzReq` | Same as /varz JSON |
| `$SYS.REQ.SERVER.{id}.CONNZ` | `ConnzReq` | Same as /connz JSON |
| `$SYS.REQ.SERVER.{id}.SUBSZ` | `SubszReq` | Same as /subz JSON |
| `$SYS.REQ.SERVER.{id}.HEALTHZ` | `HealthzReq` | Health status |
| `$SYS.REQ.SERVER.{id}.ACCOUNTZ` | `AccountzReq` | Account info |
Wildcard ping (all servers respond):
| Subject | Handler |
|---------|---------|
| `$SYS.REQ.SERVER.PING.STATSZ` | `StatszReq` |
| `$SYS.REQ.SERVER.PING.VARZ` | `VarzReq` |
| `$SYS.REQ.SERVER.PING.IDZ` | `IdzReq` |
| `$SYS.REQ.SERVER.PING.HEALTHZ` | `HealthzReq` |
Account-scoped:
| Subject | Handler |
|---------|---------|
| `$SYS.REQ.ACCOUNT.*.CONNZ` | `AccountConnzReq` |
| `$SYS.REQ.ACCOUNT.*.SUBSZ` | `AccountSubszReq` |
| `$SYS.REQ.ACCOUNT.*.INFO` | `AccountInfoReq` |
| `$SYS.REQ.ACCOUNT.*.STATZ` | `AccountStatzReq` |
### Implementation
Handlers reuse existing `MonitorServer` data builders. The request body (if present) is parsed for options (e.g., sort, limit for CONNZ). Response is serialized to JSON and published on the request's reply subject via `SendInternalMsg`.
---
## Layer 5: Import/Export Model + ACCOUNT Client
### Export Types
**New file:** `src/NATS.Server/Imports/StreamExport.cs`
```csharp
public sealed class StreamExport
{
public ExportAuth Auth { get; init; } = new();
}
```
**New file:** `src/NATS.Server/Imports/ServiceExport.cs`
```csharp
public sealed class ServiceExport
{
public ExportAuth Auth { get; init; } = new();
public Account? Account { get; init; }
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
public TimeSpan ResponseThreshold { get; init; } = TimeSpan.FromMinutes(2);
public ServiceLatency? Latency { get; init; }
public bool AllowTrace { get; init; }
}
```
**New file:** `src/NATS.Server/Imports/ExportAuth.cs`
```csharp
public sealed class ExportAuth
{
public bool TokenRequired { get; init; }
public uint AccountPosition { get; init; }
public HashSet<string>? ApprovedAccounts { get; init; }
public Dictionary<string, long>? RevokedAccounts { get; init; }
public bool IsAuthorized(Account account) { ... }
}
```
### Import Types
**New file:** `src/NATS.Server/Imports/StreamImport.cs`
```csharp
public sealed class StreamImport
{
public required Account SourceAccount { get; init; }
public required string From { get; init; }
public required string To { get; init; }
public SubjectTransform? Transform { get; init; }
public bool UsePub { get; init; }
public bool Invalid { get; set; }
}
```
**New file:** `src/NATS.Server/Imports/ServiceImport.cs`
```csharp
public sealed class ServiceImport
{
public required Account DestinationAccount { get; init; }
public required string From { get; init; }
public required string To { get; init; }
public SubjectTransform? Transform { get; init; }
public ServiceExport? Export { get; init; }
public ServiceResponseType ResponseType { get; init; }
public byte[]? Sid { get; set; }
public bool IsResponse { get; init; }
public bool UsePub { get; init; }
public bool Invalid { get; set; }
public bool Share { get; init; }
public bool Tracking { get; init; }
}
```
### Account Extensions
Add to `Account`:
```csharp
// Export/Import maps
public ExportMap Exports { get; } = new();
public ImportMap Imports { get; } = new();
// Internal ACCOUNT client (lazy)
private InternalClient? _internalClient;
public InternalClient GetOrCreateInternalClient(NatsServer server) { ... }
// Internal subscription management
private ulong _internalSubId;
public Subscription SubscribeInternal(string subject, SystemMessageHandler callback) { ... }
// Import/Export APIs
public void AddServiceExport(string subject, ServiceResponseType responseType, IEnumerable<Account>? approved);
public void AddStreamExport(string subject, IEnumerable<Account>? approved);
public ServiceImport AddServiceImport(Account destination, string from, string to);
public void AddStreamImport(Account source, string from, string to);
```
### ExportMap / ImportMap
```csharp
public sealed class ExportMap
{
public Dictionary<string, StreamExport> Streams { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceExport> Services { get; } = new(StringComparer.Ordinal);
public Dictionary<string, ServiceImport> Responses { get; } = new(StringComparer.Ordinal);
}
public sealed class ImportMap
{
public List<StreamImport> Streams { get; } = [];
public Dictionary<string, List<ServiceImport>> Services { get; } = new(StringComparer.Ordinal);
}
```
### Service Import Subscription Flow
1. `account.AddServiceImport(dest, "requests.>", "api.>")` called
2. Account creates its `InternalClient` (Kind=Account) if needed
3. Creates subscription on `"requests.>"` in account's SubList with `Client = internalClient`
4. Subscription carries a `ServiceImport` reference
5. When message matches, `DeliverMessage` detects internal client → invokes `ProcessServiceImport`
### ProcessServiceImport Callback
1. Transform subject if transform configured
2. Match against destination account's SubList
3. Deliver to destination subscribers (rewriting reply subject for response routing)
4. If reply present: set up response service import (see Layer 6)
### Stream Import Delivery
In `DeliverMessage`, before sending to subscriber:
- If subscription has `StreamImport` reference, apply subject transform
- Deliver with transformed subject
### Message Delivery Path Changes
`NatsServer.ProcessMessage` needs modification:
- After matching local account SubList, also check for service imports that might forward to other accounts
- For subscriptions with `sub.StreamImport != null`, transform subject before delivery
---
## Layer 6: Response Routing + Latency Tracking
### Service Reply Prefix
Generated per request: `_R_.{random10chars}.` — unique reply namespace in the exporting account.
### Response Service Import Creation
When `ProcessServiceImport` handles a request with a reply subject:
1. Generate new reply prefix: `_R_.{random}.`
2. Create response `ServiceImport` in the exporting account:
- `From = newReplyPrefix + ">"` (wildcard to catch all responses)
- `To = originalReply` (original reply subject in importing account)
- `IsResponse = true`
3. Subscribe to new prefix in exporting account
4. Rewrite reply in forwarded message to new prefix
5. Store in `ExportMap.Responses[newPrefix]`
### Response Delivery
When exporting account service responds on the rewritten reply:
1. Response matches the `_R_.{random}.>` subscription
2. Response service import callback fires
3. Transforms reply back to original subject
4. Delivers to original account's subscribers
### Cleanup
- **Singleton:** Remove response import after first response delivery
- **Streamed:** Track timestamp, clean up via timer after `ResponseThreshold` (default 2 min)
- **Chunked:** Same as Streamed
Timer runs periodically (every 30s), checks `ServiceImport.Timestamp` against threshold, removes stale entries.
### Latency Tracking
```csharp
public sealed class ServiceLatency
{
public int SamplingPercentage { get; init; } // 1-100
public string Subject { get; init; } = string.Empty; // where to publish metrics
}
public record ServiceLatencyMsg(
TypedEvent Event,
string Status,
string Requestor, // Account name
string Responder, // Account name
TimeSpan RequestStart,
TimeSpan ServiceLatency,
TimeSpan TotalLatency);
```
When tracking is enabled:
1. Record request timestamp when creating response import
2. On response delivery, calculate latency
3. Publish `ServiceLatencyMsg` to configured subject
4. Sampling: only track if `Random.Shared.Next(100) < SamplingPercentage`
---
## Testing Strategy
### Layer 1 Tests
- Verify `ClientKind.IsInternal()` for all kinds
- Create `InternalClient`, verify properties (Kind, Id, Account, IsInternal)
- Verify `INatsClient` interface on both `NatsClient` and `InternalClient`
### Layer 2 Tests
- Start/stop `InternalEventSystem` lifecycle
- `SysSubscribe` creates subscription in system account SubList
- `SendInternalMsg` delivers to system subscribers via send loop
- `noInlineCallback` queues to receive loop rather than executing inline
- Concurrent publish/subscribe stress test
### Layer 3 Tests
- Connect event published on `$SYS.ACCOUNT.{acc}.CONNECT` when client authenticates
- Disconnect event published when client closes
- Server stats published every 10s on `$SYS.SERVER.{id}.STATSZ`
- Account conns published every 30s for accounts with connections
- Shutdown event published during shutdown
- Auth error event published on auth failure
- Event JSON structure matches Go format
### Layer 4 Tests
- Subscribe to `$SYS.REQ.SERVER.{id}.VARZ`, send request, verify response matches /varz
- Subscribe to `$SYS.REQ.SERVER.{id}.CONNZ`, verify response
- Ping wildcard `$SYS.REQ.SERVER.PING.HEALTHZ` receives response
- Account-scoped requests work
### Layer 5 Tests
- `AddServiceExport` + `AddServiceImport` creates internal subscription
- Message published on import subject is forwarded to export account
- Wildcard imports with subject transforms
- Authorization: only approved accounts can import
- Stream import with subject transform
- Cycle detection in service imports
- Account internal client lazy creation
### Layer 6 Tests
- Service import request-reply: request forwarded with rewritten reply, response routed back
- Singleton response: import cleaned up after one response
- Streamed response: multiple responses, cleaned up after timeout
- Latency tracking: metrics published to configured subject
- Response threshold timer cleans up stale entries
---
## Files to Create/Modify
### New Files
- `src/NATS.Server/ClientKind.cs`
- `src/NATS.Server/INatsClient.cs`
- `src/NATS.Server/InternalClient.cs`
- `src/NATS.Server/Events/InternalEventSystem.cs`
- `src/NATS.Server/Events/EventTypes.cs` (all event DTOs)
- `src/NATS.Server/Events/EventJsonContext.cs` (source gen)
- `src/NATS.Server/Events/EventSubjects.cs` (subject constants)
- `src/NATS.Server/Imports/ServiceImport.cs`
- `src/NATS.Server/Imports/StreamImport.cs`
- `src/NATS.Server/Imports/ServiceExport.cs`
- `src/NATS.Server/Imports/StreamExport.cs`
- `src/NATS.Server/Imports/ExportAuth.cs`
- `src/NATS.Server/Imports/ExportMap.cs`
- `src/NATS.Server/Imports/ImportMap.cs`
- `src/NATS.Server/Imports/ServiceResponseType.cs`
- `src/NATS.Server/Imports/ServiceLatency.cs`
- `tests/NATS.Server.Tests/InternalClientTests.cs`
- `tests/NATS.Server.Tests/EventSystemTests.cs`
- `tests/NATS.Server.Tests/SystemEventsTests.cs`
- `tests/NATS.Server.Tests/SystemRequestReplyTests.cs`
- `tests/NATS.Server.Tests/ImportExportTests.cs`
- `tests/NATS.Server.Tests/ResponseRoutingTests.cs`
### Modified Files
- `src/NATS.Server/NatsClient.cs` — implement `INatsClient`, add `Kind` property
- `src/NATS.Server/NatsServer.cs` — integrate event system, add import/export message path, system event publishing
- `src/NATS.Server/Auth/Account.cs` — add exports/imports, internal client, subscription APIs
- `src/NATS.Server/Subscriptions/Subscription.cs``Client``INatsClient?`, add `ServiceImport?`, `StreamImport?`
- `src/NATS.Server/Subscriptions/SubList.cs` — work with `INatsClient` if needed
- `src/NATS.Server/Monitoring/MonitorServer.cs` — expose data builders for request-reply handlers
- `differences.md` — update SYSTEM, ACCOUNT, import/export status

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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"
}

View File

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

View File

@@ -1,69 +1,227 @@
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:
options.ConfigFile = args[++i];
// Already handled in first pass; skip the value
i++;
break;
case "--pid" when i + 1 < args.Length:
options.PidFile = args[++i];
options.InCmdLine.Add("PidFile");
break;
case "--ports_file_dir" when i + 1 < args.Length:
options.PortsFileDir = args[++i];
options.InCmdLine.Add("PortsFileDir");
break;
case "--tls":
break;
case "--tlscert" when i + 1 < args.Length:
options.TlsCert = args[++i];
options.InCmdLine.Add("TlsCert");
break;
case "--tlskey" when i + 1 < args.Length:
options.TlsKey = args[++i];
options.InCmdLine.Add("TlsKey");
break;
case "--tlscacert" when i + 1 < args.Length:
options.TlsCaCert = args[++i];
options.InCmdLine.Add("TlsCaCert");
break;
case "--tlsverify":
options.TlsVerify = true;
options.InCmdLine.Add("TlsVerify");
break;
case "-D" or "--debug":
options.Debug = true;
options.InCmdLine.Add("Debug");
break;
case "-V" or "-T" or "--trace":
options.Trace = true;
options.InCmdLine.Add("Trace");
break;
case "-DV":
options.Debug = true;
options.Trace = true;
options.InCmdLine.Add("Debug");
options.InCmdLine.Add("Trace");
break;
case "-l" or "--log" or "--log_file" when i + 1 < args.Length:
options.LogFile = args[++i];
options.InCmdLine.Add("LogFile");
break;
case "--log_size_limit" when i + 1 < args.Length:
options.LogSizeLimit = long.Parse(args[++i]);
options.InCmdLine.Add("LogSizeLimit");
break;
case "--log_max_files" when i + 1 < args.Length:
options.LogMaxFiles = int.Parse(args[++i]);
options.InCmdLine.Add("LogMaxFiles");
break;
case "--logtime" when i + 1 < args.Length:
options.Logtime = bool.Parse(args[++i]);
options.InCmdLine.Add("Logtime");
break;
case "--logtime_utc":
options.LogtimeUTC = true;
options.InCmdLine.Add("LogtimeUTC");
break;
case "--syslog":
options.Syslog = true;
options.InCmdLine.Add("Syslog");
break;
case "--remote_syslog" when i + 1 < args.Length:
options.RemoteSyslog = args[++i];
options.InCmdLine.Add("RemoteSyslog");
break;
case "--service":
windowsService = true;
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;
}
}
// Build Serilog configuration from options
var logConfig = new LoggerConfiguration()
.Enrich.FromLogContext();
// 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) =>
{

View File

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

View 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; }
}

View File

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

View File

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

View File

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

View 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; }
}

View 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;
}
}

View 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; }
}

View 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();
}
}

View 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; }
}

View 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;
}
}

View 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;
}
}
}

View 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);
}
}
}

View 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,
};
}
}

View File

@@ -23,6 +23,7 @@ public enum ClientClosedReason
ServerShutdown,
MsgHeaderViolation,
NoRespondersRequiresHeaders,
AuthenticationExpired,
}
public static class ClientClosedReasonExtensions
@@ -46,6 +47,7 @@ public static class ClientClosedReasonExtensions
ClientClosedReason.ServerShutdown => "Server Shutdown",
ClientClosedReason.MsgHeaderViolation => "Message Header Violation",
ClientClosedReason.NoRespondersRequiresHeaders => "No Responders Requires Headers",
ClientClosedReason.AuthenticationExpired => "Authentication Expired",
_ => reason.ToString(),
};
}

View File

@@ -15,6 +15,7 @@ public enum ClientFlags
WriteLoopStarted = 1 << 4,
IsSlowConsumer = 1 << 5,
ConnectProcessFinished = 1 << 6,
TraceMode = 1 << 7,
}
/// <summary>

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
using NATS.Server.Auth;
namespace NATS.Server.Imports;
public sealed class ServiceExport
{
public ExportAuth Auth { get; init; } = new();
public Account? Account { get; init; }
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
public TimeSpan ResponseThreshold { get; init; } = TimeSpan.FromMinutes(2);
public ServiceLatency? Latency { get; init; }
public bool AllowTrace { get; init; }
}

View File

@@ -0,0 +1,21 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Imports;
public sealed class ServiceImport
{
public required Account DestinationAccount { get; init; }
public required string From { get; init; }
public required string To { get; init; }
public SubjectTransform? Transform { get; init; }
public ServiceExport? Export { get; init; }
public ServiceResponseType ResponseType { get; init; } = ServiceResponseType.Singleton;
public byte[]? Sid { get; set; }
public bool IsResponse { get; init; }
public bool UsePub { get; init; }
public bool Invalid { get; set; }
public bool Share { get; init; }
public bool Tracking { get; init; }
public long TimestampTicks { get; set; }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
using NATS.Server.Auth;
using NATS.Server.Subscriptions;
namespace NATS.Server.Imports;
public sealed class StreamImport
{
public required Account SourceAccount { get; init; }
public required string From { get; init; }
public required string To { get; init; }
public SubjectTransform? Transform { get; init; }
public bool UsePub { get; init; }
public bool Invalid { get; set; }
}

View File

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

View File

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

View File

@@ -168,6 +168,9 @@ public enum SortOpt
ByLast,
ByIdle,
ByUptime,
ByRtt,
ByStop,
ByReason,
}
/// <summary>

View File

@@ -12,9 +12,25 @@ public sealed class ConnzHandler(NatsServer server)
{
var opts = ParseQueryParams(ctx);
var now = DateTime.UtcNow;
var clients = server.GetClients().ToArray();
var connInfos = clients.Select(c => BuildConnInfo(c, now, opts)).ToList();
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
@@ -30,6 +46,9 @@ public sealed class ConnzHandler(NatsServer server)
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(),
};
@@ -73,6 +92,7 @@ public sealed class ConnzHandler(NatsServer server)
Reason = client.CloseReason.ToReasonString(),
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
Rtt = FormatRtt(client.Rtt),
};
if (opts.Subscriptions)
@@ -96,6 +116,35 @@ public sealed class ConnzHandler(NatsServer server)
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;
@@ -116,6 +165,9 @@ public sealed class ConnzHandler(NatsServer server)
"last" => SortOpt.ByLast,
"idle" => SortOpt.ByIdle,
"uptime" => SortOpt.ByUptime,
"rtt" => SortOpt.ByRtt,
"stop" => SortOpt.ByStop,
"reason" => SortOpt.ByReason,
_ => SortOpt.ByCid,
};
}
@@ -128,6 +180,17 @@ public sealed class ConnzHandler(NatsServer server)
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;
@@ -137,6 +200,16 @@ public sealed class ConnzHandler(NatsServer server)
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)

View File

@@ -15,6 +15,7 @@ public sealed class MonitorServer : IAsyncDisposable
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)
{
@@ -29,6 +30,7 @@ public sealed class MonitorServer : IAsyncDisposable
_varzHandler = new VarzHandler(server, options);
_connzHandler = new ConnzHandler(server);
_subszHandler = new SubszHandler(server);
_app.MapGet(basePath + "/", () =>
{
@@ -75,15 +77,15 @@ public sealed class MonitorServer : IAsyncDisposable
stats.HttpReqStats.AddOrUpdate("/leafz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/subz", () =>
_app.MapGet(basePath + "/subz", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/subz", 1, (_, v) => v + 1);
return Results.Ok(new { });
return Results.Ok(_subszHandler.HandleSubsz(ctx));
});
_app.MapGet(basePath + "/subscriptionsz", () =>
_app.MapGet(basePath + "/subscriptionsz", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/subscriptionsz", 1, (_, v) => v + 1);
return Results.Ok(new { });
return Results.Ok(_subszHandler.HandleSubsz(ctx));
});
_app.MapGet(basePath + "/accountz", () =>
{

View 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; } = "";
}

View 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;
}
}

View File

@@ -157,6 +157,12 @@ public sealed class Varz
[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; }
@@ -219,6 +225,25 @@ public sealed class SlowConsumersStats
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.

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Protocol;
namespace NATS.Server.Monitoring;
@@ -47,6 +48,22 @@ public sealed class VarzHandler : IDisposable
_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,
@@ -63,6 +80,8 @@ public sealed class VarzHandler : IDisposable
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,
@@ -91,6 +110,14 @@ public sealed class VarzHandler : IDisposable
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),

View File

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

View File

@@ -19,6 +19,8 @@ public interface IMessageRouter
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
void PublishConnectEvent(NatsClient client);
void PublishDisconnectEvent(NatsClient client);
}
public interface ISubListAccess
@@ -26,7 +28,7 @@ public interface ISubListAccess
SubList SubList { get; }
}
public sealed class NatsClient : IDisposable
public sealed class NatsClient : INatsClient, IDisposable
{
private readonly Socket _socket;
private readonly Stream _stream;
@@ -45,14 +47,30 @@ public sealed class NatsClient : IDisposable
private readonly ServerStats _serverStats;
public ulong Id { get; }
public ClientKind Kind => ClientKind.Client;
public ClientOptions? ClientOpts { get; private set; }
public IMessageRouter? Router { get; set; }
public Account? Account { get; private set; }
public ClientPermissions? Permissions => _permissions;
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);
@@ -73,6 +91,11 @@ public sealed class NatsClient : IDisposable
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; }
@@ -90,7 +113,7 @@ public sealed class NatsClient : IDisposable
_nonce = nonce;
_logger = logger;
_serverStats = serverStats;
_parser = new NatsParser(options.MaxPayload);
_parser = new NatsParser(options.MaxPayload, options.Trace ? logger : null);
StartTime = DateTime.UtcNow;
_lastActivityTicks = StartTime.Ticks;
if (socket.RemoteEndPoint is IPEndPoint ep)
@@ -321,6 +344,14 @@ public sealed class NatsClient : IDisposable
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:
@@ -348,16 +379,18 @@ 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, ClientClosedReason.AuthenticationViolation);
@@ -365,17 +398,23 @@ public sealed class NatsClient : IDisposable
}
// 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
if (_nonce != null)
@@ -386,7 +425,13 @@ 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;
}
}
// Validate no_responders requires headers
@@ -401,6 +446,35 @@ public sealed class NatsClient : IDisposable
_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 void ProcessSub(ParsedCommand cmd)
@@ -413,6 +487,24 @@ public sealed class NatsClient : IDisposable
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;
}
var sub = new Subscription
{
Subject = cmd.Subject!,
@@ -443,6 +535,7 @@ public sealed class NatsClient : IDisposable
}
_subs.Remove(cmd.Sid!);
Account?.DecrementSubscriptions();
Account?.SubList.Remove(sub);
}
@@ -491,9 +584,20 @@ public sealed class NatsClient : IDisposable
private void SendInfo()
{
var infoJson = JsonSerializer.Serialize(_serverInfo);
var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
QueueOutbound(infoLine);
// 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)
{
QueueOutbound(server.CachedInfoLine);
}
else
{
var infoJson = JsonSerializer.Serialize(_serverInfo);
var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
QueueOutbound(infoLine);
}
}
public void SendMessage(string subject, string sid, string? replyTo,
@@ -504,26 +608,78 @@ public sealed class NatsClient : IDisposable
Interlocked.Increment(ref _serverStats.OutMsgs);
Interlocked.Add(ref _serverStats.OutBytes, payload.Length + headers.Length);
byte[] line;
// 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)
{
int totalSize = headers.Length + payload.Length;
line = Encoding.ASCII.GetBytes($"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
"HMSG "u8.CopyTo(span);
pos = 5;
}
else
{
line = Encoding.ASCII.GetBytes($"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
"MSG "u8.CopyTo(span);
pos = 4;
}
var totalLen = line.Length + headers.Length + payload.Length + NatsProtocol.CrLf.Length;
var msg = new byte[totalLen];
var offset = 0;
line.CopyTo(msg.AsSpan(offset)); offset += line.Length;
if (headers.Length > 0) { headers.Span.CopyTo(msg.AsSpan(offset)); offset += headers.Length; }
if (payload.Length > 0) { payload.Span.CopyTo(msg.AsSpan(offset)); offset += payload.Length; }
NatsProtocol.CrLf.CopyTo(msg.AsSpan(offset));
// Subject
pos += Encoding.ASCII.GetBytes(subject, span[pos..]);
span[pos++] = (byte)' ';
QueueOutbound(msg);
// 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));
}
private void WriteProtocol(byte[] data)
@@ -611,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)
{
@@ -622,6 +785,8 @@ public sealed class NatsClient : IDisposable
if (Volatile.Read(ref _pingsOut) + 1 > _options.MaxPingsOut)
{
_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;
}
@@ -629,6 +794,7 @@ public sealed class NatsClient : IDisposable
var currentPingsOut = Interlocked.Increment(ref _pingsOut);
_logger.LogDebug("Client {ClientId} sending PING ({PingsOut}/{MaxPingsOut})",
Id, currentPingsOut, _options.MaxPingsOut);
Interlocked.Exchange(ref _rttStartTicks, DateTime.UtcNow.Ticks);
WriteProtocol(NatsProtocol.PingBytes);
}
}
@@ -689,6 +855,12 @@ public sealed class NatsClient : IDisposable
catch (ObjectDisposedException) { }
}
public void RemoveSubscription(string sid)
{
if (_subs.Remove(sid))
Account?.DecrementSubscriptions();
}
public void RemoveAllSubscriptions(SubList subList)
{
foreach (var sub in _subs.Values)

View File

@@ -1,5 +1,6 @@
using System.Security.Authentication;
using NATS.Server.Auth;
using NATS.Server.Tls;
namespace NATS.Server;
@@ -16,6 +17,16 @@ public sealed class NatsOptions
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; }
@@ -47,9 +58,35 @@ public sealed class NatsOptions
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; }
@@ -64,5 +101,19 @@ public sealed class NatsOptions
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;
}

View File

@@ -8,6 +8,9 @@ using System.Text;
using Microsoft.Extensions.Logging;
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Configuration;
using NATS.Server.Events;
using NATS.Server.Imports;
using NATS.Server.Monitoring;
using NATS.Server.Protocol;
using NATS.Server.Subscriptions;
@@ -19,17 +22,25 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
private readonly NatsOptions _options;
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly ConcurrentQueue<ClosedClient> _closedClients = new();
private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly ServerStats _stats = new();
private readonly TaskCompletionSource _listeningStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly AuthService _authService;
private AuthService _authService;
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
// Config reload state
private NatsOptions? _cliSnapshot;
private HashSet<string> _cliFlags = [];
private string? _configDigest;
private readonly Account _globalAccount;
private readonly Account _systemAccount;
private InternalEventSystem? _eventSystem;
private readonly SslServerAuthenticationOptions? _sslOptions;
private readonly TlsRateLimiter? _tlsRateLimiter;
private readonly SubjectTransform[] _subjectTransforms;
private Socket? _listener;
private MonitorServer? _monitorServer;
private ulong _nextClientId;
@@ -43,6 +54,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private int _lameDuck;
private byte[] _cachedInfoLine = [];
private readonly List<PosixSignalRegistration> _signalRegistrations = [];
private string? _portsFilePath;
@@ -51,6 +64,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
private static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1);
public SubList SubList => _globalAccount.SubList;
public byte[] CachedInfoLine => _cachedInfoLine;
public ServerStats Stats => _stats;
public DateTime StartTime => new(Interlocked.Read(ref _startTimeTicks), DateTimeKind.Utc);
public string ServerId => _serverInfo.ServerId;
@@ -59,10 +73,16 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public int Port => _options.Port;
public Account SystemAccount => _systemAccount;
public string ServerNKey { get; }
public InternalEventSystem? EventSystem => _eventSystem;
public bool IsShuttingDown => Volatile.Read(ref _shutdown) != 0;
public bool IsLameDuckMode => Volatile.Read(ref _lameDuck) != 0;
public Action? ReOpenLogFile { get; set; }
public IEnumerable<NatsClient> GetClients() => _clients.Values;
public IEnumerable<ClosedClient> GetClosedClients() => _closedClients;
public IEnumerable<Auth.Account> GetAccounts() => _accounts.Values;
public Task WaitForReadyAsync() => _listeningStarted.Task;
public void WaitForShutdown() => _shutdownComplete.Task.GetAwaiter().GetResult();
@@ -74,6 +94,21 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_logger.LogInformation("Initiating Shutdown...");
// Publish shutdown advisory before tearing down the event system
if (_eventSystem != null)
{
var shutdownSubject = string.Format(EventSubjects.ServerShutdown, _serverInfo.ServerId);
_eventSystem.Enqueue(new PublishMessage
{
Subject = shutdownSubject,
Body = new ShutdownEventMsg { Server = BuildEventServerInfo(), Reason = "Server Shutdown" },
IsLast = true,
});
// Give the send loop time to process the shutdown event
await Task.Delay(100);
await _eventSystem.DisposeAsync();
}
// Signal all internal loops to stop
await _quitCts.CancelAsync();
@@ -192,7 +227,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
/// <summary>
/// Registers Unix signal handlers.
/// SIGTERM → shutdown, SIGUSR2 → lame duck, SIGUSR1 → log reopen (stub), SIGHUP → reload (stub).
/// SIGTERM → shutdown, SIGUSR2 → lame duck, SIGUSR1 → log reopen, SIGHUP → reload (stub).
/// </summary>
public void HandleSignals()
{
@@ -213,7 +248,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_signalRegistrations.Add(PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
ctx.Cancel = true;
_logger.LogWarning("Trapped SIGHUP signal — config reload not yet supported");
_logger.LogInformation("Trapped SIGHUP signal — reloading configuration");
_ = Task.Run(() => ReloadConfig());
}));
// SIGUSR1 and SIGUSR2 only on non-Windows
@@ -222,7 +258,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_signalRegistrations.Add(PosixSignalRegistration.Create((PosixSignal)10, ctx =>
{
ctx.Cancel = true;
_logger.LogWarning("Trapped SIGUSR1 signal — log reopen not yet supported");
_logger.LogInformation("Trapped SIGUSR1 signal — reopening log file");
ReOpenLogFile?.Invoke();
}));
_signalRegistrations.Add(PosixSignalRegistration.Create((PosixSignal)12, ctx =>
@@ -247,6 +284,14 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
_systemAccount = new Account("$SYS");
_accounts["$SYS"] = _systemAccount;
// Create system internal client and event system
var sysClientId = Interlocked.Increment(ref _nextClientId);
var sysClient = new InternalClient(sysClientId, ClientKind.System, _systemAccount);
_eventSystem = new InternalEventSystem(
_systemAccount, sysClient,
options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
_loggerFactory.CreateLogger<InternalEventSystem>());
// Generate Ed25519 server NKey identity
using var serverKeyPair = KeyPair.CreatePair(PrefixByte.Server);
ServerNKey = serverKeyPair.GetPublicKey();
@@ -265,6 +310,19 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (options.HasTls)
{
_sslOptions = TlsHelper.BuildServerAuthOptions(options);
// OCSP stapling: build a certificate context so the runtime can
// fetch and cache a fresh OCSP response and staple it during the
// TLS handshake. offline:false tells the runtime to contact the
// OCSP responder; if the responder is unreachable we fall back to
// no stapling rather than refusing all connections.
var certContext = TlsHelper.BuildCertificateContext(options, offline: false);
if (certContext != null)
{
_sslOptions.ServerCertificateContext = certContext;
_logger.LogInformation("OCSP stapling enabled (mode: {OcspMode})", options.OcspConfig!.Mode);
}
_serverInfo.TlsRequired = !options.AllowNonTls;
_serverInfo.TlsAvailable = options.AllowNonTls;
_serverInfo.TlsVerify = options.TlsVerify;
@@ -272,6 +330,49 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
if (options.TlsRateLimit > 0)
_tlsRateLimiter = new TlsRateLimiter(options.TlsRateLimit);
}
// Compile subject transforms
if (options.SubjectMappings is { Count: > 0 })
{
var transforms = new List<SubjectTransform>();
foreach (var (source, dest) in options.SubjectMappings)
{
var t = SubjectTransform.Create(source, dest);
if (t != null)
transforms.Add(t);
else
_logger.LogWarning("Invalid subject mapping: {Source} -> {Dest}", source, dest);
}
_subjectTransforms = transforms.ToArray();
if (_subjectTransforms.Length > 0)
_logger.LogInformation("Compiled {Count} subject transform(s)", _subjectTransforms.Length);
}
else
{
_subjectTransforms = [];
}
BuildCachedInfo();
// Store initial config digest for reload change detection
if (options.ConfigFile != null)
{
try
{
var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile);
_configDigest = digest;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not compute initial config digest for {ConfigFile}", options.ConfigFile);
}
}
}
private void BuildCachedInfo()
{
var infoJson = System.Text.Json.JsonSerializer.Serialize(_serverInfo);
_cachedInfoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
}
public async Task StartAsync(CancellationToken ct)
@@ -292,15 +393,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
var actualPort = ((IPEndPoint)_listener.LocalEndPoint!).Port;
_options.Port = actualPort;
_serverInfo.Port = actualPort;
BuildCachedInfo();
}
_listeningStarted.TrySetResult();
_eventSystem?.Start(this);
_eventSystem?.InitEventTracking(this);
_logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port);
// Warn about stub features
if (_options.ConfigFile != null)
_logger.LogWarning("Config file parsing not yet supported (file: {ConfigFile})", _options.ConfigFile);
if (_options.ProfPort > 0)
_logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);
@@ -479,6 +582,20 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
// Apply subject transforms
if (_subjectTransforms.Length > 0)
{
foreach (var transform in _subjectTransforms)
{
var mapped = transform.Apply(subject);
if (mapped != null)
{
subject = mapped;
break; // First matching transform wins
}
}
}
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
var result = subList.Match(subject);
var delivered = false;
@@ -515,6 +632,27 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
// Check for service imports that match this subject.
// When a client in the importer account publishes to a subject
// that matches a service import "From" pattern, we forward the
// message to the destination (exporter) account's subscribers
// using the mapped "To" subject.
if (sender.Account != null)
{
foreach (var kvp in sender.Account.Imports.Services)
{
foreach (var si in kvp.Value)
{
if (si.Invalid) continue;
if (SubjectMatch.MatchLiteral(subject, si.From))
{
ProcessServiceImport(si, subject, replyTo, headers, payload);
delivered = true;
}
}
}
}
// No-responders: if nobody received the message and the publisher
// opted in, send back a 503 status HMSG on the reply subject.
if (!delivered && replyTo != null && sender.ClientOpts?.NoResponders == true)
@@ -523,7 +661,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
private static void DeliverMessage(Subscription sub, string subject, string? replyTo,
private void DeliverMessage(Subscription sub, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
var client = sub.Client;
@@ -532,9 +670,173 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
// Check auto-unsub
var count = Interlocked.Increment(ref sub.MessageCount);
if (sub.MaxMessages > 0 && count > sub.MaxMessages)
{
// Clean up exhausted subscription from trie and client tracking
var subList = client.Account?.SubList ?? _globalAccount.SubList;
subList.Remove(sub);
client.RemoveSubscription(sub.Sid);
return;
}
// Deny-list delivery filter
if (client.Permissions?.IsDeliveryAllowed(subject) == false)
return;
client.SendMessage(subject, sub.Sid, replyTo, headers, payload);
// Track reply subject for response permissions
if (replyTo != null && client.Permissions?.ResponseTracker != null)
{
if (client.Permissions.IsPublishAllowed(replyTo) == false)
client.Permissions.ResponseTracker.RegisterReply(replyTo);
}
}
/// <summary>
/// Processes a service import by transforming the subject from the importer's
/// subject space to the exporter's subject space, then delivering to matching
/// subscribers in the destination account.
/// Reference: Go server/accounts.go addServiceImport / processServiceImport.
/// </summary>
public void ProcessServiceImport(ServiceImport si, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
if (si.Invalid) return;
// Transform subject: map from importer subject space to exporter subject space
string targetSubject;
if (si.Transform != null)
{
var transformed = si.Transform.Apply(subject);
targetSubject = transformed ?? si.To;
}
else if (si.UsePub)
{
targetSubject = subject;
}
else
{
// Default: use the "To" subject from the import definition.
// For wildcard imports (e.g. "requests.>" -> "api.>"), we need
// to map the specific subject tokens from the source pattern to
// the destination pattern.
targetSubject = MapImportSubject(subject, si.From, si.To);
}
// Match against destination account's SubList
var destSubList = si.DestinationAccount.SubList;
var result = destSubList.Match(targetSubject);
// Deliver to plain subscribers in the destination account
foreach (var sub in result.PlainSubs)
{
if (sub.Client == null) continue;
DeliverMessage(sub, targetSubject, replyTo, headers, payload);
}
// Deliver to one member of each queue group
foreach (var queueGroup in result.QueueSubs)
{
if (queueGroup.Length == 0) continue;
var sub = queueGroup[0]; // Simple selection: first available
if (sub.Client != null)
DeliverMessage(sub, targetSubject, replyTo, headers, payload);
}
}
/// <summary>
/// Maps a published subject from the import "From" pattern to the "To" pattern.
/// For example, if From="requests.>" and To="api.>" and subject="requests.test",
/// this returns "api.test".
/// </summary>
private static string MapImportSubject(string subject, string fromPattern, string toPattern)
{
// If "To" doesn't contain wildcards, use it directly
if (SubjectMatch.IsLiteral(toPattern))
return toPattern;
// For wildcard patterns, replace matching wildcard segments.
// Split into tokens and map from source to destination.
var subTokens = subject.Split('.');
var fromTokens = fromPattern.Split('.');
var toTokens = toPattern.Split('.');
var result = new string[toTokens.Length];
int subIdx = 0;
// Build a mapping: for each wildcard position in "from",
// capture the corresponding subject token(s)
var wildcardValues = new List<string>();
string? fwcValue = null;
for (int i = 0; i < fromTokens.Length && subIdx < subTokens.Length; i++)
{
if (fromTokens[i] == "*")
{
wildcardValues.Add(subTokens[subIdx]);
subIdx++;
}
else if (fromTokens[i] == ">")
{
// Capture all remaining tokens
fwcValue = string.Join(".", subTokens[subIdx..]);
subIdx = subTokens.Length;
}
else
{
subIdx++; // Skip literal match
}
}
// Now build the output using the "to" pattern
int wcIdx = 0;
var sb = new StringBuilder();
for (int i = 0; i < toTokens.Length; i++)
{
if (i > 0) sb.Append('.');
if (toTokens[i] == "*")
{
sb.Append(wcIdx < wildcardValues.Count ? wildcardValues[wcIdx] : "*");
wcIdx++;
}
else if (toTokens[i] == ">")
{
sb.Append(fwcValue ?? ">");
}
else
{
sb.Append(toTokens[i]);
}
}
return sb.ToString();
}
/// <summary>
/// Wires service import subscriptions for an account. Creates marker
/// subscriptions in the account's SubList so that the import paths
/// are tracked. The actual forwarding happens in ProcessMessage when
/// it checks the account's Imports.Services.
/// Reference: Go server/accounts.go addServiceImportSub.
/// </summary>
public void WireServiceImports(Account account)
{
foreach (var kvp in account.Imports.Services)
{
foreach (var si in kvp.Value)
{
if (si.Invalid) continue;
// Create a marker subscription in the importer account.
// This subscription doesn't directly deliver messages;
// the ProcessMessage method checks service imports after
// the regular SubList match.
_logger.LogDebug(
"Wired service import for account {Account}: {From} -> {To} (dest: {DestAccount})",
account.Name, si.From, si.To, si.DestinationAccount.Name);
}
}
}
private static void SendNoResponders(NatsClient sender, string replyTo)
@@ -569,13 +871,236 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
public Account GetOrCreateAccount(string name)
{
return _accounts.GetOrAdd(name, n => new Account(n));
return _accounts.GetOrAdd(name, n =>
{
var acc = new Account(n);
if (_options.Accounts != null && _options.Accounts.TryGetValue(n, out var config))
{
acc.MaxConnections = config.MaxConnections;
acc.MaxSubscriptions = config.MaxSubscriptions;
acc.DefaultPermissions = config.DefaultPermissions;
}
return acc;
});
}
public void SendInternalMsg(string subject, string? reply, object? msg)
{
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
}
public void SendInternalAccountMsg(Account account, string subject, object? msg)
{
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Body = msg });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.VARZ requests.
/// Returns core server information including stats counters.
/// </summary>
public void HandleVarzRequest(string subject, string? reply)
{
if (reply == null) return;
var varz = new
{
server_id = _serverInfo.ServerId,
server_name = _serverInfo.ServerName,
version = NatsProtocol.Version,
host = _options.Host,
port = _options.Port,
max_payload = _options.MaxPayload,
connections = ClientCount,
total_connections = Interlocked.Read(ref _stats.TotalConnections),
in_msgs = Interlocked.Read(ref _stats.InMsgs),
out_msgs = Interlocked.Read(ref _stats.OutMsgs),
in_bytes = Interlocked.Read(ref _stats.InBytes),
out_bytes = Interlocked.Read(ref _stats.OutBytes),
};
SendInternalMsg(reply, null, varz);
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.HEALTHZ requests.
/// Returns a simple health status response.
/// </summary>
public void HandleHealthzRequest(string subject, string? reply)
{
if (reply == null) return;
SendInternalMsg(reply, null, new { status = "ok" });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.SUBSZ requests.
/// Returns the current subscription count.
/// </summary>
public void HandleSubszRequest(string subject, string? reply)
{
if (reply == null) return;
SendInternalMsg(reply, null, new { num_subscriptions = SubList.Count });
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.STATSZ requests.
/// Publishes current server statistics through the event system.
/// </summary>
public void HandleStatszRequest(string subject, string? reply)
{
if (reply == null) return;
var process = System.Diagnostics.Process.GetCurrentProcess();
var statsMsg = new Events.ServerStatsMsg
{
Server = BuildEventServerInfo(),
Stats = new Events.ServerStatsData
{
Start = StartTime,
Mem = process.WorkingSet64,
Cores = Environment.ProcessorCount,
Connections = ClientCount,
TotalConnections = Interlocked.Read(ref _stats.TotalConnections),
Subscriptions = SubList.Count,
InMsgs = Interlocked.Read(ref _stats.InMsgs),
OutMsgs = Interlocked.Read(ref _stats.OutMsgs),
InBytes = Interlocked.Read(ref _stats.InBytes),
OutBytes = Interlocked.Read(ref _stats.OutBytes),
SlowConsumers = Interlocked.Read(ref _stats.SlowConsumers),
},
};
SendInternalMsg(reply, null, statsMsg);
}
/// <summary>
/// Handles $SYS.REQ.SERVER.{id}.IDZ requests.
/// Returns basic server identity information.
/// </summary>
public void HandleIdzRequest(string subject, string? reply)
{
if (reply == null) return;
var idz = new
{
server_id = _serverInfo.ServerId,
server_name = _serverInfo.ServerName,
version = NatsProtocol.Version,
host = _options.Host,
port = _options.Port,
};
SendInternalMsg(reply, null, idz);
}
/// <summary>
/// Builds an EventServerInfo block for embedding in system event messages.
/// Maps to Go's serverInfo() helper used in events.go advisory publishing.
/// </summary>
public EventServerInfo BuildEventServerInfo()
{
var seq = _eventSystem?.NextSequence() ?? 0;
return new EventServerInfo
{
Name = _serverInfo.ServerName,
Host = _options.Host,
Id = _serverInfo.ServerId,
Version = NatsProtocol.Version,
Seq = seq,
};
}
private static EventClientInfo BuildEventClientInfo(NatsClient client)
{
return new EventClientInfo
{
Id = client.Id,
Host = client.RemoteIp,
Account = client.Account?.Name,
Name = client.ClientOpts?.Name,
Lang = client.ClientOpts?.Lang,
Version = client.ClientOpts?.Version,
Start = client.StartTime,
};
}
/// <summary>
/// Publishes a $SYS.ACCOUNT.{account}.CONNECT advisory when a client
/// completes authentication. Maps to Go's sendConnectEvent in events.go.
/// </summary>
public void PublishConnectEvent(NatsClient client)
{
if (_eventSystem == null) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.ConnectEvent, accountName);
var evt = new ConnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
};
SendInternalMsg(subject, null, evt);
}
/// <summary>
/// Publishes a $SYS.ACCOUNT.{account}.DISCONNECT advisory when a client
/// disconnects. Maps to Go's sendDisconnectEvent in events.go.
/// </summary>
public void PublishDisconnectEvent(NatsClient client)
{
if (_eventSystem == null) return;
var accountName = client.Account?.Name ?? Account.GlobalAccountName;
var subject = string.Format(EventSubjects.DisconnectEvent, accountName);
var evt = new DisconnectEventMsg
{
Id = Guid.NewGuid().ToString("N"),
Time = DateTime.UtcNow,
Server = BuildEventServerInfo(),
Client = BuildEventClientInfo(client),
Sent = new DataStats
{
Msgs = Interlocked.Read(ref client.OutMsgs),
Bytes = Interlocked.Read(ref client.OutBytes),
},
Received = new DataStats
{
Msgs = Interlocked.Read(ref client.InMsgs),
Bytes = Interlocked.Read(ref client.InBytes),
},
Reason = client.CloseReason.ToReasonString(),
};
SendInternalMsg(subject, null, evt);
}
public void RemoveClient(NatsClient client)
{
// Publish disconnect advisory before removing client state
if (client.ConnectReceived)
PublishDisconnectEvent(client);
_clients.TryRemove(client.Id, out _);
_logger.LogDebug("Removed client {ClientId}", client.Id);
// Snapshot for closed-connections tracking
_closedClients.Enqueue(new ClosedClient
{
Cid = client.Id,
Ip = client.RemoteIp ?? "",
Port = client.RemotePort,
Start = client.StartTime,
Stop = DateTime.UtcNow,
Reason = client.CloseReason.ToReasonString(),
Name = client.ClientOpts?.Name ?? "",
Lang = client.ClientOpts?.Lang ?? "",
Version = client.ClientOpts?.Version ?? "",
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,
Rtt = client.Rtt,
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
});
// Cap closed clients list
while (_closedClients.Count > _options.MaxClosedClients)
_closedClients.TryDequeue(out _);
var subList = client.Account?.SubList ?? _globalAccount.SubList;
client.RemoveAllSubscriptions(subList);
client.Account?.RemoveClient(client.Id);
@@ -643,6 +1168,155 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
}
}
/// <summary>
/// Stores the CLI snapshot and flags so that command-line overrides
/// always take precedence during config reload.
/// </summary>
public void SetCliSnapshot(NatsOptions cliSnapshot, HashSet<string> cliFlags)
{
_cliSnapshot = cliSnapshot;
_cliFlags = cliFlags;
}
/// <summary>
/// Reloads the configuration file, diffs against current options, validates
/// the changes, and applies reloadable settings. CLI overrides are preserved.
/// </summary>
public void ReloadConfig()
{
if (_options.ConfigFile == null)
{
_logger.LogWarning("No config file specified, cannot reload");
return;
}
try
{
var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(_options.ConfigFile);
if (digest == _configDigest)
{
_logger.LogInformation("Config file unchanged, no reload needed");
return;
}
var newOpts = new NatsOptions { ConfigFile = _options.ConfigFile };
ConfigProcessor.ApplyConfig(newConfig, newOpts);
// CLI flags override config
if (_cliSnapshot != null)
ConfigReloader.MergeCliOverrides(newOpts, _cliSnapshot, _cliFlags);
var changes = ConfigReloader.Diff(_options, newOpts);
var errors = ConfigReloader.Validate(changes);
if (errors.Count > 0)
{
foreach (var err in errors)
_logger.LogError("Config reload error: {Error}", err);
return;
}
// Apply changes to running options
ApplyConfigChanges(changes, newOpts);
_configDigest = digest;
_logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to reload config file: {ConfigFile}", _options.ConfigFile);
}
}
private void ApplyConfigChanges(List<IConfigChange> changes, NatsOptions newOpts)
{
bool hasLoggingChanges = false;
bool hasAuthChanges = false;
foreach (var change in changes)
{
if (change.IsLoggingChange) hasLoggingChanges = true;
if (change.IsAuthChange) hasAuthChanges = true;
}
// Copy reloadable values from newOpts to _options
CopyReloadableOptions(newOpts);
// Trigger side effects
if (hasLoggingChanges)
{
ReOpenLogFile?.Invoke();
_logger.LogInformation("Logging configuration reloaded");
}
if (hasAuthChanges)
{
// Rebuild auth service with new options
_authService = AuthService.Build(_options);
_logger.LogInformation("Authorization configuration reloaded");
}
}
private void CopyReloadableOptions(NatsOptions newOpts)
{
// Logging
_options.Debug = newOpts.Debug;
_options.Trace = newOpts.Trace;
_options.TraceVerbose = newOpts.TraceVerbose;
_options.Logtime = newOpts.Logtime;
_options.LogtimeUTC = newOpts.LogtimeUTC;
_options.LogFile = newOpts.LogFile;
_options.LogSizeLimit = newOpts.LogSizeLimit;
_options.LogMaxFiles = newOpts.LogMaxFiles;
_options.Syslog = newOpts.Syslog;
_options.RemoteSyslog = newOpts.RemoteSyslog;
// Auth
_options.Username = newOpts.Username;
_options.Password = newOpts.Password;
_options.Authorization = newOpts.Authorization;
_options.Users = newOpts.Users;
_options.NKeys = newOpts.NKeys;
_options.NoAuthUser = newOpts.NoAuthUser;
_options.AuthTimeout = newOpts.AuthTimeout;
// Limits
_options.MaxConnections = newOpts.MaxConnections;
_options.MaxPayload = newOpts.MaxPayload;
_options.MaxPending = newOpts.MaxPending;
_options.WriteDeadline = newOpts.WriteDeadline;
_options.PingInterval = newOpts.PingInterval;
_options.MaxPingsOut = newOpts.MaxPingsOut;
_options.MaxControlLine = newOpts.MaxControlLine;
_options.MaxSubs = newOpts.MaxSubs;
_options.MaxSubTokens = newOpts.MaxSubTokens;
_options.MaxTracedMsgLen = newOpts.MaxTracedMsgLen;
_options.MaxClosedClients = newOpts.MaxClosedClients;
// TLS
_options.TlsCert = newOpts.TlsCert;
_options.TlsKey = newOpts.TlsKey;
_options.TlsCaCert = newOpts.TlsCaCert;
_options.TlsVerify = newOpts.TlsVerify;
_options.TlsMap = newOpts.TlsMap;
_options.TlsTimeout = newOpts.TlsTimeout;
_options.TlsHandshakeFirst = newOpts.TlsHandshakeFirst;
_options.TlsHandshakeFirstFallback = newOpts.TlsHandshakeFirstFallback;
_options.AllowNonTls = newOpts.AllowNonTls;
_options.TlsRateLimit = newOpts.TlsRateLimit;
_options.TlsPinnedCerts = newOpts.TlsPinnedCerts;
// Misc
_options.Tags = newOpts.Tags;
_options.LameDuckDuration = newOpts.LameDuckDuration;
_options.LameDuckGracePeriod = newOpts.LameDuckGracePeriod;
_options.ClientAdvertise = newOpts.ClientAdvertise;
_options.DisableSublistCache = newOpts.DisableSublistCache;
_options.ConnectErrorReports = newOpts.ConnectErrorReports;
_options.ReconnectErrorReports = newOpts.ReconnectErrorReports;
_options.NoHeaderSupport = newOpts.NoHeaderSupport;
_options.NoSystemAccount = newOpts.NoSystemAccount;
_options.SystemAccount = newOpts.SystemAccount;
}
public void Dispose()
{
if (!IsShuttingDown)

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ public static class NatsProtocol
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
@@ -133,4 +134,7 @@ public sealed class ClientOptions
[JsonPropertyName("sig")]
public string? Sig { get; set; }
[JsonPropertyName("jwt")]
public string? JWT { get; set; }
}

View File

@@ -16,5 +16,9 @@ public sealed class ServerStats
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();
}

View File

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

View 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; }
}

View File

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

View 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)
{
}
}
}

View File

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

View File

@@ -0,0 +1,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; } = [];
}

View File

@@ -33,6 +33,10 @@ public static class TlsHelper
if (opts.TlsVerify && opts.TlsCaCert != null)
{
var revocationMode = opts.OcspPeerVerify
? X509RevocationMode.Online
: X509RevocationMode.NoCheck;
var caCerts = LoadCaCertificates(opts.TlsCaCert);
authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
{
@@ -41,7 +45,19 @@ public static class TlsHelper
chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
foreach (var ca in caCerts)
chain2.ChainPolicy.CustomTrustStore.Add(ca);
chain2.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
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);
};
@@ -50,6 +66,25 @@ public static class TlsHelper
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();

View 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);
}
}

View File

@@ -0,0 +1,48 @@
using NATS.Server.Auth;
namespace NATS.Server.Tests;
public class AccountStatsTests
{
[Fact]
public void Account_tracks_inbound_stats()
{
var account = new Account("test");
account.IncrementInbound(1, 100);
account.IncrementInbound(1, 200);
account.InMsgs.ShouldBe(2);
account.InBytes.ShouldBe(300);
}
[Fact]
public void Account_tracks_outbound_stats()
{
var account = new Account("test");
account.IncrementOutbound(1, 50);
account.IncrementOutbound(1, 75);
account.OutMsgs.ShouldBe(2);
account.OutBytes.ShouldBe(125);
}
[Fact]
public void Account_stats_start_at_zero()
{
var account = new Account("test");
account.InMsgs.ShouldBe(0);
account.OutMsgs.ShouldBe(0);
account.InBytes.ShouldBe(0);
account.OutBytes.ShouldBe(0);
}
[Fact]
public void Account_stats_are_independent()
{
var account = new Account("test");
account.IncrementInbound(5, 500);
account.IncrementOutbound(3, 300);
account.InMsgs.ShouldBe(5);
account.OutMsgs.ShouldBe(3);
account.InBytes.ShouldBe(500);
account.OutBytes.ShouldBe(300);
}
}

View File

@@ -32,4 +32,40 @@ public class AccountTests
{
Account.GlobalAccountName.ShouldBe("$G");
}
[Fact]
public void Account_enforces_max_connections()
{
var acc = new Account("test") { MaxConnections = 2 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeTrue();
acc.AddClient(3).ShouldBeFalse(); // exceeds limit
acc.ClientCount.ShouldBe(2);
}
[Fact]
public void Account_unlimited_connections_when_zero()
{
var acc = new Account("test") { MaxConnections = 0 };
acc.AddClient(1).ShouldBeTrue();
acc.AddClient(2).ShouldBeTrue();
}
[Fact]
public void Account_enforces_max_subscriptions()
{
var acc = new Account("test") { MaxSubscriptions = 2 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeTrue();
acc.IncrementSubscriptions().ShouldBeFalse();
}
[Fact]
public void Account_decrement_subscriptions()
{
var acc = new Account("test") { MaxSubscriptions = 1 };
acc.IncrementSubscriptions().ShouldBeTrue();
acc.DecrementSubscriptions();
acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed
}
}

View File

@@ -5,10 +5,10 @@ public class ClientClosedReasonTests
[Fact]
public void All_expected_close_reasons_exist()
{
// Verify all 17 enum values exist and are distinct (None + 16 named reasons)
// Verify all 18 enum values exist and are distinct (None + 17 named reasons)
var values = Enum.GetValues<ClientClosedReason>();
values.Length.ShouldBe(17);
values.Distinct().Count().ShouldBe(17);
values.Length.ShouldBe(18);
values.Distinct().Count().ShouldBe(18);
}
[Theory]

View File

@@ -0,0 +1,15 @@
namespace NATS.Server.Tests;
public class ClientTraceModeTests
{
[Fact]
public void TraceMode_flag_can_be_set_and_cleared()
{
var holder = new ClientFlagHolder();
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
holder.SetFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeTrue();
holder.ClearFlag(ClientFlags.TraceMode);
holder.HasFlag(ClientFlags.TraceMode).ShouldBeFalse();
}
}

View File

@@ -0,0 +1,76 @@
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class ConfigIntegrationTests
{
[Fact]
public void Server_WithConfigFile_LoadsOptionsFromFile()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var confPath = Path.Combine(dir, "test.conf");
File.WriteAllText(confPath, "port: 14222\nmax_payload: 2mb\ndebug: true");
var opts = ConfigProcessor.ProcessConfigFile(confPath);
opts.Port.ShouldBe(14222);
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
opts.Debug.ShouldBeTrue();
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Server_CliOverridesConfig()
{
var dir = Path.Combine(Path.GetTempPath(), $"nats_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
try
{
var confPath = Path.Combine(dir, "test.conf");
File.WriteAllText(confPath, "port: 14222\ndebug: true");
var opts = ConfigProcessor.ProcessConfigFile(confPath);
opts.Port.ShouldBe(14222);
// Simulate CLI override: user passed -p 5222 on command line
var cliSnapshot = new NatsOptions { Port = 5222 };
var cliFlags = new HashSet<string> { "Port" };
ConfigReloader.MergeCliOverrides(opts, cliSnapshot, cliFlags);
opts.Port.ShouldBe(5222);
opts.Debug.ShouldBeTrue(); // Config file value preserved
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Reload_ChangingPort_ReturnsError()
{
var oldOpts = new NatsOptions { Port = 4222 };
var newOpts = new NatsOptions { Port = 5222 };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var errors = ConfigReloader.Validate(changes);
errors.Count.ShouldBeGreaterThan(0);
errors[0].ShouldContain("Port");
}
[Fact]
public void Reload_ChangingDebug_IsValid()
{
var oldOpts = new NatsOptions { Debug = false };
var newOpts = new NatsOptions { Debug = true };
var changes = ConfigReloader.Diff(oldOpts, newOpts);
var errors = ConfigReloader.Validate(changes);
errors.ShouldBeEmpty();
changes.ShouldContain(c => c.IsLoggingChange);
}
}

View File

@@ -0,0 +1,504 @@
using NATS.Server;
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class ConfigProcessorTests
{
private static string TestDataPath(string fileName) =>
Path.Combine(AppContext.BaseDirectory, "TestData", fileName);
// ─── Basic config ──────────────────────────────────────────────
[Fact]
public void BasicConf_Port()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Port.ShouldBe(4222);
}
[Fact]
public void BasicConf_Host()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Host.ShouldBe("0.0.0.0");
}
[Fact]
public void BasicConf_ServerName()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.ServerName.ShouldBe("test-server");
}
[Fact]
public void BasicConf_MaxPayload()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxPayload.ShouldBe(2 * 1024 * 1024);
}
[Fact]
public void BasicConf_MaxConnections()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxConnections.ShouldBe(1000);
}
[Fact]
public void BasicConf_Debug()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Debug.ShouldBeTrue();
}
[Fact]
public void BasicConf_Trace()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Trace.ShouldBeFalse();
}
[Fact]
public void BasicConf_PingInterval()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(30));
}
[Fact]
public void BasicConf_MaxPingsOut()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxPingsOut.ShouldBe(3);
}
[Fact]
public void BasicConf_WriteDeadline()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.WriteDeadline.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void BasicConf_MaxSubs()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxSubs.ShouldBe(100);
}
[Fact]
public void BasicConf_MaxSubTokens()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxSubTokens.ShouldBe(16);
}
[Fact]
public void BasicConf_MaxControlLine()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxControlLine.ShouldBe(2048);
}
[Fact]
public void BasicConf_MaxPending()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MaxPending.ShouldBe(32L * 1024 * 1024);
}
[Fact]
public void BasicConf_LameDuckDuration()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.LameDuckDuration.ShouldBe(TimeSpan.FromSeconds(60));
}
[Fact]
public void BasicConf_LameDuckGracePeriod()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void BasicConf_MonitorPort()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.MonitorPort.ShouldBe(8222);
}
[Fact]
public void BasicConf_Logtime()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("basic.conf"));
opts.Logtime.ShouldBeTrue();
opts.LogtimeUTC.ShouldBeFalse();
}
// ─── Auth config ───────────────────────────────────────────────
[Fact]
public void AuthConf_SimpleUser()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.Username.ShouldBe("admin");
opts.Password.ShouldBe("s3cret");
}
[Fact]
public void AuthConf_AuthTimeout()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void AuthConf_NoAuthUser()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.NoAuthUser.ShouldBe("guest");
}
[Fact]
public void AuthConf_UsersArray()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
opts.Users.ShouldNotBeNull();
opts.Users.Count.ShouldBe(2);
}
[Fact]
public void AuthConf_AliceUser()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
var alice = opts.Users!.First(u => u.Username == "alice");
alice.Password.ShouldBe("pw1");
alice.Permissions.ShouldNotBeNull();
alice.Permissions!.Publish.ShouldNotBeNull();
alice.Permissions.Publish!.Allow.ShouldNotBeNull();
alice.Permissions.Publish.Allow!.ShouldContain("foo.>");
alice.Permissions.Subscribe.ShouldNotBeNull();
alice.Permissions.Subscribe!.Allow.ShouldNotBeNull();
alice.Permissions.Subscribe.Allow!.ShouldContain(">");
}
[Fact]
public void AuthConf_BobUser()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("auth.conf"));
var bob = opts.Users!.First(u => u.Username == "bob");
bob.Password.ShouldBe("pw2");
bob.Permissions.ShouldBeNull();
}
// ─── TLS config ────────────────────────────────────────────────
[Fact]
public void TlsConf_CertFiles()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsCert.ShouldBe("/path/to/cert.pem");
opts.TlsKey.ShouldBe("/path/to/key.pem");
opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
}
[Fact]
public void TlsConf_Verify()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsVerify.ShouldBeTrue();
opts.TlsMap.ShouldBeTrue();
}
[Fact]
public void TlsConf_Timeout()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(3));
}
[Fact]
public void TlsConf_RateLimit()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsRateLimit.ShouldBe(100);
}
[Fact]
public void TlsConf_PinnedCerts()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsPinnedCerts.ShouldNotBeNull();
opts.TlsPinnedCerts!.Count.ShouldBe(1);
opts.TlsPinnedCerts.ShouldContain("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789");
}
[Fact]
public void TlsConf_HandshakeFirst()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.TlsHandshakeFirst.ShouldBeTrue();
}
[Fact]
public void TlsConf_AllowNonTls()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.AllowNonTls.ShouldBeFalse();
}
// ─── Full config ───────────────────────────────────────────────
[Fact]
public void FullConf_CoreOptions()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.Port.ShouldBe(4222);
opts.Host.ShouldBe("0.0.0.0");
opts.ServerName.ShouldBe("full-test");
opts.ClientAdvertise.ShouldBe("nats://public.example.com:4222");
}
[Fact]
public void FullConf_Limits()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.MaxPayload.ShouldBe(1024 * 1024);
opts.MaxControlLine.ShouldBe(4096);
opts.MaxConnections.ShouldBe(65536);
opts.MaxPending.ShouldBe(64L * 1024 * 1024);
opts.MaxSubs.ShouldBe(0);
opts.MaxSubTokens.ShouldBe(0);
opts.MaxTracedMsgLen.ShouldBe(1024);
opts.DisableSublistCache.ShouldBeFalse();
opts.MaxClosedClients.ShouldBe(5000);
}
[Fact]
public void FullConf_Logging()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.Debug.ShouldBeFalse();
opts.Trace.ShouldBeFalse();
opts.TraceVerbose.ShouldBeFalse();
opts.Logtime.ShouldBeTrue();
opts.LogtimeUTC.ShouldBeFalse();
opts.LogFile.ShouldBe("/var/log/nats.log");
opts.LogSizeLimit.ShouldBe(100L * 1024 * 1024);
opts.LogMaxFiles.ShouldBe(5);
}
[Fact]
public void FullConf_Monitoring()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.MonitorPort.ShouldBe(8222);
opts.MonitorBasePath.ShouldBe("/nats");
}
[Fact]
public void FullConf_Files()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.PidFile.ShouldBe("/var/run/nats.pid");
opts.PortsFileDir.ShouldBe("/var/run");
}
[Fact]
public void FullConf_Lifecycle()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.LameDuckDuration.ShouldBe(TimeSpan.FromMinutes(2));
opts.LameDuckGracePeriod.ShouldBe(TimeSpan.FromSeconds(10));
}
[Fact]
public void FullConf_Tags()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.Tags.ShouldNotBeNull();
opts.Tags!["region"].ShouldBe("us-east");
opts.Tags["env"].ShouldBe("production");
}
[Fact]
public void FullConf_Auth()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.Username.ShouldBe("admin");
opts.Password.ShouldBe("secret");
opts.AuthTimeout.ShouldBe(TimeSpan.FromSeconds(2));
}
[Fact]
public void FullConf_Tls()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("full.conf"));
opts.TlsCert.ShouldBe("/path/to/cert.pem");
opts.TlsKey.ShouldBe("/path/to/key.pem");
opts.TlsCaCert.ShouldBe("/path/to/ca.pem");
opts.TlsVerify.ShouldBeTrue();
opts.TlsTimeout.ShouldBe(TimeSpan.FromSeconds(2));
opts.TlsHandshakeFirst.ShouldBeTrue();
}
// ─── Listen combined format ────────────────────────────────────
[Fact]
public void ListenCombined_HostAndPort()
{
var opts = ConfigProcessor.ProcessConfig("listen: \"10.0.0.1:5222\"");
opts.Host.ShouldBe("10.0.0.1");
opts.Port.ShouldBe(5222);
}
[Fact]
public void ListenCombined_PortOnly()
{
var opts = ConfigProcessor.ProcessConfig("listen: \":5222\"");
opts.Port.ShouldBe(5222);
}
[Fact]
public void ListenCombined_BarePort()
{
var opts = ConfigProcessor.ProcessConfig("listen: 5222");
opts.Port.ShouldBe(5222);
}
// ─── HTTP combined format ──────────────────────────────────────
[Fact]
public void HttpCombined_HostAndPort()
{
var opts = ConfigProcessor.ProcessConfig("http: \"10.0.0.1:8333\"");
opts.MonitorHost.ShouldBe("10.0.0.1");
opts.MonitorPort.ShouldBe(8333);
}
[Fact]
public void HttpsCombined_HostAndPort()
{
var opts = ConfigProcessor.ProcessConfig("https: \"10.0.0.1:8444\"");
opts.MonitorHost.ShouldBe("10.0.0.1");
opts.MonitorHttpsPort.ShouldBe(8444);
}
// ─── Duration as number ────────────────────────────────────────
[Fact]
public void DurationAsNumber_TreatedAsSeconds()
{
var opts = ConfigProcessor.ProcessConfig("ping_interval: 60");
opts.PingInterval.ShouldBe(TimeSpan.FromSeconds(60));
}
[Fact]
public void DurationAsString_Milliseconds()
{
var opts = ConfigProcessor.ProcessConfig("write_deadline: \"500ms\"");
opts.WriteDeadline.ShouldBe(TimeSpan.FromMilliseconds(500));
}
[Fact]
public void DurationAsString_Hours()
{
var opts = ConfigProcessor.ProcessConfig("ping_interval: \"1h\"");
opts.PingInterval.ShouldBe(TimeSpan.FromHours(1));
}
// ─── Unknown keys ──────────────────────────────────────────────
[Fact]
public void UnknownKeys_SilentlyIgnored()
{
var opts = ConfigProcessor.ProcessConfig("""
port: 4222
cluster { name: "my-cluster" }
jetstream { store_dir: "/tmp/js" }
unknown_key: "whatever"
""");
opts.Port.ShouldBe(4222);
}
// ─── Server name validation ────────────────────────────────────
[Fact]
public void ServerNameWithSpaces_ReportsError()
{
var ex = Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("server_name: \"my server\""));
ex.Errors.ShouldContain(e => e.Contains("server_name cannot contain spaces"));
}
// ─── Max sub tokens validation ─────────────────────────────────
[Fact]
public void MaxSubTokens_ExceedsLimit_ReportsError()
{
var ex = Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("max_sub_tokens: 300"));
ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens cannot exceed 256"));
}
// ─── ProcessConfig from string ─────────────────────────────────
[Fact]
public void ProcessConfig_FromString()
{
var opts = ConfigProcessor.ProcessConfig("""
port: 9222
host: "127.0.0.1"
debug: true
""");
opts.Port.ShouldBe(9222);
opts.Host.ShouldBe("127.0.0.1");
opts.Debug.ShouldBeTrue();
}
// ─── TraceVerbose sets Trace ────────────────────────────────────
[Fact]
public void TraceVerbose_AlsoSetsTrace()
{
var opts = ConfigProcessor.ProcessConfig("trace_verbose: true");
opts.TraceVerbose.ShouldBeTrue();
opts.Trace.ShouldBeTrue();
}
// ─── Error collection (not fail-fast) ──────────────────────────
[Fact]
public void MultipleErrors_AllCollected()
{
var ex = Should.Throw<ConfigProcessorException>(() =>
ConfigProcessor.ProcessConfig("""
server_name: "bad name"
max_sub_tokens: 999
"""));
ex.Errors.Count.ShouldBe(2);
ex.Errors.ShouldContain(e => e.Contains("server_name"));
ex.Errors.ShouldContain(e => e.Contains("max_sub_tokens"));
}
// ─── ConfigFile path tracking ──────────────────────────────────
[Fact]
public void ProcessConfigFile_SetsConfigFilePath()
{
var path = TestDataPath("basic.conf");
var opts = ConfigProcessor.ProcessConfigFile(path);
opts.ConfigFile.ShouldBe(path);
}
// ─── HasTls derived property ───────────────────────────────────
[Fact]
public void HasTls_TrueWhenCertAndKeySet()
{
var opts = ConfigProcessor.ProcessConfigFile(TestDataPath("tls.conf"));
opts.HasTls.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,89 @@
using NATS.Server;
using NATS.Server.Auth;
using NATS.Server.Configuration;
namespace NATS.Server.Tests;
public class ConfigReloadTests
{
[Fact]
public void Diff_NoChanges_ReturnsEmpty()
{
var old = new NatsOptions { Port = 4222, Debug = true };
var @new = new NatsOptions { Port = 4222, Debug = true };
var changes = ConfigReloader.Diff(old, @new);
changes.ShouldBeEmpty();
}
[Fact]
public void Diff_ReloadableChange_ReturnsChange()
{
var old = new NatsOptions { Debug = false };
var @new = new NatsOptions { Debug = true };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(1);
changes[0].Name.ShouldBe("Debug");
changes[0].IsLoggingChange.ShouldBeTrue();
}
[Fact]
public void Diff_NonReloadableChange_ReturnsNonReloadableChange()
{
var old = new NatsOptions { Port = 4222 };
var @new = new NatsOptions { Port = 5222 };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(1);
changes[0].IsNonReloadable.ShouldBeTrue();
}
[Fact]
public void Diff_MultipleChanges_ReturnsAll()
{
var old = new NatsOptions { Debug = false, MaxPayload = 1024 };
var @new = new NatsOptions { Debug = true, MaxPayload = 2048 };
var changes = ConfigReloader.Diff(old, @new);
changes.Count.ShouldBe(2);
}
[Fact]
public void Diff_AuthChange_MarkedCorrectly()
{
var old = new NatsOptions { Username = "alice" };
var @new = new NatsOptions { Username = "bob" };
var changes = ConfigReloader.Diff(old, @new);
changes[0].IsAuthChange.ShouldBeTrue();
}
[Fact]
public void Diff_TlsChange_MarkedCorrectly()
{
var old = new NatsOptions { TlsCert = "/old/cert.pem" };
var @new = new NatsOptions { TlsCert = "/new/cert.pem" };
var changes = ConfigReloader.Diff(old, @new);
changes[0].IsTlsChange.ShouldBeTrue();
}
[Fact]
public void Validate_NonReloadableChanges_ReturnsErrors()
{
var changes = new List<IConfigChange>
{
new ConfigChange("Port", isNonReloadable: true),
};
var errors = ConfigReloader.Validate(changes);
errors.Count.ShouldBe(1);
errors[0].ShouldContain("Port");
}
[Fact]
public void MergeWithCli_CliOverridesConfig()
{
var fromConfig = new NatsOptions { Port = 5222, Debug = true };
var cliFlags = new HashSet<string> { "Port" };
var cliValues = new NatsOptions { Port = 4222 };
ConfigReloader.MergeCliOverrides(fromConfig, cliValues, cliFlags);
fromConfig.Port.ShouldBe(4222); // CLI wins
fromConfig.Debug.ShouldBeTrue(); // config value kept (not in CLI)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,591 @@
using System.Text;
using NATS.NKeys;
using NATS.Server.Auth;
using NATS.Server.Auth.Jwt;
using NATS.Server.Protocol;
namespace NATS.Server.Tests;
public class JwtAuthenticatorTests
{
private static string Base64UrlEncode(string input) =>
Base64UrlEncode(Encoding.UTF8.GetBytes(input));
private static string Base64UrlEncode(byte[] input) =>
Convert.ToBase64String(input).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
{
var header = Base64UrlEncode("""{"typ":"JWT","alg":"ed25519-nkey"}""");
var payload = Base64UrlEncode(payloadJson);
var signingInput = Encoding.UTF8.GetBytes($"{header}.{payload}");
var sig = new byte[64];
signingKey.Sign(signingInput, sig);
return $"{header}.{payload}.{Base64UrlEncode(sig)}";
}
private static string SignNonce(KeyPair kp, byte[] nonce)
{
var sig = new byte[64];
kp.Sign(nonce, sig);
return Convert.ToBase64String(sig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
[Fact]
public async Task Valid_bearer_jwt_returns_auth_result()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "test-nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public async Task Valid_jwt_with_nonce_signature_returns_auth_result()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var nonce = "test-nonce-data"u8.ToArray();
var sig = SignNonce(userKp, nonce);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt, Nkey = userPub, Sig = sig },
Nonce = nonce,
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public void No_jwt_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions(),
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Non_jwt_string_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorKp.GetPublicKey()], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = "not-a-jwt" },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Expired_jwt_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// Expired in 2020
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1500000000,
"exp":1600000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Revoked_user_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with revocation for user
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"revocations":{
"{{userPub}}":1700000001
}
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued at 1700000000 (before revocation time 1700000001)
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Untrusted_operator_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
// Use a different trusted key that doesn't match the operator
var otherOperator = KeyPair.CreatePair(PrefixByte.Operator).GetPublicKey();
var auth = new JwtAuthenticator([otherOperator], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public void Unknown_account_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
// Don't store the account JWT in the resolver
var resolver = new MemAccountResolver();
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Non_bearer_without_sig_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// Non-bearer user JWT
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt }, // No Sig provided
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
[Fact]
public async Task Jwt_with_permissions_returns_permissions()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{"type":"account","version":2}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}",
"pub":{"allow":["foo.>","bar.*"]}
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Permissions.ShouldNotBeNull();
result.Permissions.Publish.ShouldNotBeNull();
result.Permissions.Publish.Allow.ShouldNotBeNull();
result.Permissions.Publish.Allow.ShouldContain("foo.>");
result.Permissions.Publish.Allow.ShouldContain("bar.*");
}
[Fact]
public async Task Signing_key_based_user_jwt_succeeds()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var signingPub = signingKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with signing key
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"signing_keys":["{{signingPub}}"]
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued by the signing key
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{signingPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, signingKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
var result = auth.Authenticate(ctx);
result.ShouldNotBeNull();
result.Identity.ShouldBe(userPub);
result.AccountName.ShouldBe(accountPub);
}
[Fact]
public async Task Wildcard_revocation_returns_null()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var userKp = KeyPair.CreatePair(PrefixByte.User);
var operatorPub = operatorKp.GetPublicKey();
var accountPub = accountKp.GetPublicKey();
var userPub = userKp.GetPublicKey();
// Account JWT with wildcard revocation
var accountPayload = $$"""
{
"sub":"{{accountPub}}",
"iss":"{{operatorPub}}",
"iat":1700000000,
"nats":{
"type":"account","version":2,
"revocations":{
"*":1700000001
}
}
}
""";
var accountJwt = BuildSignedToken(accountPayload, operatorKp);
// User JWT issued at 1700000000 (before wildcard revocation)
var userPayload = $$"""
{
"sub":"{{userPub}}",
"iss":"{{accountPub}}",
"iat":1700000000,
"nats":{
"type":"user","version":2,
"bearer_token":true,
"issuer_account":"{{accountPub}}"
}
}
""";
var userJwt = BuildSignedToken(userPayload, accountKp);
var resolver = new MemAccountResolver();
await resolver.StoreAsync(accountPub, accountJwt);
var auth = new JwtAuthenticator([operatorPub], resolver);
var ctx = new ClientAuthContext
{
Opts = new ClientOptions { JWT = userJwt },
Nonce = "nonce"u8.ToArray(),
};
auth.Authenticate(ctx).ShouldBeNull();
}
}

View File

@@ -0,0 +1,932 @@
using System.Text;
using System.Text.Json;
using NATS.NKeys;
using NATS.Server.Auth.Jwt;
namespace NATS.Server.Tests;
public class JwtTests
{
/// <summary>
/// Helper: base64url-encode a string for constructing test JWTs.
/// </summary>
private static string Base64UrlEncode(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Helper: build a minimal unsigned JWT from header and payload JSON strings.
/// The signature part is a base64url-encoded 64-byte zero array (invalid but structurally correct).
/// </summary>
private static string BuildUnsignedToken(string headerJson, string payloadJson)
{
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var fakeSig = Convert.ToBase64String(new byte[64])
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{fakeSig}";
}
/// <summary>
/// Helper: build a real signed NATS JWT using an NKey keypair.
/// Signs header.payload with Ed25519.
/// </summary>
private static string BuildSignedToken(string payloadJson, KeyPair signingKey)
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var header = Base64UrlEncode(headerJson);
var payload = Base64UrlEncode(payloadJson);
var signingInput = $"{header}.{payload}";
var signingInputBytes = Encoding.UTF8.GetBytes(signingInput);
var sig = new byte[64];
signingKey.Sign(signingInputBytes, sig);
var sigB64 = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"{header}.{payload}.{sigB64}";
}
// =====================================================================
// IsJwt tests
// =====================================================================
[Fact]
public void IsJwt_returns_true_for_eyJ_prefix()
{
NatsJwt.IsJwt("eyJhbGciOiJlZDI1NTE5LW5rZXkiLCJ0eXAiOiJKV1QifQ.payload.sig").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_true_for_minimal_eyJ()
{
NatsJwt.IsJwt("eyJ").ShouldBeTrue();
}
[Fact]
public void IsJwt_returns_false_for_non_jwt()
{
NatsJwt.IsJwt("notajwt").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_empty_string()
{
NatsJwt.IsJwt("").ShouldBeFalse();
}
[Fact]
public void IsJwt_returns_false_for_null()
{
NatsJwt.IsJwt(null!).ShouldBeFalse();
}
// =====================================================================
// Decode tests
// =====================================================================
[Fact]
public void Decode_splits_header_payload_signature_correctly()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Header.ShouldNotBeNull();
result.Header.Type.ShouldBe("JWT");
result.Header.Algorithm.ShouldBe("ed25519-nkey");
}
[Fact]
public void Decode_returns_payload_json()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.PayloadJson.ShouldNotBeNullOrEmpty();
// The payload JSON should parse back to matching fields
using var doc = JsonDocument.Parse(result.PayloadJson);
doc.RootElement.GetProperty("sub").GetString().ShouldBe("UAXXX");
doc.RootElement.GetProperty("iss").GetString().ShouldBe("AAXXX");
}
[Fact]
public void Decode_preserves_signature_bytes()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
result.Signature.ShouldNotBeNull();
result.Signature.Length.ShouldBe(64);
}
[Fact]
public void Decode_preserves_signing_input()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """{"sub":"test"}""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var result = NatsJwt.Decode(token);
result.ShouldNotBeNull();
// SigningInput should be "header.payload" (the first two parts)
var parts = token.Split('.');
var expectedSigningInput = $"{parts[0]}.{parts[1]}";
result.SigningInput.ShouldBe(expectedSigningInput);
}
[Fact]
public void Decode_returns_null_for_invalid_token_missing_parts()
{
NatsJwt.Decode("onlyonepart").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_two_parts()
{
NatsJwt.Decode("part1.part2").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_empty_string()
{
NatsJwt.Decode("").ShouldBeNull();
}
[Fact]
public void Decode_returns_null_for_invalid_base64_in_header()
{
NatsJwt.Decode("!!!invalid.payload.sig").ShouldBeNull();
}
// =====================================================================
// Verify tests
// =====================================================================
[Fact]
public void Verify_returns_true_for_valid_signed_token()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""{"sub":"UAXXX","iss":"{{accountPublicKey}}","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
}
[Fact]
public void Verify_returns_false_for_wrong_key()
{
var signingKp = KeyPair.CreatePair(PrefixByte.Account);
var wrongKp = KeyPair.CreatePair(PrefixByte.Account);
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, signingKp);
NatsJwt.Verify(token, wrongKp.GetPublicKey()).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_tampered_payload()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = """{"sub":"UAXXX","iss":"AAXXX","iat":1700000000}""";
var token = BuildSignedToken(payloadJson, accountKp);
// Tamper with the payload
var parts = token.Split('.');
var tamperedPayload = Base64UrlEncode("""{"sub":"HACKED","iss":"AAXXX","iat":1700000000}""");
var tampered = $"{parts[0]}.{tamperedPayload}.{parts[2]}";
NatsJwt.Verify(tampered, accountPublicKey).ShouldBeFalse();
}
[Fact]
public void Verify_returns_false_for_invalid_token()
{
var kp = KeyPair.CreatePair(PrefixByte.Account);
NatsJwt.Verify("not.a.jwt", kp.GetPublicKey()).ShouldBeFalse();
}
// =====================================================================
// VerifyNonce tests
// =====================================================================
[Fact]
public void VerifyNonce_accepts_base64url_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as base64url
var sigB64Url = Convert.ToBase64String(sig)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
NatsJwt.VerifyNonce(nonce, sigB64Url, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_accepts_standard_base64_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce-data"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
// Encode as standard base64
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(nonce, sigB64, publicKey).ShouldBeTrue();
}
[Fact]
public void VerifyNonce_returns_false_for_wrong_nonce()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "original-nonce"u8.ToArray();
var wrongNonce = "different-nonce"u8.ToArray();
var sig = new byte[64];
kp.Sign(nonce, sig);
var sigB64 = Convert.ToBase64String(sig);
NatsJwt.VerifyNonce(wrongNonce, sigB64, publicKey).ShouldBeFalse();
}
[Fact]
public void VerifyNonce_returns_false_for_invalid_signature()
{
var kp = KeyPair.CreatePair(PrefixByte.User);
var publicKey = kp.GetPublicKey();
var nonce = "test-nonce"u8.ToArray();
NatsJwt.VerifyNonce(nonce, "invalid-sig!", publicKey).ShouldBeFalse();
}
// =====================================================================
// DecodeUserClaims tests
// =====================================================================
[Fact]
public void DecodeUserClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX_USER_KEY",
"iss":"AAXXX_ISSUER",
"iat":1700000000,
"name":"test-user",
"nats":{
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER_KEY");
claims.Issuer.ShouldBe("AAXXX_ISSUER");
claims.Name.ShouldBe("test-user");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeUserClaims_parses_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{"allow":["foo.>","bar.*"],"deny":["bar.secret"]},
"sub":{"allow":[">"],"deny":["_INBOX.private.>"]},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["foo.>", "bar.*"]);
claims.Nats.Pub.Deny.ShouldBe(["bar.secret"]);
claims.Nats.Sub.ShouldNotBeNull();
claims.Nats.Sub.Allow.ShouldBe([">"]);
claims.Nats.Sub.Deny.ShouldBe(["_INBOX.private.>"]);
}
[Fact]
public void DecodeUserClaims_parses_response_permission()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"resp":{"max":5,"ttl":3000000000},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Resp.ShouldNotBeNull();
claims.Nats.Resp.MaxMsgs.ShouldBe(5);
claims.Nats.Resp.TtlNanos.ShouldBe(3000000000L);
claims.Nats.Resp.Ttl.ShouldBe(TimeSpan.FromSeconds(3));
}
[Fact]
public void DecodeUserClaims_parses_bearer_token_flag()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ISSUER_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.BearerToken.ShouldBeTrue();
claims.Nats.IssuerAccount.ShouldBe("AAXXX_ISSUER_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_parses_tags_src_connection_types()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"tags":["web","mobile"],
"src":["192.168.1.0/24","10.0.0.0/8"],
"allowed_connection_types":["STANDARD","WEBSOCKET"],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Tags.ShouldBe(["web", "mobile"]);
claims.Nats.Src.ShouldBe(["192.168.1.0/24", "10.0.0.0/8"]);
claims.Nats.AllowedConnectionTypes.ShouldBe(["STANDARD", "WEBSOCKET"]);
}
[Fact]
public void DecodeUserClaims_parses_time_ranges()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"times":[
{"start":"08:00:00","end":"17:00:00"},
{"start":"20:00:00","end":"22:00:00"}
],
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Times.ShouldNotBeNull();
claims.Nats.Times.Length.ShouldBe(2);
claims.Nats.Times[0].Start.ShouldBe("08:00:00");
claims.Nats.Times[0].End.ShouldBe("17:00:00");
claims.Nats.Times[1].Start.ShouldBe("20:00:00");
claims.Nats.Times[1].End.ShouldBe("22:00:00");
}
[Fact]
public void DecodeUserClaims_convenience_properties_delegate_to_nats()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"bearer_token":true,
"issuer_account":"AAXXX_ACCOUNT",
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
// Convenience properties should delegate to Nats sub-object
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe("AAXXX_ACCOUNT");
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_when_no_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(0);
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldBeNull();
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_true_for_past_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expired in 2020
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1500000000,
"exp":1600000000,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1600000000);
claims.IsExpired().ShouldBeTrue();
claims.GetExpiry().ShouldNotBeNull();
claims.GetExpiry()!.Value.ToUnixTimeSeconds().ShouldBe(1600000000);
}
[Fact]
public void DecodeUserClaims_IsExpired_returns_false_for_future_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
// Expires far in the future
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"exp":4102444800,
"nats":{"type":"user","version":2}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.IsExpired().ShouldBeFalse();
claims.GetExpiry().ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeUserClaims("not-a-jwt").ShouldBeNull();
}
// =====================================================================
// DecodeAccountClaims tests
// =====================================================================
[Fact]
public void DecodeAccountClaims_parses_subject_and_issuer()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX_ACCOUNT_KEY",
"iss":"OAXXX_OPERATOR",
"iat":1700000000,
"name":"test-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT_KEY");
claims.Issuer.ShouldBe("OAXXX_OPERATOR");
claims.Name.ShouldBe("test-account");
claims.IssuedAt.ShouldBe(1700000000);
}
[Fact]
public void DecodeAccountClaims_parses_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":100,
"subs":1000,
"payload":1048576,
"data":10737418240
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(100);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(1000);
claims.Nats.Limits.MaxPayload.ShouldBe(1048576);
claims.Nats.Limits.MaxData.ShouldBe(10737418240L);
}
[Fact]
public void DecodeAccountClaims_parses_signing_keys()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"signing_keys":["AAXXX_SIGN_1","AAXXX_SIGN_2","AAXXX_SIGN_3"],
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldNotBeNull();
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SIGN_1", "AAXXX_SIGN_2", "AAXXX_SIGN_3"]);
}
[Fact]
public void DecodeAccountClaims_parses_revocations()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"revocations":{
"UAXXX_REVOKED_1":1700000000,
"UAXXX_REVOKED_2":1700001000
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations.Count.ShouldBe(2);
claims.Nats.Revocations["UAXXX_REVOKED_1"].ShouldBe(1700000000);
claims.Nats.Revocations["UAXXX_REVOKED_2"].ShouldBe(1700001000);
}
[Fact]
public void DecodeAccountClaims_handles_negative_one_unlimited_limits()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{
"limits":{
"conn":-1,
"subs":-1,
"payload":-1,
"data":-1
},
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(-1);
claims.Nats.Limits.MaxSubscriptions.ShouldBe(-1);
claims.Nats.Limits.MaxPayload.ShouldBe(-1);
claims.Nats.Limits.MaxData.ShouldBe(-1);
}
[Fact]
public void DecodeAccountClaims_returns_null_for_invalid_token()
{
NatsJwt.DecodeAccountClaims("invalid").ShouldBeNull();
}
[Fact]
public void DecodeAccountClaims_parses_expiry()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"exp":1800000000,
"name":"expiring-account",
"nats":{
"type":"account",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Expires.ShouldBe(1800000000);
}
// =====================================================================
// Round-trip with real Ed25519 signing tests
// =====================================================================
[Fact]
public void Roundtrip_sign_and_verify_user_claims()
{
var accountKp = KeyPair.CreatePair(PrefixByte.Account);
var accountPublicKey = accountKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"UAXXX_USER",
"iss":"{{accountPublicKey}}",
"iat":1700000000,
"name":"roundtrip-user",
"nats":{
"pub":{"allow":["test.>"]},
"bearer_token":true,
"issuer_account":"{{accountPublicKey}}",
"type":"user",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, accountKp);
// Verify signature
NatsJwt.Verify(token, accountPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX_USER");
claims.Name.ShouldBe("roundtrip-user");
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Pub.Allow.ShouldBe(["test.>"]);
claims.BearerToken.ShouldBeTrue();
claims.IssuerAccount.ShouldBe(accountPublicKey);
}
[Fact]
public void Roundtrip_sign_and_verify_account_claims()
{
var operatorKp = KeyPair.CreatePair(PrefixByte.Operator);
var operatorPublicKey = operatorKp.GetPublicKey();
var payloadJson = $$"""
{
"sub":"AAXXX_ACCOUNT",
"iss":"{{operatorPublicKey}}",
"iat":1700000000,
"name":"roundtrip-account",
"nats":{
"limits":{"conn":50,"subs":500,"payload":65536,"data":-1},
"signing_keys":["AAXXX_SK1"],
"revocations":{"UAXXX_OLD":1699000000},
"type":"account",
"version":2
}
}
""";
var token = BuildSignedToken(payloadJson, operatorKp);
// Verify signature
NatsJwt.Verify(token, operatorPublicKey).ShouldBeTrue();
// Decode claims
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX_ACCOUNT");
claims.Name.ShouldBe("roundtrip-account");
claims.Nats.ShouldNotBeNull();
claims.Nats.Limits.ShouldNotBeNull();
claims.Nats.Limits.MaxConnections.ShouldBe(50);
claims.Nats.SigningKeys.ShouldBe(["AAXXX_SK1"]);
claims.Nats.Revocations.ShouldNotBeNull();
claims.Nats.Revocations["UAXXX_OLD"].ShouldBe(1699000000);
}
// =====================================================================
// Edge case tests
// =====================================================================
[Fact]
public void DecodeUserClaims_handles_missing_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
// Should still decode the outer fields even if nats is missing
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("UAXXX");
claims.Issuer.ShouldBe("AAXXX");
}
[Fact]
public void DecodeAccountClaims_handles_empty_nats_object()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"AAXXX",
"iss":"OAXXX",
"iat":1700000000,
"nats":{}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeAccountClaims(token);
claims.ShouldNotBeNull();
claims.Subject.ShouldBe("AAXXX");
claims.Nats.ShouldNotBeNull();
}
[Fact]
public void DecodeUserClaims_handles_empty_pub_sub_permissions()
{
var headerJson = """{"typ":"JWT","alg":"ed25519-nkey"}""";
var payloadJson = """
{
"sub":"UAXXX",
"iss":"AAXXX",
"iat":1700000000,
"nats":{
"pub":{},
"sub":{},
"type":"user",
"version":2
}
}
""";
var token = BuildUnsignedToken(headerJson, payloadJson);
var claims = NatsJwt.DecodeUserClaims(token);
claims.ShouldNotBeNull();
claims.Nats.ShouldNotBeNull();
claims.Nats.Pub.ShouldNotBeNull();
claims.Nats.Sub.ShouldNotBeNull();
// Allow/Deny should be null when not specified
claims.Nats.Pub.Allow.ShouldBeNull();
claims.Nats.Pub.Deny.ShouldBeNull();
}
}

View File

@@ -0,0 +1,60 @@
using Serilog;
namespace NATS.Server.Tests;
public class LoggingTests : IDisposable
{
private readonly string _logDir;
public LoggingTests()
{
_logDir = Path.Combine(Path.GetTempPath(), $"nats-log-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_logDir);
}
public void Dispose()
{
try { Directory.Delete(_logDir, true); } catch { }
}
[Fact]
public void File_sink_creates_log_file()
{
var logPath = Path.Combine(_logDir, "test.log");
using var logger = new LoggerConfiguration()
.WriteTo.File(logPath)
.CreateLogger();
logger.Information("Hello from test");
logger.Dispose();
File.Exists(logPath).ShouldBeTrue();
var content = File.ReadAllText(logPath);
content.ShouldContain("Hello from test");
}
[Fact]
public void File_sink_rotates_on_size_limit()
{
var logPath = Path.Combine(_logDir, "rotate.log");
using var logger = new LoggerConfiguration()
.WriteTo.File(
logPath,
fileSizeLimitBytes: 200,
rollOnFileSizeLimit: true,
retainedFileCountLimit: 3)
.CreateLogger();
// Write enough to trigger rotation
for (int i = 0; i < 50; i++)
logger.Information("Log message number {Number} with some padding text", i);
logger.Dispose();
// Should have created rotated files
var logFiles = Directory.GetFiles(_logDir, "rotate*.log");
logFiles.Length.ShouldBeGreaterThan(1);
}
}

Some files were not shown because too many files have changed in this diff Show More