Compare commits

..

149 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
182 changed files with 47084 additions and 398 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

@@ -8,6 +8,15 @@
<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" />

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

View File

@@ -9,8 +9,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" />
</ItemGroup>
</Project>

View File

@@ -1,46 +1,245 @@
using NATS.Server;
using NATS.Server.Configuration;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
// First pass: scan args for -c flag to get config file path
string? configFile = null;
for (int i = 0; i < args.Length; i++)
{
if (args[i] == "-c" && i + 1 < args.Length)
{
configFile = args[++i];
break;
}
}
var options = new NatsOptions();
var windowsService = false;
// Simple CLI argument parsing
// If config file specified, load it as the base options
var options = configFile != null
? ConfigProcessor.ProcessConfigFile(configFile)
: new NatsOptions();
// Second pass: apply CLI args on top of config-loaded options, tracking InCmdLine
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "-p" or "--port" when i + 1 < args.Length:
options.Port = int.Parse(args[++i]);
options.InCmdLine.Add("Port");
break;
case "-a" or "--addr" when i + 1 < args.Length:
options.Host = args[++i];
options.InCmdLine.Add("Host");
break;
case "-n" or "--name" when i + 1 < args.Length:
options.ServerName = args[++i];
options.InCmdLine.Add("ServerName");
break;
case "-m" or "--http_port" when i + 1 < args.Length:
options.MonitorPort = int.Parse(args[++i]);
options.InCmdLine.Add("MonitorPort");
break;
case "--http_base_path" when i + 1 < args.Length:
options.MonitorBasePath = args[++i];
options.InCmdLine.Add("MonitorBasePath");
break;
case "--https_port" when i + 1 < args.Length:
options.MonitorHttpsPort = int.Parse(args[++i]);
options.InCmdLine.Add("MonitorHttpsPort");
break;
case "-c" when i + 1 < args.Length:
// Already handled in first pass; skip the value
i++;
break;
case "--pid" when i + 1 < args.Length:
options.PidFile = args[++i];
options.InCmdLine.Add("PidFile");
break;
case "--ports_file_dir" when i + 1 < args.Length:
options.PortsFileDir = args[++i];
options.InCmdLine.Add("PortsFileDir");
break;
case "--tls":
break;
case "--tlscert" when i + 1 < args.Length:
options.TlsCert = args[++i];
options.InCmdLine.Add("TlsCert");
break;
case "--tlskey" when i + 1 < args.Length:
options.TlsKey = args[++i];
options.InCmdLine.Add("TlsKey");
break;
case "--tlscacert" when i + 1 < args.Length:
options.TlsCaCert = args[++i];
options.InCmdLine.Add("TlsCaCert");
break;
case "--tlsverify":
options.TlsVerify = true;
options.InCmdLine.Add("TlsVerify");
break;
case "-D" or "--debug":
options.Debug = true;
options.InCmdLine.Add("Debug");
break;
case "-V" or "-T" or "--trace":
options.Trace = true;
options.InCmdLine.Add("Trace");
break;
case "-DV":
options.Debug = true;
options.Trace = true;
options.InCmdLine.Add("Debug");
options.InCmdLine.Add("Trace");
break;
case "-l" or "--log" or "--log_file" when i + 1 < args.Length:
options.LogFile = args[++i];
options.InCmdLine.Add("LogFile");
break;
case "--log_size_limit" when i + 1 < args.Length:
options.LogSizeLimit = long.Parse(args[++i]);
options.InCmdLine.Add("LogSizeLimit");
break;
case "--log_max_files" when i + 1 < args.Length:
options.LogMaxFiles = int.Parse(args[++i]);
options.InCmdLine.Add("LogMaxFiles");
break;
case "--logtime" when i + 1 < args.Length:
options.Logtime = bool.Parse(args[++i]);
options.InCmdLine.Add("Logtime");
break;
case "--logtime_utc":
options.LogtimeUTC = true;
options.InCmdLine.Add("LogtimeUTC");
break;
case "--syslog":
options.Syslog = true;
options.InCmdLine.Add("Syslog");
break;
case "--remote_syslog" when i + 1 < args.Length:
options.RemoteSyslog = args[++i];
options.InCmdLine.Add("RemoteSyslog");
break;
case "--service":
windowsService = true;
break;
case "--log_level_override" when i + 1 < args.Length:
var parts = args[++i].Split('=', 2);
if (parts.Length == 2)
{
options.LogOverrides ??= new();
options.LogOverrides[parts[0]] = parts[1];
}
break;
}
}
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
var server = new NatsServer(options, loggerFactory);
// Build Serilog configuration from options
var logConfig = new LoggerConfiguration()
.Enrich.FromLogContext();
var cts = new CancellationTokenSource();
// Set minimum level based on flags
if (options.Trace)
logConfig.MinimumLevel.Verbose();
else if (options.Debug)
logConfig.MinimumLevel.Debug();
else
logConfig.MinimumLevel.Information();
// Build output template
var timestampFormat = options.LogtimeUTC
? "{Timestamp:yyyy/MM/dd HH:mm:ss.ffffff} "
: "{Timestamp:HH:mm:ss} ";
var template = options.Logtime
? $"[{timestampFormat}{{Level:u3}}] {{Message:lj}}{{NewLine}}{{Exception}}"
: "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
// Console sink with color auto-detection
if (!Console.IsOutputRedirected)
logConfig.WriteTo.Console(outputTemplate: template, theme: AnsiConsoleTheme.Code);
else
logConfig.WriteTo.Console(outputTemplate: template);
// File sink with rotation
if (!string.IsNullOrEmpty(options.LogFile))
{
logConfig.WriteTo.File(
options.LogFile,
fileSizeLimitBytes: options.LogSizeLimit > 0 ? options.LogSizeLimit : null,
retainedFileCountLimit: options.LogMaxFiles > 0 ? options.LogMaxFiles : null,
rollOnFileSizeLimit: options.LogSizeLimit > 0,
outputTemplate: template);
}
// Syslog sink
if (!string.IsNullOrEmpty(options.RemoteSyslog))
{
logConfig.WriteTo.UdpSyslog(options.RemoteSyslog);
}
else if (options.Syslog)
{
logConfig.WriteTo.LocalSyslog("nats-server");
}
// Apply per-subsystem log level overrides
if (options.LogOverrides is not null)
{
foreach (var (ns, level) in options.LogOverrides)
{
if (Enum.TryParse<Serilog.Events.LogEventLevel>(level, true, out var serilogLevel))
logConfig.MinimumLevel.Override(ns, serilogLevel);
}
}
Log.Logger = logConfig.CreateLogger();
if (windowsService)
{
Log.Information("Windows Service mode requested");
}
using var loggerFactory = new Serilog.Extensions.Logging.SerilogLoggerFactory(Log.Logger);
using var server = new NatsServer(options, loggerFactory);
// Store CLI snapshot for reload precedence (CLI flags always win over config file)
if (configFile != null && options.InCmdLine.Count > 0)
{
var cliSnapshot = new NatsOptions();
ConfigReloader.MergeCliOverrides(cliSnapshot, options, options.InCmdLine);
server.SetCliSnapshot(cliSnapshot, options.InCmdLine);
}
// Register signal handlers
server.HandleSignals();
server.ReOpenLogFile = () =>
{
Log.Information("Reopening log file");
Log.CloseAndFlush();
Log.Logger = logConfig.CreateLogger();
Log.Information("File log re-opened");
};
// Ctrl+C triggers graceful shutdown
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
Log.Information("Trapped SIGINT signal");
_ = Task.Run(async () => await server.ShutdownAsync());
};
try
{
await server.StartAsync(cts.Token);
_ = server.StartAsync(CancellationToken.None);
await server.WaitForReadyAsync();
server.WaitForShutdown();
}
catch (OperationCanceledException)
{
Log.Information("Server shutdown requested");
}
catch (OperationCanceledException) { }
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,
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
namespace NATS.Server.Monitoring;
/// <summary>
/// Snapshot of a closed client connection for /connz reporting.
/// </summary>
public sealed record ClosedClient
{
public required ulong Cid { get; init; }
public string Ip { get; init; } = "";
public int Port { get; init; }
public DateTime Start { get; init; }
public DateTime Stop { get; init; }
public string Reason { get; init; } = "";
public string Name { get; init; } = "";
public string Lang { get; init; } = "";
public string Version { get; init; } = "";
public long InMsgs { get; init; }
public long OutMsgs { get; init; }
public long InBytes { get; init; }
public long OutBytes { get; init; }
public uint NumSubs { get; init; }
public TimeSpan Rtt { get; init; }
public string TlsVersion { get; init; } = "";
public string TlsCipherSuite { get; init; } = "";
}

View File

@@ -0,0 +1,210 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Monitoring;
/// <summary>
/// Connection information response. Corresponds to Go server/monitor.go Connz struct.
/// </summary>
public sealed class Connz
{
[JsonPropertyName("server_id")]
public string Id { get; set; } = "";
[JsonPropertyName("now")]
public DateTime Now { get; set; }
[JsonPropertyName("num_connections")]
public int NumConns { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
[JsonPropertyName("connections")]
public ConnInfo[] Conns { get; set; } = [];
}
/// <summary>
/// Detailed information on a per-connection basis.
/// Corresponds to Go server/monitor.go ConnInfo struct.
/// </summary>
public sealed class ConnInfo
{
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
[JsonPropertyName("kind")]
public string Kind { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("ip")]
public string Ip { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("last_activity")]
public DateTime LastActivity { get; set; }
[JsonPropertyName("stop")]
public DateTime? Stop { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = "";
[JsonPropertyName("rtt")]
public string Rtt { get; set; } = "";
[JsonPropertyName("uptime")]
public string Uptime { get; set; } = "";
[JsonPropertyName("idle")]
public string Idle { get; set; } = "";
[JsonPropertyName("pending_bytes")]
public int Pending { get; set; }
[JsonPropertyName("in_msgs")]
public long InMsgs { get; set; }
[JsonPropertyName("out_msgs")]
public long OutMsgs { get; set; }
[JsonPropertyName("in_bytes")]
public long InBytes { get; set; }
[JsonPropertyName("out_bytes")]
public long OutBytes { get; set; }
[JsonPropertyName("subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("subscriptions_list")]
public string[] Subs { get; set; } = [];
[JsonPropertyName("subscriptions_list_detail")]
public SubDetail[] SubsDetail { get; set; } = [];
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("lang")]
public string Lang { get; set; } = "";
[JsonPropertyName("version")]
public string Version { get; set; } = "";
[JsonPropertyName("authorized_user")]
public string AuthorizedUser { get; set; } = "";
[JsonPropertyName("account")]
public string Account { get; set; } = "";
[JsonPropertyName("tls_version")]
public string TlsVersion { get; set; } = "";
[JsonPropertyName("tls_cipher_suite")]
public string TlsCipherSuite { get; set; } = "";
[JsonPropertyName("tls_first")]
public bool TlsFirst { get; set; }
[JsonPropertyName("mqtt_client")]
public string MqttClient { get; set; } = "";
}
/// <summary>
/// Subscription detail information.
/// Corresponds to Go server/monitor.go SubDetail struct.
/// </summary>
public sealed class SubDetail
{
[JsonPropertyName("account")]
public string Account { get; set; } = "";
[JsonPropertyName("subject")]
public string Subject { get; set; } = "";
[JsonPropertyName("qgroup")]
public string Queue { get; set; } = "";
[JsonPropertyName("sid")]
public string Sid { get; set; } = "";
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("max")]
public long Max { get; set; }
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
}
/// <summary>
/// Sort options for connection listing.
/// Corresponds to Go server/monitor_sort_opts.go SortOpt type.
/// </summary>
public enum SortOpt
{
ByCid,
ByStart,
BySubs,
ByPending,
ByMsgsTo,
ByMsgsFrom,
ByBytesTo,
ByBytesFrom,
ByLast,
ByIdle,
ByUptime,
ByRtt,
ByStop,
ByReason,
}
/// <summary>
/// Connection state filter.
/// Corresponds to Go server/monitor.go ConnState type.
/// </summary>
public enum ConnState
{
Open,
Closed,
All,
}
/// <summary>
/// Options passed to Connz() for filtering and sorting.
/// Corresponds to Go server/monitor.go ConnzOptions struct.
/// </summary>
public sealed class ConnzOptions
{
public SortOpt Sort { get; set; } = SortOpt.ByCid;
public bool Subscriptions { get; set; }
public bool SubscriptionsDetail { get; set; }
public ConnState State { get; set; } = ConnState.Open;
public string User { get; set; } = "";
public string Account { get; set; } = "";
public string FilterSubject { get; set; } = "";
public int Offset { get; set; }
public int Limit { get; set; } = 1024;
}

View File

@@ -0,0 +1,223 @@
using Microsoft.AspNetCore.Http;
namespace NATS.Server.Monitoring;
/// <summary>
/// Handles /connz endpoint requests, returning detailed connection information.
/// Corresponds to Go server/monitor.go handleConnz function.
/// </summary>
public sealed class ConnzHandler(NatsServer server)
{
public Connz HandleConnz(HttpContext ctx)
{
var opts = ParseQueryParams(ctx);
var now = DateTime.UtcNow;
var connInfos = new List<ConnInfo>();
// Collect open connections
if (opts.State is ConnState.Open or ConnState.All)
{
var clients = server.GetClients().ToArray();
connInfos.AddRange(clients.Select(c => BuildConnInfo(c, now, opts)));
}
// Collect closed connections
if (opts.State is ConnState.Closed or ConnState.All)
{
connInfos.AddRange(server.GetClosedClients().Select(c => BuildClosedConnInfo(c, now, opts)));
}
// Validate sort options that require closed state
if (opts.Sort is SortOpt.ByStop or SortOpt.ByReason && opts.State == ConnState.Open)
opts.Sort = SortOpt.ByCid; // Fallback
// Sort
connInfos = opts.Sort switch
{
SortOpt.ByCid => connInfos.OrderBy(c => c.Cid).ToList(),
SortOpt.ByStart => connInfos.OrderBy(c => c.Start).ToList(),
SortOpt.BySubs => connInfos.OrderByDescending(c => c.NumSubs).ToList(),
SortOpt.ByPending => connInfos.OrderByDescending(c => c.Pending).ToList(),
SortOpt.ByMsgsTo => connInfos.OrderByDescending(c => c.OutMsgs).ToList(),
SortOpt.ByMsgsFrom => connInfos.OrderByDescending(c => c.InMsgs).ToList(),
SortOpt.ByBytesTo => connInfos.OrderByDescending(c => c.OutBytes).ToList(),
SortOpt.ByBytesFrom => connInfos.OrderByDescending(c => c.InBytes).ToList(),
SortOpt.ByLast => connInfos.OrderByDescending(c => c.LastActivity).ToList(),
SortOpt.ByIdle => connInfos.OrderByDescending(c => now - c.LastActivity).ToList(),
SortOpt.ByUptime => connInfos.OrderByDescending(c => now - c.Start).ToList(),
SortOpt.ByStop => connInfos.OrderByDescending(c => c.Stop ?? DateTime.MinValue).ToList(),
SortOpt.ByReason => connInfos.OrderBy(c => c.Reason).ToList(),
SortOpt.ByRtt => connInfos.OrderBy(c => c.Rtt).ToList(),
_ => connInfos.OrderBy(c => c.Cid).ToList(),
};
var total = connInfos.Count;
var paged = connInfos.Skip(opts.Offset).Take(opts.Limit).ToArray();
return new Connz
{
Id = server.ServerId,
Now = now,
NumConns = paged.Length,
Total = total,
Offset = opts.Offset,
Limit = opts.Limit,
Conns = paged,
};
}
private static ConnInfo BuildConnInfo(NatsClient client, DateTime now, ConnzOptions opts)
{
var info = new ConnInfo
{
Cid = client.Id,
Kind = "Client",
Type = "Client",
Ip = client.RemoteIp ?? "",
Port = client.RemotePort,
Start = client.StartTime,
LastActivity = client.LastActivity,
Uptime = FormatDuration(now - client.StartTime),
Idle = FormatDuration(now - client.LastActivity),
InMsgs = Interlocked.Read(ref client.InMsgs),
OutMsgs = Interlocked.Read(ref client.OutMsgs),
InBytes = Interlocked.Read(ref client.InBytes),
OutBytes = Interlocked.Read(ref client.OutBytes),
NumSubs = (uint)client.Subscriptions.Count,
Name = client.ClientOpts?.Name ?? "",
Lang = client.ClientOpts?.Lang ?? "",
Version = client.ClientOpts?.Version ?? "",
Pending = (int)client.PendingBytes,
Reason = client.CloseReason.ToReasonString(),
TlsVersion = client.TlsState?.TlsVersion ?? "",
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
Rtt = FormatRtt(client.Rtt),
};
if (opts.Subscriptions)
{
info.Subs = client.Subscriptions.Values.Select(s => s.Subject).ToArray();
}
if (opts.SubscriptionsDetail)
{
info.SubsDetail = client.Subscriptions.Values.Select(s => new SubDetail
{
Subject = s.Subject,
Queue = s.Queue ?? "",
Sid = s.Sid,
Msgs = Interlocked.Read(ref s.MessageCount),
Max = s.MaxMessages,
Cid = client.Id,
}).ToArray();
}
return info;
}
private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts)
{
return new ConnInfo
{
Cid = closed.Cid,
Kind = "Client",
Type = "Client",
Ip = closed.Ip,
Port = closed.Port,
Start = closed.Start,
Stop = closed.Stop,
LastActivity = closed.Stop,
Uptime = FormatDuration(closed.Stop - closed.Start),
Idle = FormatDuration(now - closed.Stop),
InMsgs = closed.InMsgs,
OutMsgs = closed.OutMsgs,
InBytes = closed.InBytes,
OutBytes = closed.OutBytes,
NumSubs = closed.NumSubs,
Name = closed.Name,
Lang = closed.Lang,
Version = closed.Version,
Reason = closed.Reason,
Rtt = FormatRtt(closed.Rtt),
TlsVersion = closed.TlsVersion,
TlsCipherSuite = closed.TlsCipherSuite,
};
}
private static ConnzOptions ParseQueryParams(HttpContext ctx)
{
var q = ctx.Request.Query;
var opts = new ConnzOptions();
if (q.TryGetValue("sort", out var sort))
{
opts.Sort = sort.ToString().ToLowerInvariant() switch
{
"cid" => SortOpt.ByCid,
"start" => SortOpt.ByStart,
"subs" => SortOpt.BySubs,
"pending" => SortOpt.ByPending,
"msgs_to" => SortOpt.ByMsgsTo,
"msgs_from" => SortOpt.ByMsgsFrom,
"bytes_to" => SortOpt.ByBytesTo,
"bytes_from" => SortOpt.ByBytesFrom,
"last" => SortOpt.ByLast,
"idle" => SortOpt.ByIdle,
"uptime" => SortOpt.ByUptime,
"rtt" => SortOpt.ByRtt,
"stop" => SortOpt.ByStop,
"reason" => SortOpt.ByReason,
_ => SortOpt.ByCid,
};
}
if (q.TryGetValue("subs", out var subs))
{
if (subs == "detail")
opts.SubscriptionsDetail = true;
else
opts.Subscriptions = true;
}
if (q.TryGetValue("state", out var state))
{
opts.State = state.ToString().ToLowerInvariant() switch
{
"open" => ConnState.Open,
"closed" => ConnState.Closed,
"all" => ConnState.All,
_ => ConnState.Open,
};
}
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
opts.Offset = o;
if (q.TryGetValue("limit", out var limit) && int.TryParse(limit, out var l))
opts.Limit = l;
return opts;
}
private static string FormatRtt(TimeSpan rtt)
{
if (rtt == TimeSpan.Zero) return "";
if (rtt.TotalMilliseconds < 1)
return $"{rtt.TotalMicroseconds:F3}\u00b5s";
if (rtt.TotalSeconds < 1)
return $"{rtt.TotalMilliseconds:F3}ms";
return $"{rtt.TotalSeconds:F3}s";
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalDays >= 1)
return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
return $"{(int)ts.TotalSeconds}s";
}
}

View File

@@ -0,0 +1,119 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace NATS.Server.Monitoring;
/// <summary>
/// HTTP monitoring server providing /healthz, /varz, and other monitoring endpoints.
/// Corresponds to Go server/monitor.go HTTP server setup.
/// </summary>
public sealed class MonitorServer : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly ILogger<MonitorServer> _logger;
private readonly VarzHandler _varzHandler;
private readonly ConnzHandler _connzHandler;
private readonly SubszHandler _subszHandler;
public MonitorServer(NatsServer server, NatsOptions options, ServerStats stats, ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<MonitorServer>();
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls($"http://{options.MonitorHost}:{options.MonitorPort}");
builder.Logging.ClearProviders();
_app = builder.Build();
var basePath = options.MonitorBasePath ?? "";
_varzHandler = new VarzHandler(server, options);
_connzHandler = new ConnzHandler(server);
_subszHandler = new SubszHandler(server);
_app.MapGet(basePath + "/", () =>
{
stats.HttpReqStats.AddOrUpdate("/", 1, (_, v) => v + 1);
return Results.Ok(new
{
endpoints = new[]
{
"/varz", "/connz", "/healthz", "/routez",
"/gatewayz", "/leafz", "/subz", "/accountz", "/jsz",
},
});
});
_app.MapGet(basePath + "/healthz", () =>
{
stats.HttpReqStats.AddOrUpdate("/healthz", 1, (_, v) => v + 1);
return Results.Ok("ok");
});
_app.MapGet(basePath + "/varz", async (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/varz", 1, (_, v) => v + 1);
return Results.Ok(await _varzHandler.HandleVarzAsync(ctx.RequestAborted));
});
_app.MapGet(basePath + "/connz", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/connz", 1, (_, v) => v + 1);
return Results.Ok(_connzHandler.HandleConnz(ctx));
});
// Stubs for unimplemented endpoints
_app.MapGet(basePath + "/routez", () =>
{
stats.HttpReqStats.AddOrUpdate("/routez", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/gatewayz", () =>
{
stats.HttpReqStats.AddOrUpdate("/gatewayz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/leafz", () =>
{
stats.HttpReqStats.AddOrUpdate("/leafz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/subz", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/subz", 1, (_, v) => v + 1);
return Results.Ok(_subszHandler.HandleSubsz(ctx));
});
_app.MapGet(basePath + "/subscriptionsz", (HttpContext ctx) =>
{
stats.HttpReqStats.AddOrUpdate("/subscriptionsz", 1, (_, v) => v + 1);
return Results.Ok(_subszHandler.HandleSubsz(ctx));
});
_app.MapGet(basePath + "/accountz", () =>
{
stats.HttpReqStats.AddOrUpdate("/accountz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/accstatz", () =>
{
stats.HttpReqStats.AddOrUpdate("/accstatz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
_app.MapGet(basePath + "/jsz", () =>
{
stats.HttpReqStats.AddOrUpdate("/jsz", 1, (_, v) => v + 1);
return Results.Ok(new { });
});
}
public async Task StartAsync(CancellationToken ct)
{
await _app.StartAsync(ct);
_logger.LogInformation("Monitoring listening on {Urls}", string.Join(", ", _app.Urls));
}
public async ValueTask DisposeAsync()
{
await _app.StopAsync();
await _app.DisposeAsync();
_varzHandler.Dispose();
}
}

View File

@@ -0,0 +1,45 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Monitoring;
/// <summary>
/// Subscription information response. Corresponds to Go server/monitor.go Subsz struct.
/// </summary>
public sealed class Subsz
{
[JsonPropertyName("server_id")]
public string Id { get; set; } = "";
[JsonPropertyName("now")]
public DateTime Now { get; set; }
[JsonPropertyName("num_subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("num_cache")]
public int NumCache { get; set; }
[JsonPropertyName("total")]
public int Total { get; set; }
[JsonPropertyName("offset")]
public int Offset { get; set; }
[JsonPropertyName("limit")]
public int Limit { get; set; }
[JsonPropertyName("subscriptions")]
public SubDetail[] Subs { get; set; } = [];
}
/// <summary>
/// Options passed to Subsz() for filtering.
/// </summary>
public sealed class SubszOptions
{
public int Offset { get; set; }
public int Limit { get; set; } = 1024;
public bool Subscriptions { get; set; }
public string Account { get; set; } = "";
public string Test { get; set; } = "";
}

View File

@@ -0,0 +1,97 @@
using Microsoft.AspNetCore.Http;
using NATS.Server.Subscriptions;
namespace NATS.Server.Monitoring;
/// <summary>
/// Handles /subz endpoint requests, returning subscription information.
/// Corresponds to Go server/monitor.go handleSubsz.
/// </summary>
public sealed class SubszHandler(NatsServer server)
{
public Subsz HandleSubsz(HttpContext ctx)
{
var opts = ParseQueryParams(ctx);
var now = DateTime.UtcNow;
// Collect subscriptions from all accounts (or filtered).
// Exclude the $SYS system account unless explicitly requested — its internal
// subscriptions are infrastructure and not user-facing.
var allSubs = new List<Subscription>();
foreach (var account in server.GetAccounts())
{
if (!string.IsNullOrEmpty(opts.Account) && account.Name != opts.Account)
continue;
if (string.IsNullOrEmpty(opts.Account) && account.Name == "$SYS")
continue;
allSubs.AddRange(account.SubList.GetAllSubscriptions());
}
// Filter by test subject if provided
if (!string.IsNullOrEmpty(opts.Test))
{
allSubs = allSubs.Where(s => SubjectMatch.MatchLiteral(opts.Test, s.Subject)).ToList();
}
var total = allSubs.Count;
var numSubs = server.GetAccounts()
.Where(a => (string.IsNullOrEmpty(opts.Account) && a.Name != "$SYS") || a.Name == opts.Account)
.Aggregate(0u, (sum, a) => sum + a.SubList.Count);
var numCache = server.GetAccounts()
.Where(a => (string.IsNullOrEmpty(opts.Account) && a.Name != "$SYS") || a.Name == opts.Account)
.Sum(a => a.SubList.CacheCount);
SubDetail[] details = [];
if (opts.Subscriptions)
{
details = allSubs
.Skip(opts.Offset)
.Take(opts.Limit)
.Select(s => new SubDetail
{
Subject = s.Subject,
Queue = s.Queue ?? "",
Sid = s.Sid,
Msgs = Interlocked.Read(ref s.MessageCount),
Max = s.MaxMessages,
Cid = s.Client?.Id ?? 0,
})
.ToArray();
}
return new Subsz
{
Id = server.ServerId,
Now = now,
NumSubs = numSubs,
NumCache = numCache,
Total = total,
Offset = opts.Offset,
Limit = opts.Limit,
Subs = details,
};
}
private static SubszOptions ParseQueryParams(HttpContext ctx)
{
var q = ctx.Request.Query;
var opts = new SubszOptions();
if (q.TryGetValue("subs", out var subs))
opts.Subscriptions = subs == "true" || subs == "1" || subs == "detail";
if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
opts.Offset = o;
if (q.TryGetValue("limit", out var limit) && int.TryParse(limit, out var l))
opts.Limit = l;
if (q.TryGetValue("acc", out var acc))
opts.Account = acc.ToString();
if (q.TryGetValue("test", out var test))
opts.Test = test.ToString();
return opts;
}
}

View File

@@ -0,0 +1,440 @@
using System.Text.Json.Serialization;
namespace NATS.Server.Monitoring;
/// <summary>
/// Server general information. Corresponds to Go server/monitor.go Varz struct.
/// </summary>
public sealed class Varz
{
// Identity
[JsonPropertyName("server_id")]
public string Id { get; set; } = "";
[JsonPropertyName("server_name")]
public string Name { get; set; } = "";
[JsonPropertyName("version")]
public string Version { get; set; } = "";
[JsonPropertyName("proto")]
public int Proto { get; set; }
[JsonPropertyName("git_commit")]
public string GitCommit { get; set; } = "";
[JsonPropertyName("go")]
public string GoVersion { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
// Network
[JsonPropertyName("ip")]
public string Ip { get; set; } = "";
[JsonPropertyName("connect_urls")]
public string[] ConnectUrls { get; set; } = [];
[JsonPropertyName("ws_connect_urls")]
public string[] WsConnectUrls { get; set; } = [];
[JsonPropertyName("http_host")]
public string HttpHost { get; set; } = "";
[JsonPropertyName("http_port")]
public int HttpPort { get; set; }
[JsonPropertyName("http_base_path")]
public string HttpBasePath { get; set; } = "";
[JsonPropertyName("https_port")]
public int HttpsPort { get; set; }
// Security
[JsonPropertyName("auth_required")]
public bool AuthRequired { get; set; }
[JsonPropertyName("tls_required")]
public bool TlsRequired { get; set; }
[JsonPropertyName("tls_verify")]
public bool TlsVerify { get; set; }
[JsonPropertyName("tls_ocsp_peer_verify")]
public bool TlsOcspPeerVerify { get; set; }
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
// Limits
[JsonPropertyName("max_connections")]
public int MaxConnections { get; set; }
[JsonPropertyName("max_subscriptions")]
public int MaxSubscriptions { get; set; }
[JsonPropertyName("max_payload")]
public int MaxPayload { get; set; }
[JsonPropertyName("max_pending")]
public long MaxPending { get; set; }
[JsonPropertyName("max_control_line")]
public int MaxControlLine { get; set; }
[JsonPropertyName("ping_max")]
public int MaxPingsOut { get; set; }
// Timing
[JsonPropertyName("ping_interval")]
public long PingInterval { get; set; }
[JsonPropertyName("write_deadline")]
public long WriteDeadline { get; set; }
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("now")]
public DateTime Now { get; set; }
[JsonPropertyName("uptime")]
public string Uptime { get; set; } = "";
// Runtime
[JsonPropertyName("mem")]
public long Mem { get; set; }
[JsonPropertyName("cpu")]
public double Cpu { get; set; }
[JsonPropertyName("cores")]
public int Cores { get; set; }
[JsonPropertyName("gomaxprocs")]
public int MaxProcs { get; set; }
// Connections
[JsonPropertyName("connections")]
public int Connections { get; set; }
[JsonPropertyName("total_connections")]
public ulong TotalConnections { get; set; }
[JsonPropertyName("routes")]
public int Routes { get; set; }
[JsonPropertyName("remotes")]
public int Remotes { get; set; }
[JsonPropertyName("leafnodes")]
public int Leafnodes { get; set; }
// Messages
[JsonPropertyName("in_msgs")]
public long InMsgs { get; set; }
[JsonPropertyName("out_msgs")]
public long OutMsgs { get; set; }
[JsonPropertyName("in_bytes")]
public long InBytes { get; set; }
[JsonPropertyName("out_bytes")]
public long OutBytes { get; set; }
// Health
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
[JsonPropertyName("slow_consumer_stats")]
public SlowConsumersStats SlowConsumerStats { get; set; } = new();
[JsonPropertyName("stale_connections")]
public long StaleConnections { get; set; }
[JsonPropertyName("stale_connection_stats")]
public StaleConnectionStats StaleConnectionStatsDetail { get; set; } = new();
[JsonPropertyName("subscriptions")]
public uint Subscriptions { get; set; }
// Config
[JsonPropertyName("config_load_time")]
public DateTime ConfigLoadTime { get; set; }
[JsonPropertyName("tags")]
public string[] Tags { get; set; } = [];
[JsonPropertyName("system_account")]
public string SystemAccount { get; set; } = "";
[JsonPropertyName("pinned_account_fails")]
public ulong PinnedAccountFail { get; set; }
[JsonPropertyName("tls_cert_not_after")]
public DateTime TlsCertNotAfter { get; set; }
// HTTP
[JsonPropertyName("http_req_stats")]
public Dictionary<string, ulong> HttpReqStats { get; set; } = new();
// Subsystems
[JsonPropertyName("cluster")]
public ClusterOptsVarz Cluster { get; set; } = new();
[JsonPropertyName("gateway")]
public GatewayOptsVarz Gateway { get; set; } = new();
[JsonPropertyName("leaf")]
public LeafNodeOptsVarz Leaf { get; set; } = new();
[JsonPropertyName("mqtt")]
public MqttOptsVarz Mqtt { get; set; } = new();
[JsonPropertyName("websocket")]
public WebsocketOptsVarz Websocket { get; set; } = new();
[JsonPropertyName("jetstream")]
public JetStreamVarz JetStream { get; set; } = new();
}
/// <summary>
/// Statistics about slow consumers by connection type.
/// Corresponds to Go server/monitor.go SlowConsumersStats struct.
/// </summary>
public sealed class SlowConsumersStats
{
[JsonPropertyName("clients")]
public ulong Clients { get; set; }
[JsonPropertyName("routes")]
public ulong Routes { get; set; }
[JsonPropertyName("gateways")]
public ulong Gateways { get; set; }
[JsonPropertyName("leafs")]
public ulong Leafs { get; set; }
}
/// <summary>
/// Statistics about stale connections by connection type.
/// Corresponds to Go server/monitor.go StaleConnectionStats struct.
/// </summary>
public sealed class StaleConnectionStats
{
[JsonPropertyName("clients")]
public ulong Clients { get; set; }
[JsonPropertyName("routes")]
public ulong Routes { get; set; }
[JsonPropertyName("gateways")]
public ulong Gateways { get; set; }
[JsonPropertyName("leafs")]
public ulong Leafs { get; set; }
}
/// <summary>
/// Cluster configuration monitoring information.
/// Corresponds to Go server/monitor.go ClusterOptsVarz struct.
/// </summary>
public sealed class ClusterOptsVarz
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("addr")]
public string Host { get; set; } = "";
[JsonPropertyName("cluster_port")]
public int Port { get; set; }
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
[JsonPropertyName("tls_required")]
public bool TlsRequired { get; set; }
[JsonPropertyName("tls_verify")]
public bool TlsVerify { get; set; }
[JsonPropertyName("pool_size")]
public int PoolSize { get; set; }
[JsonPropertyName("urls")]
public string[] Urls { get; set; } = [];
}
/// <summary>
/// Gateway configuration monitoring information.
/// Corresponds to Go server/monitor.go GatewayOptsVarz struct.
/// </summary>
public sealed class GatewayOptsVarz
{
[JsonPropertyName("name")]
public string Name { get; set; } = "";
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
[JsonPropertyName("tls_required")]
public bool TlsRequired { get; set; }
[JsonPropertyName("tls_verify")]
public bool TlsVerify { get; set; }
[JsonPropertyName("advertise")]
public string Advertise { get; set; } = "";
[JsonPropertyName("connect_retries")]
public int ConnectRetries { get; set; }
[JsonPropertyName("reject_unknown")]
public bool RejectUnknown { get; set; }
}
/// <summary>
/// Leaf node configuration monitoring information.
/// Corresponds to Go server/monitor.go LeafNodeOptsVarz struct.
/// </summary>
public sealed class LeafNodeOptsVarz
{
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
[JsonPropertyName("tls_required")]
public bool TlsRequired { get; set; }
[JsonPropertyName("tls_verify")]
public bool TlsVerify { get; set; }
[JsonPropertyName("tls_ocsp_peer_verify")]
public bool TlsOcspPeerVerify { get; set; }
}
/// <summary>
/// MQTT configuration monitoring information.
/// Corresponds to Go server/monitor.go MQTTOptsVarz struct.
/// </summary>
public sealed class MqttOptsVarz
{
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
}
/// <summary>
/// Websocket configuration monitoring information.
/// Corresponds to Go server/monitor.go WebsocketOptsVarz struct.
/// </summary>
public sealed class WebsocketOptsVarz
{
[JsonPropertyName("host")]
public string Host { get; set; } = "";
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("tls_timeout")]
public double TlsTimeout { get; set; }
}
/// <summary>
/// JetStream runtime information.
/// Corresponds to Go server/monitor.go JetStreamVarz struct.
/// </summary>
public sealed class JetStreamVarz
{
[JsonPropertyName("config")]
public JetStreamConfig Config { get; set; } = new();
[JsonPropertyName("stats")]
public JetStreamStats Stats { get; set; } = new();
}
/// <summary>
/// JetStream configuration.
/// Corresponds to Go server/jetstream.go JetStreamConfig struct.
/// </summary>
public sealed class JetStreamConfig
{
[JsonPropertyName("max_memory")]
public long MaxMemory { get; set; }
[JsonPropertyName("max_storage")]
public long MaxStorage { get; set; }
[JsonPropertyName("store_dir")]
public string StoreDir { get; set; } = "";
}
/// <summary>
/// JetStream statistics.
/// Corresponds to Go server/jetstream.go JetStreamStats struct.
/// </summary>
public sealed class JetStreamStats
{
[JsonPropertyName("memory")]
public ulong Memory { get; set; }
[JsonPropertyName("storage")]
public ulong Storage { get; set; }
[JsonPropertyName("accounts")]
public int Accounts { get; set; }
[JsonPropertyName("ha_assets")]
public int HaAssets { get; set; }
[JsonPropertyName("api")]
public JetStreamApiStats Api { get; set; } = new();
}
/// <summary>
/// JetStream API statistics.
/// Corresponds to Go server/jetstream.go JetStreamAPIStats struct.
/// </summary>
public sealed class JetStreamApiStats
{
[JsonPropertyName("total")]
public ulong Total { get; set; }
[JsonPropertyName("errors")]
public ulong Errors { get; set; }
}

View File

@@ -0,0 +1,150 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
using NATS.Server.Protocol;
namespace NATS.Server.Monitoring;
/// <summary>
/// Handles building the Varz response from server state and process metrics.
/// Corresponds to Go server/monitor.go handleVarz function.
/// </summary>
public sealed class VarzHandler : IDisposable
{
private readonly NatsServer _server;
private readonly NatsOptions _options;
private readonly SemaphoreSlim _varzMu = new(1, 1);
private DateTime _lastCpuSampleTime;
private TimeSpan _lastCpuUsage;
private double _cachedCpuPercent;
public VarzHandler(NatsServer server, NatsOptions options)
{
_server = server;
_options = options;
using var proc = Process.GetCurrentProcess();
_lastCpuSampleTime = DateTime.UtcNow;
_lastCpuUsage = proc.TotalProcessorTime;
}
public async Task<Varz> HandleVarzAsync(CancellationToken ct = default)
{
await _varzMu.WaitAsync(ct);
try
{
using var proc = Process.GetCurrentProcess();
var now = DateTime.UtcNow;
var uptime = now - _server.StartTime;
var stats = _server.Stats;
// CPU sampling with 1-second cache to avoid excessive sampling
if ((now - _lastCpuSampleTime).TotalSeconds >= 1.0)
{
var currentCpu = proc.TotalProcessorTime;
var elapsed = now - _lastCpuSampleTime;
_cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds
/ elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0;
_lastCpuSampleTime = now;
_lastCpuUsage = currentCpu;
}
// Load the TLS certificate to report its expiry date in /varz.
// Corresponds to Go server/monitor.go handleVarz populating TLSCertExpiry.
DateTime? tlsCertExpiry = null;
if (_options.HasTls && !string.IsNullOrEmpty(_options.TlsCert))
{
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(_options.TlsCert);
tlsCertExpiry = cert.NotAfter;
}
catch
{
// cert load failure — leave field as default
}
}
return new Varz
{
Id = _server.ServerId,
Name = _server.ServerName,
Version = NatsProtocol.Version,
Proto = NatsProtocol.ProtoVersion,
GoVersion = $"dotnet {RuntimeInformation.FrameworkDescription}",
Host = _options.Host,
Port = _options.Port,
HttpHost = _options.MonitorHost,
HttpPort = _options.MonitorPort,
HttpBasePath = _options.MonitorBasePath ?? "",
HttpsPort = _options.MonitorHttpsPort,
TlsRequired = _options.HasTls && !_options.AllowNonTls,
TlsVerify = _options.HasTls && _options.TlsVerify,
TlsTimeout = _options.HasTls ? _options.TlsTimeout.TotalSeconds : 0,
TlsCertNotAfter = tlsCertExpiry ?? default,
TlsOcspPeerVerify = _options.OcspPeerVerify,
MaxConnections = _options.MaxConnections,
MaxPayload = _options.MaxPayload,
MaxControlLine = _options.MaxControlLine,
MaxPending = _options.MaxPending,
WriteDeadline = (long)_options.WriteDeadline.TotalNanoseconds,
MaxPingsOut = _options.MaxPingsOut,
PingInterval = (long)_options.PingInterval.TotalNanoseconds,
Start = _server.StartTime,
Now = now,
Uptime = FormatUptime(uptime),
Mem = proc.WorkingSet64,
Cpu = Math.Round(_cachedCpuPercent, 2),
Cores = Environment.ProcessorCount,
MaxProcs = ThreadPool.ThreadCount,
Connections = _server.ClientCount,
TotalConnections = (ulong)Interlocked.Read(ref stats.TotalConnections),
InMsgs = Interlocked.Read(ref stats.InMsgs),
OutMsgs = Interlocked.Read(ref stats.OutMsgs),
InBytes = Interlocked.Read(ref stats.InBytes),
OutBytes = Interlocked.Read(ref stats.OutBytes),
SlowConsumers = Interlocked.Read(ref stats.SlowConsumers),
SlowConsumerStats = new SlowConsumersStats
{
Clients = (ulong)Interlocked.Read(ref stats.SlowConsumerClients),
Routes = (ulong)Interlocked.Read(ref stats.SlowConsumerRoutes),
Gateways = (ulong)Interlocked.Read(ref stats.SlowConsumerGateways),
Leafs = (ulong)Interlocked.Read(ref stats.SlowConsumerLeafs),
},
StaleConnections = Interlocked.Read(ref stats.StaleConnections),
StaleConnectionStatsDetail = new StaleConnectionStats
{
Clients = (ulong)Interlocked.Read(ref stats.StaleConnectionClients),
Routes = (ulong)Interlocked.Read(ref stats.StaleConnectionRoutes),
Gateways = (ulong)Interlocked.Read(ref stats.StaleConnectionGateways),
Leafs = (ulong)Interlocked.Read(ref stats.StaleConnectionLeafs),
},
Subscriptions = _server.SubList.Count,
ConfigLoadTime = _server.StartTime,
HttpReqStats = stats.HttpReqStats.ToDictionary(kv => kv.Key, kv => (ulong)kv.Value),
};
}
finally
{
_varzMu.Release();
}
}
public void Dispose()
{
_varzMu.Dispose();
}
/// <summary>
/// Formats a TimeSpan as a human-readable uptime string matching Go server format.
/// </summary>
private static string FormatUptime(TimeSpan ts)
{
if (ts.TotalDays >= 1)
return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalHours >= 1)
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
if (ts.TotalMinutes >= 1)
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
return $"{(int)ts.TotalSeconds}s";
}
}

View File

@@ -1,5 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<InternalsVisibleTo Include="NATS.Server.Tests" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NATS.NKeys" />
<PackageReference Include="BCrypt.Net-Next" />
</ItemGroup>
</Project>

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