Compare commits

...

159 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
Joseph Doherty
3941c85e76 Merge branch 'feature/core-lifecycle' into main
Reconcile close reason tracking: feature branch's MarkClosed() and
ShouldSkipFlush/FlushAndCloseAsync now use main's ClientClosedReason
enum. ClosedState enum retained for forward compatibility.
2026-02-23 00:09:30 -05:00
Joseph Doherty
2baf8a85bf docs: update differences.md section 2 to reflect implemented features 2026-02-22 23:59:19 -05:00
Joseph Doherty
f5c0c4f906 feat: wire pending bytes and close reason into connz monitoring 2026-02-22 23:57:39 -05:00
Joseph Doherty
5323c8bb30 docs: update differences.md section 1 to reflect core lifecycle implementation 2026-02-22 23:56:57 -05:00
Joseph Doherty
2fb14821e0 feat: add no-responders CONNECT validation and tests
Reject connections that send no_responders:true without headers:true,
since the 503 HMSG response requires header support. Add three tests:
connection rejection, acceptance with headers, and 503 delivery flow.
2026-02-22 23:56:49 -05:00
Joseph Doherty
04305447f9 feat: implement verbose mode (+OK after commands)
When a client sends CONNECT {"verbose":true}, the server now responds
with +OK\r\n after successfully processing CONNECT, PING, SUB, UNSUB,
and PUB/HPUB commands, matching the Go NATS server behavior.
2026-02-22 23:54:41 -05:00
Joseph Doherty
df39ebdc58 feat: add signal handling (SIGTERM, SIGUSR2, SIGHUP) and CLI stubs 2026-02-22 23:52:49 -05:00
Joseph Doherty
bce793fd42 perf: batch stat increments per read cycle in ProcessCommandsAsync
Accumulate InMsgs/InBytes locally per ReadAsync cycle and flush once,
reducing from 4 Interlocked operations per published message to 2 per
read cycle. This matches the Go server's approach of batching stats.
2026-02-22 23:52:09 -05:00
Joseph Doherty
e57605f090 feat: add PID file and ports file support 2026-02-22 23:50:22 -05:00
Joseph Doherty
c522ce99f5 feat: add delivery tracking and no-responders 503 support to ProcessMessage
When a PUB with a reply-to subject has no matching subscribers and the
sender opted into no_responders, send a 503 HMSG back on the reply
subject so request-reply callers can fail fast instead of timing out.
2026-02-22 23:49:39 -05:00
Joseph Doherty
34067f2b9b feat: add lame duck mode with staggered client shutdown 2026-02-22 23:48:06 -05:00
Joseph Doherty
b289041761 test: add write loop and slow consumer detection tests
Verify channel-based write loop behavior: QueueOutbound writes data
to client socket, PendingBytes tracking, slow consumer detection
when MaxPending is exceeded, close reason propagation, and server
stats incrementation on slow consumer events.
2026-02-22 23:47:31 -05:00
Joseph Doherty
45de110a84 feat: add flush-before-close for graceful client shutdown 2026-02-22 23:45:26 -05:00
Joseph Doherty
b68f898fa0 feat: add graceful shutdown, accept loop backoff, and task tracking 2026-02-22 23:43:25 -05:00
Joseph Doherty
31660a4187 feat: replace inline writes with channel-based write loop and batch flush 2026-02-22 23:41:44 -05:00
Joseph Doherty
600c6f9e5a feat: add system account ($SYS) and server NKey identity stubs 2026-02-22 23:39:22 -05:00
Joseph Doherty
086b4f50e8 feat: add close reason tracking to NatsClient 2026-02-22 23:36:55 -05:00
Joseph Doherty
38eaaa8b83 feat: add ephemeral port (port=0) support 2026-02-22 23:36:01 -05:00
Joseph Doherty
ad6a02b9a2 refactor: replace _connectReceived with ClientFlagHolder and add CloseReason tracking 2026-02-22 23:35:35 -05:00
Joseph Doherty
9ae75207fc feat: add ClosedState enum ported from Go client.go 2026-02-22 23:34:05 -05:00
Joseph Doherty
61c6b832e5 feat: add MaxPending, WriteDeadline options and error constants 2026-02-22 23:33:49 -05:00
Joseph Doherty
d0aa6a5fdd feat: add lifecycle options (lame duck, PID file, ports file, config stub) 2026-02-22 23:33:44 -05:00
Joseph Doherty
1a916a3f36 feat: add ClientFlags bitfield with thread-safe holder 2026-02-22 23:33:21 -05:00
Joseph Doherty
8bbfa54058 feat: add ClientClosedReason enum with 16 close reason values 2026-02-22 23:33:13 -05:00
Joseph Doherty
149c852510 docs: add core lifecycle implementation plan with 12 tasks
Detailed step-by-step plan covering ClosedState enum, close reason
tracking, ephemeral port, graceful shutdown, flush-before-close,
lame duck mode, PID/ports files, NKey stubs, signal handling, and
differences.md update.
2026-02-22 23:31:01 -05:00
Joseph Doherty
c2dc503e2e docs: add core server lifecycle design for section 1 gaps
Covers ClosedState enum, accept loop backoff, ephemeral port,
graceful shutdown, lame duck mode, PID/ports files, signal
handling, and stub components.
2026-02-22 23:25:53 -05:00
Joseph Doherty
4d89661e79 feat: add monitoring HTTP endpoints and TLS support
Monitoring HTTP:
- /varz, /connz, /healthz via Kestrel Minimal API
- Pagination, sorting, subscription details on /connz
- ServerStats atomic counters, CPU/memory sampling
- CLI flags: -m, --http_port, --http_base_path, --https_port

TLS Support:
- 4-mode negotiation: no TLS, required, TLS-first, mixed
- Certificate loading, pinning (SHA-256), client cert verification
- PeekableStream for non-destructive TLS detection
- Token-bucket rate limiter for TLS handshakes
- CLI flags: --tls, --tlscert, --tlskey, --tlscacert, --tlsverify

29 new tests (78 → 107 total), all passing.

# Conflicts:
#	src/NATS.Server.Host/Program.cs
#	src/NATS.Server/NATS.Server.csproj
#	src/NATS.Server/NatsClient.cs
#	src/NATS.Server/NatsOptions.cs
#	src/NATS.Server/NatsServer.cs
#	src/NATS.Server/Protocol/NatsProtocol.cs
#	tests/NATS.Server.Tests/ClientTests.cs
2026-02-22 23:13:22 -05:00
Joseph Doherty
a6e9bd1467 feat: add monitoring port CLI args to server host
Support -m/--http_port, --http_base_path, and --https_port flags for
configuring the monitoring HTTP endpoint from the command line.
2026-02-22 23:08:04 -05:00
Joseph Doherty
543b185f7e fix: address code quality issues from review
- Make ConnectReceived thread-safe with Volatile.Read/Write (accessed from auth timeout task and command pipeline)
- Include authTimeoutTask in Task.WhenAny to propagate exceptions
- Clear nonce after authentication with CryptographicOperations.ZeroMemory
- Avoid closure allocation on publish permission cache hot path (method group)
- Update AuthTimeout default to 2s to match Go server
2026-02-22 23:07:31 -05:00
Joseph Doherty
c40c2cd994 test: add permission enforcement and NKey integration tests
Fix NKey nonce verification: the NATS client signs the nonce string
(ASCII bytes of the base64url-encoded nonce), not the raw nonce bytes.
Pass the encoded nonce string bytes to the authenticator for verification.
2026-02-22 23:03:41 -05:00
Joseph Doherty
9cb3e2fe0f feat: add per-account SubList isolation for message routing
Subscriptions and message routing now go through account-specific SubLists
instead of a single global SubList. Clients in different accounts cannot
see each other's messages. When no account is specified (or auth is not
configured), all clients share the global $G account.
2026-02-22 23:00:59 -05:00
Joseph Doherty
2980a343c1 feat: integrate authentication into server accept loop and client CONNECT processing
Wire AuthService into NatsServer and NatsClient to enforce authentication
on incoming connections. The server builds an AuthService from NatsOptions,
sets auth_required in ServerInfo, and generates per-client nonces when
NKey auth is configured. NatsClient validates credentials in ProcessConnect,
enforces publish/subscribe permissions, and implements an auth timeout that
closes connections that don't send CONNECT in time. Existing tests without
auth continue to work since AuthService.IsAuthRequired is false by default.
2026-02-22 22:55:50 -05:00
Joseph Doherty
2a2cc6f0a2 feat: add AuthService orchestrator with priority-ordered authentication 2026-02-22 22:44:58 -05:00
Joseph Doherty
6ebe791c6d feat: add authenticators, Account, and ClientPermissions (Tasks 3-7, 9)
- Account: per-account SubList and client tracking
- IAuthenticator interface, AuthResult, ClientAuthContext
- TokenAuthenticator: constant-time token comparison
- UserPasswordAuthenticator: multi-user with bcrypt/plain support
- SimpleUserPasswordAuthenticator: single user/pass config
- NKeyAuthenticator: Ed25519 nonce signature verification
- ClientPermissions: SubList-based publish/subscribe authorization
2026-02-22 22:41:45 -05:00
Joseph Doherty
3b6bd08248 feat: add TLS mixed mode tests and monitoring TLS field verification
Add TlsMixedModeTests verifying that a server with AllowNonTls=true
accepts both plaintext and TLS clients on the same port. Add
MonitorTlsTests verifying that /connz reports TlsVersion and
TlsCipherSuite for TLS-connected clients.
2026-02-22 22:40:03 -05:00
Joseph Doherty
19f35e6463 feat: add --tls, --tlscert, --tlskey, --tlscacert, --tlsverify CLI flags 2026-02-22 22:36:57 -05:00
Joseph Doherty
9eb108b1df feat: add /connz endpoint with pagination, sorting, and subscription details 2026-02-22 22:36:28 -05:00
Joseph Doherty
87746168ba feat: wire TLS negotiation into NatsServer accept loop
Integrate TLS support into the server's connection accept path:
- Add SslServerAuthenticationOptions and TlsRateLimiter fields to NatsServer
- Extract AcceptClientAsync method for TLS negotiation, rate limiting, and
  TLS state extraction (protocol version, cipher suite, peer certificate)
- Add InfoAlreadySent flag to NatsClient to skip redundant INFO when
  TlsConnectionWrapper already sent it during negotiation
- Add TlsServerTests verifying TLS connect+INFO and TLS pub/sub
2026-02-22 22:35:42 -05:00
Joseph Doherty
818bc0ba1f fix: address MonitorServer review — dispose resources, add cancellation, improve test reliability 2026-02-22 22:30:14 -05:00
Joseph Doherty
63198ef83b fix: address TlsConnectionWrapper review — clone ServerInfo, fix SslStream leak, add TLS-first test 2026-02-22 22:28:19 -05:00
Joseph Doherty
a52db677e2 fix: track HTTP request stats for all monitoring endpoints 2026-02-22 22:25:00 -05:00
Joseph Doherty
562f89744d feat: add IAuthenticator interface and TokenAuthenticator with constant-time comparison 2026-02-22 22:24:53 -05:00
Joseph Doherty
0cce771907 feat: add Account type with per-account SubList and client tracking 2026-02-22 22:22:51 -05:00
Joseph Doherty
0409acc745 feat: add TlsConnectionWrapper with 4-mode TLS negotiation 2026-02-22 22:21:11 -05:00
Joseph Doherty
5305069dd8 feat: add auth model types (User, NKeyUser, Permissions) and auth config to NatsOptions 2026-02-22 22:21:00 -05:00
Joseph Doherty
f2badc3488 feat: add MonitorServer with /healthz and /varz endpoints 2026-02-22 22:20:44 -05:00
Joseph Doherty
11dc5e62f3 feat: add auth fields to ServerInfo and ClientOptions protocol types 2026-02-22 22:19:18 -05:00
Joseph Doherty
91aff1a867 chore: add NATS.NKeys and BCrypt.Net-Next packages for authentication 2026-02-22 22:17:42 -05:00
Joseph Doherty
1c8cc43fb4 docs: add authentication implementation plan with 15 TDD tasks
Covers NuGet packages, protocol types, auth models, authenticators
(token, user/password, NKey), AuthService orchestrator, permissions,
server/client integration, account isolation, and integration tests.
2026-02-22 22:15:48 -05:00
Joseph Doherty
f6b38df291 feat: add TlsHelper, PeekableStream, and TlsRateLimiter
Add TLS utility classes for certificate loading, peekable stream for TLS
detection, token-bucket rate limiter for handshake throttling, and
TlsConnectionState for post-handshake info. Add TlsState property to
NatsClient. Fix X509Certificate2 constructor usage for .NET 10 compat.
2026-02-22 22:13:53 -05:00
Joseph Doherty
045c12cce7 feat: add Varz and Connz monitoring JSON models with Go field name parity 2026-02-22 22:13:50 -05:00
Joseph Doherty
b2f7b1b2a0 feat: add -m/--http_port CLI flag for monitoring 2026-02-22 22:10:07 -05:00
Joseph Doherty
a26c1359de refactor: NatsClient accepts Stream parameter for TLS support 2026-02-22 22:09:48 -05:00
Joseph Doherty
1a777e09c9 feat: add ServerStats counters and NatsClient metadata for monitoring 2026-02-22 22:08:30 -05:00
Joseph Doherty
bca703b310 docs: add authentication design for username/password, token, and NKeys
Covers auth models, strategy pattern with pluggable authenticators,
permission enforcement, core account isolation, and server integration.
2026-02-22 22:07:16 -05:00
Joseph Doherty
ceaafc48d4 feat: add project setup for monitoring and TLS — csproj, config options, ServerInfo TLS fields
Add FrameworkReference to Microsoft.AspNetCore.App to enable Kestrel
Minimal APIs for the monitoring HTTP server. Remove the now-redundant
Microsoft.Extensions.Logging.Abstractions PackageReference (it is
included transitively via the framework reference).

Add monitoring config properties (MonitorPort, MonitorHost,
MonitorBasePath, MonitorHttpsPort) and TLS config properties (TlsCert,
TlsKey, TlsCaCert, TlsVerify, TlsHandshakeFirst, etc.) to NatsOptions.

Add TlsRequired, TlsVerify, and TlsAvailable fields to ServerInfo so
the server can advertise TLS capability in the INFO protocol message.
2026-02-22 22:00:42 -05:00
Joseph Doherty
1813250a9e chore: add .worktrees/ to .gitignore for isolated development 2026-02-22 21:56:02 -05:00
Joseph Doherty
d08ce7f6fb chore: add .worktrees/ to .gitignore for isolated development 2026-02-22 21:54:26 -05:00
Joseph Doherty
b744913296 feat: add server-side PING keepalive with stale connection detection 2026-02-22 21:53:42 -05:00
Joseph Doherty
d14d73a7d0 feat: add pedantic subject validation and max payload enforcement on PUB
Move max payload validation from the parser to ProcessPubAsync so the
server sends -ERR 'Maximum Payload Violation' and closes the connection
(matching Go reference client.go:2442). In pedantic mode, reject PUB
with wildcard subjects via -ERR 'Invalid Publish Subject' (client.go:2869).
Add disposed guard to SubList.Remove to prevent crash during shutdown.
2026-02-22 21:49:01 -05:00
Joseph Doherty
9d0d5064ac docs: add implementation plan for monitoring HTTP and TLS support
12 tasks covering ServerStats, monitoring models, Kestrel endpoints,
TLS helpers, 4-mode connection wrapper, and full integration tests.
2026-02-22 21:47:23 -05:00
Joseph Doherty
0c12b0f6e3 feat: enforce MaxConnections limit in accept loop 2026-02-22 21:44:18 -05:00
Joseph Doherty
19e8c65f6d feat: add -ERR response infrastructure with SendErrAsync and SendErrAndCloseAsync 2026-02-22 21:42:04 -05:00
Joseph Doherty
8ee5a7f97b docs: add design for monitoring HTTP and TLS support
Covers /varz, /connz endpoints via Kestrel Minimal APIs,
full TLS support with four modes (none/required/first/mixed),
cert pinning, rate limiting, and testing strategy.
2026-02-22 21:33:24 -05:00
Joseph Doherty
16b8f9e2e2 docs: add implementation plan for hardening base server
Four tasks: -ERR infrastructure, MaxConnections enforcement,
pedantic subject validation + max payload on PUB, PING keepalive.
Full TDD steps with exact code and commands.
2026-02-22 21:29:10 -05:00
Joseph Doherty
b8acca19dd docs: add design doc for hardening base server
Covers -ERR infrastructure, MaxConnections enforcement,
pedantic subject validation on PUB, and server-side PING keepalive.
2026-02-22 21:25:10 -05:00
Joseph Doherty
5dee4f5fa6 chore: expand .gitignore with standard .NET/Visual Studio entries 2026-02-22 21:15:39 -05:00
Joseph Doherty
2e1e1bb341 fix: resolve slopwatch issues — add logging to empty catches and eliminate test timing delays
Replace empty catch blocks with meaningful log statements in NatsServer,
NatsClient, and Program. Add WaitForReadyAsync() to NatsServer for
deterministic server startup. Replace Task.Delay/Thread.Sleep in tests
with PING/PONG protocol flush and SubscribeCoreAsync for reliable
subscription synchronization.
2026-02-22 21:14:16 -05:00
Joseph Doherty
539b2b7588 feat: add structured logging, Shouldly assertions, CPM, and project documentation
- Add Microsoft.Extensions.Logging + Serilog to NatsServer and NatsClient
- Convert all test assertions from xUnit Assert to Shouldly
- Add NSubstitute package for future mocking needs
- Introduce Central Package Management via Directory.Packages.props
- Add documentation_rules.md with style guide, generation/update rules, component map
- Generate 10 documentation files across 5 component folders (GettingStarted, Protocol, Subscriptions, Server, Configuration/Operations)
- Update CLAUDE.md with logging, testing, porting, agent model, CPM, and documentation guidance
2026-02-22 21:05:53 -05:00
Joseph Doherty
b9f4dec523 docs: update CLAUDE.md with verified build and test commands
Remove template UnitTest1.cs placeholder. Add actual project structure,
run commands for the NATS server host, and update test command examples
to reference the real project paths.
2026-02-22 20:34:42 -05:00
Joseph Doherty
c7fc703d7e feat: add integration tests using NATS.Client.Core NuGet package
Validates the server against the official NATS .NET client library with
tests for basic pub/sub, wildcard (* and >) matching, fan-out to
multiple subscribers, and PING/PONG keepalive. All 5 tests pass without
requiring any server changes.
2026-02-22 20:32:16 -05:00
Joseph Doherty
96e584c382 feat: add NATS.Server.Host console app with basic CLI arguments 2026-02-22 20:29:23 -05:00
Joseph Doherty
1bc6870238 feat: implement NatsServer orchestrator with accept loop and message routing 2026-02-22 20:27:31 -05:00
Joseph Doherty
8db2de37cd feat: implement NatsClient connection handler with read/write pipeline 2026-02-22 20:24:35 -05:00
Joseph Doherty
c78dc67973 feat: implement NATS protocol parser with System.IO.Pipelines
Add NatsParser that reads NATS protocol commands from
ReadOnlySequence<byte>. Identifies commands by first 2 bytes using
case-insensitive bit masking. Handles PUB/HPUB payload reading with
stateful _awaitingPayload for split-packet scenarios. Uses Span<Range>
for zero-allocation argument splitting and ParseSize for ASCII decimal
parsing. Includes CommandType enum, ParsedCommand struct, and
ProtocolViolationException.

14 tests covering PING, PONG, CONNECT, INFO, SUB (with/without queue),
UNSUB (with/without max), PUB (with/without reply, zero payload),
HPUB, multiple commands, and case insensitivity.
2026-02-22 20:19:37 -05:00
Joseph Doherty
bc8fee8e39 fix: address SubList code review findings 2026-02-22 20:14:48 -05:00
Joseph Doherty
afc419ce3f feat: implement SubList trie with wildcard matching and cache 2026-02-22 20:07:35 -05:00
Joseph Doherty
9e36b7c0fc feat: add protocol constants, ServerInfo, ClientOptions, and NatsOptions 2026-02-22 20:00:13 -05:00
196 changed files with 51631 additions and 35 deletions

View File

@@ -0,0 +1,16 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "slopwatch analyze -d . --hook",
"timeout": 60000
}
]
}
]
}
}

157
.gitignore vendored
View File

@@ -1,29 +1,168 @@
## .NET
bin/
obj/
*.user
## Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
msbuild.log
msbuild.err
msbuild.wrn
MSBuild_Logs/
*.binlog
## User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
*.userprefs
*.env
## Mono crash logs
mono_crash.*
## NuGet
*.nupkg
*.snupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
project.lock.json
project.fragment.lock.json
artifacts/
*.nuget.props
*.nuget.targets
## Visual Studio
.vs/
*.rsuser
*.csproj.user
Generated\ Files/
## JetBrains Rider
.idea/
*.sln.iml
## Test Results
TestResults/
coverage/
## VS Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
## Test results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
*.VisualState.xml
TestResult.xml
nunit-*.xml
*.received.*
## Code coverage
*.coverage
*.coveragexml
coverage*.json
coverage*.xml
*.dotCover
.axoCover/*
_NCrunch_*
nCrunchTemp_*
*.mm.*
## ReSharper
_ReSharper*/
*.DotSettings.user
## Compiled files
*.ilk
*.meta
*.obj
*.iobj
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.tlog
*.vspscc
*.vssscc
*.pidb
*.svclog
*.scc
## Build artifacts
dlldata.c
BenchmarkDotNet.Artifacts/
## Publish output
PublishScripts/
*.[Pp]ublish.xml
*.azurePubxml
*.pubxml
!**/[Pp]ublish[Pp]rofiles/*.pubxml
*.publishproj
## Azure
csx/
*.build.csdef
ecf/
rcf/
AppPackages/
BundleArtifacts/
## Web
.sass-cache/
[Ee]xpress/
node_modules/
## Database
*.mdf
*.ldf
*.ndf
## Installer outputs
*.cab
*.msi
*.msix
*.msm
*.msp
*.vsix
## Fakes and Fody
FakesAssemblies/
FodyWeavers.xsd
## Paket
**/.paket/paket.exe
**/.fake/
## Local history
**/.localhistory/
.vshistory/
.history/
## Go reference implementation
golang/
## Git worktrees
.worktrees/
## OS
.DS_Store

7
.slopwatch/baseline.json Normal file
View File

@@ -0,0 +1,7 @@
{
"version": 1,
"createdAt": "2026-02-23T02:13:34.57845+00:00",
"updatedAt": "2026-02-23T02:13:34.57845+00:00",
"description": "Initial baseline created by 'slopwatch init' on 2026-02-23 02:13:34 UTC",
"entries": []
}

View File

@@ -0,0 +1,10 @@
{
"suppressions": [
{
"ruleId": "SW002",
"pattern": "**/Generated/**",
"justification": "Generated code from protobuf/gRPC compiler - cannot be modified"
}
],
"globalSuppressions": []
}

View File

@@ -10,6 +10,8 @@ NATS is a high-performance publish-subscribe messaging system. It supports wildc
## Build & Test Commands
The solution file is `NatsDotNet.slnx`.
```bash
# Build the solution
dotnet build
@@ -17,19 +19,54 @@ dotnet build
# Run all tests
dotnet test
# Run a single test project
dotnet test <path/to/TestProject.csproj>
# Run a specific test by name
dotnet test --filter "FullyQualifiedName~TestClassName.TestMethodName"
# Run tests with verbose output
dotnet test -v normal
# Run a single test project
dotnet test tests/NATS.Server.Tests
# Run a specific test by name
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TestName"
# Run the NATS server (default port 4222)
dotnet run --project src/NATS.Server.Host
# Run the NATS server on a custom port
dotnet run --project src/NATS.Server.Host -- -p 14222
# Clean and rebuild
dotnet clean && dotnet build
```
## .NET Project Structure
```
NatsDotNet.slnx # Solution file
src/
NATS.Server/ # Core server library
NatsServer.cs # Server: listener, accept loop, shutdown
NatsClient.cs # Per-connection client: read/write loops, sub tracking
NatsOptions.cs # Server configuration (port, host, etc.)
Protocol/
NatsParser.cs # Protocol state machine (PUB, SUB, UNSUB, etc.)
NatsProtocol.cs # Wire-level protocol writing (INFO, MSG, PING/PONG)
Subscriptions/
SubjectMatch.cs # Subject validation and wildcard matching
SubList.cs # Trie-based subscription list with caching
SubListResult.cs # Match result container (plain subs + queue groups)
Subscription.cs # Subscription model (subject, sid, queue, client)
NATS.Server.Host/ # Executable host app
Program.cs # Entry point, CLI arg parsing (-p port)
tests/
NATS.Server.Tests/ # xUnit test project
ParserTests.cs # Protocol parser tests
SubjectMatchTests.cs # Subject validation & matching tests
SubListTests.cs # Subscription list trie tests
ClientTests.cs # Client-level protocol tests
ServerTests.cs # Server pubsub/wildcard tests
IntegrationTests.cs # End-to-end tests using NATS.Client.Core NuGet
```
## Go Reference Commands
```bash
@@ -108,6 +145,56 @@ Client PUB → parser → permission check → Sublist.Match() →
└─ JetStream: if subject matches a stream, store + deliver to consumers
```
## NuGet Package Management
This solution uses **Central Package Management (CPM)** via `Directory.Packages.props` at the repo root. All package versions are defined centrally there.
- In `.csproj` files, use `<PackageReference Include="Foo" />` **without** a `Version` attribute
- To add a new package: add a `<PackageVersion>` entry in `Directory.Packages.props`, then reference it without version in the project's csproj
- To update a version: change it only in `Directory.Packages.props` — all projects pick it up automatically
- Never specify `Version` on `<PackageReference>` in individual csproj files
## Logging
Use **Microsoft.Extensions.Logging** (`ILogger<T>`) for all logging throughout the server. Wire up **Serilog** as the logging provider in the host application.
- Inject `ILogger<T>` via constructor in all components (NatsServer, NatsClient, etc.)
- Use **Serilog.Context.LogContext** to push contextual properties (client ID, remote endpoint, subscription subject) so they appear on all log entries within that scope
- Use structured logging with message templates: `logger.LogInformation("Client {ClientId} subscribed to {Subject}", id, subject)` — never string interpolation
- Log levels: `Trace` for protocol bytes, `Debug` for per-message flow, `Information` for lifecycle events (connect/disconnect), `Warning` for protocol violations, `Error` for unexpected failures
## Testing
- **xUnit 3** for test framework
- **Shouldly** for assertions — use `value.ShouldBe(expected)`, `action.ShouldThrow<T>()`, etc. Do NOT use `Assert.*` from xUnit
- **NSubstitute** for mocking/substitution when needed
- Do **NOT** use FluentAssertions or Moq — these are explicitly excluded
- Test project uses global `using Shouldly;`
## Porting Guidelines
- Use modern .NET 10 / C# 14 best practices (primary constructors, collection expressions, `field` keyword where stable, file-scoped namespaces, raw string literals, etc.)
- Prefer `readonly record struct` for small value types over mutable structs
- Use `required` properties and `init` setters for initialization-only state
- Use pattern matching and switch expressions where they improve clarity
- Prefer `System.Text.Json` source generators for JSON serialization
- Use `ValueTask` where appropriate for hot-path async methods
## Agent Model Guidance
- **Sonnet** (`model: "sonnet"`) — use for simpler implementation tasks: straightforward file modifications, adding packages, converting assertions, boilerplate code
- **Opus** (default) — use for complex tasks, architectural decisions, design work, tricky protocol logic, and code review
- **Parallel subagents** — use where tasks are independent and don't touch the same files (e.g., converting test files in parallel, adding packages while updating docs)
## Documentation
Follow the documentation rules in [`documentation_rules.md`](documentation_rules.md) for all project documentation. Key points:
- Documentation lives in `Documentation/` with component subfolders (Protocol, Subscriptions, Server, Configuration, Operations)
- Use `PascalCase.md` file names, always specify language on code blocks, use real code snippets (not invented examples)
- Update documentation when code changes — see the trigger rules and component map in the rules file
- Technical and direct tone, explain "why" not just "what", present tense
## Conventions
- Reference the Go implementation file and line when porting a subsystem

32
Directory.Packages.props Normal file
View File

@@ -0,0 +1,32 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Logging -->
<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" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<!-- NATS Client (integration tests) -->
<PackageVersion Include="NATS.Client.Core" Version="2.7.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,185 @@
# Configuration Overview
`NatsOptions` is the single configuration object passed to `NatsServer` at construction time. The host application (`Program.cs`) also reads CLI arguments to populate it before server startup.
Go reference: `golang/nats-server/server/opts.go`
---
## NatsOptions Class
`NatsOptions` is a plain mutable class with no validation logic — values are consumed directly by `NatsServer` and `NatsClient`.
```csharp
namespace NATS.Server;
public sealed class NatsOptions
{
public string Host { get; set; } = "0.0.0.0";
public int Port { get; set; } = 4222;
public string? ServerName { get; set; }
public int MaxPayload { get; set; } = 1024 * 1024; // 1MB
public int MaxControlLine { get; set; } = 4096;
public int MaxConnections { get; set; } = 65536;
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
public int MaxPingsOut { get; set; } = 2;
}
```
### Option reference
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Host` | `string` | `"0.0.0.0"` | Bind address for the TCP listener. Use `"127.0.0.1"` to restrict to loopback. |
| `Port` | `int` | `4222` | Client listen port. Standard NATS client port. |
| `ServerName` | `string?` | `null` (auto: `nats-dotnet-{MachineName}`) | Server name sent in the `INFO` message. When `null`, the server constructs a name from `Environment.MachineName`. |
| `MaxPayload` | `int` | `1048576` (1 MB) | Maximum allowed message payload in bytes. Clients that publish larger payloads are rejected. |
| `MaxControlLine` | `int` | `4096` | Maximum protocol control line size in bytes. Matches the Go server default. |
| `MaxConnections` | `int` | `65536` | Maximum concurrent client connections the server will accept. |
| `PingInterval` | `TimeSpan` | `2 minutes` | Interval between server-initiated `PING` messages to connected clients. |
| `MaxPingsOut` | `int` | `2` | Number of outstanding `PING`s without a `PONG` response before the server disconnects a client. |
### How ServerName is resolved
`NatsServer` constructs the `ServerInfo` sent to each client at connection time. If `ServerName` is `null`, it uses `nats-dotnet-{Environment.MachineName}`:
```csharp
_serverInfo = new ServerInfo
{
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
Version = NatsProtocol.Version,
Host = options.Host,
Port = options.Port,
MaxPayload = options.MaxPayload,
};
```
---
## CLI Arguments
`Program.cs` parses command-line arguments before creating `NatsServer`. The three supported flags map directly to `NatsOptions` fields:
| Flag | Alias | Field | Example |
|------|-------|-------|---------|
| `-p` | `--port` | `Port` | `-p 14222` |
| `-a` | `--addr` | `Host` | `-a 127.0.0.1` |
| `-n` | `--name` | `ServerName` | `-n my-server` |
```csharp
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]);
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
break;
}
}
```
Unrecognized flags are silently ignored. There is no `--help` output.
---
## Protocol Constants
`NatsProtocol` defines wire-level constants that mirror the Go server's defaults. These values are used by the parser and the `INFO` response:
| Constant | Value | Description |
|----------|-------|-------------|
| `MaxControlLineSize` | `4096` | Maximum bytes in a protocol control line |
| `MaxPayloadSize` | `1048576` | Maximum message payload in bytes (1 MB) |
| `DefaultPort` | `4222` | Standard NATS client port |
| `Version` | `"0.1.0"` | Server version string sent in `INFO` |
| `ProtoVersion` | `1` | NATS protocol version number sent in `INFO` |
```csharp
public static class NatsProtocol
{
public const int MaxControlLineSize = 4096;
public const int MaxPayloadSize = 1024 * 1024; // 1MB
public const int DefaultPort = 4222;
public const string Version = "0.1.0";
public const int ProtoVersion = 1;
}
```
`MaxControlLine` in `NatsOptions` and `MaxControlLineSize` in `NatsProtocol` carry the same value. `NatsOptions.MaxPayload` is used as the per-server runtime limit passed into `NatsParser`; `NatsProtocol.MaxPayloadSize` is the compile-time default.
---
## Logging Configuration
### Serilog setup
Logging uses [Serilog](https://serilog.net/) with the console sink, configured in `Program.cs` before any other code runs:
```csharp
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
```
The output template produces lines like:
```
[14:32:01 INF] Listening on 0.0.0.0:4222
[14:32:01 DBG] Client 1 connected from 127.0.0.1:54321
```
### ILoggerFactory injection
`Program.cs` wraps the Serilog logger in a `SerilogLoggerFactory` and passes it to `NatsServer`:
```csharp
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
```
`NatsServer` stores the factory and uses it to create two kinds of loggers:
- **`ILogger<NatsServer>`** — obtained via `loggerFactory.CreateLogger<NatsServer>()`, used for server-level events (listening, client connect/disconnect).
- **`ILogger` with named category** — obtained via `loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]")`, created per connection. This gives each client a distinct category in log output so messages can be correlated by client ID without structured log properties.
```csharp
// In NatsServer.StartAsync — one logger per accepted connection
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
```
`NatsClient` takes `ILogger` (not `ILogger<NatsClient>`) so it can accept the pre-named logger instance rather than deriving its category from its own type.
### Log flushing
`Log.CloseAndFlush()` runs in the `finally` block of `Program.cs` after the server stops, ensuring buffered log entries are written before the process exits:
```csharp
try
{
await server.StartAsync(cts.Token);
}
catch (OperationCanceledException) { }
finally
{
Log.CloseAndFlush();
}
```
---
## Related Documentation
- [Operations Overview](../Operations/Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,197 @@
# Architecture
This document describes the overall architecture of the NATS .NET server — its layers, component responsibilities, message flow, and the mapping from the Go reference implementation.
## Project Overview
This project is a port of the [NATS server](https://github.com/nats-io/nats-server) (`golang/nats-server/`) to .NET 10 / C#. The Go source in `golang/nats-server/server/` is the authoritative reference.
Current scope: base publish-subscribe server with wildcard subject matching and queue groups. Authentication, clustering (routes, gateways, leaf nodes), JetStream, and HTTP monitoring are not yet implemented.
---
## Solution Layout
```
NatsDotNet.slnx
src/
NATS.Server/ # Core server library — no executable entry point
NATS.Server.Host/ # Console application — wires logging, parses CLI args, starts server
tests/
NATS.Server.Tests/ # xUnit test project — unit and integration tests
```
`NATS.Server` depends only on `Microsoft.Extensions.Logging.Abstractions`. All Serilog wiring is in `NATS.Server.Host`. This keeps the core library testable without a console host.
---
## Layers
The implementation is organized bottom-up: each layer depends only on the layers below it.
```
NatsServer (orchestrator: accept loop, client registry, message routing)
└── NatsClient (per-connection: I/O pipeline, command dispatch, subscription tracking)
└── NatsParser (protocol state machine: bytes → ParsedCommand)
└── SubList (trie: subject → matching subscriptions)
└── SubjectMatch (validation and wildcard matching primitives)
```
### SubjectMatch and SubList
`SubjectMatch` (`Subscriptions/SubjectMatch.cs`) provides the primitive operations used throughout the subscription system:
- `IsValidSubject(string)` — rejects empty tokens, whitespace, tokens after `>`
- `IsLiteral(string)` — returns `false` if the subject contains a bare `*` or `>` wildcard token
- `IsValidPublishSubject(string)` — combines both; publish subjects must be literal
- `MatchLiteral(string literal, string pattern)` — token-by-token matching for cache maintenance
`SubList` (`Subscriptions/SubList.cs`) is a trie-based subscription store. Each trie level (`TrieLevel`) holds a `Dictionary<string, TrieNode>` for literal tokens plus dedicated `Pwc` and `Fwc` pointers for `*` and `>` wildcards. Each `TrieNode` holds `PlainSubs` (a `HashSet<Subscription>`) and `QueueSubs` (a `Dictionary<string, HashSet<Subscription>>` keyed by queue group name).
`SubList.Match(string subject)` checks an in-memory cache first, then falls back to a recursive trie walk (`MatchLevel`) if there is a cache miss. The result is stored as a `SubListResult` — an immutable snapshot containing `PlainSubs` (array) and `QueueSubs` (jagged array, one sub-array per group).
The cache holds up to 1,024 entries. When that limit is exceeded, 256 entries are swept. Wildcard subscriptions invalidate all matching cache keys on insert and removal; literal subscriptions invalidate only their own key.
### NatsParser
`NatsParser` (`Protocol/NatsParser.cs`) is a stateless-per-invocation parser that operates on `ReadOnlySequence<byte>` from `System.IO.Pipelines`. The public entry point is:
```csharp
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
```
It reads one control line at a time (up to `NatsProtocol.MaxControlLineSize` = 4,096 bytes), identifies the command by the first two lowercased bytes, then dispatches to a per-command parse method. Commands with payloads (`PUB`, `HPUB`) set `_awaitingPayload = true` and return on the next call via `TryReadPayload` once enough bytes arrive. This handles TCP fragmentation without buffering the entire payload before parsing begins.
`ParsedCommand` is a `readonly struct` — zero heap allocation for control-only commands (`PING`, `PONG`, `SUB`, `UNSUB`, `CONNECT`). Payload commands allocate a `byte[]` for the payload body.
Command dispatch in `NatsClient.DispatchCommandAsync` covers: `Connect`, `Ping`/`Pong`, `Sub`, `Unsub`, `Pub`, `HPub`.
### NatsClient
`NatsClient` (`NatsClient.cs`) handles a single TCP connection. On `RunAsync`, it sends the initial `INFO` frame and then starts two concurrent tasks:
```csharp
var fillTask = FillPipeAsync(pipe.Writer, ct); // socket → PipeWriter
var processTask = ProcessCommandsAsync(pipe.Reader, ct); // PipeReader → parser → dispatch
```
`FillPipeAsync` reads from the `NetworkStream` into a `PipeWriter` in 4,096-byte chunks. `ProcessCommandsAsync` reads from the `PipeReader`, calls `NatsParser.TryParse` in a loop, and dispatches each `ParsedCommand`. The tasks share a `Pipe` instance from `System.IO.Pipelines`. Either task completing (EOF, cancellation, or error) causes `RunAsync` to return, which triggers cleanup via `Router.RemoveClient(this)`.
Write serialization uses a `SemaphoreSlim(1,1)` (`_writeLock`). All outbound writes (`SendMessageAsync`, `WriteAsync`) acquire this lock before touching the `NetworkStream`, preventing interleaved writes from concurrent message deliveries.
Subscription state is a `Dictionary<string, Subscription>` keyed by SID. This dictionary is accessed only from the single processing task, so no locking is needed. `SUB` inserts into this dictionary and into `SubList`; `UNSUB` either sets `MaxMessages` for auto-unsubscribe or immediately removes from both.
`NatsClient` exposes two interfaces to `NatsServer`:
```csharp
public interface IMessageRouter
{
void ProcessMessage(string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
}
public interface ISubListAccess
{
SubList SubList { get; }
}
```
`NatsServer` implements both. `NatsClient.Router` is set to the server instance immediately after construction.
### NatsServer
`NatsServer` (`NatsServer.cs`) owns the TCP listener, the shared `SubList`, and the client registry. Its `StartAsync` method runs the accept loop:
```csharp
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.Bind(new IPEndPoint(
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
_options.Port));
_listener.Listen(128);
// ...
while (!ct.IsCancellationRequested)
{
var socket = await _listener.AcceptAsync(ct);
// create NatsClient, fire-and-forget RunClientAsync
}
}
```
Each accepted connection gets a unique `clientId` (incremented via `Interlocked.Increment`), a scoped logger, and a `NatsClient` instance registered in `_clients` (`ConcurrentDictionary<ulong, NatsClient>`). `RunClientAsync` is fired as a detached task — the accept loop does not await it.
Message delivery happens in `ProcessMessage`:
1. Call `_subList.Match(subject)` to get a `SubListResult`.
2. Iterate `result.PlainSubs` — deliver to each subscriber, skipping the sender if `echo` is false.
3. Iterate `result.QueueSubs` — for each group, use round-robin (modulo `Interlocked.Increment`) to pick one member to receive the message.
Delivery via `SendMessageAsync` is fire-and-forget (not awaited) to avoid blocking the publishing client's processing task while waiting for slow subscribers.
---
## Message Flow
```
Client sends: PUB orders.new 12\r\nhello world\r\n
1. FillPipeAsync reads bytes from socket → PipeWriter
2. ProcessCommandsAsync reads from PipeReader
3. NatsParser.TryParse parses control line "PUB orders.new 12\r\n"
sets _awaitingPayload = true
on next call: reads payload + \r\n trailer
returns ParsedCommand { Type=Pub, Subject="orders.new", Payload=[...] }
4. DispatchCommandAsync calls ProcessPub(cmd)
5. ProcessPub calls Router.ProcessMessage("orders.new", null, default, payload, this)
6. NatsServer.ProcessMessage
→ _subList.Match("orders.new")
returns SubListResult { PlainSubs=[sub1, sub2], QueueSubs=[[sub3, sub4]] }
→ DeliverMessage(sub1, ...) → sub1.Client.SendMessageAsync(...)
→ DeliverMessage(sub2, ...) → sub2.Client.SendMessageAsync(...)
→ round-robin pick from [sub3, sub4], e.g. sub3
→ DeliverMessage(sub3, ...) → sub3.Client.SendMessageAsync(...)
7. SendMessageAsync acquires _writeLock, writes MSG frame to socket
```
---
## Go Reference Mapping
| Go source | .NET source |
|-----------|-------------|
| `server/sublist.go` | `src/NATS.Server/Subscriptions/SubList.cs` |
| `server/parser.go` | `src/NATS.Server/Protocol/NatsParser.cs` |
| `server/client.go` | `src/NATS.Server/NatsClient.cs` |
| `server/server.go` | `src/NATS.Server/NatsServer.cs` |
| `server/opts.go` | `src/NATS.Server/NatsOptions.cs` |
The Go `sublist.go` uses atomic generation counters to invalidate a result cache. The .NET `SubList` uses a different strategy: it maintains the cache under `ReaderWriterLockSlim` and does targeted invalidation at insert/remove time, avoiding the need for generation counters.
The Go `client.go` uses goroutines for `readLoop` and `writeLoop`. The .NET equivalent uses `async Task` with `System.IO.Pipelines`: `FillPipeAsync` (writer side) and `ProcessCommandsAsync` (reader side).
---
## Key .NET Design Choices
| Concern | .NET choice | Reason |
|---------|-------------|--------|
| I/O buffering | `System.IO.Pipelines` (`Pipe`, `PipeReader`, `PipeWriter`) | Zero-copy buffer management; backpressure built in |
| SubList thread safety | `ReaderWriterLockSlim` | Multiple concurrent readers (match), exclusive writers (insert/remove) |
| Client registry | `ConcurrentDictionary<ulong, NatsClient>` | Lock-free concurrent access from accept loop and cleanup tasks |
| Write serialization | `SemaphoreSlim(1,1)` per client | Prevents interleaved MSG frames from concurrent deliveries |
| Concurrency | `async/await` + `Task` | Maps Go goroutines to .NET task-based async; no dedicated threads per connection |
| Protocol constants | `NatsProtocol` static class | Pre-encoded byte arrays (`PongBytes`, `CrLf`, etc.) avoid per-call allocations |
---
## Related Documentation
- [Setup](./Setup.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Subscriptions Overview](../Subscriptions/Overview.md)
- [Server Overview](../Server/Overview.md)
- [Configuration Overview](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,183 @@
# Setup
This guide covers prerequisites, building, running, and testing the NATS .NET server.
## Prerequisites
### .NET 10 SDK
The project targets `net10.0` with `LangVersion preview` (configured in `Directory.Build.props`). Install the .NET 10 preview SDK from https://dotnet.microsoft.com/download.
Verify your installation:
```bash
dotnet --version
# Expected: 10.0.x
```
### Go Toolchain (optional)
The Go toolchain is only needed if you want to run the reference server from `golang/nats-server/` or execute Go test suites for cross-validation. It is not required for building or testing the .NET port.
```bash
go version
# Expected: go1.22 or later
```
---
## Building
The solution file uses the `.slnx` format (not `.sln`). Pass it explicitly if your tooling requires it.
```bash
# Build all projects
dotnet build
# Clean and rebuild
dotnet clean && dotnet build
```
The solution contains three projects:
| Project | Path |
|---------|------|
| `NATS.Server` | `src/NATS.Server/` |
| `NATS.Server.Host` | `src/NATS.Server.Host/` |
| `NATS.Server.Tests` | `tests/NATS.Server.Tests/` |
All projects share settings from `Directory.Build.props`: `net10.0` target framework, nullable reference types enabled, warnings treated as errors.
---
## Running
The server executable is `NATS.Server.Host`. With no arguments it binds to `0.0.0.0:4222`.
```bash
dotnet run --project src/NATS.Server.Host
```
### CLI Arguments
| Flag | Alias | Type | Default | Description |
|------|-------|------|---------|-------------|
| `-p` | `--port` | `int` | `4222` | TCP port to listen on |
| `-a` | `--addr` | `string` | `0.0.0.0` | Bind address |
| `-n` | `--name` | `string` | `nats-dotnet-<hostname>` | Server name reported in INFO |
```bash
# Custom port
dotnet run --project src/NATS.Server.Host -- -p 14222
# Custom address and name
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -p 4222 -n dev-server
```
The `--` separator is required to pass arguments through `dotnet run` to the application.
Startup log output (Serilog console sink):
```
[12:00:00 INF] Listening on 0.0.0.0:4222
```
---
## Testing
```bash
# Run all tests
dotnet test
# Run tests with verbose output
dotnet test -v normal
# Run a single test project
dotnet test tests/NATS.Server.Tests
# Run a specific test by name
dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubListTests"
```
### Test Stack
| Package | Version | Purpose |
|---------|---------|---------|
| `xunit` | 2.9.3 | Test framework |
| `xunit.runner.visualstudio` | 3.1.4 | VS/Rider test runner integration |
| `Shouldly` | 4.3.0 | Assertion library |
| `NSubstitute` | 5.3.0 | Mocking |
| `NATS.Client.Core` | 2.7.2 | Official NATS .NET client for integration tests |
| `coverlet.collector` | 6.0.4 | Code coverage |
Do not use FluentAssertions or Moq — the project uses Shouldly and NSubstitute exclusively.
### Test Files
| File | Covers |
|------|--------|
| `ParserTests.cs` | `NatsParser.TryParse` for each command type |
| `SubjectMatchTests.cs` | `SubjectMatch` validation and wildcard matching |
| `SubListTests.cs` | `SubList` trie insert, remove, match, and cache behaviour |
| `ClientTests.cs` | `NatsClient` command dispatch and subscription tracking |
| `ServerTests.cs` | `NatsServer` pub/sub, wildcards, queue groups |
| `IntegrationTests.cs` | End-to-end tests using `NATS.Client.Core` against a live server |
---
## NuGet: Central Package Management
Package versions are defined centrally in `Directory.Packages.props`. Individual `.csproj` files reference packages without a `Version` attribute:
```xml
<!-- Directory.Packages.props -->
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
```
```xml
<!-- NATS.Server.Tests.csproj -->
<PackageReference Include="Shouldly" />
<PackageReference Include="NSubstitute" />
```
To add a new dependency: add a `<PackageVersion>` entry in `Directory.Packages.props`, then add a `<PackageReference>` (without `Version`) in the relevant `.csproj`.
---
## Logging
The host configures Serilog via `Microsoft.Extensions.Logging` in `Program.cs`:
```csharp
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
```
`NatsServer` and `NatsClient` receive `ILoggerFactory` and `ILogger` respectively via constructor injection. The core library (`NATS.Server`) depends only on `Microsoft.Extensions.Logging.Abstractions` — it has no direct Serilog dependency. The Serilog packages are wired in `NATS.Server.Host`.
Per-client loggers are created with a scoped category name that includes the client ID:
```csharp
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
```
To adjust log levels at runtime, modify the `LoggerConfiguration` in `Program.cs`.
---
## Related Documentation
- [Architecture](./Architecture.md)
- [Configuration Overview](../Configuration/Overview.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,193 @@
# Operations Overview
This document covers running the server, graceful shutdown, connecting clients, and the test suite.
---
## Running the Server
The host application is `src/NATS.Server.Host`. Run it with `dotnet run`:
```bash
# Default (port 4222, bind 0.0.0.0)
dotnet run --project src/NATS.Server.Host
# Custom port
dotnet run --project src/NATS.Server.Host -- -p 14222
# Custom bind address and name
dotnet run --project src/NATS.Server.Host -- -a 127.0.0.1 -n my-server
```
Arguments after `--` are passed to the program. The three supported flags are `-p`/`--port`, `-a`/`--addr`, and `-n`/`--name`. See [Configuration Overview](../Configuration/Overview.md) for the full CLI reference.
On startup, the server logs the address it is listening on:
```
[14:32:01 INF] Listening on 0.0.0.0:4222
```
### Full host setup
`Program.cs` initializes Serilog, parses CLI arguments, starts the server, and handles graceful shutdown:
```csharp
using NATS.Server;
using Serilog;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var options = new NatsOptions();
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]);
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
break;
}
}
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
try
{
await server.StartAsync(cts.Token);
}
catch (OperationCanceledException) { }
finally
{
Log.CloseAndFlush();
}
```
---
## Graceful Shutdown
Pressing Ctrl+C triggers `Console.CancelKeyPress`. The handler sets `e.Cancel = true` — this prevents the process from terminating immediately — and calls `cts.Cancel()` to signal the `CancellationToken` passed to `server.StartAsync`.
`NatsServer.StartAsync` exits its accept loop on cancellation. In-flight client connections are left to drain naturally. After `StartAsync` returns (via `OperationCanceledException` which is caught), the `finally` block runs `Log.CloseAndFlush()` to ensure all buffered log output is written before the process exits.
---
## Testing
Run the full test suite with:
```bash
dotnet test
```
The test project is at `tests/NATS.Server.Tests/`. It uses xUnit with Shouldly for assertions.
### Test summary
69 tests across 6 test files:
| File | Tests | Coverage |
|------|-------|----------|
| `SubjectMatchTests.cs` | 33 | Subject validation and wildcard matching |
| `SubListTests.cs` | 12 | Trie insert, remove, match, queue groups, cache |
| `ParserTests.cs` | 14 | All command types, split packets, case insensitivity |
| `ClientTests.cs` | 2 | Socket-level INFO on connect, PING/PONG |
| `ServerTests.cs` | 3 | End-to-end accept, pub/sub, wildcard delivery |
| `IntegrationTests.cs` | 5 | NATS.Client.Core protocol compatibility |
### Test categories
**SubjectMatchTests** — 33 `[Theory]` cases verifying `SubjectMatch.IsValidSubject` (16 cases), `SubjectMatch.IsValidPublishSubject` (6 cases), and `SubjectMatch.MatchLiteral` (11 cases). Covers empty strings, leading/trailing dots, embedded spaces, `>` in non-terminal position, and all wildcard combinations.
**SubListTests** — 12 `[Fact]` tests exercising the `SubList` trie directly: literal insert and match, empty result, `*` wildcard at various token levels, `>` wildcard, root `>`, multiple overlapping subscriptions, remove, queue group grouping, `Count` tracking, and cache invalidation after a wildcard insert.
**ParserTests** — 14 `async [Fact]` tests that write protocol bytes into a `Pipe` and assert on the resulting `ParsedCommand` list. Covers `PING`, `PONG`, `CONNECT`, `SUB` (with and without queue group), `UNSUB` (with and without `max-messages`), `PUB` (with payload, with reply-to, zero payload), `HPUB` (with header), `INFO`, multiple commands in a single buffer, and case-insensitive parsing.
**ClientTests** — 2 `async [Fact]` tests using a real loopback socket pair. Verifies that `NatsClient` sends an `INFO` frame immediately on connection, and that it responds `PONG` to a `PING` after `CONNECT`.
**ServerTests** — 3 `async [Fact]` tests that start `NatsServer` on a random port. Verifies `INFO` on connect, basic pub/sub delivery (`MSG` format), and wildcard subscription matching.
**IntegrationTests** — 5 `async [Fact]` tests using the official `NATS.Client.Core` v2.7.2 NuGet package. Verifies end-to-end protocol compatibility with a real NATS client library: basic pub/sub, `*` wildcard delivery, `>` wildcard delivery, fan-out to two subscribers, and `PingAsync`.
Integration tests use `NullLoggerFactory.Instance` for the server so test output is not cluttered with server logs.
---
## Connecting with Standard NATS Clients
The server speaks the standard NATS wire protocol. Any NATS client library can connect to it.
```bash
# Using the nats CLI tool
nats sub "test.>" --server nats://127.0.0.1:4222
nats pub test.hello "world" --server nats://127.0.0.1:4222
```
From .NET, using `NATS.Client.Core`:
```csharp
var opts = new NatsOpts { Url = "nats://127.0.0.1:4222" };
await using var conn = new NatsConnection(opts);
await conn.ConnectAsync();
await conn.PublishAsync("test.hello", "world");
```
---
## Go Reference Server
The Go reference implementation can be built from `golang/nats-server/` for comparison testing:
```bash
# Build
cd golang/nats-server && go build
# Run on default port
./nats-server
# Run on custom port
./nats-server -p 14222
```
The Go server is useful for verifying that the .NET port produces identical protocol output and behaves the same way under edge-case client interactions.
---
## Current Limitations
The following features present in the Go reference server are not yet ported:
- Authentication — no username/password, token, NKey, or JWT support
- Clustering — no routes, gateways, or leaf nodes
- JetStream — no persistent streaming, streams, consumers, or RAFT
- Monitoring — no HTTP endpoints (`/varz`, `/connz`, `/healthz`, etc.)
- TLS — all connections are plaintext
- WebSocket — no WebSocket transport
---
## Related Documentation
- [Configuration Overview](../Configuration/Overview.md)
- [Server Overview](../Server/Overview.md)
- [Protocol Overview](../Protocol/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,151 @@
# Protocol Overview
NATS uses a line-oriented, text-based protocol over TCP. All commands are terminated by `\r\n`. This simplicity makes it easy to debug with raw TCP tools and keeps parsing overhead low.
## Command Reference
All commands flow either from server to client (S→C) or client to server (C→S). PING and PONG travel in both directions as part of the keepalive mechanism.
| Command | Direction | Format |
|---------|-----------|--------|
| INFO | S→C | `INFO {json}\r\n` |
| CONNECT | C→S | `CONNECT {json}\r\n` |
| PUB | C→S | `PUB subject [reply] size\r\n[payload]\r\n` |
| HPUB | C→S | `HPUB subject [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
| SUB | C→S | `SUB subject [queue] sid\r\n` |
| UNSUB | C→S | `UNSUB sid [max_msgs]\r\n` |
| MSG | S→C | `MSG subject sid [reply] size\r\n[payload]\r\n` |
| HMSG | S→C | `HMSG subject sid [reply] hdr_size total_size\r\n[headers+payload]\r\n` |
| PING/PONG | Both | `PING\r\n` / `PONG\r\n` |
| +OK/-ERR | S→C | `+OK\r\n` / `-ERR 'msg'\r\n` |
Arguments in brackets are optional. `sid` is the subscription ID string assigned by the client. Commands with a payload body (PUB, HPUB, MSG, HMSG) use a two-line structure: a control line with sizes, then the raw payload bytes, then a terminating `\r\n`.
## Connection Handshake
The handshake is always server-initiated:
1. Client opens TCP connection.
2. Server immediately sends `INFO {json}\r\n` describing itself.
3. Client sends `CONNECT {json}\r\n` with its options.
4. Normal operation begins (PUB, SUB, MSG, PING/PONG, etc.).
If `verbose` is enabled in `ClientOptions`, the server sends `+OK` after each valid client command. If the server rejects the CONNECT (bad credentials, unsupported protocol version, etc.) it sends `-ERR 'message'\r\n` and closes the connection.
## ServerInfo
The `ServerInfo` JSON payload is sent in the initial INFO message. The `ClientId` and `ClientIp` fields are omitted from JSON when not set.
```csharp
public sealed class ServerInfo
{
[JsonPropertyName("server_id")]
public required string ServerId { get; set; }
[JsonPropertyName("server_name")]
public required string ServerName { get; set; }
[JsonPropertyName("version")]
public required string Version { get; set; }
[JsonPropertyName("proto")]
public int Proto { get; set; } = NatsProtocol.ProtoVersion;
[JsonPropertyName("host")]
public required string Host { get; set; }
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("headers")]
public bool Headers { get; set; } = true;
[JsonPropertyName("max_payload")]
public int MaxPayload { get; set; } = NatsProtocol.MaxPayloadSize;
[JsonPropertyName("client_id")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public ulong ClientId { get; set; }
[JsonPropertyName("client_ip")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientIp { get; set; }
}
```
`headers` signals that the server supports HPUB/HMSG. `max_payload` advertises the largest message body the server accepts (default 1 MB). `proto` is the protocol version integer; the current value is `1`.
## ClientOptions
The `ClientOptions` JSON payload is sent by the client in the CONNECT command.
```csharp
public sealed class ClientOptions
{
[JsonPropertyName("verbose")]
public bool Verbose { get; set; }
[JsonPropertyName("pedantic")]
public bool Pedantic { get; set; }
[JsonPropertyName("echo")]
public bool Echo { get; set; } = true;
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("lang")]
public string? Lang { get; set; }
[JsonPropertyName("version")]
public string? Version { get; set; }
[JsonPropertyName("protocol")]
public int Protocol { get; set; }
[JsonPropertyName("headers")]
public bool Headers { get; set; }
[JsonPropertyName("no_responders")]
public bool NoResponders { get; set; }
}
```
`echo` defaults to `true`, meaning a client receives its own published messages if it has a matching subscription. Setting `echo` to `false` suppresses that. `no_responders` causes the server to send a status message when a request has no subscribers, rather than letting the client time out.
## Protocol Constants
`NatsProtocol` centralises limits and pre-encoded byte arrays to avoid repeated allocations in the hot path.
```csharp
public static class NatsProtocol
{
public const int MaxControlLineSize = 4096;
public const int MaxPayloadSize = 1024 * 1024; // 1MB
public const int DefaultPort = 4222;
// Pre-encoded protocol fragments
public static readonly byte[] CrLf = "\r\n"u8.ToArray();
public static readonly byte[] PingBytes = "PING\r\n"u8.ToArray();
public static readonly byte[] PongBytes = "PONG\r\n"u8.ToArray();
public static readonly byte[] OkBytes = "+OK\r\n"u8.ToArray();
public static readonly byte[] InfoPrefix = "INFO "u8.ToArray();
public static readonly byte[] MsgPrefix = "MSG "u8.ToArray();
public static readonly byte[] HmsgPrefix = "HMSG "u8.ToArray();
public static readonly byte[] ErrPrefix = "-ERR "u8.ToArray();
}
```
`MaxControlLineSize` (4096 bytes) is the maximum length of a command line before the payload. Any control line that exceeds this limit causes the parser to throw `ProtocolViolationException`. `MaxPayloadSize` (1 MB) is the default limit enforced by the parser; it is configurable per server instance.
## Go Reference
The Go implementation of protocol parsing is in `golang/nats-server/server/parser.go`. The .NET implementation follows the same command identification strategy and enforces the same control line and payload size limits.
## Related Documentation
- [Parser](Parser.md)
- [Server Overview](../Server/Overview.md)
- [Configuration Overview](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,271 @@
# Parser
`NatsParser` is a stateful byte-level parser that processes NATS protocol commands from a `ReadOnlySequence<byte>` provided by `System.IO.Pipelines`. It is called repeatedly in a read loop until no more complete commands are available in the buffer.
## Key Types
### CommandType
The `CommandType` enum identifies every command the parser can produce:
```csharp
public enum CommandType
{
Ping,
Pong,
Connect,
Info,
Pub,
HPub,
Sub,
Unsub,
Ok,
Err,
}
```
### ParsedCommand
`ParsedCommand` is a `readonly struct` that carries the result of a successful parse. Using a struct avoids a heap allocation per command on the fast path.
```csharp
public readonly struct ParsedCommand
{
public CommandType Type { get; init; }
public string? Subject { get; init; }
public string? ReplyTo { get; init; }
public string? Queue { get; init; }
public string? Sid { get; init; }
public int MaxMessages { get; init; }
public int HeaderSize { get; init; }
public ReadOnlyMemory<byte> Payload { get; init; }
public static ParsedCommand Simple(CommandType type) => new() { Type = type, MaxMessages = -1 };
}
```
Fields that do not apply to a given command type are left at their default values (`null` for strings, `0` for integers). `MaxMessages` uses `-1` as a sentinel meaning "unset" (relevant for UNSUB with no max). `HeaderSize` is set for HPUB/HMSG; `-1` indicates no headers. `Payload` carries the raw body bytes for PUB/HPUB, and the raw JSON bytes for CONNECT/INFO.
## TryParse
`TryParse` is the main entry point. It is called by the read loop after each `PipeReader.ReadAsync` completes.
```csharp
public bool TryParse(ref ReadOnlySequence<byte> buffer, out ParsedCommand command)
```
The method returns `true` and advances `buffer` past the consumed bytes when a complete command is available. It returns `false` — leaving `buffer` unchanged — when more data is needed. The caller must call `TryParse` in a loop until it returns `false`, then call `PipeReader.AdvanceTo` to signal how far the buffer was consumed.
If the parser detects a malformed command it throws `ProtocolViolationException`, which the read loop catches to close the connection.
## Command Identification
After locating the `\r\n` control line terminator, the parser lowercase-normalises the first two bytes using a bitwise OR with `0x20` and dispatches on them. This single branch handles both upper- and lowercase input without a string comparison or allocation.
```csharp
byte b0 = (byte)(lineSpan[0] | 0x20); // lowercase
byte b1 = (byte)(lineSpan[1] | 0x20);
switch (b0)
{
case (byte)'p':
if (b1 == (byte)'i') // PING
{
command = ParsedCommand.Simple(CommandType.Ping);
buffer = buffer.Slice(reader.Position);
return true;
}
if (b1 == (byte)'o') // PONG
{
command = ParsedCommand.Simple(CommandType.Pong);
buffer = buffer.Slice(reader.Position);
return true;
}
if (b1 == (byte)'u') // PUB
{
return ParsePub(lineSpan, ref buffer, reader.Position, out command);
}
break;
case (byte)'h':
if (b1 == (byte)'p') // HPUB
{
return ParseHPub(lineSpan, ref buffer, reader.Position, out command);
}
break;
case (byte)'s':
if (b1 == (byte)'u') // SUB
{
command = ParseSub(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'u':
if (b1 == (byte)'n') // UNSUB
{
command = ParseUnsub(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'c':
if (b1 == (byte)'o') // CONNECT
{
command = ParseConnect(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'i':
if (b1 == (byte)'n') // INFO
{
command = ParseInfo(lineSpan);
buffer = buffer.Slice(reader.Position);
return true;
}
break;
case (byte)'+': // +OK
command = ParsedCommand.Simple(CommandType.Ok);
buffer = buffer.Slice(reader.Position);
return true;
case (byte)'-': // -ERR
command = ParsedCommand.Simple(CommandType.Err);
buffer = buffer.Slice(reader.Position);
return true;
}
throw new ProtocolViolationException("Unknown protocol operation");
```
The two-character pairs are: `p+i` = PING, `p+o` = PONG, `p+u` = PUB, `h+p` = HPUB, `s+u` = SUB, `u+n` = UNSUB, `c+o` = CONNECT, `i+n` = INFO. `+` and `-` are matched on `b0` alone since their second characters are unambiguous.
## Two-Phase Parsing for PUB and HPUB
PUB and HPUB require a payload body that follows the control line. The parser handles split reads — where the TCP segment boundary falls inside the payload — through an `_awaitingPayload` state flag.
**Phase 1 — control line:** The parser reads the control line up to `\r\n`, extracts the subject, optional reply-to, and payload size(s), then stores these in private fields (`_pendingSubject`, `_pendingReplyTo`, `_expectedPayloadSize`, `_pendingHeaderSize`, `_pendingType`) and sets `_awaitingPayload = true`. It then immediately calls `TryReadPayload` to attempt phase 2.
**Phase 2 — payload read:** `TryReadPayload` checks whether `buffer.Length >= _expectedPayloadSize + 2` (the `+ 2` accounts for the trailing `\r\n`). If enough data is present, the payload bytes are copied to a new `byte[]`, the trailing `\r\n` is verified, the `ParsedCommand` is constructed, and `_awaitingPayload` is reset to `false`. If not enough data is present, `TryReadPayload` returns `false` and `_awaitingPayload` remains `true`.
On the next call to `TryParse`, the check at the top of the method routes straight to `TryReadPayload` without re-parsing the control line:
```csharp
if (_awaitingPayload)
return TryReadPayload(ref buffer, out command);
```
This means the parser correctly handles payloads that arrive across multiple `PipeReader.ReadAsync` completions without buffering the control line a second time.
## Zero-Allocation Argument Splitting
`SplitArgs` splits the argument portion of a control line into token ranges without allocating. The caller `stackalloc`s a `Span<Range>` sized to the maximum expected argument count for the command, then passes it to `SplitArgs`:
```csharp
internal static int SplitArgs(Span<byte> data, Span<Range> ranges)
{
int count = 0;
int start = -1;
for (int i = 0; i < data.Length; i++)
{
byte b = data[i];
if (b is (byte)' ' or (byte)'\t')
{
if (start >= 0)
{
if (count >= ranges.Length)
throw new ProtocolViolationException("Too many arguments");
ranges[count++] = start..i;
start = -1;
}
}
else
{
if (start < 0)
start = i;
}
}
if (start >= 0)
{
if (count >= ranges.Length)
throw new ProtocolViolationException("Too many arguments");
ranges[count++] = start..data.Length;
}
return count;
}
```
The returned `int` is the number of populated entries in `ranges`. Callers index into the original span using those ranges (e.g. `argsSpan[ranges[0]]`) to extract each token as a sub-span, then decode to `string` with `Encoding.ASCII.GetString`. Consecutive whitespace is collapsed: a new token only begins on a non-whitespace byte after one or more whitespace bytes.
## Decimal Integer Parsing
`ParseSize` converts an ASCII decimal integer in a byte span to an `int`. It is used for payload sizes and UNSUB max-message counts.
```csharp
internal static int ParseSize(Span<byte> data)
{
if (data.Length == 0 || data.Length > 9)
return -1;
int n = 0;
foreach (byte b in data)
{
if (b < (byte)'0' || b > (byte)'9')
return -1;
n = n * 10 + (b - '0');
}
return n;
}
```
The length cap of 9 digits prevents overflow without a checked-arithmetic check. A return value of `-1` signals a parse failure; callers treat this as a `ProtocolViolationException`.
## Error Handling
`ProtocolViolationException` is thrown for all malformed input:
- Control line exceeds `MaxControlLineSize` (4096 bytes).
- Unknown command bytes.
- Wrong number of arguments for a command.
- Payload size is negative, exceeds `MaxPayloadSize`, or the trailing `\r\n` after the payload is absent.
- `SplitArgs` receives more tokens than the caller's `ranges` span can hold.
The read loop is responsible for catching `ProtocolViolationException`, sending `-ERR` to the client, and closing the connection.
## Limits
| Limit | Value | Source |
|-------|-------|--------|
| Max control line | 4096 bytes | `NatsProtocol.MaxControlLineSize` |
| Max payload (default) | 1 048 576 bytes | `NatsProtocol.MaxPayloadSize` |
| Max size field digits | 9 | `ParseSize` length check |
The max payload is configurable: `NatsParser` accepts a `maxPayload` constructor argument, which `NatsClient` sets from `NatsOptions`.
## Go Reference
The .NET parser is a direct port of the state machine in `golang/nats-server/server/parser.go`. The Go implementation uses the same two-byte command identification technique and the same two-phase control-line/payload split for PUB and HPUB.
## Related Documentation
- [Protocol Overview](Overview.md)
- [Server Overview](../Server/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,375 @@
# Client Connection Handler
`NatsClient` manages the full lifecycle of one TCP connection: sending the initial INFO handshake, reading and parsing the incoming byte stream, dispatching protocol commands, and writing outbound messages. One `NatsClient` instance exists per accepted socket.
## Key Concepts
### Fields and properties
```csharp
public sealed class NatsClient : IDisposable
{
private readonly Socket _socket;
private readonly NetworkStream _stream;
private readonly NatsOptions _options;
private readonly ServerInfo _serverInfo;
private readonly NatsParser _parser;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly Dictionary<string, Subscription> _subs = new();
private readonly ILogger _logger;
public ulong Id { get; }
public ClientOptions? ClientOpts { get; private set; }
public IMessageRouter? Router { get; set; }
public bool ConnectReceived { get; private set; }
public long InMsgs;
public long OutMsgs;
public long InBytes;
public long OutBytes;
public IReadOnlyDictionary<string, Subscription> Subscriptions => _subs;
}
```
`_writeLock` is a `SemaphoreSlim(1, 1)` — a binary semaphore that serializes all writes to `_stream`. Without it, concurrent `SendMessageAsync` calls from different publisher threads would interleave bytes on the wire. See [Write serialization](#write-serialization) below.
`_subs` maps subscription IDs (SIDs) to `Subscription` objects. SIDs are client-assigned strings; `Dictionary<string, Subscription>` gives O(1) lookup for UNSUB processing.
The four stat fields (`InMsgs`, `OutMsgs`, `InBytes`, `OutBytes`) are `long` fields accessed via `Interlocked` operations throughout the hot path. They are exposed as public fields rather than properties to allow `Interlocked.Increment` and `Interlocked.Add` directly by reference.
`Router` is set by `NatsServer` after construction, before `RunAsync` is called. It is typed as `IMessageRouter?` rather than `NatsServer` so that tests can substitute a stub.
### Constructor
```csharp
public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo, ILogger logger)
{
Id = id;
_socket = socket;
_stream = new NetworkStream(socket, ownsSocket: false);
_options = options;
_serverInfo = serverInfo;
_logger = logger;
_parser = new NatsParser(options.MaxPayload);
}
```
`NetworkStream` is created with `ownsSocket: false`. This keeps socket lifetime management in `NatsServer`, which disposes the socket explicitly in `Dispose`. If `ownsSocket` were `true`, disposing the `NetworkStream` would close the socket, potentially racing with the disposal path in `NatsServer`.
`NatsParser` is constructed with `MaxPayload` from options. The parser enforces this limit: a payload larger than `MaxPayload` causes a `ProtocolViolationException` and terminates the connection.
## Connection Lifecycle
### RunAsync
`RunAsync` is the single entry point for a connection. `NatsServer` calls it as a fire-and-forget task.
```csharp
public async Task RunAsync(CancellationToken ct)
{
var pipe = new Pipe();
try
{
await SendInfoAsync(ct);
var fillTask = FillPipeAsync(pipe.Writer, ct);
var processTask = ProcessCommandsAsync(pipe.Reader, ct);
await Task.WhenAny(fillTask, processTask);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
}
finally
{
Router?.RemoveClient(this);
}
}
```
The method:
1. Sends `INFO {json}\r\n` immediately on connect — required by the NATS protocol before the client sends CONNECT.
2. Creates a `System.IO.Pipelines.Pipe` and starts two concurrent tasks: `FillPipeAsync` reads bytes from the socket into the pipe's write end; `ProcessCommandsAsync` reads from the pipe's read end and dispatches commands.
3. Awaits `Task.WhenAny`. Either task completing signals the connection is done — either the socket closed (fill task returns) or a protocol error caused the process task to throw.
4. In `finally`, calls `Router?.RemoveClient(this)` to clean up subscriptions and remove the client from the server's client dictionary.
`Router?.RemoveClient(this)` uses a null-conditional because `Router` could be null if the client is used in a test context without a server.
### FillPipeAsync
```csharp
private async Task FillPipeAsync(PipeWriter writer, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var memory = writer.GetMemory(4096);
int bytesRead = await _stream.ReadAsync(memory, ct);
if (bytesRead == 0)
break;
writer.Advance(bytesRead);
var result = await writer.FlushAsync(ct);
if (result.IsCompleted)
break;
}
}
finally
{
await writer.CompleteAsync();
}
}
```
`writer.GetMemory(4096)` requests at least 4096 bytes of buffer space from the pipe. The pipe may provide more. `_stream.ReadAsync` fills as many bytes as the OS delivers in one call. `writer.Advance(bytesRead)` commits those bytes. `writer.FlushAsync` makes them available to the reader.
When `bytesRead` is 0 the socket has closed. `writer.CompleteAsync()` in the `finally` block signals end-of-stream to the reader, which causes `ProcessCommandsAsync` to exit its loop on the next iteration.
### ProcessCommandsAsync
```csharp
private async Task ProcessCommandsAsync(PipeReader reader, CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
var result = await reader.ReadAsync(ct);
var buffer = result.Buffer;
while (_parser.TryParse(ref buffer, out var cmd))
{
await DispatchCommandAsync(cmd, ct);
}
reader.AdvanceTo(buffer.Start, buffer.End);
if (result.IsCompleted)
break;
}
}
finally
{
await reader.CompleteAsync();
}
}
```
`reader.ReadAsync` returns a `ReadResult` containing a `ReadOnlySequence<byte>`. The inner `while` loop calls `_parser.TryParse` repeatedly, which slices `buffer` forward past each complete command. When `TryParse` returns `false`, not enough data is available for a complete command.
`reader.AdvanceTo(buffer.Start, buffer.End)` uses the two-argument form: `buffer.Start` (the consumed position — data before this is discarded) and `buffer.End` (the examined position — the pipe knows to wake this task when more data arrives beyond this point). This is the standard `System.IO.Pipelines` backpressure pattern.
## Command Dispatch
`DispatchCommandAsync` switches on the `CommandType` returned by the parser:
```csharp
private async ValueTask DispatchCommandAsync(ParsedCommand cmd, CancellationToken ct)
{
switch (cmd.Type)
{
case CommandType.Connect:
ProcessConnect(cmd);
break;
case CommandType.Ping:
await WriteAsync(NatsProtocol.PongBytes, ct);
break;
case CommandType.Pong:
// Update RTT tracking (placeholder)
break;
case CommandType.Sub:
ProcessSub(cmd);
break;
case CommandType.Unsub:
ProcessUnsub(cmd);
break;
case CommandType.Pub:
case CommandType.HPub:
ProcessPub(cmd);
break;
}
}
```
### CONNECT
`ProcessConnect` deserializes the JSON payload into a `ClientOptions` record and sets `ConnectReceived = true`. `ClientOptions` carries the `echo` flag (default `true`), the client name, language, and version strings.
### PING / PONG
PING is responded to immediately with the pre-allocated `NatsProtocol.PongBytes` (`"PONG\r\n"`). The response goes through `WriteAsync`, which acquires the write lock. PONG handling is currently a placeholder for future RTT tracking.
### SUB
```csharp
private void ProcessSub(ParsedCommand cmd)
{
var sub = new Subscription
{
Subject = cmd.Subject!,
Queue = cmd.Queue,
Sid = cmd.Sid!,
};
_subs[cmd.Sid!] = sub;
sub.Client = this;
if (Router is ISubListAccess sl)
sl.SubList.Insert(sub);
}
```
A `Subscription` is stored in `_subs` (keyed by SID) and inserted into the shared `SubList` trie. The `Client` back-reference on `Subscription` is set to `this` so that `NatsServer.ProcessMessage` can reach the client from the subscription without a separate lookup.
`Router is ISubListAccess sl` checks the interface at runtime. In production, `Router` is `NatsServer`, which implements both interfaces. In tests using a stub `IMessageRouter` that does not implement `ISubListAccess`, the insert is silently skipped.
### UNSUB
```csharp
private void ProcessUnsub(ParsedCommand cmd)
{
if (!_subs.TryGetValue(cmd.Sid!, out var sub))
return;
if (cmd.MaxMessages > 0)
{
sub.MaxMessages = cmd.MaxMessages;
return;
}
_subs.Remove(cmd.Sid!);
if (Router is ISubListAccess sl)
sl.SubList.Remove(sub);
}
```
UNSUB has two modes:
- With `max_msgs > 0`: sets `sub.MaxMessages` to limit future deliveries. The subscription stays in the trie and the client's `_subs` dict. `DeliverMessage` in `NatsServer` checks `MessageCount` against `MaxMessages` on each delivery and silently drops messages beyond the limit.
- Without `max_msgs` (or `max_msgs == 0`): removes the subscription immediately from both `_subs` and the `SubList`.
### PUB and HPUB
```csharp
private void ProcessPub(ParsedCommand cmd)
{
Interlocked.Increment(ref InMsgs);
Interlocked.Add(ref InBytes, cmd.Payload.Length);
ReadOnlyMemory<byte> headers = default;
ReadOnlyMemory<byte> payload = cmd.Payload;
if (cmd.Type == CommandType.HPub && cmd.HeaderSize > 0)
{
headers = cmd.Payload[..cmd.HeaderSize];
payload = cmd.Payload[cmd.HeaderSize..];
}
Router?.ProcessMessage(cmd.Subject!, cmd.ReplyTo, headers, payload, this);
}
```
Stats are updated before routing. For HPUB, the combined payload from the parser is split into a header slice and a body slice using `cmd.HeaderSize`. Both slices are `ReadOnlyMemory<byte>` views over the same backing array — no copy. `Router.ProcessMessage` then delivers to all matching subscribers.
## Write Serialization
Multiple concurrent `SendMessageAsync` calls can arrive from different publisher connections at the same time. Without coordination, their writes would interleave on the socket and corrupt the message stream for the receiving client. `_writeLock` prevents this:
```csharp
public async Task SendMessageAsync(string subject, string sid, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload, CancellationToken ct)
{
Interlocked.Increment(ref OutMsgs);
Interlocked.Add(ref OutBytes, payload.Length + headers.Length);
byte[] line;
if (headers.Length > 0)
{
int totalSize = headers.Length + payload.Length;
line = Encoding.ASCII.GetBytes(
$"HMSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{headers.Length} {totalSize}\r\n");
}
else
{
line = Encoding.ASCII.GetBytes(
$"MSG {subject} {sid} {(replyTo != null ? replyTo + " " : "")}{payload.Length}\r\n");
}
await _writeLock.WaitAsync(ct);
try
{
await _stream.WriteAsync(line, ct);
if (headers.Length > 0)
await _stream.WriteAsync(headers, ct);
if (payload.Length > 0)
await _stream.WriteAsync(payload, ct);
await _stream.WriteAsync(NatsProtocol.CrLf, ct);
await _stream.FlushAsync(ct);
}
finally
{
_writeLock.Release();
}
}
```
The control line is constructed before acquiring the lock so the string formatting work happens outside the critical section. Once the lock is held, all writes for one message — control line, optional headers, payload, and trailing `\r\n` — happen atomically from the perspective of other writers.
Stats (`OutMsgs`, `OutBytes`) are updated before the lock because they are independent of the write ordering constraint.
## Subscription Cleanup
`RemoveAllSubscriptions` is called by `NatsServer.RemoveClient` when a connection ends:
```csharp
public void RemoveAllSubscriptions(SubList subList)
{
foreach (var sub in _subs.Values)
subList.Remove(sub);
_subs.Clear();
}
```
This removes every subscription this client holds from the shared `SubList` trie, then clears the local dictionary. After this call, no future `ProcessMessage` call will deliver to this client's subscriptions.
## Dispose
```csharp
public void Dispose()
{
_stream.Dispose();
_socket.Dispose();
_writeLock.Dispose();
}
```
Disposing `_stream` closes the network stream. Disposing `_socket` closes the OS socket. Any in-flight `ReadAsync` or `WriteAsync` will fault with an `ObjectDisposedException` or `IOException`, which causes the read/write tasks to terminate. `_writeLock` is disposed last to release the `SemaphoreSlim`'s internal handle.
## Go Reference
The Go counterpart is `golang/nats-server/server/client.go`. Key differences in the .NET port:
- Go uses separate goroutines for `readLoop` and `writeLoop`; the .NET port uses `FillPipeAsync` and `ProcessCommandsAsync` as concurrent `Task`s sharing a `Pipe`.
- Go uses dynamic buffer sizing (512 to 65536 bytes) in `readLoop`; the .NET port requests 4096-byte chunks from the `PipeWriter`.
- Go uses a mutex for write serialization (`c.mu`); the .NET port uses `SemaphoreSlim(1,1)` to allow `await`-based waiting without blocking a thread.
- The `System.IO.Pipelines` `Pipe` replaces Go's direct `net.Conn` reads. This separates the I/O pump from command parsing and avoids partial-read handling in the parser itself.
## Related Documentation
- [Server Overview](Overview.md)
- [Protocol Parser](../Protocol/Parser.md)
- [SubList Trie](../Subscriptions/SubList.md)
- [Subscriptions Overview](../Subscriptions/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,223 @@
# Server Overview
`NatsServer` is the top-level orchestrator: it binds the TCP listener, accepts incoming connections, and routes published messages to matching subscribers. Each connected client is managed by a `NatsClient` instance; `NatsServer` coordinates them through two interfaces that decouple message routing from connection management.
## Key Concepts
### Interfaces
`NatsServer` exposes two interfaces that `NatsClient` depends on:
```csharp
public interface IMessageRouter
{
void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender);
void RemoveClient(NatsClient client);
}
public interface ISubListAccess
{
SubList SubList { get; }
}
```
`IMessageRouter` is the surface `NatsClient` calls when a PUB command arrives. `ISubListAccess` gives `NatsClient` access to the shared `SubList` so it can insert and remove subscriptions directly — without needing a concrete reference to `NatsServer`. Both interfaces are implemented by `NatsServer`, and both are injected into `NatsClient` through the `Router` property after construction.
Defining them separately makes unit testing straightforward: a test can supply a stub `IMessageRouter` without standing up a real server.
### Fields and State
```csharp
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
private readonly NatsOptions _options;
private readonly ConcurrentDictionary<ulong, NatsClient> _clients = new();
private readonly SubList _subList = new();
private readonly ServerInfo _serverInfo;
private readonly ILogger<NatsServer> _logger;
private readonly ILoggerFactory _loggerFactory;
private Socket? _listener;
private ulong _nextClientId;
public SubList SubList => _subList;
}
```
`_clients` tracks every live connection. `_nextClientId` is incremented with `Interlocked.Increment` for each accepted socket, producing monotonically increasing client IDs without a lock. `_loggerFactory` is retained so per-client loggers can be created at accept time, each tagged with the client ID.
### Constructor
The constructor takes `NatsOptions` and `ILoggerFactory`. It builds a `ServerInfo` struct that is sent to every connecting client in the initial INFO message:
```csharp
public NatsServer(NatsOptions options, ILoggerFactory loggerFactory)
{
_options = options;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<NatsServer>();
_serverInfo = new ServerInfo
{
ServerId = Guid.NewGuid().ToString("N")[..20].ToUpperInvariant(),
ServerName = options.ServerName ?? $"nats-dotnet-{Environment.MachineName}",
Version = NatsProtocol.Version,
Host = options.Host,
Port = options.Port,
MaxPayload = options.MaxPayload,
};
}
```
The `ServerId` is derived from a GUID — taking the first 20 characters of its `"N"` format (32 hex digits, no hyphens) and uppercasing them. This matches the fixed-length alphanumeric server ID format used by the Go server.
## Accept Loop
`StartAsync` binds the socket, enables `SO_REUSEADDR` so the port can be reused immediately after a restart, and enters an async accept loop:
```csharp
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_listener.Bind(new IPEndPoint(
_options.Host == "0.0.0.0" ? IPAddress.Any : IPAddress.Parse(_options.Host),
_options.Port));
_listener.Listen(128);
while (!ct.IsCancellationRequested)
{
var socket = await _listener.AcceptAsync(ct);
var clientId = Interlocked.Increment(ref _nextClientId);
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
client.Router = this;
_clients[clientId] = client;
_ = RunClientAsync(client, ct);
}
}
```
`RunClientAsync` is fire-and-forget (`_ = ...`). The accept loop does not await it, so accepting new connections is not blocked by any single client's I/O. Each client runs concurrently on the thread pool.
The backlog of 128 passed to `Listen` controls the OS-level queue of unaccepted connections — matching the Go server default.
## Message Routing
`ProcessMessage` is called by `NatsClient` for every PUB or HPUB command. It is the hot path: called once per published message.
```csharp
public void ProcessMessage(string subject, string? replyTo, ReadOnlyMemory<byte> headers,
ReadOnlyMemory<byte> payload, NatsClient sender)
{
var result = _subList.Match(subject);
// Deliver to plain subscribers
foreach (var sub in result.PlainSubs)
{
if (sub.Client == null || sub.Client == sender && !(sender.ClientOpts?.Echo ?? true))
continue;
DeliverMessage(sub, subject, replyTo, headers, payload);
}
// Deliver to one member of each queue group (round-robin)
foreach (var queueGroup in result.QueueSubs)
{
if (queueGroup.Length == 0) continue;
var idx = Math.Abs((int)Interlocked.Increment(ref sender.OutMsgs)) % queueGroup.Length;
Interlocked.Decrement(ref sender.OutMsgs);
for (int attempt = 0; attempt < queueGroup.Length; attempt++)
{
var sub = queueGroup[(idx + attempt) % queueGroup.Length];
if (sub.Client != null && (sub.Client != sender || (sender.ClientOpts?.Echo ?? true)))
{
DeliverMessage(sub, subject, replyTo, headers, payload);
break;
}
}
}
}
```
### Plain subscriber delivery
Each subscription in `result.PlainSubs` receives the message unless:
- `sub.Client` is null (the subscription was removed concurrently), or
- the subscriber is the sender and the sender has `echo: false` in its CONNECT options.
The `echo` flag defaults to `true` in `ClientOptions`, so publishers receive their own messages unless they explicitly opt out.
### Queue group delivery
Queue groups provide load-balanced fan-out: exactly one member of each group receives each message. The selection uses a round-robin counter derived from `sender.OutMsgs`. An `Interlocked.Increment` picks the starting index; the `Interlocked.Decrement` immediately after undoes the side effect on the stat, since `OutMsgs` will be incremented correctly inside `SendMessageAsync` when the message is actually dispatched.
The loop walks from the selected index, wrapping around, until it finds an eligible member (non-null client, echo check). This handles stale subscriptions where the client has disconnected but the subscription object has not yet been cleaned up.
### DeliverMessage and auto-unsub
```csharp
private static void DeliverMessage(Subscription sub, string subject, string? replyTo,
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
{
var client = sub.Client;
if (client == null) return;
var count = Interlocked.Increment(ref sub.MessageCount);
if (sub.MaxMessages > 0 && count > sub.MaxMessages)
return;
_ = client.SendMessageAsync(subject, sub.Sid, replyTo, headers, payload, CancellationToken.None);
}
```
`MessageCount` is incremented atomically before the send. If it exceeds `MaxMessages` (set by an UNSUB with a message count argument), the message is silently dropped. The subscription itself is not removed here — removal happens when the client processes the count limit through `ProcessUnsub`, or when the client disconnects and `RemoveAllSubscriptions` is called.
`SendMessageAsync` is again fire-and-forget. Multiple deliveries to different clients happen concurrently.
## Client Removal
```csharp
public void RemoveClient(NatsClient client)
{
_clients.TryRemove(client.Id, out _);
client.RemoveAllSubscriptions(_subList);
}
```
`RemoveClient` is called either from `RunClientAsync`'s `finally` block (after a client disconnects or errors) or from `NatsClient.RunAsync`'s own `finally` block. Both paths may call it; `TryRemove` is idempotent, so double-calls are safe. After removal from `_clients`, all subscriptions belonging to that client are purged from the `SubList` trie and its internal cache.
## Shutdown and Dispose
```csharp
public void Dispose()
{
_listener?.Dispose();
foreach (var client in _clients.Values)
client.Dispose();
_subList.Dispose();
}
```
Disposing the listener socket causes `AcceptAsync` to throw, which unwinds `StartAsync`. Client sockets are disposed, which closes their `NetworkStream` and causes their read loops to terminate. `SubList.Dispose` releases its `ReaderWriterLockSlim`.
## Go Reference
The Go counterpart is `golang/nats-server/server/server.go`. Key differences in the .NET port:
- Go uses goroutines for the accept loop and per-client read/write loops; the .NET port uses `async`/`await` with `Task`.
- Go uses `sync/atomic` for client ID generation; the .NET port uses `Interlocked.Increment`.
- Go passes the server to clients via the `srv` field on the client struct; the .NET port uses the `IMessageRouter` interface through the `Router` property.
## Related Documentation
- [Client Connection Handler](Client.md)
- [SubList Trie](../Subscriptions/SubList.md)
- [Protocol Overview](../Protocol/Overview.md)
- [Configuration](../Configuration/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,169 @@
# Subscriptions Overview
The subscription system maps published subjects to interested subscribers. It consists of four classes: `Subscription` (the subscription model), `SubListResult` (a match result container), `SubjectMatch` (subject validation and wildcard matching), and `SubList` (the trie that routes messages to subscribers).
Go reference: `golang/nats-server/server/sublist.go`
---
## Subjects
A NATS subject is a dot-separated string of tokens: `foo.bar.baz`. Each token is a non-empty sequence of non-whitespace characters. The token separator is `.`.
### Wildcards
Two wildcard tokens are supported:
| Token | Name | Matches |
|-------|------|---------|
| `*` | Partial wildcard (Pwc) | Exactly one token at that position |
| `>` | Full wildcard (Fwc) | One or more remaining tokens |
Rules:
- `foo.*` matches `foo.bar` but not `foo.bar.baz` (only one token after `foo`).
- `foo.>` matches `foo.bar` and `foo.bar.baz` (one or more tokens after `foo`).
- `>` must be the last token in a subject. `foo.>.bar` is invalid.
- Publishers cannot use wildcards — only subscribers can.
### Queue Groups
Subscribers with the same queue name form a queue group. When a message matches a queue group, exactly one member of that group receives the message. The server selects a member per published message, which distributes load across the group.
Queue subscriptions are independent of plain subscriptions. If a subject has both plain subscribers and queue group subscribers, all plain subscribers receive the message and one member from each queue group receives it.
---
## The `Subscription` Class
A `Subscription` represents one subscriber's interest in a subject.
```csharp
public sealed class Subscription
{
public required string Subject { get; init; }
public string? Queue { get; init; }
public required string Sid { get; init; }
public long MessageCount; // Interlocked
public long MaxMessages; // 0 = unlimited
public NatsClient? Client { get; set; }
}
```
- `Subject` — the subject pattern, which may contain wildcards.
- `Queue` — the queue group name. `null` means this is a plain subscription.
- `Sid` — the subscription ID string assigned by the client. Used to identify the subscription in `UNSUB` commands.
- `MessageCount` — updated with `Interlocked.Increment` on each delivered message. Compared against `MaxMessages` to implement `UNSUB maxMessages`.
- `MaxMessages` — the auto-unsubscribe limit. `0` means unlimited.
- `Client` — back-reference to the `NatsClient` that owns this subscription. Used during message delivery to write `MSG` frames to the connection.
---
## The `SubListResult` Class
`SubListResult` is the return type of `SubList.Match()`. It separates plain subscribers from queue group subscribers.
```csharp
public sealed class SubListResult
{
public static readonly SubListResult Empty = new([], []);
public Subscription[] PlainSubs { get; }
public Subscription[][] QueueSubs { get; } // outer = groups, inner = members
public SubListResult(Subscription[] plainSubs, Subscription[][] queueSubs)
{
PlainSubs = plainSubs;
QueueSubs = queueSubs;
}
}
```
- `PlainSubs` — all plain subscribers whose subject pattern matches the published subject.
- `QueueSubs` — one entry per queue group. Each inner array contains all members of that group. The message router picks one member per group.
- `Empty` — a static singleton returned when no subscriptions match. Avoids allocation on the common case of no interest.
---
## The `SubjectMatch` Class
`SubjectMatch` is a static utility class for subject validation and wildcard pattern matching. It defines the three fundamental constants used throughout the subscription system:
```csharp
public static class SubjectMatch
{
public const char Pwc = '*'; // partial wildcard
public const char Fwc = '>'; // full wildcard
public const char Sep = '.'; // token separator
...
}
```
### Validation methods
**`IsValidSubject(string subject)`** — returns `true` if the subject is a well-formed NATS subject. Rejects:
- Empty strings.
- Empty tokens (consecutive separators, or leading/trailing separators).
- Tokens containing whitespace characters.
- Any token after `>` (full wildcard must be last).
**`IsLiteral(string subject)`** — returns `true` if the subject contains no wildcards. A character is treated as a wildcard only when it appears as a complete token (preceded and followed by `.` or the string boundary).
**`IsValidPublishSubject(string subject)`** — returns `true` if the subject is both valid and literal. Publishers must use literal subjects; this is the combined check applied before inserting a message into the routing path.
### Pattern matching
**`MatchLiteral(string literal, string pattern)`** — tests whether a literal subject matches a pattern that may contain wildcards. Used by the cache invalidation logic to determine which cached results are affected when a wildcard subscription is added or removed.
```csharp
public static bool MatchLiteral(string literal, string pattern)
{
int li = 0, pi = 0;
while (pi < pattern.Length)
{
// Get next pattern token
int pTokenStart = pi;
while (pi < pattern.Length && pattern[pi] != Sep)
pi++;
int pTokenLen = pi - pTokenStart;
if (pi < pattern.Length)
pi++; // skip separator
// Full wildcard -- matches everything remaining
if (pTokenLen == 1 && pattern[pTokenStart] == Fwc)
return li < literal.Length; // must have at least one token left
// Get next literal token
if (li >= literal.Length)
return false;
int lTokenStart = li;
while (li < literal.Length && literal[li] != Sep)
li++;
int lTokenLen = li - lTokenStart;
if (li < literal.Length)
li++; // skip separator
// Partial wildcard -- matches any single token
if (pTokenLen == 1 && pattern[pTokenStart] == Pwc)
continue;
// Literal comparison
if (pTokenLen != lTokenLen)
return false;
if (string.Compare(literal, lTokenStart, pattern, pTokenStart, pTokenLen, StringComparison.Ordinal) != 0)
return false;
}
return li >= literal.Length; // both exhausted
}
```
The algorithm walks both strings token by token without allocating. `>` short-circuits immediately, returning `true` as long as at least one literal token remains. `*` skips the corresponding literal token without comparison. Literal tokens are compared with `StringComparison.Ordinal`.
---
## Related Documentation
- [SubList Trie](../Subscriptions/SubList.md)
<!-- Last verified against codebase: 2026-02-22 -->

View File

@@ -0,0 +1,242 @@
# SubList
`SubList` is the subscription routing trie. Every published message triggers a `Match()` call to find all interested subscribers. `SubList` stores subscriptions indexed by their subject tokens and returns a `SubListResult` containing both plain subscribers and queue groups.
Go reference: `golang/nats-server/server/sublist.go`
---
## Thread Safety
`SubList` uses a `ReaderWriterLockSlim` (`_lock`) with the following locking discipline:
| Operation | Lock |
|-----------|------|
| `Count` read | Read lock |
| `Match()` — cache hit | Read lock only |
| `Match()` — cache miss | Write lock (to update cache) |
| `Insert()` | Write lock |
| `Remove()` | Write lock |
Cache misses in `Match()` require a write lock because the cache must be updated after the trie traversal. To avoid a race between the read-lock check and the write-lock update, `Match()` uses double-checked locking: after acquiring the write lock, it checks the cache again before doing trie work.
---
## Trie Structure
The trie is built from two private classes, `TrieLevel` and `TrieNode`, nested inside `SubList`.
### `TrieLevel` and `TrieNode`
```csharp
private sealed class TrieLevel
{
public readonly Dictionary<string, TrieNode> Nodes = new(StringComparer.Ordinal);
public TrieNode? Pwc; // partial wildcard (*)
public TrieNode? Fwc; // full wildcard (>)
}
private sealed class TrieNode
{
public TrieLevel? Next;
public readonly HashSet<Subscription> PlainSubs = [];
public readonly Dictionary<string, HashSet<Subscription>> QueueSubs = new(StringComparer.Ordinal);
public bool IsEmpty => PlainSubs.Count == 0 && QueueSubs.Count == 0 &&
(Next == null || (Next.Nodes.Count == 0 && Next.Pwc == null && Next.Fwc == null));
}
```
Each level in the trie represents one token position in a subject. A `TrieLevel` holds:
- `Nodes` — a dictionary keyed by literal token string, mapping to the `TrieNode` for that token. Uses `StringComparer.Ordinal` for performance.
- `Pwc` — the node for the `*` wildcard at this level, or `null` if no `*` subscriptions exist at this depth.
- `Fwc` — the node for the `>` wildcard at this level, or `null` if no `>` subscriptions exist at this depth.
A `TrieNode` sits at the boundary between two levels. It holds the subscriptions registered for subjects whose last token leads to this node:
- `PlainSubs` — a `HashSet<Subscription>` of plain (non-queue) subscribers.
- `QueueSubs` — a dictionary from queue name to the set of members in that queue group. Uses `StringComparer.Ordinal`.
- `Next` — the next `TrieLevel` for deeper token positions. `null` for leaf nodes.
- `IsEmpty``true` when the node and all its descendants have no subscriptions. Used during `Remove()` to prune dead branches.
The trie root is a `TrieLevel` (`_root`) with no parent node.
### `TokenEnumerator`
`TokenEnumerator` is a `ref struct` that splits a subject string by `.` without allocating. It operates on a `ReadOnlySpan<char>` derived from the original string.
```csharp
private ref struct TokenEnumerator
{
private ReadOnlySpan<char> _remaining;
public TokenEnumerator(string subject)
{
_remaining = subject.AsSpan();
Current = default;
}
public ReadOnlySpan<char> Current { get; private set; }
public TokenEnumerator GetEnumerator() => this;
public bool MoveNext()
{
if (_remaining.IsEmpty)
return false;
int sep = _remaining.IndexOf(SubjectMatch.Sep);
if (sep < 0)
{
Current = _remaining;
_remaining = default;
}
else
{
Current = _remaining[..sep];
_remaining = _remaining[(sep + 1)..];
}
return true;
}
}
```
`TokenEnumerator` implements the `foreach` pattern directly (via `GetEnumerator()` returning `this`), so it can be used in `foreach` loops without boxing. `Insert()` uses it during trie traversal to avoid string allocations per token.
---
## Insert
`Insert(Subscription sub)` adds a subscription to the trie under a write lock.
The method walks the trie one token at a time using `TokenEnumerator`. For each token:
- If the token is `*`, it creates or follows `level.Pwc`.
- If the token is `>`, it creates or follows `level.Fwc` and sets `sawFwc = true` to reject further tokens.
- Otherwise it creates or follows `level.Nodes[token]`.
At each step, `node.Next` is created if absent, and `level` advances to `node.Next`.
After all tokens are consumed, the subscription is added to the terminal node:
- Plain subscription: `node.PlainSubs.Add(sub)`.
- Queue subscription: `node.QueueSubs[sub.Queue].Add(sub)`, creating the inner `HashSet<Subscription>` if this is the first member of that group.
`_count` is incremented and `AddToCache` is called to update any cached results that would now include this subscription.
---
## Remove
`Remove(Subscription sub)` removes a subscription from the trie under a write lock.
The method walks the trie along the subscription's subject, recording the path as a `List<(TrieLevel, TrieNode, string token, bool isPwc, bool isFwc)>`. If any node along the path is missing, the method returns without error (the subscription was never inserted).
After locating the terminal node, the subscription is removed from `PlainSubs` or from the appropriate `QueueSubs` group. If the queue group becomes empty, its entry is removed from the dictionary.
If removal succeeds:
- `_count` is decremented.
- `RemoveFromCache` is called to invalidate affected cache entries.
- The path list is walked backwards. At each step, if `node.IsEmpty` is `true`, the node is removed from its parent level (`Pwc = null`, `Fwc = null`, or `Nodes.Remove(token)`). This prunes dead branches so the trie does not accumulate empty nodes over time.
---
## Match
`Match(string subject)` is called for every published message. It returns a `SubListResult` containing all matching plain and queue subscriptions.
### Cache check and fallback
```csharp
public SubListResult Match(string subject)
{
// Check cache under read lock first.
_lock.EnterReadLock();
try
{
if (_cache != null && _cache.TryGetValue(subject, out var cached))
return cached;
}
finally
{
_lock.ExitReadLock();
}
// Cache miss -- tokenize and match under write lock (needed for cache update).
var tokens = Tokenize(subject);
if (tokens == null)
return SubListResult.Empty;
_lock.EnterWriteLock();
try
{
// Re-check cache after acquiring write lock.
if (_cache != null && _cache.TryGetValue(subject, out var cached))
return cached;
var plainSubs = new List<Subscription>();
var queueSubs = new List<List<Subscription>>();
MatchLevel(_root, tokens, 0, plainSubs, queueSubs);
...
if (_cache != null)
{
_cache[subject] = result;
if (_cache.Count > CacheMax) { /* sweep */ }
}
return result;
}
finally { _lock.ExitWriteLock(); }
}
```
On a read-lock cache hit, `Match()` returns immediately with no trie traversal. On a miss, `Tokenize()` splits the subject before acquiring the write lock (subjects with empty tokens return `SubListResult.Empty` immediately). The write lock is then taken and the cache is checked again before invoking `MatchLevel`.
### `MatchLevel` traversal
`MatchLevel` is a recursive method that descends the trie matching tokens against the subject array. At each level, for each remaining token position:
1. If `level.Fwc` is set, all subscriptions from that node are added to the result. The `>` wildcard matches all remaining tokens, so no further recursion is needed for this branch.
2. If `level.Pwc` is set, `MatchLevel` recurses with the next token index and `pwc.Next` as the new level. This handles `*` matching the current token.
3. A literal dictionary lookup on `level.Nodes[tokens[i]]` advances the level pointer for the next iteration.
After all tokens are consumed, subscriptions from the final literal node and the final `*` position (if present at the last level) are added to the result. The `*` case at the last token requires explicit handling because the loop exits before the recursive call for `*` can execute.
`AddNodeToResults` flattens a node's `PlainSubs` into the accumulator list and merges its `QueueSubs` groups into the queue accumulator, combining groups by name across multiple matching nodes.
---
## Cache Strategy
The cache is a `Dictionary<string, SubListResult>` keyed by the literal published subject. All operations use `StringComparer.Ordinal`.
**Size limits:** The cache holds at most `CacheMax` (1024) entries. When `_cache.Count` exceeds this, a sweep removes entries until the count reaches `CacheSweep` (256). The sweep takes the first `count - 256` keys from the dictionary — no LRU ordering is maintained.
**`AddToCache`** is called from `Insert()` to keep cached results consistent after adding a subscription:
- For a literal subscription subject, `AddToCache` does a direct lookup. If the exact key is in the cache, it creates a new `SubListResult` with the subscription appended and replaces the cached entry.
- For a wildcard subscription subject, `AddToCache` scans all cached keys and updates any entry whose key is matched by `SubjectMatch.MatchLiteral(key, subject)`.
**`RemoveFromCache`** is called from `Remove()` to invalidate cached results after removing a subscription:
- For a literal subscription subject, `RemoveFromCache` removes the exact cache key.
- For a wildcard subscription subject, `RemoveFromCache` removes all cached keys matched by the pattern. Because it is difficult to reconstruct the correct result without a full trie traversal, invalidation is preferred over update.
The asymmetry between `AddToCache` (updates in place) and `RemoveFromCache` (invalidates) avoids a second trie traversal on removal at the cost of a cache miss on the next `Match()` for those keys.
---
## Disposal
`SubList` implements `IDisposable`. `Dispose()` releases the `ReaderWriterLockSlim`:
```csharp
public void Dispose() => _lock.Dispose();
```
`SubList` instances are owned by `NatsServer` and disposed during server shutdown.
---
## Related Documentation
- [Subscriptions Overview](../Subscriptions/Overview.md)
<!-- Last verified against codebase: 2026-02-22 -->

416
differences.md Normal file
View File

@@ -0,0 +1,416 @@
# Go vs .NET NATS Server: Functionality Differences
> Excludes clustering/routes, gateways, leaf nodes, and JetStream.
> Generated 2026-02-22 by comparing `golang/nats-server/server/` against `src/NATS.Server/`.
---
## 1. Core Server Lifecycle
### Server Initialization
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| NKey generation (server identity) | Y | Y | Ed25519 key pair via NATS.NKeys at startup |
| System account setup | Y | Y | `$SYS` account with InternalEventSystem, event publishing, request-reply services |
| Config file validation on startup | Y | Y | Full config parsing with error collection via `ConfigProcessor` |
| PID file writing | Y | Y | Written on startup, deleted on shutdown |
| Profiling HTTP endpoint (`/debug/pprof`) | Y | Stub | `ProfPort` option exists but endpoint not implemented |
| Ports file output | Y | Y | JSON ports file written to `PortsFileDir` on startup |
### Accept Loop
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Exponential backoff on accept errors | Y | Y | .NET backs off from 10ms to 1s on repeated failures |
| Config reload lock during client creation | Y | N | Go holds `reloadMu` around `createClient` |
| Goroutine/task tracking (WaitGroup) | Y | Y | `Interlocked` counter + drain with 10s timeout on shutdown |
| Callback-based error handling | Y | N | Go uses `errFunc` callback pattern |
| Random/ephemeral port (port=0) | Y | Y | Port resolved after `Bind`+`Listen`, stored in `_options.Port` |
### Shutdown
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Graceful shutdown with `WaitForShutdown()` | Y | Y | Idempotent CAS-guarded `ShutdownAsync()` + blocking `WaitForShutdown()` |
| Close reason tracking per connection | Y | Y | 37-value `ClosedState` enum, CAS-based first-writer-wins `MarkClosed()` |
| Lame duck mode (stop new, drain existing) | Y | Y | `LameDuckShutdownAsync()` with grace period + stagger-close with jitter |
| Wait for accept loop completion | Y | Y | `TaskCompletionSource` signaled in accept loop `finally` |
| Flush pending data before close | Y | Y | `FlushAndCloseAsync()` with best-effort flush, skip-flush for error conditions |
### Signal Handling
| Signal | Go | .NET | Notes |
|--------|:--:|:----:|-------|
| SIGINT (Ctrl+C) | Y | Y | Both handle graceful shutdown |
| SIGTERM | Y | Y | `PosixSignalRegistration` triggers `ShutdownAsync()` |
| SIGUSR1 (reopen logs) | Y | Y | SIGUSR1 handler calls ReOpenLogFile |
| SIGUSR2 (lame duck mode) | Y | Y | Triggers `LameDuckShutdownAsync()` |
| SIGHUP (config reload) | Y | Y | Re-parses config, diffs options, applies reloadable subset; CLI flags preserved |
| Windows Service integration | Y | Y | `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices` |
---
## 2. Client / Connection Handling
### Concurrency Model
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Separate read + write loops | Y | Y | Channel-based `RunWriteLoopAsync` with `QueueOutbound()` |
| Write coalescing / batch flush | Y | Y | Write loop drains all channel items before single `FlushAsync` |
| Dynamic buffer sizing (512B-64KB) | Y | N | .NET delegates to `System.IO.Pipelines` |
| Output buffer pooling (3-tier) | Y | N | Go pools at 512B, 4KB, 64KB |
### Connection Types
| Type | Go | .NET | Notes |
|------|:--:|:----:|-------|
| CLIENT | Y | Y | |
| ROUTER | Y | N | Excluded per scope |
| GATEWAY | Y | N | Excluded per scope |
| LEAF | Y | N | Excluded per scope |
| SYSTEM (internal) | Y | Y | InternalClient + InternalEventSystem with Channel-based send/receive loops |
| JETSTREAM (internal) | Y | N | |
| ACCOUNT (internal) | Y | Y | Lazy per-account InternalClient with import/export subscription support |
| WebSocket clients | Y | N | |
| MQTT clients | Y | N | |
### Client Features
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Echo suppression (`echo: false`) | Y | Y | .NET checks echo in delivery path (NatsServer.cs:234,253) |
| Verbose mode (`+OK` responses) | Y | Y | Sends `+OK` after CONNECT, SUB, UNSUB, PUB when `verbose:true` |
| No-responders validation | Y | Y | CONNECT rejects `no_responders` without `headers`; 503 HMSG on no match |
| Slow consumer detection | Y | Y | Pending bytes threshold (64MB) + write deadline timeout (10s) |
| Write deadline / timeout policies | Y | Y | `WriteDeadline` option with `CancellationTokenSource.CancelAfter` on flush |
| RTT measurement | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
| Per-client trace mode | Y | Y | `SetTraceMode()` toggles parser logger dynamically via `ClientFlags.TraceMode` |
| Detailed close reason tracking | Y | Y | 37-value `ClosedState` enum with CAS-based `MarkClosed()` |
| Connection state flags (16 flags) | Y | Y | 7-flag `ClientFlagHolder` with `Interlocked.Or`/`And` |
### Slow Consumer Handling
Go implements a sophisticated slow consumer detection system:
- Tracks `pendingBytes` per client output buffer
- If pending exceeds `maxPending`, enters stall mode (2-5ms waits)
- Total stall capped at 10ms per read cycle
- Closes with `SlowConsumerPendingBytes` or `SlowConsumerWriteDeadline`
- Sets `isSlowConsumer` flag for monitoring
.NET now implements pending bytes tracking and write deadline enforcement via `Channel<ReadOnlyMemory<byte>>`. Key differences from Go: no stall/retry mode (immediate close on threshold exceeded), write deadline via `CancellationTokenSource.CancelAfter` instead of `SetWriteDeadline`. `IsSlowConsumer` flag and server-level `SlowConsumerCount` stats are tracked for monitoring.
### Stats Tracking
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Per-connection atomic stats | Y | Y | .NET uses `Interlocked` for stats access |
| Per-read-cycle stat batching | Y | Y | Local accumulators flushed via `Interlocked.Add` per read cycle |
| Per-account stats | Y | Y | `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes per `Account` |
| Slow consumer counters | Y | Y | `SlowConsumers` and `SlowConsumerClients` incremented on detection |
---
## 3. Protocol Parsing
### Parser Architecture
| Aspect | Go | .NET |
|--------|-----|------|
| Approach | Byte-by-byte state machine (74 states) | Two-phase: line extraction + command dispatch |
| Case handling | Per-state character checks | Bit-mask lowercase normalization (`\| 0x20`) |
| Buffer strategy | Jump-ahead optimization for payloads | Direct size-based reads via Pipe |
| Split-buffer handling | argBuf accumulation with scratch buffer | State variables (`_awaitingPayload`, etc.) |
| Error model | Inline error sending + error return | Exception-based (`ProtocolViolationException`) |
| CRLF in payload | Included in message buffer | Excluded by design |
### Protocol Operations
| Operation | Go | .NET | Notes |
|-----------|:--:|:----:|-------|
| PUB | Y | Y | |
| HPUB (headers) | Y | Y | |
| SUB | Y | Y | |
| UNSUB | Y | Y | |
| CONNECT | Y | Y | |
| INFO | Y | Y | |
| PING / PONG | Y | Y | |
| MSG / HMSG | Y | Y | |
| +OK / -ERR | Y | Y | |
| RS+/RS-/RMSG (routes) | Y | N | Excluded per scope |
| A+/A- (accounts) | Y | N | Excluded per scope |
| LS+/LS-/LMSG (leaf) | Y | N | Excluded per scope |
### Protocol Parsing Gaps
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Multi-client-type command routing | Y | N | Go checks `c.kind` to allow/reject commands |
| Protocol tracing in parser | Y | Y | `TraceInOp()` logs `<<- OP arg` at `LogLevel.Trace` via optional `ILogger` |
| Subject mapping (input→output) | Y | Y | Compiled `SubjectTransform` engine with 9 function tokens; wired into `ProcessMessage` |
| MIME header parsing | Y | Y | `NatsHeaderParser.Parse()` — status line + key-value headers from `ReadOnlySpan<byte>` |
| Message trace event initialization | Y | N | |
### Protocol Writing
| Aspect | Go | .NET | Notes |
|--------|:--:|:----:|-------|
| INFO serialization | Once at startup | Once at startup | Cached at startup; nonce connections serialize per-connection |
| MSG/HMSG construction | Direct buffer write | Span-based buffer write | `int.TryFormat` + `CopyTo` into rented buffer, no string allocations |
| Pre-encoded constants | Y | Y | Both pre-encode PING/PONG/OK |
---
## 4. Subscriptions & Subject Matching
### Trie Implementation
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Basic trie with `*`/`>` wildcards | Y | Y | Core matching identical |
| Queue group support | Y | Y | |
| Result caching (1024 max) | Y | Y | Same limits |
| `plist` optimization (>256 subs) | Y | N | Go converts high-fanout nodes to array |
| Async cache sweep (background) | Y | N | .NET sweeps inline under write lock |
| Atomic generation ID for invalidation | Y | Y | `Interlocked.Increment` on insert/remove; cached results store generation |
| Cache eviction strategy | Random | First-N | Semantic difference minimal |
### SubList Features
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| `Stats()` — comprehensive statistics | Y | Y | Matches, cache hits, inserts, removes tracked via `Interlocked` |
| `HasInterest()` — fast bool check | Y | Y | Walks trie without allocating result list |
| `NumInterest()` — fast count | Y | Y | Counts plain + queue subs without allocation |
| `ReverseMatch()` — pattern→literal query | Y | Y | Finds subscriptions whose wildcards match a literal subject |
| `RemoveBatch()` — efficient bulk removal | Y | Y | Single generation increment for batch; increments `_removes` per sub |
| `All()` — enumerate all subscriptions | Y | Y | Recursive trie walk returning all subscriptions |
| Notification system (interest changes) | Y | N | |
| Local/remote subscription filtering | Y | N | |
| Queue weight expansion (remote subs) | Y | N | |
| `MatchBytes()` — zero-copy byte API | Y | N | |
### Subject Validation
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Basic validation (empty tokens, wildcards) | Y | Y | |
| Literal subject check | Y | Y | |
| UTF-8/null rune validation | Y | Y | `IsValidSubject(string, bool checkRunes)` rejects null bytes |
| Collision detection (`SubjectsCollide`) | Y | Y | Token-by-token wildcard comparison; O(n) via upfront `Split` |
| Token utilities (`tokenAt`, `numTokens`) | Y | Y | `TokenAt` returns `ReadOnlySpan<char>`; `NumTokens` counts separators |
| Stack-allocated token buffer | Y | N | Go uses `[32]string{}` on stack |
### Subscription Lifecycle
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Per-account subscription limit | Y | Y | `Account.IncrementSubscriptions()` returns false when `MaxSubscriptions` exceeded |
| Auto-unsubscribe on max messages | Y | Y | Enforced at delivery; sub removed from trie + client dict when exhausted |
| Subscription routing propagation | Y | N | For clusters |
| Queue weight (`qw`) field | Y | N | For remote queue load balancing |
---
## 5. Authentication & Authorization
### Auth Mechanisms
| Mechanism | Go | .NET | Notes |
|-----------|:--:|:----:|-------|
| Username/password | Y | Y | |
| Token | Y | Y | |
| NKeys (Ed25519) | Y | Y | .NET has framework but integration is basic |
| JWT validation | Y | Y | `NatsJwt` decode/verify, `JwtAuthenticator` with account resolution + revocation |
| Bcrypt password hashing | Y | Y | .NET supports bcrypt (`$2*` prefix) with constant-time fallback |
| TLS certificate mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
| Custom auth interface | Y | N | |
| External auth callout | Y | N | |
| Proxy authentication | Y | N | |
| Bearer tokens | Y | Y | `UserClaims.BearerToken` skips nonce signature verification |
| User revocation tracking | Y | Y | Per-account `ConcurrentDictionary` with wildcard (`*`) revocation support |
### Account System
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Per-account SubList isolation | Y | Y | |
| Multi-account user resolution | Y | Y | `AccountConfig` per account in `NatsOptions.Accounts`; `GetOrCreateAccount` wires limits |
| Account exports/imports | Y | Y | ServiceImport/StreamImport with ExportAuth, subject transforms, response routing |
| Per-account connection limits | Y | Y | `Account.AddClient()` returns false when `MaxConnections` exceeded |
| Per-account subscription limits | Y | Y | `Account.IncrementSubscriptions()` enforced in `ProcessSub()` |
| Account JetStream limits | Y | N | Excluded per scope |
### Permissions
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Publish allow list | Y | Y | |
| Subscribe allow list | Y | Y | |
| Publish deny list | Y | Y | Full enforcement with LRU-cached results |
| Subscribe deny list | Y | Y | Queue-aware deny checking in `IsSubscribeAllowed` |
| Message-level deny filtering | Y | Y | `IsDeliveryAllowed()` checked before MSG send; auto-unsub cleanup on deny |
| Permission caching (128 entries) | Y | Y | `PermissionLruCache` — Dictionary+LinkedList LRU, matching Go's `maxPermCacheSize` |
| Response permissions (reply tracking) | Y | Y | `ResponseTracker` with configurable TTL + max messages; not LRU-cached |
| Auth expiry enforcement | Y | Y | `Task.Delay` timer closes client when JWT/auth expires |
| Permission templates (JWT) | Y | Y | `PermissionTemplates.Expand()` — 6 functions with cartesian product for multi-value tags |
---
## 6. Configuration
### CLI Flags
| Flag | Go | .NET | Notes |
|------|:--:|:----:|-------|
| `-p/--port` | Y | Y | |
| `-a/--addr` | Y | Y | |
| `-n/--name` (ServerName) | Y | Y | |
| `-m/--http_port` (monitoring) | Y | Y | |
| `-c` (config file) | Y | Y | Full config parsing: lexer → parser → processor; CLI args override config |
| `-D/-V/-DV` (debug/trace) | Y | Y | `-D`/`--debug` for debug, `-V`/`-T`/`--trace` for trace, `-DV` for both |
| `--tlscert/--tlskey/--tlscacert` | Y | Y | |
| `--tlsverify` | Y | Y | |
| `--http_base_path` | Y | Y | |
| `--https_port` | Y | Y | |
### Configuration System
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Config file parsing | Y | Y | Custom NATS conf lexer/parser ported from Go; supports includes, variables, blocks |
| Hot reload (SIGHUP) | Y | Y | Reloads logging, auth, limits, TLS certs on SIGHUP; rejects non-reloadable changes |
| Config change detection | Y | Y | SHA256 digest comparison; `InCmdLine` tracks CLI flag precedence |
| ~450 option fields | Y | ~72 | .NET covers core + all single-server options; cluster/JetStream keys silently ignored |
### Missing Options Categories
- ~~Logging options~~ — file logging, rotation, syslog, debug/trace, color, timestamps, per-subsystem log control all implemented
- ~~Advanced limits (MaxSubs, MaxSubTokens, MaxPending, WriteDeadline)~~ — `MaxSubs`, `MaxSubTokens` implemented; MaxPending/WriteDeadline already existed
- ~~Tags/metadata~~ — `Tags` dictionary implemented in `NatsOptions`
- ~~OCSP configuration~~ — `OcspConfig` with 4 modes (Auto/Always/Must/Never), peer verification, and stapling
- WebSocket/MQTT options
- ~~Operator mode / account resolver~~ — `JwtAuthenticator` + `IAccountResolver` + `MemAccountResolver` with trusted keys
---
## 7. Monitoring
### HTTP Endpoints
| Endpoint | Go | .NET | Notes |
|----------|:--:|:----:|-------|
| `/healthz` | Y | Y | |
| `/varz` | Y | Y | |
| `/connz` | Y | Y | |
| `/` (root listing) | Y | Y | |
| `/routez` | Y | Stub | Returns empty response |
| `/gatewayz` | Y | Stub | Returns empty response |
| `/leafz` | Y | Stub | Returns empty response |
| `/subz` / `/subscriptionsz` | Y | Y | Account filtering, test subject filtering, pagination, and subscription details |
| `/accountz` | Y | Stub | Returns empty response |
| `/accstatz` | Y | Stub | Returns empty response |
| `/jsz` | Y | Stub | Returns empty response |
### Varz Response
| Field Category | Go | .NET | Notes |
|----------------|:--:|:----:|-------|
| Identity (ID, Name, Version) | Y | Y | |
| Network (Host, Port, URLs) | Y | Y | |
| Security (AuthRequired, TLS) | Y | Y | |
| Limits (MaxConn, MaxPayload) | Y | Y | |
| Timing (Start, Now, Uptime) | Y | Y | |
| Runtime (Mem, CPU, Cores) | Y | Y | |
| Connections (current, total) | Y | Y | |
| Messages (in/out msgs/bytes) | Y | Y | |
| SlowConsumer breakdown | Y | N | Go tracks per connection type |
| Cluster/Gateway/Leaf blocks | Y | N | Excluded per scope |
| JetStream block | Y | N | Excluded per scope |
| TLS cert expiry info | Y | Y | `TlsCertNotAfter` loaded via `X509CertificateLoader` in `/varz` |
### Connz Response
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Filtering by CID, user, account | Y | Partial | |
| Sorting (11 options) | Y | Y | All options including ByStop, ByReason, ByRtt |
| State filtering (open/closed/all) | Y | Y | `state=open|closed|all` query parameter |
| Closed connection tracking | Y | Y | `ConcurrentQueue<ClosedClient>` capped at 10,000 entries |
| Pagination (offset, limit) | Y | Y | |
| Subscription detail mode | Y | N | |
| TLS peer certificate info | Y | N | |
| JWT/IssuerKey/Tags fields | Y | N | |
| MQTT client ID filtering | Y | N | |
| Proxy info | Y | N | |
---
## 8. TLS
### TLS Modes
| Mode | Go | .NET | Notes |
|------|:--:|:----:|-------|
| No TLS | Y | Y | |
| INFO-first (default NATS) | Y | Y | |
| TLS-first (before INFO) | Y | Y | |
| Mixed/Fallback | Y | Y | |
| TLS-required | Y | Y | |
### TLS Features
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| PEM cert/key loading | Y | Y | |
| CA chain validation | Y | Y | |
| Mutual TLS (client certs) | Y | Y | |
| Certificate pinning (SHA256 SPKI) | Y | Y | |
| TLS handshake timeout | Y | Y | |
| TLS rate limiting | Y | Y | Rate enforcement with refill; unit tests cover rate limiting and refill |
| First-byte peeking (0x16 detection) | Y | Y | |
| Cert subject→user mapping | Y | Y | X500DistinguishedName with full DN match and CN fallback |
| OCSP stapling | Y | Y | `SslStreamCertificateContext.Create` with `offline:false` for runtime OCSP fetch |
| Min TLS version control | Y | Y | |
---
## 9. Logging
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Structured logging | Partial | Y | .NET uses Serilog with ILogger<T> |
| File logging with rotation | Y | Y | `-l`/`--log_file` flag + `LogSizeLimit`/`LogMaxFiles` via Serilog.Sinks.File |
| Syslog (local and remote) | Y | Y | `--syslog` and `--remote_syslog` flags via Serilog.Sinks.SyslogMessages |
| Log reopening (SIGUSR1) | Y | Y | SIGUSR1 handler calls ReOpenLogFile callback |
| Trace mode (protocol-level) | Y | Y | `-V`/`-T`/`--trace` flags; parser `TraceInOp()` logs at Trace level |
| Debug mode | Y | Y | `-D`/`--debug` flag lowers Serilog minimum to Debug |
| Per-subsystem log control | Y | Y | `--log_level_override ns=level` CLI flag with Serilog `MinimumLevel.Override` |
| Color output on TTY | Y | Y | Auto-detected via `Console.IsOutputRedirected`, uses `AnsiConsoleTheme.Code` |
| Timestamp format control | Y | Y | `--logtime` and `--logtime_utc` flags |
---
## 10. Ping/Pong & Keepalive
| Feature | Go | .NET | Notes |
|---------|:--:|:----:|-------|
| Server-initiated PING | Y | Y | |
| Configurable interval | Y | Y | PingInterval option |
| Max pings out | Y | Y | MaxPingsOut option |
| Stale connection close | Y | Y | |
| RTT-based first PING delay | Y | Y | Skips PING until FirstPongSent or 2s elapsed |
| RTT tracking | Y | Y | `_rttStartTicks`/`Rtt` property, computed on PONG receipt |
| Stale connection stats | Y | Y | `StaleConnectionStats` model, exposed in `/varz` |
---
## Summary: Critical Gaps for Production Use
### Resolved Since Initial Audit
The following items from the original gap list have been implemented:
- **Slow consumer detection** — pending bytes threshold (64MB) with write deadline enforcement
- **Write coalescing / batch flush** — channel-based write loop drains all items before single flush
- **Verbose mode** — `+OK` responses for CONNECT, SUB, UNSUB, PUB when `verbose:true`
- **Permission deny enforcement at delivery** — `IsDeliveryAllowed` + auto-unsub cleanup
- **No-responders validation** — CONNECT rejects `no_responders` without `headers`; 503 HMSG on no match
- **File logging with rotation** — Serilog.Sinks.File with rolling file support
- **TLS certificate mapping** — X500DistinguishedName with full DN match and CN fallback
- **Protocol tracing** — `-V`/`-T` flag enables trace-level logging; `-D` for debug
- **Subscription statistics** — `Stats()`, `HasInterest()`, `NumInterest()`, etc.
- **Per-account limits** — connection + subscription limits via `AccountConfig`
- **Reply subject tracking** — `ResponseTracker` with TTL + max messages
- **JWT authentication** — `JwtAuthenticator` with decode/verify, account resolution, revocation, permission templates
- **OCSP support** — peer verification via `X509RevocationMode.Online`, stapling via `SslStreamCertificateContext`
- **Subject mapping** — compiled `SubjectTransform` engine with 9 function tokens, wired into message delivery
- **Windows Service integration** — `--service` flag with `Microsoft.Extensions.Hosting.WindowsServices`
- **Per-subsystem log control** — `--log_level_override` CLI flag with Serilog overrides
- **Per-client trace mode** — `SetTraceMode()` with dynamic parser logger toggling
- **Per-account stats** — `Interlocked` counters for InMsgs/OutMsgs/InBytes/OutBytes
- **TLS cert expiry in /varz** — `TlsCertNotAfter` populated via `X509CertificateLoader`
- **Permission templates** — `PermissionTemplates.Expand()` with 6 functions and cartesian product
- **Bearer tokens** — `UserClaims.BearerToken` skips nonce verification
- **User revocation** — per-account tracking with wildcard (`*`) revocation
- **Config file parsing** — custom lexer/parser ported from Go; supports includes, variables, nested blocks, size suffixes
- **Hot reload (SIGHUP)** — re-parses config, diffs changes, validates reloadable set, applies with CLI precedence
- **SYSTEM client type** — InternalClient with InternalEventSystem, Channel-based send/receive loops, event publishing
- **ACCOUNT client type** — lazy per-account InternalClient with import/export subscription support
- **System event publishing** — connect/disconnect advisories, server stats, shutdown/lame-duck events, auth errors
- **System request-reply services** — $SYS.REQ.SERVER.*.VARZ/CONNZ/SUBSZ/HEALTHZ/IDZ/STATSZ with ping wildcards
- **Account exports/imports** — service and stream imports with ExportAuth, subject transforms, response routing, latency tracking
### Remaining Lower Priority
1. **Dynamic buffer sizing** — delegated to Pipe, less optimized for long-lived connections

View File

@@ -0,0 +1,286 @@
# Authentication Design
**Date:** 2026-02-22
**Scope:** Username/password, token, NKeys, permissions, core account isolation
**Approach:** Strategy pattern with pluggable authenticators
## Overview
Port the NATS server authentication subsystem from Go (`server/auth.go`, `server/accounts.go`) to .NET. The system authenticates clients during the CONNECT phase using nonce-based challenge-response (NKeys), password comparison (plain/bcrypt), or bearer tokens. Each authenticated user can have per-subject publish/subscribe permissions. Clients are assigned to accounts that provide subscription isolation.
## Core Auth Models
### User (username/password auth)
```csharp
public sealed class User
{
public required string Username { get; init; }
public required string Password { get; init; } // plain or bcrypt ($2...)
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public DateTimeOffset? ConnectionDeadline { get; init; }
}
```
### NKeyUser (Ed25519 auth)
```csharp
public sealed class NKeyUser
{
public required string Nkey { get; init; } // public nkey
public Permissions? Permissions { get; init; }
public Account? Account { get; init; }
public string? SigningKey { get; init; }
}
```
### Permissions
```csharp
public sealed class Permissions
{
public SubjectPermission? Publish { get; init; }
public SubjectPermission? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
}
public sealed class SubjectPermission
{
public IReadOnlyList<string>? Allow { get; init; }
public IReadOnlyList<string>? Deny { get; init; }
}
public sealed class ResponsePermission
{
public int MaxMsgs { get; init; }
public TimeSpan Expires { get; init; }
}
```
### Account
Per-account isolation with its own SubList:
```csharp
public sealed class Account
{
public string Name { get; }
public SubList SubList { get; }
public ConcurrentDictionary<ulong, NatsClient> Clients { get; }
public Permissions? DefaultPermissions { get; set; }
}
```
Server holds `ConcurrentDictionary<string, Account>` with lazy global account (`DEFAULT`).
## Authentication Flow
```
Client connects
-> Server sends INFO { auth_required: true, nonce: "..." }
-> Client sends CONNECT { user, pass, token, nkey, sig }
-> AuthService.Authenticate(context)
|- NKeyAuthenticator (if nkeys configured)
|- UserPasswordAuthenticator (if users configured)
|- TokenAuthenticator (if token configured)
|- SimpleUserPasswordAuthenticator (if single user configured)
'- NoAuthUser fallback (if configured)
-> Success: assign permissions, assign account
-> Failure: -ERR 'Authorization Violation', disconnect
```
### Interfaces
```csharp
public interface IAuthenticator
{
AuthResult? Authenticate(ClientAuthContext context);
}
public sealed class ClientAuthContext
{
public required ClientOptions Opts { get; init; }
public required byte[] Nonce { get; init; }
public required NatsClient Client { get; init; }
}
public sealed class AuthResult
{
public required string Identity { get; init; }
public Account Account { get; init; }
public Permissions? Permissions { get; init; }
public DateTimeOffset? Expiry { get; init; }
}
```
### AuthService
Orchestrates authenticators in priority order (matching Go's `processClientOrLeafAuthentication`):
```csharp
public sealed class AuthService
{
private readonly List<IAuthenticator> _authenticators;
public AuthResult? Authenticate(ClientAuthContext context);
public bool IsAuthRequired { get; }
}
```
### Concrete Authenticators
- **NKeyAuthenticator** - looks up nkey in server map, verifies Ed25519 signature against nonce using NATS.NKeys
- **UserPasswordAuthenticator** - looks up username in server map, compares password (bcrypt via BCrypt.Net-Next, or constant-time plain comparison)
- **TokenAuthenticator** - constant-time comparison against configured token
- **SimpleUserPasswordAuthenticator** - for single `username`/`password` option (no map lookup)
### Auth Timeout
`CancellationTokenSource` with configurable timeout (default 1 second, matching Go `DEFAULT_AUTH_TIMEOUT`). If CONNECT not received and authenticated within window, disconnect client.
### Nonce Generation
Per-client nonce: 11 random bytes, base64url-encoded to 15 chars (matching Go). Generated using `RandomNumberGenerator.Fill()`. Included in INFO only when auth is required.
### Password Comparison
- Bcrypt passwords (prefixed `$2`): use `BCrypt.Net.BCrypt.Verify()` (constant-time)
- Plain passwords: use `CryptographicOperations.FixedTimeEquals()` (constant-time)
## Permission Enforcement
### ClientPermissions
```csharp
public sealed class ClientPermissions
{
public PermissionSet? Publish { get; init; }
public PermissionSet? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
private readonly ConcurrentDictionary<string, bool> _pubCache = new();
public bool IsPublishAllowed(string subject);
public bool IsSubscribeAllowed(string subject, string? queue = null);
}
public sealed class PermissionSet
{
public SubList? Allow { get; init; }
public SubList? Deny { get; init; }
}
```
### Logic (matches Go)
1. No permissions set -> allow everything
2. Allow list exists -> subject must match allow list
3. Deny list exists -> subject must NOT match deny list
4. Subscribe checks queue groups against queue-specific entries
### Integration Points
- `ProcessPub()` - check `IsPublishAllowed(subject)` before routing. `-ERR 'Permissions Violation for Publish to "<subject>"'` on deny.
- `ProcessSub()` - check `IsSubscribeAllowed(subject, queue)` before adding subscription. `-ERR 'Permissions Violation for Subscription to "<subject>"'` on deny.
## Configuration Changes
### NatsOptions
```csharp
public sealed class NatsOptions
{
// ... existing fields ...
// Simple auth (single user)
public string? Username { get; set; }
public string? Password { get; set; }
public string? Authorization { get; set; } // bearer token
// Multiple users/nkeys
public IReadOnlyList<User>? Users { get; set; }
public IReadOnlyList<NKeyUser>? NKeys { get; set; }
// Default/fallback
public string? NoAuthUser { get; set; }
// Auth timing
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(1);
}
```
### ServerInfo Additions
```csharp
public bool AuthRequired { get; set; }
public string? Nonce { get; set; }
```
### ClientOptions Additions
```csharp
[JsonPropertyName("user")]
public string? Username { get; set; }
[JsonPropertyName("pass")]
public string? Password { get; set; }
[JsonPropertyName("auth_token")]
public string? Token { get; set; }
[JsonPropertyName("nkey")]
public string? Nkey { get; set; }
[JsonPropertyName("sig")]
public string? Sig { get; set; }
```
## Server/Client Integration
### NatsServer
- Build `AuthService` during startup based on options
- Maintain `ConcurrentDictionary<string, Account>` with lazy global account
- Build `users` and `nkeys` lookup maps from options
- Generate per-client nonce when `AuthService.IsAuthRequired`
- Include `AuthRequired` and `Nonce` in per-client INFO
### NatsClient
- Store `ClientPermissions` after successful auth
- Store assigned `Account` reference
- Auth timeout via `CancellationTokenSource` on CONNECT wait
- Register/unregister with account's client set on connect/disconnect
- PUB messages route through account's SubList for isolation
## NuGet Packages
- `NATS.NKeys` - Ed25519 NKey operations
- `BCrypt.Net-Next` - bcrypt password hashing
## Testing Strategy
### Unit Tests
- `TokenAuthenticatorTests` - correct/wrong token, constant-time comparison
- `UserPasswordAuthenticatorTests` - plain password, bcrypt password, wrong password, unknown user
- `NKeyAuthenticatorTests` - valid/invalid signature, unknown nkey
- `AuthServiceTests` - priority ordering, NoAuthUser fallback, auth-not-required case
- `ClientPermissionsTests` - allow/deny lists, wildcards, queue groups, publish cache
### Account Tests
- Two accounts configured, cross-account isolation verified
### Integration Tests
- Connect with valid token/user-pass/nkey -> success
- Connect with wrong credentials -> `-ERR`, disconnect
- Connect with no credentials when auth required -> `-ERR`, disconnect
- NoAuthUser configured -> success with default permissions
- Auth timeout -> disconnect
- Publish/subscribe to denied subjects -> `-ERR 'Permissions Violation ...'`
## Go Reference Files
- `server/auth.go` - Core authentication logic
- `server/accounts.go` - Account struct and management
- `server/client.go` - CONNECT processing, RegisterUser, RegisterNkeyUser, permission checks
- `server/nkey.go` - Nonce generation
- `server/opts.go` - Auth configuration parsing

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"planPath": "docs/plans/2026-02-22-authentication-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Add NuGet packages (NATS.NKeys, BCrypt.Net-Next)", "status": "pending"},
{"id": 1, "subject": "Task 1: Add auth fields to protocol types", "status": "pending", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Add auth config to NatsOptions + model types", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Implement Account type with per-account SubList", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Implement TokenAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 5, "subject": "Task 5: Implement UserPasswordAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 6, "subject": "Task 6: Implement SimpleUserPasswordAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 7, "subject": "Task 7: Implement NKeyAuthenticator", "status": "pending", "blockedBy": [2]},
{"id": 8, "subject": "Task 8: Implement AuthService orchestrator", "status": "pending", "blockedBy": [3, 4, 5, 6, 7]},
{"id": 9, "subject": "Task 9: Implement ClientPermissions", "status": "pending", "blockedBy": [2]},
{"id": 10, "subject": "Task 10: Integrate auth into NatsServer and NatsClient", "status": "pending", "blockedBy": [8, 9]},
{"id": 11, "subject": "Task 11: Implement account isolation in message routing", "status": "pending", "blockedBy": [10]},
{"id": 12, "subject": "Task 12: Add permission enforcement integration tests", "status": "pending", "blockedBy": [10]},
{"id": 13, "subject": "Task 13: Add NKey integration test", "status": "pending", "blockedBy": [10]},
{"id": 14, "subject": "Task 14: Final regression test and cleanup", "status": "pending", "blockedBy": [11, 12, 13]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

View File

@@ -0,0 +1,139 @@
# Core Server Lifecycle — Design
Implements all gaps from section 1 of `differences.md` (Core Server Lifecycle).
Reference: `golang/nats-server/server/server.go`, `client.go`, `signal.go`
## Components
### 1. ClosedState Enum & Close Reason Tracking
New file `src/NATS.Server/ClosedState.cs` — full Go enum (37 values from `client.go:188-228`).
- `NatsClient` gets `CloseReason` property, `MarkClosed(ClosedState)` method
- Close reason set in `RunAsync` finally blocks based on exception type
- Error-related reasons (ReadError, WriteError, TLSHandshakeError) skip flush on close
- `NatsServer.RemoveClient` logs close reason via structured logging
### 2. Accept Loop Exponential Backoff
Port Go's `acceptError` pattern from `server.go:4607-4627`.
- Constants: `AcceptMinSleep = 10ms`, `AcceptMaxSleep = 1s`
- On `SocketException`: sleep `tmpDelay`, double it, cap at 1s
- On success: reset to 10ms
- During sleep: check `_quitCts` to abort if shutting down
- Non-temporary errors break the loop
### 3. Ephemeral Port (port=0)
After `_listener.Bind()` + `Listen()`, resolve actual port:
```csharp
if (_options.Port == 0)
{
var actualPort = ((IPEndPoint)_listener.LocalEndPoint!).Port;
_options.Port = actualPort;
_serverInfo.Port = actualPort;
}
```
Add public `Port` property on `NatsServer` exposing the resolved port.
### 4. Graceful Shutdown with WaitForShutdown
New fields on `NatsServer`:
- `_shutdown` (volatile bool)
- `_shutdownComplete` (TaskCompletionSource)
- `_quitCts` (CancellationTokenSource) — internal shutdown signal
`ShutdownAsync()` sequence:
1. Guard: if already shutting down, return
2. Set `_shutdown = true`, cancel `_quitCts`
3. Close `_listener` (stops accept loop)
4. Close all client connections with `ServerShutdown` reason
5. Wait for active client tasks to drain
6. Stop monitor server
7. Signal `_shutdownComplete`
`WaitForShutdown()`: blocks on `_shutdownComplete.Task`.
`Dispose()`: calls `ShutdownAsync` synchronously if not already shut down.
### 5. Task Tracking
Track active client tasks for clean shutdown:
- `_activeClientCount` (int, Interlocked)
- `_allClientsExited` (TaskCompletionSource, signaled when count hits 0 during shutdown)
- Increment in `AcceptClientAsync`, decrement in `RunClientAsync` finally block
- `ShutdownAsync` waits on `_allClientsExited` with timeout
### 6. Flush Pending Data Before Close
`NatsClient.FlushAndCloseAsync(bool minimalFlush)`:
- If not skip-flush reason: flush stream with 100ms write deadline
- Close socket
`MarkClosed(ClosedState)` sets skip-flush flag for: ReadError, WriteError, SlowConsumerPendingBytes, SlowConsumerWriteDeadline, TLSHandshakeError.
### 7. Lame Duck Mode
New options: `LameDuckDuration` (default 2min), `LameDuckGracePeriod` (default 10s).
`LameDuckShutdownAsync()`:
1. Set `_lameDuckMode = true`
2. Close listener (stop new connections)
3. Wait `LameDuckGracePeriod` (10s default) for clients to drain naturally
4. Stagger-close remaining clients over `LameDuckDuration - GracePeriod`
- Sleep interval = remaining duration / client count (min 1ms, max 1s)
- Randomize slightly to avoid reconnect storms
5. Call `ShutdownAsync()` for final cleanup
Accept loop: on error, if `_lameDuckMode`, exit cleanly.
### 8. PID File & Ports File
New options: `PidFile` (string?), `PortsFileDir` (string?).
PID file: `File.WriteAllText(pidFile, Process.GetCurrentProcess().Id.ToString())`
Ports file: JSON with `{ "client": port, "monitor": monitorPort }` written to `{dir}/{exe}_{pid}.ports`
Written at startup, deleted at shutdown.
### 9. Signal Handling
In `Program.cs`, use `PosixSignalRegistration` (.NET 6+):
- `SIGTERM``server.ShutdownAsync()` then exit
- `SIGUSR2``server.LameDuckShutdownAsync()`
- `SIGUSR1` → log "log reopen not yet supported"
- `SIGHUP` → log "config reload not yet supported"
Keep existing Ctrl+C handler (SIGINT).
### 10. Server Identity NKey (Stub)
Generate Ed25519 key pair at construction. Store as `ServerNKey` (public) and `_serverSeed` (private). Not used in protocol yet — placeholder for future cluster identity.
### 11. System Account (Stub)
Create `$SYS` account in `_accounts` at construction. Expose as `SystemAccount` property. No internal subscriptions yet.
### 12. Config File & Profiling (Stubs)
- `NatsOptions.ConfigFile` — if set, log warning "config file parsing not yet supported"
- `NatsOptions.ProfPort` — if set, log warning "profiling endpoint not yet supported"
- `Program.cs`: add `-c` CLI flag
## Testing
- Accept loop backoff: mock socket that throws N times, verify delays
- Ephemeral port: start server with port=0, verify resolved port > 0
- Graceful shutdown: start server, connect clients, call ShutdownAsync, verify all disconnected
- WaitForShutdown: verify it blocks until shutdown completes
- Close reason tracking: verify correct ClosedState for auth timeout, max connections, stale connection
- Lame duck mode: start server, connect clients, trigger lame duck, verify staggered closure
- PID file: start server with PidFile option, verify file contents, verify deleted on shutdown
- Ports file: start server with PortsFileDir, verify JSON contents
- Flush before close: verify data is flushed before socket close during shutdown
- System account: verify $SYS account exists after construction

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-02-22-core-lifecycle-plan.md",
"tasks": [
{"id": 5, "subject": "Task 0: Create ClosedState enum", "status": "pending"},
{"id": 6, "subject": "Task 1: Add close reason tracking to NatsClient", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 2: Add NatsOptions for lifecycle features", "status": "pending"},
{"id": 8, "subject": "Task 3: Ephemeral port support", "status": "pending"},
{"id": 9, "subject": "Task 4: Graceful shutdown infrastructure", "status": "pending", "blockedBy": [5, 6, 7, 8]},
{"id": 10, "subject": "Task 5: Flush pending data before close", "status": "pending", "blockedBy": [9]},
{"id": 11, "subject": "Task 6: Lame duck mode", "status": "pending", "blockedBy": [9]},
{"id": 12, "subject": "Task 7: PID file and ports file", "status": "pending", "blockedBy": [9]},
{"id": 13, "subject": "Task 8: System account and NKey identity stubs", "status": "pending"},
{"id": 14, "subject": "Task 9: Signal handling and CLI stubs", "status": "pending", "blockedBy": [11]},
{"id": 15, "subject": "Task 10: Update differences.md", "status": "pending", "blockedBy": [14]},
{"id": 16, "subject": "Task 11: Final verification", "status": "pending", "blockedBy": [15]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

View File

@@ -0,0 +1,141 @@
# Harden Base Server — Design Document
**Date:** 2026-02-22
**Status:** Approved
**Scope:** Small — fills gaps in the base single-node pub/sub server
## Overview
The base NATS server port handles PUB/SUB, wildcards, queue groups, and integration with real NATS clients. However, it lacks several hardening features present in the Go reference implementation. This design covers four areas:
1. `-ERR` response infrastructure
2. MaxConnections enforcement
3. Subject validation on PUB (pedantic mode) + max payload validation
4. Server-side PING keepalive with stale connection detection
## 1. -ERR Response Infrastructure
**Go reference:** `client.go:93,2608-2617`
### Wire Format
All errors use: `-ERR '{message}'\r\n` (single quotes around the message).
### Constants (NatsProtocol.cs)
Add standard error message constants matching the Go server:
| Constant | Wire message | Connection closed? |
|---|---|---|
| `MaxConnectionsExceeded` | `maximum connections exceeded` | Yes |
| `StaleConnection` | `Stale Connection` | Yes |
| `MaxPayloadViolation` | `Maximum Payload Violation` | Yes |
| `InvalidPublishSubject` | `Invalid Publish Subject` | No |
| `InvalidSubject` | `Invalid Subject` | No |
### Client Methods (NatsClient.cs)
- `SendErrAsync(string message)` — writes `-ERR '{message}'\r\n` using the existing write-lock pattern. Connection stays open.
- `SendErrAndCloseAsync(string message)` — sends the `-ERR` then triggers client shutdown via cancellation token.
## 2. MaxConnections Enforcement
**Go reference:** `server.go:3378-3384`, `client.go:2428-2431`
### Design
In `NatsServer.StartAsync`, after `AcceptAsync` returns a new TCP connection:
1. Check `_clients.Count >= _options.MaxConnections`
2. If exceeded: write `-ERR 'maximum connections exceeded'\r\n` directly on the raw `NetworkStream`, close the `TcpClient`. No `NatsClient` is created.
3. Log at Warning level: `"Client connection rejected: maximum connections ({MaxConnections}) exceeded"`
The check uses `ConcurrentDictionary.Count` which is safe for this purpose — a slight race is acceptable since Go has the same pattern (check under lock, but the lock is released before the reject write).
Default `MaxConnections` remains `65536`.
## 3. Subject Validation on PUB
**Go reference:** `client.go:2869-2871`
### Pedantic Mode Subject Validation
In `ProcessPub` and `ProcessHPub`, after parsing the command:
1. If `ClientOptions.Pedantic == true`, call `SubjectMatch.IsValidPublishSubject(subject)`
2. If invalid: `SendErrAsync("Invalid Publish Subject")` — connection stays open, message is dropped (not routed)
3. Log at Debug level
This matches Go which only validates publish subjects in pedantic mode.
### Max Payload Validation (Always)
In `ProcessPub` and `ProcessHPub`:
1. If payload size > `_options.MaxPayload`, call `SendErrAndCloseAsync("Maximum Payload Violation")`
2. This is a hard close matching Go behavior
## 4. Server-Side PING Keepalive
**Go reference:** `client.go:5537-5654,2577-2584,2680-2682`
### New Client State
- `_pingsOut: int` — count of unanswered server-initiated PINGs (via `Interlocked`)
- `_lastIn: long``Environment.TickCount64` timestamp of last inbound data
### Timer Implementation
Use `PeriodicTimer` in a dedicated `RunPingTimerAsync` method, launched alongside `FillPipeAsync` and `ProcessCommandsAsync` in `RunAsync`. This avoids fire-and-forget async from sync timer callbacks.
### Lifecycle
1. **Start:** After CONNECT handshake completes, launch `RunPingTimerAsync` with period = `PingInterval`
2. **Each tick:**
- If `TickCount64 - _lastIn < PingInterval.TotalMilliseconds`: client was recently active, reset `_pingsOut = 0`, skip
- Else if `_pingsOut + 1 > MaxPingsOut`: send `-ERR 'Stale Connection'` and close
- Else: increment `_pingsOut`, send `PING\r\n`
3. **PONG received:** In `DispatchCommandAsync` for `CommandType.Pong`, set `_pingsOut = 0`
4. **Cleanup:** Cancel and dispose the timer in client shutdown
### Updating `_lastIn`
Set `_lastIn = Environment.TickCount64` at the top of `ProcessCommandsAsync` whenever a complete command is parsed from the pipe.
### Defaults
- `PingInterval = 2 minutes`
- `MaxPingsOut = 2`
- Stale detection: non-responding client disconnected after ~6 minutes (3 intervals)
## Testing Strategy
### -ERR Infrastructure
- Unit test: `SendErrAsync` writes correct wire format
- Unit test: `SendErrAndCloseAsync` sends error then disconnects
### MaxConnections
- Integration test: connect N clients up to max, verify N+1 receives `-ERR` and is disconnected
- Verify existing clients are unaffected
### Subject Validation
- Test with pedantic client: PUB to `foo.*` gets `-ERR`, connection stays open
- Test with non-pedantic client: PUB to `foo.*` is accepted (no validation)
- Test max payload: PUB exceeding limit gets `-ERR` and connection closes
### PING Keepalive
- Test with short intervals: verify server sends PING after inactivity
- Test PONG response resets the counter
- Test stale detection: non-responding client is disconnected
- Test active client: recent data suppresses PING
## Files Modified
| File | Changes |
|---|---|
| `NatsProtocol.cs` | Error message constants |
| `NatsClient.cs` | `SendErrAsync`, `SendErrAndCloseAsync`, ping timer, `_lastIn` tracking, pedantic validation, max payload check |
| `NatsServer.cs` | MaxConnections check in accept loop |
| `ClientTests.cs` | -ERR format tests, max payload tests |
| `ServerTests.cs` | MaxConnections test, PING keepalive tests |
| `IntegrationTests.cs` | End-to-end stale connection test |

View File

@@ -0,0 +1,817 @@
# Harden Base Server Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add -ERR responses, MaxConnections enforcement, pedantic subject validation on PUB, max payload validation, and server-side PING keepalive to the base NATS server.
**Architecture:** Four incremental features building on the existing server. Task 1 (-ERR infrastructure) is a prerequisite for all others. Tasks 2-4 are independent of each other once Task 1 is done.
**Tech Stack:** .NET 10 / C# 14, xUnit 3, Shouldly, System.IO.Pipelines, System.Threading.PeriodicTimer
---
### Task 1: -ERR Response Infrastructure
**Files:**
- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:5-22`
- Modify: `src/NATS.Server/NatsClient.cs:24-298`
- Modify: `tests/NATS.Server.Tests/ClientTests.cs`
**Step 1: Write the failing tests**
Add to `tests/NATS.Server.Tests/ClientTests.cs`:
```csharp
[Fact]
public async Task Client_SendErrAsync_writes_correct_wire_format()
{
var runTask = _natsClient.RunAsync(_cts.Token);
// Read INFO first
var buf = new byte[4096];
await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
// Trigger SendErrAsync
await _natsClient.SendErrAsync("Invalid Subject");
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
response.ShouldBe("-ERR 'Invalid Subject'\r\n");
await _cts.CancelAsync();
}
[Fact]
public async Task Client_SendErrAndCloseAsync_sends_error_then_disconnects()
{
var runTask = _natsClient.RunAsync(_cts.Token);
// Read INFO first
var buf = new byte[4096];
await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
// Trigger SendErrAndCloseAsync
await _natsClient.SendErrAndCloseAsync("maximum connections exceeded");
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
response.ShouldBe("-ERR 'maximum connections exceeded'\r\n");
// Connection should be closed — next read returns 0
n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
n.ShouldBe(0);
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Client_SendErr" -v normal`
Expected: Build fails — `SendErrAsync` and `SendErrAndCloseAsync` don't exist.
**Step 3: Add error message constants to NatsProtocol.cs**
In `src/NATS.Server/Protocol/NatsProtocol.cs`, add after line 21 (after `ErrPrefix`):
```csharp
// Standard error messages (matching Go server)
public const string ErrMaxConnectionsExceeded = "maximum connections exceeded";
public const string ErrStaleConnection = "Stale Connection";
public const string ErrMaxPayloadViolation = "Maximum Payload Violation";
public const string ErrInvalidPublishSubject = "Invalid Publish Subject";
public const string ErrInvalidSubject = "Invalid Subject";
```
**Step 4: Add a linked CancellationTokenSource to NatsClient for self-close**
In `src/NATS.Server/NatsClient.cs`, add a field after the `_writeLock` field (line 31):
```csharp
private CancellationTokenSource? _clientCts;
```
Modify `RunAsync` (line 59-85) so it creates a linked CTS and uses it internally. The linked CTS lets the client cancel itself while still respecting server shutdown:
```csharp
public async Task RunAsync(CancellationToken ct)
{
_clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var pipe = new Pipe();
try
{
// Send INFO
await SendInfoAsync(_clientCts.Token);
// Start read pump and command processing in parallel
var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
await Task.WhenAny(fillTask, processTask);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Client {ClientId} operation cancelled", Id);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
}
finally
{
Router?.RemoveClient(this);
}
}
```
**Step 5: Add SendErrAsync and SendErrAndCloseAsync to NatsClient**
Add after `WriteAsync` (after line 283):
```csharp
public async Task SendErrAsync(string message)
{
var errLine = Encoding.ASCII.GetBytes($"-ERR '{message}'\r\n");
try
{
await WriteAsync(errLine, _clientCts?.Token ?? CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} failed to send -ERR", Id);
}
}
public async Task SendErrAndCloseAsync(string message)
{
await SendErrAsync(message);
_clientCts?.Cancel();
}
```
**Step 6: Dispose the linked CTS**
Update `Dispose()` (line 292-297):
```csharp
public void Dispose()
{
_clientCts?.Dispose();
_stream.Dispose();
_socket.Dispose();
_writeLock.Dispose();
}
```
**Step 7: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Client_SendErr" -v normal`
Expected: Both tests PASS.
**Step 8: Run all existing tests to verify no regressions**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: All tests PASS.
**Step 9: Commit**
```bash
git add src/NATS.Server/Protocol/NatsProtocol.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ClientTests.cs
git commit -m "feat: add -ERR response infrastructure with SendErrAsync and SendErrAndCloseAsync"
```
---
### Task 2: MaxConnections Enforcement
**Files:**
- Modify: `src/NATS.Server/NatsServer.cs:42-75`
- Modify: `tests/NATS.Server.Tests/ServerTests.cs`
**Step 1: Write the failing test**
Add to `tests/NATS.Server.Tests/ServerTests.cs`. This test needs its own server with a low MaxConnections. Add a new test class at the end of the file:
```csharp
public class MaxConnectionsTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _port;
private readonly CancellationTokenSource _cts = new();
public MaxConnectionsTests()
{
_port = GetFreePort();
_server = new NatsServer(new NatsOptions { Port = _port, MaxConnections = 2 }, NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
[Fact]
public async Task Server_rejects_connection_when_max_reached()
{
// Connect two clients (at limit)
var client1 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client1.ConnectAsync(IPAddress.Loopback, _port);
var buf = new byte[4096];
var n = await client1.ReceiveAsync(buf, SocketFlags.None);
Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO ");
var client2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client2.ConnectAsync(IPAddress.Loopback, _port);
n = await client2.ReceiveAsync(buf, SocketFlags.None);
Encoding.ASCII.GetString(buf, 0, n).ShouldStartWith("INFO ");
// Third client should be rejected
var client3 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client3.ConnectAsync(IPAddress.Loopback, _port);
n = await client3.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
response.ShouldContain("-ERR 'maximum connections exceeded'");
// Connection should be closed
n = await client3.ReceiveAsync(buf, SocketFlags.None);
n.ShouldBe(0);
client1.Dispose();
client2.Dispose();
client3.Dispose();
}
}
```
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Server_rejects_connection_when_max_reached" -v normal`
Expected: FAIL — third client gets INFO instead of -ERR.
**Step 3: Add MaxConnections check in the accept loop**
In `src/NATS.Server/NatsServer.cs`, modify the accept loop (lines 56-69). Insert the check after `AcceptAsync` and before creating the `NatsClient`:
```csharp
while (!ct.IsCancellationRequested)
{
var socket = await _listener.AcceptAsync(ct);
// Check MaxConnections before creating the client
if (_options.MaxConnections > 0 && _clients.Count >= _options.MaxConnections)
{
_logger.LogWarning("Client connection rejected: maximum connections ({MaxConnections}) exceeded",
_options.MaxConnections);
try
{
var stream = new NetworkStream(socket, ownsSocket: false);
var errBytes = Encoding.ASCII.GetBytes(
$"-ERR '{NatsProtocol.ErrMaxConnectionsExceeded}'\r\n");
await stream.WriteAsync(errBytes, ct);
await stream.FlushAsync(ct);
stream.Dispose();
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to send -ERR to rejected client");
}
finally
{
socket.Dispose();
}
continue;
}
var clientId = Interlocked.Increment(ref _nextClientId);
_logger.LogDebug("Client {ClientId} connected from {RemoteEndpoint}", clientId, socket.RemoteEndPoint);
var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger);
client.Router = this;
_clients[clientId] = client;
_ = RunClientAsync(client, ct);
}
```
Add `using System.Text;` to the top of `NatsServer.cs` if not already present.
**Step 4: Run test to verify it passes**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Server_rejects_connection_when_max_reached" -v normal`
Expected: PASS.
**Step 5: Run all tests**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: All tests PASS.
**Step 6: Commit**
```bash
git add src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: enforce MaxConnections limit in accept loop"
```
---
### Task 3: Subject Validation and Max Payload on PUB
**Files:**
- Modify: `src/NATS.Server/NatsClient.cs:213-228`
- Modify: `tests/NATS.Server.Tests/ServerTests.cs`
**Step 1: Write the failing tests**
Add to `tests/NATS.Server.Tests/ServerTests.cs` in the existing `ServerTests` class:
```csharp
[Fact]
public async Task Server_pedantic_rejects_invalid_publish_subject()
{
using var pub = await ConnectClientAsync();
using var sub = await ConnectClientAsync();
// Read INFO from both
await ReadLineAsync(pub);
await ReadLineAsync(sub);
// Connect with pedantic mode ON
await pub.SendAsync(Encoding.ASCII.GetBytes(
"CONNECT {\"pedantic\":true}\r\nPING\r\n"));
var pong = await ReadUntilAsync(pub, "PONG");
// Subscribe on sub
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n"));
await ReadUntilAsync(sub, "PONG");
// PUB with wildcard subject (invalid for publish)
await pub.SendAsync(Encoding.ASCII.GetBytes("PUB foo.* 5\r\nHello\r\n"));
// Publisher should get -ERR
var errResponse = await ReadUntilAsync(pub, "-ERR", timeoutMs: 3000);
errResponse.ShouldContain("-ERR 'Invalid Publish Subject'");
}
[Fact]
public async Task Server_nonpedantic_allows_wildcard_publish_subject()
{
using var pub = await ConnectClientAsync();
using var sub = await ConnectClientAsync();
await ReadLineAsync(pub);
await ReadLineAsync(sub);
// Connect without pedantic mode (default)
await sub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nSUB foo.* 1\r\nPING\r\n"));
await ReadUntilAsync(sub, "PONG");
await pub.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPUB foo.* 5\r\nHello\r\n"));
// Sub should still receive the message (no validation in non-pedantic mode)
var msg = await ReadUntilAsync(sub, "Hello\r\n");
msg.ShouldContain("MSG foo.* 1 5\r\nHello\r\n");
}
[Fact]
public async Task Server_rejects_max_payload_violation()
{
// Create server with tiny max payload
var port = GetFreePort();
using var cts = new CancellationTokenSource();
var server = new NatsServer(new NatsOptions { Port = port, MaxPayload = 10 }, NullLoggerFactory.Instance);
_ = server.StartAsync(cts.Token);
await server.WaitForReadyAsync();
try
{
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client.ConnectAsync(IPAddress.Loopback, port);
var buf = new byte[4096];
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
// Send PUB with payload larger than MaxPayload (10 bytes)
await client.SendAsync(Encoding.ASCII.GetBytes("PUB foo 20\r\n12345678901234567890\r\n"));
var n = await client.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
response.ShouldContain("-ERR 'Maximum Payload Violation'");
// Connection should be closed
n = await client.ReceiveAsync(buf, SocketFlags.None);
n.ShouldBe(0);
client.Dispose();
}
finally
{
await cts.CancelAsync();
server.Dispose();
}
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~pedantic|FullyQualifiedName~max_payload" -v normal`
Expected: FAIL — no validation exists.
**Step 3: Add validation to ProcessPub**
In `src/NATS.Server/NatsClient.cs`, replace the `ProcessPub` method (lines 213-228):
```csharp
private async ValueTask ProcessPubAsync(ParsedCommand cmd)
{
Interlocked.Increment(ref InMsgs);
Interlocked.Add(ref InBytes, cmd.Payload.Length);
// Max payload validation (always, hard close)
if (cmd.Payload.Length > _options.MaxPayload)
{
_logger.LogWarning("Client {ClientId} exceeded max payload: {Size} > {MaxPayload}",
Id, cmd.Payload.Length, _options.MaxPayload);
await SendErrAndCloseAsync(NatsProtocol.ErrMaxPayloadViolation);
return;
}
// Pedantic mode: validate publish subject
if (ClientOpts?.Pedantic == true && !SubjectMatch.IsValidPublishSubject(cmd.Subject!))
{
_logger.LogDebug("Client {ClientId} invalid publish subject: {Subject}", Id, cmd.Subject);
await SendErrAsync(NatsProtocol.ErrInvalidPublishSubject);
return;
}
ReadOnlyMemory<byte> headers = default;
ReadOnlyMemory<byte> payload = cmd.Payload;
if (cmd.Type == CommandType.HPub && cmd.HeaderSize > 0)
{
headers = cmd.Payload[..cmd.HeaderSize];
payload = cmd.Payload[cmd.HeaderSize..];
}
Router?.ProcessMessage(cmd.Subject!, cmd.ReplyTo, headers, payload, this);
}
```
Since `ProcessPub` is now async, update `DispatchCommandAsync` (line 160-163) to await it:
```csharp
case CommandType.Pub:
case CommandType.HPub:
await ProcessPubAsync(cmd);
break;
```
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~pedantic|FullyQualifiedName~max_payload" -v normal`
Expected: All three PASS.
**Step 5: Run all tests**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: All tests PASS.
**Step 6: Commit**
```bash
git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add pedantic subject validation and max payload enforcement on PUB"
```
---
### Task 4: Server-Side PING Keepalive
**Files:**
- Modify: `src/NATS.Server/NatsClient.cs`
- Modify: `tests/NATS.Server.Tests/ServerTests.cs`
**Step 1: Write the failing tests**
Add a new test class at the end of `tests/NATS.Server.Tests/ServerTests.cs`:
```csharp
public class PingKeepaliveTests : IAsyncLifetime
{
private readonly NatsServer _server;
private readonly int _port;
private readonly CancellationTokenSource _cts = new();
public PingKeepaliveTests()
{
_port = GetFreePort();
// Short intervals for testing: 500ms ping interval, 2 max pings out
_server = new NatsServer(
new NatsOptions
{
Port = _port,
PingInterval = TimeSpan.FromMilliseconds(500),
MaxPingsOut = 2,
},
NullLoggerFactory.Instance);
}
public async Task InitializeAsync()
{
_ = _server.StartAsync(_cts.Token);
await _server.WaitForReadyAsync();
}
public async Task DisposeAsync()
{
await _cts.CancelAsync();
_server.Dispose();
}
private static int GetFreePort()
{
using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
return ((IPEndPoint)sock.LocalEndPoint!).Port;
}
private static async Task<string> ReadUntilAsync(Socket sock, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected))
{
var n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
[Fact]
public async Task Server_sends_PING_after_inactivity()
{
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client.ConnectAsync(IPAddress.Loopback, _port);
// Read INFO
var buf = new byte[4096];
await client.ReceiveAsync(buf, SocketFlags.None);
// Send CONNECT to start keepalive
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
// Wait for server to send PING (should come within ~500ms)
var response = await ReadUntilAsync(client, "PING", timeoutMs: 3000);
response.ShouldContain("PING");
client.Dispose();
}
[Fact]
public async Task Server_pong_resets_ping_counter()
{
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client.ConnectAsync(IPAddress.Loopback, _port);
var buf = new byte[4096];
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
// Wait for first PING
var response = await ReadUntilAsync(client, "PING", timeoutMs: 3000);
response.ShouldContain("PING");
// Respond with PONG — this resets the counter
await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n"));
// Wait for next PING (counter reset, so we should get another one)
response = await ReadUntilAsync(client, "PING", timeoutMs: 3000);
response.ShouldContain("PING");
// Respond again to keep alive
await client.SendAsync(Encoding.ASCII.GetBytes("PONG\r\n"));
// Client should still be alive — send a PING and expect PONG back
await client.SendAsync(Encoding.ASCII.GetBytes("PING\r\n"));
response = await ReadUntilAsync(client, "PONG", timeoutMs: 3000);
response.ShouldContain("PONG");
client.Dispose();
}
[Fact]
public async Task Server_disconnects_stale_client()
{
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
await client.ConnectAsync(IPAddress.Loopback, _port);
var buf = new byte[4096];
await client.ReceiveAsync(buf, SocketFlags.None); // INFO
await client.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\n"));
// Don't respond to PINGs — wait for stale disconnect
// With 500ms interval and MaxPingsOut=2:
// t=500ms: PING #1, pingsOut=1
// t=1000ms: PING #2, pingsOut=2
// t=1500ms: pingsOut+1 > MaxPingsOut → -ERR 'Stale Connection' + close
var sb = new StringBuilder();
try
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (true)
{
var n = await client.ReceiveAsync(buf, SocketFlags.None, timeout.Token);
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
}
catch (OperationCanceledException)
{
// Timeout is acceptable — check what we got
}
var allData = sb.ToString();
allData.ShouldContain("-ERR 'Stale Connection'");
client.Dispose();
}
}
```
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PingKeepalive" -v normal`
Expected: FAIL — server never sends PING.
**Step 3: Add ping state fields to NatsClient**
In `src/NATS.Server/NatsClient.cs`, add after the stats fields (after line 44):
```csharp
// PING keepalive state
private int _pingsOut;
private long _lastIn;
```
**Step 4: Add RunPingTimerAsync method**
Add after `SendErrAndCloseAsync`:
```csharp
private async Task RunPingTimerAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_options.PingInterval);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
var elapsed = Environment.TickCount64 - Interlocked.Read(ref _lastIn);
if (elapsed < (long)_options.PingInterval.TotalMilliseconds)
{
// Client was recently active, skip ping
Interlocked.Exchange(ref _pingsOut, 0);
continue;
}
var currentPingsOut = Interlocked.Increment(ref _pingsOut);
if (currentPingsOut > _options.MaxPingsOut)
{
_logger.LogDebug("Client {ClientId} stale connection — closing", Id);
await SendErrAndCloseAsync(NatsProtocol.ErrStaleConnection);
return;
}
_logger.LogDebug("Client {ClientId} sending PING ({PingsOut}/{MaxPingsOut})",
Id, currentPingsOut, _options.MaxPingsOut);
try
{
await WriteAsync(NatsProtocol.PingBytes, ct);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} failed to send PING", Id);
return;
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown
}
}
```
**Step 5: Update PONG handling in DispatchCommandAsync**
Replace the `Pong` case (lines 148-149):
```csharp
case CommandType.Pong:
Interlocked.Exchange(ref _pingsOut, 0);
break;
```
**Step 6: Update _lastIn on every parsed command**
In `ProcessCommandsAsync` (line 110-134), add `_lastIn` update inside the inner loop, right after `TryParse` succeeds (inside `while (_parser.TryParse(...))`):
```csharp
while (_parser.TryParse(ref buffer, out var cmd))
{
Interlocked.Exchange(ref _lastIn, Environment.TickCount64);
await DispatchCommandAsync(cmd, ct);
}
```
**Step 7: Launch ping timer in RunAsync after CONNECT**
The ping timer must start after CONNECT is received. The cleanest approach: start it unconditionally in `RunAsync` alongside the other tasks. Even if CONNECT hasn't been received yet, the timer won't fire until `PingInterval` has elapsed, and `_lastIn` will be updated by command processing.
Update `RunAsync` to launch the ping timer as a third concurrent task:
```csharp
public async Task RunAsync(CancellationToken ct)
{
_clientCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Interlocked.Exchange(ref _lastIn, Environment.TickCount64);
var pipe = new Pipe();
try
{
// Send INFO
await SendInfoAsync(_clientCts.Token);
// Start read pump, command processing, and ping timer in parallel
var fillTask = FillPipeAsync(pipe.Writer, _clientCts.Token);
var processTask = ProcessCommandsAsync(pipe.Reader, _clientCts.Token);
var pingTask = RunPingTimerAsync(_clientCts.Token);
await Task.WhenAny(fillTask, processTask, pingTask);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Client {ClientId} operation cancelled", Id);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Client {ClientId} connection error", Id);
}
finally
{
Router?.RemoveClient(this);
}
}
```
**Step 8: Run tests to verify they pass**
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~PingKeepalive" -v normal`
Expected: All three PASS.
**Step 9: Run all tests to verify no regressions**
Run: `dotnet test tests/NATS.Server.Tests -v normal`
Expected: All tests PASS.
Note: The existing integration tests (`IntegrationTests.cs`) use `NATS.Client.Core` which automatically responds to server PINGs, so they won't be affected by the new keepalive feature. However, if existing `ClientTests` or `ServerTests` that use raw sockets hang because of unexpected PINGs, the fix is that those tests use the default `NatsOptions` which has `PingInterval = 2 minutes` — far longer than any test runs. This should not be an issue.
**Step 10: Commit**
```bash
git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ServerTests.cs
git commit -m "feat: add server-side PING keepalive with stale connection detection"
```
---
## Summary
| Task | Description | Dependencies | Estimated Steps |
|------|-------------|--------------|----------------|
| Task 1 | -ERR infrastructure | None | 9 steps |
| Task 2 | MaxConnections enforcement | Task 1 | 6 steps |
| Task 3 | Subject validation + max payload | Task 1 | 6 steps |
| Task 4 | PING keepalive | Task 1 | 10 steps |
Tasks 2, 3, and 4 are independent of each other and can be done in any order (or in parallel) after Task 1 is complete.

View File

@@ -0,0 +1,10 @@
{
"planPath": "docs/plans/2026-02-22-harden-base-server-plan.md",
"tasks": [
{"id": 5, "subject": "Task 1: -ERR Response Infrastructure", "status": "pending"},
{"id": 6, "subject": "Task 2: MaxConnections Enforcement", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 3: Subject Validation and Max Payload on PUB", "status": "pending", "blockedBy": [5]},
{"id": 8, "subject": "Task 4: Server-Side PING Keepalive", "status": "pending", "blockedBy": [5]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

View File

@@ -0,0 +1,243 @@
# Monitoring HTTP & TLS Support Design
**Date:** 2026-02-22
**Scope:** Port monitoring endpoints (`/varz`, `/connz`) and full TLS support from Go NATS server
**Go Reference:** `golang/nats-server/server/monitor.go`, `server.go` (TLS), `client.go` (TLS), `opts.go`
## Overview
Two features ported from Go NATS:
1. **Monitoring HTTP** — Kestrel Minimal API embedded in `NatsServer`, serving `/varz`, `/connz`, `/healthz` and stub endpoints. Exact Go JSON schema for tooling compatibility.
2. **TLS Support**`SslStream` wrapping with four modes: no TLS, TLS required, TLS-first, and mixed TLS/plaintext. Certificate pinning, client cert verification, rate limiting.
## 1. Server-Level Stats Aggregation
New `ServerStats` class with atomic counters, replacing the need to sum across all clients on each `/varz` request.
### ServerStats Fields
```csharp
// src/NATS.Server/ServerStats.cs
public sealed class ServerStats
{
public long InMsgs;
public long OutMsgs;
public long InBytes;
public long OutBytes;
public long TotalConnections;
public long SlowConsumers;
public long StaleConnections;
public long Stalls;
public long SlowConsumerClients;
public long SlowConsumerRoutes;
public long SlowConsumerLeafs;
public long SlowConsumerGateways;
public readonly ConcurrentDictionary<string, long> HttpReqStats = new();
}
```
### Integration Points
- `NatsServer` owns a `ServerStats` instance, passes it to each `NatsClient`
- `NatsClient.ProcessPub` increments server-level `InMsgs`/`InBytes` alongside client-level counters
- `NatsClient.SendMessageAsync` increments server-level `OutMsgs`/`OutBytes`
- Accept loop increments `TotalConnections`
- `NatsServer.StartTime` field added (set once at startup)
## 2. Monitoring HTTP Endpoints
### HTTP Stack
Kestrel Minimal APIs via `FrameworkReference` to `Microsoft.AspNetCore.App`. No NuGet packages needed.
### Endpoints
| Path | Handler | Description |
|------|---------|-------------|
| `/` | `HandleRoot` | Links to all endpoints |
| `/varz` | `HandleVarz` | Server stats and config |
| `/connz` | `HandleConnz` | Connection info (paginated) |
| `/healthz` | `HandleHealthz` | Health check (200 OK) |
| `/routez` | stub | Returns `{}` |
| `/gatewayz` | stub | Returns `{}` |
| `/leafz` | stub | Returns `{}` |
| `/subz` | stub | Returns `{}` |
| `/accountz` | stub | Returns `{}` |
| `/jsz` | stub | Returns `{}` |
All paths support optional base path prefix via `MonitorBasePath` config.
### Configuration
```csharp
// Added to NatsOptions
public int MonitorPort { get; set; } // 0 = disabled, CLI: -m
public string MonitorHost { get; set; } = "0.0.0.0";
public string? MonitorBasePath { get; set; }
public int MonitorHttpsPort { get; set; } // 0 = disabled
```
### Varz Model
Exact Go JSON field names. All fields from Go's `Varz` struct including nested config structs (`ClusterOptsVarz`, `GatewayOptsVarz`, `LeafNodeOptsVarz`, `MqttOptsVarz`, `WebsocketOptsVarz`, `JetStreamVarz`). Nested structs return defaults/zeros until those subsystems are ported.
Key field categories: identification, network config, security/limits, timing/lifecycle, runtime metrics (mem, CPU, cores), connection stats, message stats, health counters, subsystem configs, HTTP request stats.
### Connz Model
Paginated connection list with query parameter support:
- `sort` — sort field (cid, bytes_to, msgs_to, etc.)
- `subs` / `subs=detail` — include subscription lists
- `offset` / `limit` — pagination (default limit 1024)
- `state` — filter open/closed/all
- `auth` — include usernames
`ConnInfo` includes all Go fields: cid, kind, ip, port, start, last_activity, rtt, uptime, idle, pending, msg/byte stats, subscription count, client name/lang/version, TLS version/cipher, account.
### Concurrency
- `HandleVarz` acquires a `SemaphoreSlim(1,1)` to serialize JSON building (matches Go's `varzMu`)
- `HandleConnz` snapshots `_clients.Values.ToArray()` to avoid holding the dictionary during serialization
- CPU percentage sampled via `Process.TotalProcessorTime` delta, cached for 1 second
### NatsClient Additions for ConnInfo
```csharp
public DateTime StartTime { get; } // set in constructor
public DateTime LastActivity; // updated on every command dispatch
public string? RemoteIp { get; } // from socket.RemoteEndPoint
public int RemotePort { get; } // from socket.RemoteEndPoint
```
## 3. TLS Support
### Configuration
```csharp
// Added to NatsOptions
public string? TlsCert { get; set; }
public string? TlsKey { get; set; }
public string? TlsCaCert { get; set; }
public bool TlsVerify { get; set; }
public bool TlsMap { get; set; }
public double TlsTimeout { get; set; } = 2.0;
public bool TlsHandshakeFirst { get; set; }
public TimeSpan TlsHandshakeFirstFallback { get; set; } = TimeSpan.FromMilliseconds(50);
public bool AllowNonTls { get; set; }
public long TlsRateLimit { get; set; }
public HashSet<string>? TlsPinnedCerts { get; set; }
public SslProtocols TlsMinVersion { get; set; } = SslProtocols.Tls12;
```
CLI args: `--tls`, `--tlscert`, `--tlskey`, `--tlscacert`, `--tlsverify`
### INFO Message Changes
Three new fields on `ServerInfo`: `tls_required`, `tls_verify`, `tls_available`.
- `tls_required = (TlsConfig != null && !AllowNonTls)`
- `tls_verify = (TlsConfig != null && TlsVerify)`
- `tls_available = (TlsConfig != null && AllowNonTls)`
### Four TLS Modes
**Mode 1: No TLS** — current behavior, unchanged.
**Mode 2: TLS Required** — send INFO with `tls_required=true`, client initiates TLS, server detects 0x16 byte, performs `SslStream` handshake, validates pinned certs, continues protocol over encrypted stream.
**Mode 3: TLS First** — do NOT send INFO, wait up to 50ms for data. If 0x16 byte arrives: TLS handshake then send INFO over encrypted stream. If timeout or non-TLS byte: fallback to Mode 2 flow.
**Mode 4: Mixed** — send INFO with `tls_available=true`, peek first byte. 0x16 → TLS handshake. Other → continue plaintext.
### Key Components
**`TlsHelper`** — static class for cert loading (`X509Certificate2` from PEM/PFX), CA cert loading, building `SslServerAuthenticationOptions`, pinned cert validation (SHA256 of SubjectPublicKeyInfo).
**`TlsConnectionWrapper`** — per-connection negotiation state machine. Takes socket + options, returns `(Stream stream, bool infoAlreadySent)`. Handles peek logic, timeout, handshake, cert validation.
**`PeekableStream`** — wraps `NetworkStream`, buffers peeked bytes, replays them on first `ReadAsync`. Required so `SslStream.AuthenticateAsServerAsync` sees the full TLS ClientHello including the peeked byte.
**`TlsRateLimiter`** — token-bucket rate limiter. Refills `TlsRateLimit` tokens per second. `WaitAsync` blocks if no tokens. Only applies to TLS handshakes, not plain connections.
**`TlsConnectionState`** — post-handshake record: `TlsVersion`, `CipherSuite`, `PeerCert`. Stored on `NatsClient` for `/connz` reporting.
### NatsClient Changes
Constructor takes `Stream` instead of building `NetworkStream` internally. TLS negotiation happens before `NatsClient` is constructed. `NatsClient` receives the already-negotiated stream and `TlsConnectionState`.
### Accept Loop Changes
```
Accept socket
→ Increment TotalConnections
→ Rate limit check (if TLS configured)
→ TlsConnectionWrapper.NegotiateAsync (returns stream + infoAlreadySent)
→ Extract TlsConnectionState from SslStream if applicable
→ Construct NatsClient with stream + tlsState
→ client.InfoAlreadySent flag set if TLS-first sent INFO during negotiation
→ RunClientAsync
```
## 4. File Layout
```
src/NATS.Server/
ServerStats.cs
Monitoring/
MonitorServer.cs # Kestrel host, route registration
Varz.cs # Varz + nested config structs
Connz.cs # Connz, ConnInfo, ConnzOptions, SubDetail
VarzHandler.cs # Snapshot logic, CPU/mem sampling
ConnzHandler.cs # Query param parsing, sort, pagination
Tls/
TlsHelper.cs # Cert loading, auth options builder
TlsConnectionWrapper.cs # Per-connection TLS negotiation
TlsConnectionState.cs # Post-handshake state record
TlsRateLimiter.cs # Token-bucket rate limiter
PeekableStream.cs # Buffered-peek stream wrapper
```
### Package Dependencies
- `FrameworkReference` to `Microsoft.AspNetCore.App` in `NATS.Server.csproj` (for Kestrel)
- No new NuGet packages — `SslStream`, `X509Certificate2`, `SslServerAuthenticationOptions` all in `System.Net.Security`
- Tests use `HttpClient` (built-in) and `CertificateRequest` (built-in) for self-signed test certs
## 5. Testing Strategy
### Monitoring Tests (`MonitorTests.cs`)
- `/varz` returns correct server identity, config limits, zero stats on fresh server
- After pub/sub traffic: message/byte counters are accurate
- `/connz` pagination: `?limit=2&offset=0` with 5 clients returns 2, total=5
- `/connz?sort=bytes_to` ordering
- `/connz?subs=true` includes subscription subjects
- `/healthz` returns 200
- HTTP request stats tracked in `/varz` response
### TLS Tests (`TlsTests.cs`)
Self-signed certs generated in-memory via `CertificateRequest` + `RSA.Create()`.
- Basic TLS: server cert, client connects with SslStream, pub/sub works
- TLS Required: plaintext client rejected
- TLS Verify: valid client cert succeeds, wrong cert fails
- Mixed mode: TLS and plaintext clients coexist
- TLS First: immediate TLS handshake without reading INFO first
- TLS First fallback: slow client gets INFO sent, normal negotiation
- Certificate pinning: matching cert accepted, non-matching rejected
- Rate limiting: rapid connections throttled
- TLS timeout: incomplete handshake closed after configured timeout
- Integration: NATS.Client.Core NuGet client works over TLS
- Monitoring: `/connz` shows `tls_version` and `tls_cipher_suite`
## 6. Error Handling
- **TLS handshake failures** are non-fatal: log warning, close socket, increment counter
- **Mixed mode byte detection**: 0x16 → TLS, printable ASCII → plain, connection close → clean disconnect
- **Rate limiter**: holds TCP connection open until token available (not rejected)
- **Monitoring concurrency**: `varzMu` semaphore serializes `/varz`, client snapshot for `/connz`
- **CPU sampling**: cached 1 second to avoid overhead on rapid polls
- **Graceful shutdown**: `MonitorServer.DisposeAsync()` stops Kestrel, rate limiter disposes timer, in-flight handshakes cancelled via CancellationToken

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-02-22-monitoring-tls-plan.md",
"tasks": [
{"id": 0, "nativeId": 6, "subject": "Task 0: Project setup — csproj and configuration options", "status": "pending"},
{"id": 1, "nativeId": 7, "subject": "Task 1: ServerStats and NatsClient metadata", "status": "pending", "blockedBy": [0]},
{"id": 2, "nativeId": 8, "subject": "Task 2: Refactor NatsClient to accept Stream", "status": "pending", "blockedBy": [1]},
{"id": 3, "nativeId": 9, "subject": "Task 3: Monitoring JSON models (Varz, Connz, nested stubs)", "status": "pending", "blockedBy": [2]},
{"id": 4, "nativeId": 10, "subject": "Task 4: MonitorServer with /healthz and /varz endpoints", "status": "pending", "blockedBy": [3]},
{"id": 5, "nativeId": 11, "subject": "Task 5: ConnzHandler and /connz endpoint", "status": "pending", "blockedBy": [4]},
{"id": 6, "nativeId": 12, "subject": "Task 6: Wire monitoring CLI args into Host", "status": "pending", "blockedBy": [0]},
{"id": 7, "nativeId": 13, "subject": "Task 7: TLS helpers — TlsHelper, PeekableStream, TlsRateLimiter", "status": "pending", "blockedBy": [2]},
{"id": 8, "nativeId": 14, "subject": "Task 8: TlsConnectionWrapper — 4-mode negotiation", "status": "pending", "blockedBy": [7]},
{"id": 9, "nativeId": 15, "subject": "Task 9: Wire TLS into NatsServer accept loop", "status": "pending", "blockedBy": [8]},
{"id": 10, "nativeId": 16, "subject": "Task 10: TLS CLI args in Host", "status": "pending", "blockedBy": [9]},
{"id": 11, "nativeId": 17, "subject": "Task 11: Full integration tests — TLS modes, mixed mode, monitoring + TLS", "status": "pending", "blockedBy": [9, 5]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

View File

@@ -0,0 +1,199 @@
# Section 2: Client/Connection Handling — Design
> Implements all in-scope gaps from differences.md Section 2.
## Scope
8 features, all single-server client-facing (no clustering/routes/gateways/leaf):
1. Close reason tracking (ClosedState enum)
2. Connection state flags (bitfield replacing `_connectReceived`)
3. Channel-based write loop with batch flush
4. Slow consumer detection (pending bytes + write deadline)
5. Write deadline / timeout
6. Verbose mode (`+OK` responses)
7. No-responders validation and notification
8. Per-read-cycle stat batching
## A. Close Reasons
New `ClientClosedReason` enum with 16 values scoped to single-server:
```
ClientClosed, AuthenticationTimeout, AuthenticationViolation, TLSHandshakeError,
SlowConsumerPendingBytes, SlowConsumerWriteDeadline, WriteError, ReadError,
ParseError, StaleConnection, ProtocolViolation, MaxPayloadExceeded,
MaxSubscriptionsExceeded, ServerShutdown, MsgHeaderViolation, NoRespondersRequiresHeaders
```
Go has 37 values; excluded: route/gateway/leaf/JWT/operator-mode values.
Per-client `CloseReason` property set before closing. Available in monitoring (`/connz`).
## B. Connection State Flags
`ClientFlags` bitfield enum backed by `int`, manipulated via `Interlocked.Or`/`Interlocked.And`:
```
ConnectReceived = 1,
FirstPongSent = 2,
HandshakeComplete = 4,
CloseConnection = 8,
WriteLoopStarted = 16,
IsSlowConsumer = 32,
ConnectProcessFinished = 64
```
Replaces current `_connectReceived` (int with Volatile.Read/Write).
Helper methods: `SetFlag(flag)`, `ClearFlag(flag)`, `HasFlag(flag)`.
## C. Channel-based Write Loop
### Architecture
Replace inline `_writeLock` + direct stream writes:
```
Producer threads → QueueOutbound(bytes) → Channel<ReadOnlyMemory<byte>> → WriteLoop → Stream
```
### Components
- `Channel<ReadOnlyMemory<byte>>` — bounded (capacity derived from MaxPending / avg message size, or 8192 items)
- `_pendingBytes` (long) — tracks queued but unflushed bytes via `Interlocked.Add`
- `RunWriteLoopAsync` — background task: `WaitToReadAsync` → drain all via `TryRead` → single `FlushAsync`
- `QueueOutbound(ReadOnlyMemory<byte>)` — enqueue, update pending bytes, check slow consumer
### Coalescing
The write loop drains all available items from the channel before flushing:
```
while (await reader.WaitToReadAsync(ct))
{
while (reader.TryRead(out var data))
await stream.WriteAsync(data, ct); // buffered writes, no flush yet
await stream.FlushAsync(ct); // single flush after batch
}
```
### Migration
All existing write paths refactored:
- `SendMessageAsync` → serialize MSG/HMSG to byte array → `QueueOutbound`
- `WriteAsync` → serialize protocol message → `QueueOutbound`
- Remove `_writeLock` SemaphoreSlim
## D. Slow Consumer Detection
### Pending Bytes (Hard Limit)
In `QueueOutbound`, before writing to channel:
```
if (_pendingBytes + data.Length > _maxPending)
{
SetFlag(IsSlowConsumer);
CloseWithReason(SlowConsumerPendingBytes);
return;
}
```
- `MaxPending` default: 64MB (matching Go's `MAX_PENDING_SIZE`)
- New option in `NatsOptions`
### Write Deadline (Timeout)
In write loop flush:
```
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_writeDeadline);
await stream.FlushAsync(cts.Token);
```
On timeout → close with `SlowConsumerWriteDeadline`.
- `WriteDeadline` default: 10 seconds
- New option in `NatsOptions`
### Monitoring
- `IsSlowConsumer` flag readable for `/connz`
- Server-level `SlowConsumerCount` stat incremented
## E. Verbose Mode
After successful command processing (CONNECT, SUB, UNSUB, PUB), check `ClientOpts?.Verbose`:
```
if (ClientOpts?.Verbose == true)
QueueOutbound(OkBytes);
```
`OkBytes` = pre-encoded `+OK\r\n` static byte array in `NatsProtocol`.
## F. No-Responders
### CONNECT Validation
```
if (clientOpts.NoResponders && !clientOpts.Headers)
{
CloseWithReason(NoRespondersRequiresHeaders);
return;
}
```
### Publish-time Notification
In `NatsServer` message delivery, after `Match()` returns zero subscribers:
```
if (!delivered && reply.Length > 0 && publisher.ClientOpts?.NoResponders == true)
{
// Send HMSG with NATS/1.0 503 status back to publisher
var header = $"NATS/1.0 503\r\nNats-Subject: {subject}\r\n\r\n";
publisher.SendNoRespondersAsync(reply, sid, header);
}
```
## G. Stat Batching
In read loop, accumulate locally:
```
long localInMsgs = 0, localInBytes = 0;
// ... per message: localInMsgs++; localInBytes += size;
// End of read cycle:
Interlocked.Add(ref _inMsgs, localInMsgs);
Interlocked.Add(ref _inBytes, localInBytes);
// Same for server stats
```
Reduces atomic operations from per-message to per-read-cycle.
## Files
| File | Change | Size |
|------|--------|------|
| `ClientClosedReason.cs` | New | Small |
| `ClientFlags.cs` | New | Small |
| `NatsClient.cs` | Major rewrite of write path | Large |
| `NatsServer.cs` | No-responders, close reason | Medium |
| `NatsOptions.cs` | MaxPending, WriteDeadline | Small |
| `NatsProtocol.cs` | +OK bytes, NoResponders | Small |
| `ClientTests.cs` | Verbose, close reasons, flags | Medium |
| `ServerTests.cs` | No-responders, slow consumer | Medium |
## Test Plan
- **Verbose mode**: Connect with `verbose:true`, send SUB/PUB, verify `+OK` responses
- **Close reasons**: Trigger each close path, verify reason is set
- **State flags**: Set/clear/check flags concurrently
- **Slow consumer (pending bytes)**: Queue more than MaxPending, verify close
- **Slow consumer (write deadline)**: Use a slow/blocked stream, verify timeout close
- **No-responders**: Publish to empty subject with reply, verify 503 HMSG
- **Write coalescing**: Send multiple messages rapidly, verify batched flush
- **Stat batching**: Send N messages, verify stats match after read cycle

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-02-22-section2-client-connection-handling-plan.md",
"tasks": [
{"id": 4, "subject": "Task 1: Add ClientClosedReason enum", "status": "pending"},
{"id": 5, "subject": "Task 2: Add ClientFlags bitfield", "status": "pending"},
{"id": 6, "subject": "Task 3: Add MaxPending and WriteDeadline to NatsOptions", "status": "pending"},
{"id": 7, "subject": "Task 4: Integrate ClientFlags into NatsClient", "status": "pending", "blockedBy": [4, 5, 6]},
{"id": 8, "subject": "Task 5: Implement channel-based write loop", "status": "pending", "blockedBy": [7]},
{"id": 9, "subject": "Task 6: Write tests for write loop and slow consumer", "status": "pending", "blockedBy": [8]},
{"id": 10, "subject": "Task 7: Update NatsServer for SendMessage + no-responders", "status": "pending", "blockedBy": [8]},
{"id": 11, "subject": "Task 8: Implement verbose mode", "status": "pending", "blockedBy": [10]},
{"id": 12, "subject": "Task 9: Implement no-responders CONNECT validation", "status": "pending", "blockedBy": [10]},
{"id": 13, "subject": "Task 10: Implement stat batching in read loop", "status": "pending", "blockedBy": [8]},
{"id": 14, "subject": "Task 11: Update ConnzHandler for close reason + pending bytes", "status": "pending", "blockedBy": [13]},
{"id": 15, "subject": "Task 12: Fix existing tests for new write model", "status": "pending", "blockedBy": [13]},
{"id": 16, "subject": "Task 13: Final verification and differences.md update", "status": "pending", "blockedBy": [14, 15]}
],
"lastUpdated": "2026-02-22T00:00:00Z"
}

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

318
documentation_rules.md Normal file
View File

@@ -0,0 +1,318 @@
# Documentation Rules
This document defines the documentation system for the NATS .NET server project. It provides guidelines for generating, updating, and maintaining project documentation.
The documentation is intended for internal team reference — explaining what the system is, how it works, how to extend it, and how to debug it.
## Folder Structure
```
Documentation/
├── Instructions/ # Guidelines for LLMs (meta-documentation)
│ └── (this file serves as the single instructions reference)
├── GettingStarted/ # Onboarding, prerequisites, first run
├── Protocol/ # Wire protocol, parser, command types
├── Subscriptions/ # SubList trie, subject matching, wildcards
├── Server/ # NatsServer orchestrator, NatsClient handler
├── Configuration/ # NatsOptions, appsettings, CLI arguments
├── Operations/ # Deployment, monitoring, health checks, troubleshooting
└── Plans/ # Design documents and implementation plans
```
Future module folders (add as modules are ported):
```
├── Authentication/ # Auth mechanisms, NKeys, JWT, accounts
├── Clustering/ # Routes, gateways, leaf nodes
├── JetStream/ # Streams, consumers, storage, RAFT
├── Monitoring/ # HTTP endpoints (/varz, /connz, etc.)
├── WebSocket/ # WebSocket transport
└── TLS/ # TLS configuration and setup
```
---
## Style Guide
### Tone and Voice
- **Technical and direct** — no marketing language. Avoid "powerful", "robust", "seamless", "blazing fast".
- **Assume the reader is a .NET developer** — don't explain dependency injection, async/await, or LINQ basics.
- **Explain "why" not just "what"** — document reasoning behind patterns and decisions.
- **Use present tense** — "The parser reads..." not "The parser will read..."
### Formatting Rules
| Aspect | Convention |
|--------|------------|
| File names | `PascalCase.md` |
| H1 (`#`) | Document title only, Title Case |
| H2 (`##`) | Major sections, Title Case |
| H3+ (`###`) | Subsections, Sentence case |
| Code blocks | Always specify language (`csharp`, `json`, `bash`, `xml`) |
| Code snippets | 5-25 lines typical; include class/method context |
| Cross-references | Relative paths: `[See SubList](../Subscriptions/SubList.md)` |
| Inline code | Backticks for code refs: `NatsServer`, `SubList.Match()`, `NatsOptions` |
| Lists | Bullets for unordered, numbers for sequential steps |
| Tables | For structured reference (config options, command formats) |
### Naming Conventions
- Match code terminology exactly: `SubList` not "Subject List", `NatsClient` not "NATS Client Handler"
- Use backticks for all code references: `NatsParser`, `appsettings.json`, `dotnet test`
- Spell out acronyms on first use: "NATS Adaptive Edge Messaging (NATS)" — common acronyms that don't need expansion: API, JSON, TCP, HTTP, TLS, JWT
### Code Snippet Guidelines
**Do:**
- Copy snippets from actual source files
- Include enough context (class name, method signature)
- Specify the language in code blocks
- Show 5-25 line examples
```csharp
// Good — shows class context
public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
{
public async Task StartAsync(CancellationToken ct)
{
_listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
_listener.Bind(new IPEndPoint(IPAddress.Parse(_options.Host), _options.Port));
_listener.Listen(128);
}
}
```
**Don't:**
- Invent example code that doesn't exist in the codebase
- Include 100+ line dumps without explanation
- Use pseudocode when real code is available
- Omit the language specifier on code blocks
### Structure Conventions
Every documentation file must include:
1. **Title and purpose** — H1 heading with 1-2 sentence description
2. **Key concepts** — if the topic requires background understanding
3. **Code examples** — embedded snippets from actual codebase
4. **Configuration** — if the component has configurable options
5. **Related documentation** — links to related topics
Organize content from general to specific:
1. Overview/introduction
2. Key concepts
3. Basic usage
4. Advanced usage / internals
5. Configuration
6. Troubleshooting
7. Related documentation
End each document with:
```markdown
## Related Documentation
- [Related Topic](../Component/Topic.md)
```
### What to Avoid
- Don't document the obvious (e.g., "The constructor creates a new instance")
- Don't duplicate source code comments — reference the file instead
- Don't include temporary information (dates, version numbers, "coming soon")
- Don't over-explain .NET basics
---
## Generating Documentation
### Document Types
Each component folder should contain these standard files:
| File | Purpose |
|------|---------|
| `Overview.md` | What the component does, key concepts, architecture |
| `Development.md` | How to add/modify features, patterns to follow |
| `Configuration.md` | All configurable options with defaults and examples |
| `Troubleshooting.md` | Common issues, error messages, debugging steps |
Create additional topic-specific files as needed (e.g., `Protocol/Parser.md`, `Subscriptions/SubList.md`).
### Generation Process
1. **Identify scope** — which component folder does this belong to? (See Component Map below)
2. **Read source code** — understand the current implementation, identify key classes/methods/patterns, note configuration options
3. **Check existing documentation** — avoid duplication, cross-reference rather than repeat
4. **Write documentation** — follow the style guide, use real code snippets
5. **Verify accuracy** — confirm snippets match source, verify file paths and class names, test commands
### Creating New Component Folders
1. Create the folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add other standard files as content warrants
4. Update the Component Map section below
5. Add cross-references from related documentation
---
## Updating Documentation
### Update Triggers
| Code Change | Update These Docs |
|-------------|-------------------|
| New protocol command | `Protocol/` relevant file |
| Parser modified | `Protocol/Parser.md` |
| Subject matching changed | `Subscriptions/SubjectMatch.md` |
| SubList trie modified | `Subscriptions/SubList.md` |
| New subscription type | `Subscriptions/Overview.md` |
| NatsServer changed | `Server/Overview.md` |
| NatsClient changed | `Server/Client.md` |
| Config option added/removed | Component's `Configuration.md` |
| NatsOptions changed | `Configuration/Overview.md` |
| Host startup changed | `Operations/Deployment.md` + `Configuration/` |
| New test patterns | Corresponding component docs |
| Auth mechanism added | `Authentication/` (create if needed) |
| Clustering added | `Clustering/` (create if needed) |
### Update Process
1. **Identify affected documentation** — use the Component Map to determine which docs need updating
2. **Read current documentation** — understand existing structure before making changes
3. **Make targeted updates** — only modify sections affected by the code change; don't rewrite unaffected sections
4. **Update code snippets** — if the code change affects documented examples, update them to match
5. **Update cross-references** — add links to newly related docs, remove links to deleted content
6. **Add verification comment** — at the bottom: `<!-- Last verified against codebase: YYYY-MM-DD -->`
### Deletion Handling
- When code is removed, remove corresponding doc sections
- When code is renamed, update all references (docs, snippets, cross-reference links)
- If an entire feature is removed, delete the doc file and update any index/overview docs
- Search all docs for links to removed content
### What Not to Update
- Don't reformat documentation that wasn't affected by the code change
- Don't update examples that still work correctly
- Don't add new content unrelated to the code change
- Don't change writing style in unaffected sections
---
## Component Map
### Source to Documentation Mapping
| Source Path | Documentation Folder |
|-------------|---------------------|
| `src/NATS.Server/Protocol/NatsParser.cs` | `Protocol/` |
| `src/NATS.Server/Protocol/NatsProtocol.cs` | `Protocol/` |
| `src/NATS.Server/Subscriptions/SubList.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubjectMatch.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/Subscription.cs` | `Subscriptions/` |
| `src/NATS.Server/Subscriptions/SubListResult.cs` | `Subscriptions/` |
| `src/NATS.Server/NatsServer.cs` | `Server/` |
| `src/NATS.Server/NatsClient.cs` | `Server/` |
| `src/NATS.Server/NatsOptions.cs` | `Configuration/` |
| `src/NATS.Server.Host/Program.cs` | `Operations/` and `Configuration/` |
| `tests/NATS.Server.Tests/` | Document in corresponding component |
| `golang/nats-server/server/` | Reference material (not documented separately) |
### Component Details
#### Protocol/
Documents the wire protocol and parser.
**Source paths:**
- `src/NATS.Server/Protocol/NatsParser.cs` — state machine parser
- `src/NATS.Server/Protocol/NatsProtocol.cs` — constants, ServerInfo, ClientOptions
**Typical files:**
- `Overview.md` — NATS protocol format, command types, wire format
- `Parser.md` — Parser implementation, `TryParse` flow, state machine
- `Commands.md` — Individual command formats (PUB, SUB, UNSUB, MSG, etc.)
#### Subscriptions/
Documents subject matching and the subscription trie.
**Source paths:**
- `src/NATS.Server/Subscriptions/SubList.cs` — trie + cache
- `src/NATS.Server/Subscriptions/SubjectMatch.cs` — validation and wildcard matching
- `src/NATS.Server/Subscriptions/Subscription.cs` — subscription model
- `src/NATS.Server/Subscriptions/SubListResult.cs` — match result container
**Typical files:**
- `Overview.md` — Subject namespace, wildcard rules, queue groups
- `SubList.md` — Trie internals, cache invalidation, thread safety
- `SubjectMatch.md` — Validation rules, wildcard matching algorithm
#### Server/
Documents the server orchestrator and client connection handler.
**Source paths:**
- `src/NATS.Server/NatsServer.cs` — accept loop, message routing
- `src/NATS.Server/NatsClient.cs` — per-connection read/write, subscription tracking
**Typical files:**
- `Overview.md` — Server architecture, connection lifecycle, message flow
- `Client.md` — Client connection handling, command dispatch, write serialization
- `MessageRouting.md` — How messages flow from PUB to subscribers
#### Configuration/
Documents server configuration options.
**Source paths:**
- `src/NATS.Server/NatsOptions.cs` — configuration model
- `src/NATS.Server.Host/Program.cs` — CLI argument parsing, Serilog setup
**Typical files:**
- `Overview.md` — All options with defaults and descriptions
- `Logging.md` — Serilog configuration, log levels, LogContext usage
#### Operations/
Documents deployment and operational concerns.
**Source paths:**
- `src/NATS.Server.Host/` — host application
**Typical files:**
- `Overview.md` — Running the server, CLI arguments
- `Deployment.md` — Deployment procedures
- `Troubleshooting.md` — Common issues and debugging
#### GettingStarted/
Documents onboarding and project overview.
**Typical files:**
- `Setup.md` — Prerequisites, building, running
- `Architecture.md` — System overview, Go reference mapping
- `Development.md` — Development workflow, testing, contributing
### Ambiguous Cases
| Code Type | Document In |
|-----------|-------------|
| Logging setup | `Configuration/Logging.md` |
| Integration tests | `Operations/Testing.md` or corresponding component |
| Shared interfaces (`IMessageRouter`, `ISubListAccess`) | `Server/Overview.md` |
| Go reference code | Don't document separately; reference in `.NET` component docs |
### Adding New Components
When a new module is ported (Authentication, Clustering, JetStream, etc.):
1. Create a new folder under `Documentation/`
2. Add at minimum `Overview.md`
3. Add this mapping table entry
4. Update CLAUDE.md documentation index if it has one
5. Cross-reference from related component docs

View File

@@ -8,4 +8,12 @@
<ProjectReference Include="..\NATS.Server\NATS.Server.csproj" />
</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,2 +1,246 @@
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
using NATS.Server;
using NATS.Server.Configuration;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;
// First pass: scan args for -c flag to get config file path
string? configFile = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-c" && i + 1 < args.Length)
{
configFile = args[++i];
break;
}
}
var windowsService = false;
// If config file specified, load it as the base options
var options = configFile != null
? ConfigProcessor.ProcessConfigFile(configFile)
: new NatsOptions();
// Second pass: apply CLI args on top of config-loaded options, tracking InCmdLine
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "-p" or "--port" when i + 1 < args.Length:
options.Port = int.Parse(args[++i]);
options.InCmdLine.Add("Port");
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
options.InCmdLine.Add("Host");
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
options.InCmdLine.Add("ServerName");
break;
case "-m" or "--http_port" when i + 1 < args.Length:
options.MonitorPort = int.Parse(args[++i]);
options.InCmdLine.Add("MonitorPort");
break;
case "--http_base_path" when i + 1 < args.Length:
options.MonitorBasePath = args[++i];
options.InCmdLine.Add("MonitorBasePath");
break;
case "--https_port" when i + 1 < args.Length:
options.MonitorHttpsPort = int.Parse(args[++i]);
options.InCmdLine.Add("MonitorHttpsPort");
break;
case "-c" when i + 1 < args.Length:
// Already handled in first pass; skip the value
i++;
break;
case "--pid" when i + 1 < args.Length:
options.PidFile = args[++i];
options.InCmdLine.Add("PidFile");
break;
case "--ports_file_dir" when i + 1 < args.Length:
options.PortsFileDir = args[++i];
options.InCmdLine.Add("PortsFileDir");
break;
case "--tls":
break;
case "--tlscert" when i + 1 < args.Length:
options.TlsCert = args[++i];
options.InCmdLine.Add("TlsCert");
break;
case "--tlskey" when i + 1 < args.Length:
options.TlsKey = args[++i];
options.InCmdLine.Add("TlsKey");
break;
case "--tlscacert" when i + 1 < args.Length:
options.TlsCaCert = args[++i];
options.InCmdLine.Add("TlsCaCert");
break;
case "--tlsverify":
options.TlsVerify = true;
options.InCmdLine.Add("TlsVerify");
break;
case "-D" or "--debug":
options.Debug = true;
options.InCmdLine.Add("Debug");
break;
case "-V" or "-T" or "--trace":
options.Trace = true;
options.InCmdLine.Add("Trace");
break;
case "-DV":
options.Debug = true;
options.Trace = true;
options.InCmdLine.Add("Debug");
options.InCmdLine.Add("Trace");
break;
case "-l" or "--log" or "--log_file" when i + 1 < args.Length:
options.LogFile = args[++i];
options.InCmdLine.Add("LogFile");
break;
case "--log_size_limit" when i + 1 < args.Length:
options.LogSizeLimit = long.Parse(args[++i]);
options.InCmdLine.Add("LogSizeLimit");
break;
case "--log_max_files" when i + 1 < args.Length:
options.LogMaxFiles = int.Parse(args[++i]);
options.InCmdLine.Add("LogMaxFiles");
break;
case "--logtime" when i + 1 < args.Length:
options.Logtime = bool.Parse(args[++i]);
options.InCmdLine.Add("Logtime");
break;
case "--logtime_utc":
options.LogtimeUTC = true;
options.InCmdLine.Add("LogtimeUTC");
break;
case "--syslog":
options.Syslog = true;
options.InCmdLine.Add("Syslog");
break;
case "--remote_syslog" when i + 1 < args.Length:
options.RemoteSyslog = args[++i];
options.InCmdLine.Add("RemoteSyslog");
break;
case "--service":
windowsService = true;
break;
case "--log_level_override" when i + 1 < args.Length:
var parts = args[++i].Split('=', 2);
if (parts.Length == 2)
{
options.LogOverrides ??= new();
options.LogOverrides[parts[0]] = parts[1];
}
break;
}
}
// 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) =>
{
e.Cancel = true;
Log.Information("Trapped SIGINT signal");
_ = Task.Run(async () => await server.ShutdownAsync());
};
try
{
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
server.WaitForShutdown();
}
catch (OperationCanceledException)
{
Log.Information("Server shutdown requested");
}
finally
{
Log.CloseAndFlush();
}

View File

@@ -0,0 +1,168 @@
using System.Collections.Concurrent;
using NATS.Server.Imports;
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth;
public sealed class Account : IDisposable
{
public const string GlobalAccountName = "$G";
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)
{
Name = name;
}
public int ClientCount => _clients.Count;
public int SubscriptionCount => Volatile.Read(ref _subscriptionCount);
/// <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

@@ -0,0 +1,9 @@
namespace NATS.Server.Auth;
public sealed class AuthResult
{
public required string Identity { get; init; }
public string? AccountName { get; init; }
public Permissions? Permissions { get; init; }
public DateTimeOffset? Expiry { get; init; }
}

View File

@@ -0,0 +1,147 @@
using System.Security.Cryptography;
namespace NATS.Server.Auth;
/// <summary>
/// Central authentication orchestrator that builds the appropriate authenticators
/// from NatsOptions and tries them in priority order matching the Go server:
/// NKeys > Users > Token > SimpleUserPassword.
/// Reference: golang/nats-server/server/auth.go — checkClientAuth, configureAuthentication.
/// </summary>
public sealed class AuthService
{
private readonly List<IAuthenticator> _authenticators;
private readonly string? _noAuthUser;
private readonly Dictionary<string, User>? _usersMap;
public bool IsAuthRequired { get; }
public bool NonceRequired { get; }
private AuthService(List<IAuthenticator> authenticators, bool authRequired, bool nonceRequired,
string? noAuthUser, Dictionary<string, User>? usersMap)
{
_authenticators = authenticators;
IsAuthRequired = authRequired;
NonceRequired = nonceRequired;
_noAuthUser = noAuthUser;
_usersMap = usersMap;
}
public static AuthService Build(NatsOptions options)
{
var authenticators = new List<IAuthenticator>();
var authRequired = false;
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 })
{
authenticators.Add(new NKeyAuthenticator(options.NKeys));
authRequired = true;
nonceRequired = true;
}
if (options.Users is { Count: > 0 })
{
authenticators.Add(new UserPasswordAuthenticator(options.Users));
authRequired = true;
usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
foreach (var u in options.Users)
usersMap[u.Username] = u;
}
if (!string.IsNullOrEmpty(options.Authorization))
{
authenticators.Add(new TokenAuthenticator(options.Authorization));
authRequired = true;
}
if (!string.IsNullOrEmpty(options.Username) && !string.IsNullOrEmpty(options.Password))
{
authenticators.Add(new SimpleUserPasswordAuthenticator(options.Username, options.Password));
authRequired = true;
}
return new AuthService(authenticators, authRequired, nonceRequired, options.NoAuthUser, usersMap);
}
public AuthResult? Authenticate(ClientAuthContext context)
{
if (!IsAuthRequired)
return new AuthResult { Identity = string.Empty };
foreach (var authenticator in _authenticators)
{
var result = authenticator.Authenticate(context);
if (result != null)
return result;
}
if (_noAuthUser != null && IsNoCredentials(context))
return ResolveNoAuthUser();
return null;
}
private static bool IsNoCredentials(ClientAuthContext context)
{
var opts = context.Opts;
return string.IsNullOrEmpty(opts.Username)
&& string.IsNullOrEmpty(opts.Password)
&& string.IsNullOrEmpty(opts.Token)
&& string.IsNullOrEmpty(opts.Nkey)
&& string.IsNullOrEmpty(opts.Sig)
&& string.IsNullOrEmpty(opts.JWT);
}
private AuthResult? ResolveNoAuthUser()
{
if (_noAuthUser == null)
return null;
if (_usersMap != null && _usersMap.TryGetValue(_noAuthUser, out var user))
{
return new AuthResult
{
Identity = user.Username,
AccountName = user.Account,
Permissions = user.Permissions,
Expiry = user.ConnectionDeadline,
};
}
return new AuthResult { Identity = _noAuthUser };
}
public byte[] GenerateNonce()
{
Span<byte> raw = stackalloc byte[11];
RandomNumberGenerator.Fill(raw);
return raw.ToArray();
}
public string EncodeNonce(byte[] nonce)
{
return Convert.ToBase64String(nonce)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -0,0 +1,165 @@
using NATS.Server.Subscriptions;
namespace NATS.Server.Auth;
public sealed class ClientPermissions : IDisposable
{
private readonly PermissionSet? _publish;
private readonly PermissionSet? _subscribe;
private readonly ResponseTracker? _responseTracker;
private readonly PermissionLruCache _pubCache = new(128);
private ClientPermissions(PermissionSet? publish, PermissionSet? subscribe, ResponseTracker? responseTracker)
{
_publish = publish;
_subscribe = subscribe;
_responseTracker = responseTracker;
}
public static ClientPermissions? Build(Permissions? permissions)
{
if (permissions == null)
return null;
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 && responseTracker == null)
return null;
return new ClientPermissions(pub, sub, responseTracker);
}
public ResponseTracker? ResponseTracker => _responseTracker;
public bool IsPublishAllowed(string subject)
{
if (_publish == null)
return true;
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;
}
public bool IsDeliveryAllowed(string subject)
{
if (_subscribe == null)
return true;
return _subscribe.IsDeliveryAllowed(subject);
}
public void Dispose()
{
_publish?.Dispose();
_subscribe?.Dispose();
}
}
public sealed class PermissionSet : IDisposable
{
private readonly SubList? _allow;
private readonly SubList? _deny;
private PermissionSet(SubList? allow, SubList? deny)
{
_allow = allow;
_deny = deny;
}
public static PermissionSet? Build(SubjectPermission? permission)
{
if (permission == null)
return null;
bool hasAllow = permission.Allow is { Count: > 0 };
bool hasDeny = permission.Deny is { Count: > 0 };
if (!hasAllow && !hasDeny)
return null;
SubList? allow = null;
SubList? deny = null;
if (hasAllow)
{
allow = new SubList();
foreach (var subject in permission.Allow!)
allow.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
}
if (hasDeny)
{
deny = new SubList();
foreach (var subject in permission.Deny!)
deny.Insert(new Subscription { Subject = subject, Sid = "_perm_" });
}
return new PermissionSet(allow, deny);
}
public bool IsAllowed(string subject)
{
bool allowed = true;
if (_allow != null)
{
var result = _allow.Match(subject);
allowed = result.PlainSubs.Length > 0 || result.QueueSubs.Length > 0;
}
if (allowed && _deny != null)
{
var result = _deny.Match(subject);
allowed = result.PlainSubs.Length == 0 && result.QueueSubs.Length == 0;
}
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();
_deny?.Dispose();
}
}

View File

@@ -0,0 +1,16 @@
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Protocol;
namespace NATS.Server.Auth;
public interface IAuthenticator
{
AuthResult? Authenticate(ClientAuthContext context);
}
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,66 @@
using NATS.NKeys;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates clients using NKey (Ed25519) public-key signature verification.
/// The server sends a random nonce in the INFO message. The client signs the nonce
/// with their private key and sends the public key + base64-encoded signature in CONNECT.
/// The server verifies the signature against the registered NKey users.
/// </summary>
/// <remarks>
/// Reference: golang/nats-server/server/auth.go — checkNKeyAuth
/// </remarks>
public sealed class NKeyAuthenticator(IEnumerable<NKeyUser> nkeyUsers) : IAuthenticator
{
private readonly Dictionary<string, NKeyUser> _nkeys = nkeyUsers.ToDictionary(
u => u.Nkey,
u => u,
StringComparer.Ordinal);
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientNkey = context.Opts.Nkey;
if (string.IsNullOrEmpty(clientNkey))
return null;
if (!_nkeys.TryGetValue(clientNkey, out var nkeyUser))
return null;
var clientSig = context.Opts.Sig;
if (string.IsNullOrEmpty(clientSig))
return null;
try
{
// Decode base64 signature (handle both standard and URL-safe base64)
byte[] sigBytes;
try
{
sigBytes = Convert.FromBase64String(clientSig);
}
catch (FormatException)
{
// Try URL-safe base64 by converting to standard base64
var padded = clientSig.Replace('-', '+').Replace('_', '/');
padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '=');
sigBytes = Convert.FromBase64String(padded);
}
var kp = KeyPair.FromPublicKey(clientNkey);
if (!kp.Verify(context.Nonce, sigBytes))
return null;
}
catch
{
return null;
}
return new AuthResult
{
Identity = clientNkey,
AccountName = nkeyUser.Account,
Permissions = nkeyUser.Permissions,
};
}
}

View File

@@ -0,0 +1,9 @@
namespace NATS.Server.Auth;
public sealed class NKeyUser
{
public required string Nkey { get; init; }
public Permissions? Permissions { get; init; }
public string? Account { get; init; }
public string? SigningKey { get; init; }
}

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,20 @@
namespace NATS.Server.Auth;
public sealed class Permissions
{
public SubjectPermission? Publish { get; init; }
public SubjectPermission? Subscribe { get; init; }
public ResponsePermission? Response { get; init; }
}
public sealed class SubjectPermission
{
public IReadOnlyList<string>? Allow { get; init; }
public IReadOnlyList<string>? Deny { get; init; }
}
public sealed class ResponsePermission
{
public int MaxMsgs { get; init; }
public TimeSpan Expires { get; init; }
}

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,61 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates a single username/password pair configured on the server.
/// Supports plain-text and bcrypt-hashed passwords.
/// Uses constant-time comparison for both username and password to prevent timing attacks.
/// Reference: golang/nats-server/server/auth.go checkClientAuth for single user.
/// </summary>
public sealed class SimpleUserPasswordAuthenticator : IAuthenticator
{
private readonly byte[] _expectedUsername;
private readonly string _serverPassword;
public SimpleUserPasswordAuthenticator(string username, string password)
{
_expectedUsername = Encoding.UTF8.GetBytes(username);
_serverPassword = password;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientUsername = context.Opts.Username;
if (string.IsNullOrEmpty(clientUsername))
return null;
var clientUsernameBytes = Encoding.UTF8.GetBytes(clientUsername);
if (!CryptographicOperations.FixedTimeEquals(clientUsernameBytes, _expectedUsername))
return null;
var clientPassword = context.Opts.Password ?? string.Empty;
if (!ComparePasswords(_serverPassword, clientPassword))
return null;
return new AuthResult { Identity = clientUsername };
}
private static bool ComparePasswords(string serverPassword, string clientPassword)
{
// Bcrypt hashes start with "$2" (e.g., $2a$, $2b$, $2y$)
if (serverPassword.StartsWith("$2"))
{
try
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
catch
{
return false;
}
}
// Plain-text: constant-time comparison to prevent timing attacks
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
}
}

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

@@ -0,0 +1,28 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
public sealed class TokenAuthenticator : IAuthenticator
{
private readonly byte[] _expectedToken;
public TokenAuthenticator(string token)
{
_expectedToken = Encoding.UTF8.GetBytes(token);
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var clientToken = context.Opts.Token;
if (string.IsNullOrEmpty(clientToken))
return null;
var clientBytes = Encoding.UTF8.GetBytes(clientToken);
if (!CryptographicOperations.FixedTimeEquals(clientBytes, _expectedToken))
return null;
return new AuthResult { Identity = "token" };
}
}

View File

@@ -0,0 +1,10 @@
namespace NATS.Server.Auth;
public sealed class User
{
public required string Username { get; init; }
public required string Password { get; init; }
public Permissions? Permissions { get; init; }
public string? Account { get; init; }
public DateTimeOffset? ConnectionDeadline { get; init; }
}

View File

@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
namespace NATS.Server.Auth;
/// <summary>
/// Authenticates clients by looking up username in a dictionary and comparing
/// the password using bcrypt (for $2-prefixed hashes) or constant-time comparison
/// (for plain text passwords).
/// Reference: golang/nats-server/server/auth.go checkClientPassword.
/// </summary>
public sealed class UserPasswordAuthenticator : IAuthenticator
{
private readonly Dictionary<string, User> _users;
public UserPasswordAuthenticator(IEnumerable<User> users)
{
_users = new Dictionary<string, User>(StringComparer.Ordinal);
foreach (var user in users)
_users[user.Username] = user;
}
public AuthResult? Authenticate(ClientAuthContext context)
{
var username = context.Opts.Username;
if (string.IsNullOrEmpty(username))
return null;
if (!_users.TryGetValue(username, out var user))
return null;
var clientPassword = context.Opts.Password ?? string.Empty;
if (!ComparePasswords(user.Password, clientPassword))
return null;
return new AuthResult
{
Identity = user.Username,
AccountName = user.Account,
Permissions = user.Permissions,
Expiry = user.ConnectionDeadline,
};
}
private static bool ComparePasswords(string serverPassword, string clientPassword)
{
if (IsBcrypt(serverPassword))
{
try
{
return BCrypt.Net.BCrypt.Verify(clientPassword, serverPassword);
}
catch
{
return false;
}
}
var serverBytes = Encoding.UTF8.GetBytes(serverPassword);
var clientBytes = Encoding.UTF8.GetBytes(clientPassword);
return CryptographicOperations.FixedTimeEquals(serverBytes, clientBytes);
}
private static bool IsBcrypt(string password) => password.StartsWith("$2");
}

View File

@@ -0,0 +1,53 @@
namespace NATS.Server;
/// <summary>
/// Reason a client connection was closed.
/// Corresponds to Go server/client.go ClosedState (subset for single-server scope).
/// </summary>
public enum ClientClosedReason
{
None = 0,
ClientClosed,
AuthenticationTimeout,
AuthenticationViolation,
TlsHandshakeError,
SlowConsumerPendingBytes,
SlowConsumerWriteDeadline,
WriteError,
ReadError,
ParseError,
StaleConnection,
ProtocolViolation,
MaxPayloadExceeded,
MaxSubscriptionsExceeded,
ServerShutdown,
MsgHeaderViolation,
NoRespondersRequiresHeaders,
AuthenticationExpired,
}
public static class ClientClosedReasonExtensions
{
public static string ToReasonString(this ClientClosedReason reason) => reason switch
{
ClientClosedReason.None => "",
ClientClosedReason.ClientClosed => "Client Closed",
ClientClosedReason.AuthenticationTimeout => "Authentication Timeout",
ClientClosedReason.AuthenticationViolation => "Authorization Violation",
ClientClosedReason.TlsHandshakeError => "TLS Handshake Error",
ClientClosedReason.SlowConsumerPendingBytes => "Slow Consumer (Pending Bytes)",
ClientClosedReason.SlowConsumerWriteDeadline => "Slow Consumer (Write Deadline)",
ClientClosedReason.WriteError => "Write Error",
ClientClosedReason.ReadError => "Read Error",
ClientClosedReason.ParseError => "Parse Error",
ClientClosedReason.StaleConnection => "Stale Connection",
ClientClosedReason.ProtocolViolation => "Protocol Violation",
ClientClosedReason.MaxPayloadExceeded => "Maximum Payload Exceeded",
ClientClosedReason.MaxSubscriptionsExceeded => "Maximum Subscriptions Exceeded",
ClientClosedReason.ServerShutdown => "Server Shutdown",
ClientClosedReason.MsgHeaderViolation => "Message Header Violation",
ClientClosedReason.NoRespondersRequiresHeaders => "No Responders Requires Headers",
ClientClosedReason.AuthenticationExpired => "Authentication Expired",
_ => reason.ToString(),
};
}

View File

@@ -0,0 +1,42 @@
namespace NATS.Server;
/// <summary>
/// Connection state flags tracked per client.
/// Corresponds to Go server/client.go clientFlag bitfield.
/// Thread-safe via Interlocked operations on the backing int.
/// </summary>
[Flags]
public enum ClientFlags
{
ConnectReceived = 1 << 0,
FirstPongSent = 1 << 1,
HandshakeComplete = 1 << 2,
CloseConnection = 1 << 3,
WriteLoopStarted = 1 << 4,
IsSlowConsumer = 1 << 5,
ConnectProcessFinished = 1 << 6,
TraceMode = 1 << 7,
}
/// <summary>
/// Thread-safe holder for client flags using Interlocked operations.
/// </summary>
public sealed class ClientFlagHolder
{
private int _flags;
public void SetFlag(ClientFlags flag)
{
Interlocked.Or(ref _flags, (int)flag);
}
public void ClearFlag(ClientFlags flag)
{
Interlocked.And(ref _flags, ~(int)flag);
}
public bool HasFlag(ClientFlags flag)
{
return (Volatile.Read(ref _flags) & (int)flag) != 0;
}
}

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,52 @@
// Ported from Go: server/client.go:188-228
namespace NATS.Server;
/// <summary>
/// Reason a client connection was closed. Stored in connection info for monitoring
/// and passed to close handlers during connection teardown.
/// </summary>
/// <remarks>
/// Values start at 1 (matching Go's <c>iota + 1</c>) so that the default zero value
/// is distinct from any valid close reason.
/// </remarks>
public enum ClosedState
{
ClientClosed = 1,
AuthenticationTimeout,
AuthenticationViolation,
TLSHandshakeError,
SlowConsumerPendingBytes,
SlowConsumerWriteDeadline,
WriteError,
ReadError,
ParseError,
StaleConnection,
ProtocolViolation,
BadClientProtocolVersion,
WrongPort,
MaxAccountConnectionsExceeded,
MaxConnectionsExceeded,
MaxPayloadExceeded,
MaxControlLineExceeded,
MaxSubscriptionsExceeded,
DuplicateRoute,
RouteRemoved,
ServerShutdown,
AuthenticationExpired,
WrongGateway,
MissingAccount,
Revocation,
InternalClient,
MsgHeaderViolation,
NoRespondersRequiresHeaders,
ClusterNameConflict,
DuplicateRemoteLeafnodeConnection,
DuplicateClientID,
DuplicateServerName,
MinimumVersionRequired,
ClusterNamesIdentical,
Kicked,
ProxyNotTrusted,
ProxyRequired,
}

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

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