16 Commits

Author SHA1 Message Date
Joseph Doherty
a0c9c0094c fix: session B — Go-faithful auth error states, NKey padding, permissions, signal disposal 2026-02-26 17:49:13 -05:00
Joseph Doherty
8c380e7ca6 feat: session B — auth implementation + signals (26 stubs complete)
Implement ConfigureAuthorization, CheckAuthentication, and full auth
dispatch in NatsServer.Auth.cs; add HandleSignals in NatsServer.Signals.cs;
extend AuthHandler with GetAuthErrClosedState, ValidateProxies,
GetTlsAuthDcs, CheckClientTlsCertSubject, ProcessUserPermissionsTemplate;
add ReadOperatorJwt/ValidateTrustedOperators to JwtProcessor; add
AuthCallout stub; add auth accessor helpers to ClientConnection; add
NATS.NKeys package for NKey signature verification; 12 new tests pass.
2026-02-26 17:38:46 -05:00
Joseph Doherty
aa1fb5ac4e fix: session A — NoSystemAccount guard, MaxControlLine default, URL/TLS converter tests 2026-02-26 17:29:05 -05:00
Joseph Doherty
9c1ffc0995 feat: session A — config binding via appsettings.json (67 stubs complete)
Add JSON attributes to ServerOptions, four custom JSON converters
(NatsDurationJsonConverter, TlsVersionJsonConverter, NatsUrlJsonConverter,
StorageSizeJsonConverter), ServerOptionsConfiguration for JSON file/string
binding, and 15 tests covering config parsing, duration parsing, and size
parsing. Mark 67 opts.go features complete in porting.db.
2026-02-26 17:18:28 -05:00
Joseph Doherty
8253f975ec docs: implementation plan for completing 93 stub features 2026-02-26 16:59:33 -05:00
Joseph Doherty
63715f256a docs: design for completing 93 stub features (config binding + auth implementation) 2026-02-26 16:54:05 -05:00
Joseph Doherty
a58e8e2572 feat: port sessions 21-23 — Streams, Consumers, MQTT, WebSocket & OCSP
Session 21 (402 features, IDs 3195-3387, 584-792):
- JetStream/StreamTypes.cs: StreamInfo, ConsumerInfo, SequenceInfo,
  JSPubAckResponse, WaitQueue, ClusterInfo, PeerInfo, message types,
  ConsumerAction enum, CreateConsumerRequest, PriorityGroupState
- JetStream/NatsStream.cs: NatsStream class (stub methods, IDisposable)
- JetStream/NatsConsumer.cs: NatsConsumer class (stub methods, IDisposable)
- Updated JetStreamApiTypes.cs: removed duplicate StreamInfo/ConsumerInfo stubs

Session 22 (153 features, IDs 2252-2404):
- Mqtt/MqttConstants.cs: all MQTT protocol constants, packet types, flags
- Mqtt/MqttTypes.cs: MqttSession, MqttSubscription, MqttWill, MqttJsa,
  MqttAccountSessionManager, MqttHandler and supporting types
- Mqtt/MqttHandler.cs: per-client MQTT state, MqttServerExtensions stubs

Session 23 (97 features, IDs 3506-3543, 2443-2501):
- WebSocket/WebSocketConstants.cs: WsOpCode enum, frame bits, close codes
- WebSocket/WebSocketTypes.cs: WsReadInfo, SrvWebsocket (replaces stub),
  WebSocketHandler stubs
- Auth/Ocsp/OcspTypes.cs: OcspMode, OcspMonitor (replaces stub),
  IOcspResponseCache (replaces stub), NoOpCache, LocalDirCache

All features (3503 complete, 0 not_started). Phase 6 now at 58.9%.
2026-02-26 16:31:42 -05:00
Joseph Doherty
e6bc76b315 feat: port session 20 — JetStream Cluster & Raft types
Port IRaftNode (37-method interface), Raft, RaftState, EntryType, Entry,
AppendEntry, CommittedEntry, VoteRequest/VoteResponse, PeerState from
jetstream_cluster.go; JetStreamCluster, StreamAssignment, ConsumerAssignment,
EntryOp (19 values) and supporting types from jetstream_cluster.go.

Removes IRaftNode stub from NatsServerTypes.cs.
429 features marked complete (IDs 2599-2796, 1520-1750).
2026-02-26 16:23:39 -05:00
Joseph Doherty
84d450b4a0 feat: port session 19 — JetStream Core
- JetStreamTypes: JetStreamConfig, JetStreamStats, JetStreamAccountLimits,
  JetStreamTier, JetStreamAccountStats, JetStream engine, JsAccount, JsaUsage
- JetStreamApiTypes: 50+ JSApi request/response types, API subject constants
- JetStreamErrors: JsApiError + JsApiErrors with all 203 error codes
- JetStreamVersioning: version constants and API level helpers
- JetStreamBatching: Batching, BatchGroup, BatchStagedDiff, BatchApply
- Removed JetStreamConfig/JetStreamState stubs from NatsServerTypes.cs
- 374 features complete (IDs 1368-1519, 1751-1972)
2026-02-26 16:14:40 -05:00
Joseph Doherty
3cffa5b156 feat: port session 18 — JetStream File Store
- FileStoreTypes: FileStoreConfig, FileStreamInfo, FileConsumerInfo, Psi,
  Cache, MsgId, CompressionInfo, ErrBadMsg, FileStoreDefaults constants
- FileStore: JetStreamFileStore implementing IStreamStore (26 methods stubbed)
  with State/Type/Stop/Register* properly implemented
- MessageBlock: MessageBlock type with all 40+ fields, ConsumerFileStore stub
- 312 features complete (IDs 951-1262)
2026-02-26 16:06:50 -05:00
Joseph Doherty
5a2c8a3250 feat: port session 17 — Store Interfaces & Memory Store
- StoreTypes: IStreamStore/IConsumerStore interfaces, StreamConfig/ConsumerConfig,
  all enums (StorageType, RetentionPolicy, DiscardPolicy, AckPolicy, etc.),
  StreamState, SimpleState, LostStreamData, DeleteBlocks/Range/Slice, StoreMsg
- MemStore: JetStreamMemStore with full message CRUD, state tracking, age expiry
- ConsumerMemStore: ConsumerMemStore with delivery/ack state tracking
- DiskAvailability: cross-platform disk space checker
- 135 features complete (IDs 3164-3194, 2068-2165, 827-832)
2026-02-26 16:02:03 -05:00
Joseph Doherty
77403e3d31 feat: port sessions 14-16 — Routes, Leaf Nodes & Gateways
Session 14 (57 features, IDs 2895-2951):
- RouteTypes: RouteType enum, Route, RouteInfo, ConnectInfo, ASubs, GossipMode

Session 15 (71 features, IDs 1979-2049):
- LeafNodeTypes: Leaf, LeafNodeCfg (replaces stub), LeafConnectInfo

Session 16 (91 features, IDs 1263-1353):
- GatewayTypes: GatewayInterestMode, SrvGateway (replaces stub), GatewayCfg,
  Gateway, OutSide, InSide, SitAlly, GwReplyMap, GwReplyMapping
2026-02-26 15:50:51 -05:00
Joseph Doherty
ce45dff994 feat: port sessions 12 & 13 — Events/Monitoring/MsgTrace + Config Reload
Session 12 (218 features, IDs 854-950, 2166-2251, 2405-2439):
- EventTypes: system subjects, event message types, InternalState, ConnectEventMsg,
  DisconnectEventMsg, AccountNumConns, ServerIdentity, DataStats
- MonitorTypes: Connz, ConnInfo, ConnzOptions, ConnState, ProxyInfo, TlsPeerCert
- MonitorSortOptions: SortOpt, ConnInfos, all 13 sort comparers
- MsgTraceTypes: IMsgTrace, MsgTraceBase + 6 concrete types, custom JSON converter

Session 13 (89 features, IDs 2800-2888):
- ReloadOptions: IReloadOption interface, NoopReloadOption base, 50 option classes
  covering logging, TLS, auth, cluster, JetStream, MQTT, OCSP, misc
2026-02-26 15:46:14 -05:00
Joseph Doherty
12a14ec476 feat: port session 11 — Accounts & Directory JWT Store
- Account: full Account class (200 features) with subject mappings,
  connection counting, export/import checks, expiration timers
- DirJwtStore: directory-based JWT storage with sharding and expiry
- AccountResolver: IAccountResolver, MemoryAccountResolver,
  UrlAccountResolver, DirAccountResolver, CacheDirAccountResolver
- AccountTypes: all supporting types (AccountLimits, SConns, ExportMap,
  ImportMap, ServiceExport, StreamExport, ServiceLatency, etc.)
- 34 unit tests (599 total), 234 features complete (IDs 150-349, 793-826)
2026-02-26 15:37:08 -05:00
Joseph Doherty
06779a1f77 feat: port session 10 — Server Core Runtime, Accept Loops & Listeners
Ports server/server.go lines 2577–4782 (~1,881 Go LOC), implementing ~97
features (IDs 3051–3147) across three new partial-class files.

New files:
- NatsServer.Lifecycle.cs: Shutdown, WaitForShutdown, RemoveClient,
  SendLDMToClients, LameDuckMode, LDMClientByID, rate-limit logging,
  DisconnectClientByID, SendAsyncInfoToClients
- NatsServer.Listeners.cs: AcceptLoop, GetServerListener, InProcessConn,
  AcceptConnections, GenerateInfoJson, CopyInfo, CreateClient/Ex/InProcess,
  StartMonitoring (HTTP/HTTPS), AddConnectURLs/RemoveConnectURLs,
  TlsVersion/TlsVersionFromString, GetClientConnectURLs, ResolveHostPorts,
  PortsInfo/PortFile/LogPorts, ReadyForListeners, GetRandomIP, AcceptError
- Internal/WaitGroup.cs: Go-style WaitGroup using TaskCompletionSource

Modified:
- Auth/AuthTypes.cs: Account now implements INatsAccount (stub)
- NatsServerTypes.cs: ServerInfo.ShallowClone(), removed duplicate RefCountedUrlSet
- NatsServer.cs: _info promoted to internal for test access
- Properties/AssemblyInfo.cs: InternalsVisibleTo(DynamicProxyGenAssembly2)
- ServerTests.cs: 20 new session-10 unit tests (GenerateInfoJson, TlsVersion,
  CopyInfo, GetRandomIP — Test IDs 2895, 2906)

All 565 unit tests + 1 integration test pass.
2026-02-26 15:08:23 -05:00
Joseph Doherty
0df93c23b0 feat: port session 09 — Server Core Init & Config
Port server/server.go account management and initialization (~1950 LOC):

- NatsServer.cs: full server struct fields (atomic counters, locks, maps,
  stubs for gateway/websocket/mqtt/ocsp/leafnode)
- NatsServer.Init.cs: factory methods (New/NewServer/NewServerFromConfig),
  compression helpers (ValidateAndNormalizeCompressionOption,
  SelectCompressionMode, SelectS2AutoModeBasedOnRtt, CompressOptsEqual),
  cluster-name management, validation (ValidateCluster, ValidatePinnedCerts,
  ValidateOptions), trusted-key processing, CLI helpers, running-state checks,
  and Start() stub
- NatsServer.Accounts.cs: account management (ConfigureAccounts,
  LookupOrRegisterAccount, RegisterAccount, SetSystemAccount,
  SetDefaultSystemAccount, SetSystemAccountInternal, CreateInternalClient*,
  ShouldTrackSubscriptions, RegisterAccountNoLock, SetAccountSublist,
  SetRouteInfo, LookupAccount, LookupOrFetchAccount, UpdateAccount,
  UpdateAccountWithClaimJwt, FetchRawAccountClaims, FetchAccountClaims,
  VerifyAccountClaims, FetchAccountFromResolver, GlobalAccountOnly,
  StandAloneMode, ConfiguredRoutes, ActivePeers, ComputeRoutePoolIdx)
- NatsServerTypes.cs: ServerInfo, ServerStats, NodeInfo, ServerProtocol,
  CompressionMode constants, AccountClaims stub, InternalState stub, and
  cross-session stubs for JetStream/gateway/websocket/mqtt/ocsp
- AuthTypes.cs: extend Account stub with Issuer, ClaimJwt, RoutePoolIdx,
  Incomplete, Updated, Sublist, Server fields, and IsExpired()
- ServerOptions.cs: add Accounts property (List<Account>)
- ServerTests.cs: 38 standalone tests (IDs 2866, 2882, plus compression
  and validation helpers); server-dependent tests marked n/a

Features: 77 complete (IDs 2974–3050)
Tests: 2 complete (2866, 2882); 18 n/a (server-dependent)
All tests: 545 unit + 1 integration pass
2026-02-26 14:18:18 -05:00
82 changed files with 26535 additions and 38 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"planPath": "docs/plans/2026-02-26-complete-stub-features.md",
"tasks": [
{
"id": 7,
"subject": "Session A — Config binding (67 stubs)",
"status": "pending",
"subtasks": [
"A1: Add JsonPropertyName attrs to ServerOptions.cs",
"A2: Create Config/NatsJsonConverters.cs",
"A3: Create Config/ServerOptionsConfiguration.cs",
"A4: Write Config/ServerOptionsConfigurationTests.cs",
"A5: DB update + commit"
]
},
{
"id": 8,
"subject": "Session B — Auth implementation (26 stubs)",
"status": "pending",
"subtasks": [
"B1: Add NATS.NKeys NuGet package",
"B2: Add operator JWT methods to JwtProcessor.cs",
"B3: Add auth helper methods to AuthHandler.cs",
"B4: Create NatsServer.Auth.cs",
"B5: Create Auth/AuthCallout.cs",
"B6: Create NatsServer.Signals.cs",
"B7: Write Auth/AuthImplementationTests.cs",
"B8: DB update + commit"
]
}
],
"lastUpdated": "2026-02-26T00:00:00Z"
}

View File

@@ -0,0 +1,185 @@
# Stub Features Implementation Design
**Date:** 2026-02-26
**Scope:** Complete the 93 remaining `stub` features in Phase 6
**Approach:** Two parallel sessions (Config + Auth)
## Overview
After Phase 6's 23 porting sessions, 93 features remain at `stub` status. They fall into two
independent concerns that can be implemented in parallel:
| Group | Go File | Stubs | Go LOC | Concern |
|-------|---------|-------|--------|---------|
| Config | `server/opts.go` | 67 | ~4,876 | Configuration file parsing / binding |
| Auth | `server/auth.go` | 19 | ~1,296 | Authentication dispatch |
| Auth | `server/auth_callout.go` | 3 | ~456 | External auth callout |
| Auth | `server/jwt.go` | 3 | ~137 | Operator JWT validation |
| Signals | `server/signal.go` | 1 | ~46 | OS signal handling |
---
## Session A: Configuration Binding (67 stubs, opts.go)
### Decision
Map all NATS server configuration to **`appsettings.json`** via
`Microsoft.Extensions.Configuration`. The Go `conf` package tokenizer and the 765-line
`processConfigFileLine` dispatch loop are **not ported** — JSON deserialization replaces them.
### New Files
**`Config/ServerOptionsConfiguration.cs`**
```csharp
public static class ServerOptionsConfiguration
{
public static ServerOptions ProcessConfigFile(string path);
public static ServerOptions ProcessConfigString(string json);
public static void BindConfiguration(IConfiguration config, ServerOptions target);
}
```
- `ProcessConfigFile` uses `new ConfigurationBuilder().AddJsonFile(path).Build()`
- `ProcessConfigString` uses `AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json)))`
- `BindConfiguration` calls `config.Bind(target)` with custom converters registered
**`Config/NatsJsonConverters.cs`**
Custom `JsonConverter<T>` for non-trivial types:
| Converter | Input | Output | Mirrors |
|-----------|-------|--------|---------|
| `DurationJsonConverter` | `"2s"`, `"100ms"`, `"1h30m"` | `TimeSpan` | `parseDuration` |
| `TlsVersionJsonConverter` | `"1.2"`, `"TLS12"` | `SslProtocols` | `parseTLSVersion` |
| `NatsUrlJsonConverter` | `"nats://host:port"` | validated `string` | `parseURL` |
| `StorageSizeJsonConverter` | `"1GB"`, `"512mb"` | `long` (bytes) | `getStorageSize` |
### ServerOptions.cs Changes
Add `[JsonPropertyName("...")]` attributes for fields whose JSON key names differ from C# names.
JSON key names follow NATS server conventions (lowercase, underscore-separated):
```json
{
"port": 4222,
"host": "0.0.0.0",
"tls": { "cert_file": "...", "key_file": "...", "ca_file": "..." },
"cluster": { "port": 6222, "name": "my-cluster" },
"gateway": { "port": 7222, "name": "my-gateway" },
"jetstream": { "store_dir": "/data/jetstream", "max_memory": "1GB" },
"leafnodes": { "port": 7422 },
"mqtt": { "port": 1883 },
"websocket": { "port": 8080 },
"accounts": [ { "name": "A", "users": [ { "user": "u1", "password": "p1" } ] } ]
}
```
### DB Outcome
All 67 opts.go stubs → `complete`:
- Feature IDs 25052574, 2580, 2584 (+ `configureSystemAccount` 2509, `setupUsersAndNKeysDuplicateCheckMap` 2515)
- `parse*` functions have no C# equivalent — their logic is subsumed by converters and JSON binding
---
## Session B: Auth Implementation (26 stubs)
### New Files
**`NatsServer.Auth.cs`** — `partial class NatsServer` with:
| Method | Go Equivalent | Notes |
|--------|--------------|-------|
| `ConfigureAuthorization()` | `configureAuthorization` | Builds `_nkeys`/`_users` dicts from `_opts` |
| `BuildNkeysAndUsersFromOptions()` | `buildNkeysAndUsersFromOptions` | Creates typed lookup maps |
| `CheckAuthforWarnings()` | `checkAuthforWarnings` | Validates auth config consistency |
| `AssignGlobalAccountToOrphanUsers()` | `assignGlobalAccountToOrphanUsers` | — |
| `CheckAuthentication(ClientConnection)` | `checkAuthentication` | Entry point |
| `IsClientAuthorized(ClientConnection)` | `isClientAuthorized` | Check user credentials |
| `ProcessClientOrLeafAuthentication(ClientConnection, ServerOptions)` | `processClientOrLeafAuthentication` | Main 554-line auth dispatch |
| `IsRouterAuthorized(ClientConnection)` | `isRouterAuthorized` | Route-specific auth |
| `IsGatewayAuthorized(ClientConnection)` | `isGatewayAuthorized` | Gateway-specific auth |
| `RegisterLeafWithAccount(ClientConnection, string)` | `registerLeafWithAccount` | — |
| `IsLeafNodeAuthorized(ClientConnection)` | `isLeafNodeAuthorized` | Leaf-specific auth |
| `ProcessProxiesTrustedKeys()` | `processProxiesTrustedKeys` | Proxy key setup |
| `ProxyCheck(ClientConnection, ServerOptions)` | `proxyCheck` | Validate proxy headers |
**Auth dispatch flow in `ProcessClientOrLeafAuthentication`:**
```
if callout configured → ProcessClientOrLeafCallout()
else if JWT bearer → JwtProcessor.ValidateAndRegisterUser()
else if NKey → verify NKey signature (NATS.NKeys NuGet)
else if user+password → BCrypt.Net.BCrypt.Verify() (BCrypt.Net-Next NuGet)
else if TLS cert map → CheckClientTlsCertSubject()
else if no-auth mode → allow (if opts.NoAuth)
→ set client account, permissions, labels
```
**`Auth/AuthCallout.cs`** — `partial class NatsServer` with:
- `ProcessClientOrLeafCallout(ClientConnection, ServerOptions)` — publishes to `$SYS.REQ.USER.AUTH`, waits for signed JWT response, validates it
- `FillClientInfo(AuthorizationRequestClaims, ClientConnection)` — populate auth request payload
- `FillConnectOpts(AuthorizationRequestClaims, ClientConnection)` — populate connect opts in payload
**`Auth/JwtProcessor.cs` additions:**
- `ReadOperatorJwt(string path)` — read operator JWT from file, decode `OperatorClaims`
- `ReadOperatorJwtInternal(string jwtString)` — decode from string
- `ValidateTrustedOperators(ServerOptions opts)` — walk operator → account → user signing key chain
**`Auth/AuthHandler.cs` additions:**
- `ProcessUserPermissionsTemplate(UserPermissionLimits, UserClaims, Account)` — expand `{{account}}`, `{{tag.*}}` template variables in JWT user permissions
- `GetTlsAuthDcs(X509DistinguishedName)` — extract DC= components from TLS cert RDN
- `CheckClientTlsCertSubject(ClientConnection, Func<string, bool>)` — TLS cert subject matching
- `ValidateProxies(ServerOptions)` — validate proxy configuration
- `GetAuthErrClosedState(ClientConnection)` — map auth failure to client closed state enum
### New NuGet Packages
| Package | Version | Purpose |
|---------|---------|---------|
| `BCrypt.Net-Next` | ≥4.0 | bcrypt password hashing and comparison |
| `NATS.NKeys` | ≥2.0 | NKey keypair creation, signature verify |
### `NatsServer.Signals.cs`
New partial class file:
```csharp
// Registers OS signal handlers via PosixSignalRegistration (cross-platform).
// SIGHUP → server.Reload()
// SIGTERM → server.Shutdown()
// SIGINT → server.Shutdown()
// Windows fallback: Console.CancelKeyPress → Shutdown()
```
### DB Outcome
All 26 auth/jwt/callout/signal stubs → `complete`:
- Feature IDs 354383, 19731976, 2584, 3156
---
## File Summary
| File | Action |
|------|--------|
| `Config/ServerOptionsConfiguration.cs` | CREATE |
| `Config/NatsJsonConverters.cs` | CREATE |
| `NatsServer.Auth.cs` | CREATE (partial) |
| `NatsServer.Signals.cs` | CREATE (partial) |
| `Auth/AuthCallout.cs` | CREATE (partial) |
| `Auth/JwtProcessor.cs` | MODIFY (add 3 methods) |
| `Auth/AuthHandler.cs` | MODIFY (add 5 methods) |
| `ServerOptions.cs` | MODIFY (add JsonPropertyName attrs) |
| `ZB.MOM.NatsNet.Server.csproj` | MODIFY (add 2 NuGet packages) |
---
## Testing
Unit tests in `ZB.MOM.NatsNet.Server.Tests/`:
- `Config/ServerOptionsConfigurationTests.cs` — round-trip JSON bind tests for each major option group
- `Auth/AuthHandlerTests.cs` additions — bcrypt comparison, NKey verify, TLS cert subject matching
- `Auth/JwtProcessorTests.cs` additions — operator JWT read/validate
No new test IDs needed — these are implementations of already-tracked Phase 6 features.
After implementation, relevant test IDs in Phase 7 will be marked complete.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/accounts.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// IAccountResolver
// Mirrors Go AccountResolver interface (accounts.go ~line 4035).
// ============================================================================
/// <summary>
/// Resolves and stores account JWTs by account public key name.
/// Mirrors Go <c>AccountResolver</c> interface.
/// </summary>
public interface IAccountResolver
{
/// <summary>
/// Fetches the JWT for the named account.
/// Throws <see cref="InvalidOperationException"/> when the account is not found.
/// Mirrors Go <c>AccountResolver.Fetch</c>.
/// </summary>
Task<string> FetchAsync(string name, CancellationToken ct = default);
/// <summary>
/// Stores the JWT for the named account.
/// Read-only implementations throw <see cref="NotSupportedException"/>.
/// Mirrors Go <c>AccountResolver.Store</c>.
/// </summary>
Task StoreAsync(string name, string jwt, CancellationToken ct = default);
/// <summary>Returns true when no writes are permitted. Mirrors Go <c>IsReadOnly</c>.</summary>
bool IsReadOnly();
/// <summary>
/// Starts any background processing needed by the resolver (system subscriptions, timers, etc.).
/// The <paramref name="server"/> parameter accepts an <c>object</c> to avoid a circular assembly
/// reference; implementations should cast it to the concrete server type as needed.
/// Mirrors Go <c>AccountResolver.Start</c>.
/// </summary>
void Start(object server);
/// <summary>Returns true when the resolver reacts to JWT update events. Mirrors Go <c>IsTrackingUpdate</c>.</summary>
bool IsTrackingUpdate();
/// <summary>Reloads state from the backing store. Mirrors Go <c>AccountResolver.Reload</c>.</summary>
void Reload();
/// <summary>Releases resources held by the resolver. Mirrors Go <c>AccountResolver.Close</c>.</summary>
void Close();
}
// ============================================================================
// ResolverDefaultsOps
// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046).
// ============================================================================
/// <summary>
/// Abstract base that provides sensible no-op / read-only defaults for <see cref="IAccountResolver"/>
/// so concrete implementations only need to override what they change.
/// Mirrors Go <c>resolverDefaultsOpsImpl</c>.
/// </summary>
public abstract class ResolverDefaultsOps : IAccountResolver
{
/// <inheritdoc/>
public abstract Task<string> FetchAsync(string name, CancellationToken ct = default);
/// <summary>
/// Default store implementation — always throws because the base defaults to read-only.
/// Mirrors Go <c>resolverDefaultsOpsImpl.Store</c>.
/// </summary>
public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default)
=> throw new NotSupportedException("store operation not supported");
/// <summary>Default: the resolver is read-only. Mirrors Go <c>resolverDefaultsOpsImpl.IsReadOnly</c>.</summary>
public virtual bool IsReadOnly() => true;
/// <summary>Default: no-op start. Mirrors Go <c>resolverDefaultsOpsImpl.Start</c>.</summary>
public virtual void Start(object server) { }
/// <summary>Default: does not track updates. Mirrors Go <c>resolverDefaultsOpsImpl.IsTrackingUpdate</c>.</summary>
public virtual bool IsTrackingUpdate() => false;
/// <summary>Default: no-op reload. Mirrors Go <c>resolverDefaultsOpsImpl.Reload</c>.</summary>
public virtual void Reload() { }
/// <summary>Default: no-op close. Mirrors Go <c>resolverDefaultsOpsImpl.Close</c>.</summary>
public virtual void Close() { }
}
// ============================================================================
// MemoryAccountResolver
// Mirrors Go MemAccResolver (accounts.go ~line 4072).
// ============================================================================
/// <summary>
/// An in-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
/// Primarily intended for testing.
/// Mirrors Go <c>MemAccResolver</c>.
/// </summary>
public sealed class MemoryAccountResolver : ResolverDefaultsOps
{
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.Ordinal);
/// <summary>In-memory resolver is not read-only.</summary>
public override bool IsReadOnly() => false;
/// <summary>
/// Returns the stored JWT for <paramref name="name"/>, or throws
/// <see cref="InvalidOperationException"/> when the account is unknown.
/// Mirrors Go <c>MemAccResolver.Fetch</c>.
/// </summary>
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
{
if (_store.TryGetValue(name, out var jwt))
{
return Task.FromResult(jwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
/// <summary>
/// Stores <paramref name="jwt"/> for <paramref name="name"/>.
/// Mirrors Go <c>MemAccResolver.Store</c>.
/// </summary>
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
_store[name] = jwt;
return Task.CompletedTask;
}
}
// ============================================================================
// UrlAccountResolver
// Mirrors Go URLAccResolver (accounts.go ~line 4097).
// ============================================================================
/// <summary>
/// An HTTP-based account resolver that fetches JWTs by appending the account public key
/// to a configured base URL.
/// Mirrors Go <c>URLAccResolver</c>.
/// </summary>
public sealed class UrlAccountResolver : ResolverDefaultsOps
{
// Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT.
private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2);
private readonly string _url;
private readonly HttpClient _httpClient;
/// <summary>
/// Creates a new URL resolver for the given <paramref name="url"/>.
/// A trailing slash is appended when absent so that account names can be concatenated
/// directly. An <see cref="HttpClient"/> is configured with connection-pooling
/// settings that amortise TLS handshakes across requests, mirroring Go's custom
/// <c>http.Transport</c>.
/// Mirrors Go <c>NewURLAccResolver</c>.
/// </summary>
public UrlAccountResolver(string url)
{
if (!url.EndsWith('/'))
{
url += "/";
}
_url = url;
// Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport.
var handler = new SocketsHttpHandler
{
MaxConnectionsPerServer = 10,
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
};
_httpClient = new HttpClient(handler)
{
Timeout = DefaultAccountFetchTimeout,
};
}
/// <summary>
/// Issues an HTTP GET to the base URL with the account name appended, and returns
/// the response body as the JWT string.
/// Throws <see cref="InvalidOperationException"/> on a non-200 response.
/// Mirrors Go <c>URLAccResolver.Fetch</c>.
/// </summary>
public override async Task<string> FetchAsync(string name, CancellationToken ct = default)
{
var requestUrl = _url + name;
HttpResponseMessage response;
try
{
response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false);
}
catch (Exception ex)
{
throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex);
}
using (response)
{
if (response.StatusCode != HttpStatusCode.OK)
{
throw new InvalidOperationException(
$"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}");
}
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
}
}
}
// ============================================================================
// DirResOption — functional option for DirAccountResolver
// Mirrors Go DirResOption func type (accounts.go ~line 4552).
// ============================================================================
/// <summary>
/// A functional option that configures a <see cref="DirAccountResolver"/> instance.
/// Mirrors Go <c>DirResOption</c> function type.
/// </summary>
public delegate void DirResOption(DirAccountResolver resolver);
/// <summary>
/// Factory methods for commonly used <see cref="DirResOption"/> values.
/// </summary>
public static class DirResOptions
{
/// <summary>
/// Returns an option that overrides the default fetch timeout.
/// <paramref name="timeout"/> must be positive.
/// Mirrors Go <c>FetchTimeout</c> option constructor.
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown at application time when <paramref name="timeout"/> is not positive.
/// </exception>
public static DirResOption FetchTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout),
$"Fetch timeout {timeout} is too small");
}
return resolver => resolver.FetchTimeout = timeout;
}
}
// ============================================================================
// DirAccountResolver (stub)
// Mirrors Go DirAccResolver (accounts.go ~line 4143).
// Full system-subscription wiring is deferred to session 12.
// ============================================================================
/// <summary>
/// A directory-backed account resolver that stores JWTs in a <see cref="DirJwtStore"/>
/// and synchronises with peers via NATS system subjects.
/// <para>
/// The Start override that wires up system subscriptions and the periodic sync goroutine
/// is a stub in this session; full implementation requires JetStream and system
/// subscription support (session 12+).
/// </para>
/// Mirrors Go <c>DirAccResolver</c>.
/// </summary>
public class DirAccountResolver : ResolverDefaultsOps, IDisposable
{
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
// Default sync interval — mirrors Go's fallback of 1 minute.
private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>The underlying directory JWT store. Mirrors Go <c>DirAccResolver.DirJWTStore</c>.</summary>
public DirJwtStore Store { get; }
/// <summary>Reference to the running server, set during <see cref="Start"/>. Mirrors Go <c>DirAccResolver.Server</c>.</summary>
public object? Server { get; protected set; }
/// <summary>How often the resolver sends a sync (pack) request to peers. Mirrors Go <c>DirAccResolver.syncInterval</c>.</summary>
public TimeSpan SyncInterval { get; protected set; }
/// <summary>Maximum time to wait for a remote JWT fetch. Mirrors Go <c>DirAccResolver.fetchTimeout</c>.</summary>
public TimeSpan FetchTimeout { get; set; }
/// <summary>
/// Creates a new directory account resolver.
/// <para>
/// When <paramref name="limit"/> is zero it is promoted to <see cref="long.MaxValue"/> (unlimited).
/// When <paramref name="syncInterval"/> is non-positive it defaults to one minute.
/// </para>
/// Mirrors Go <c>NewDirAccResolver</c>.
/// </summary>
/// <param name="path">Directory path for the JWT store.</param>
/// <param name="limit">Maximum number of JWTs the store may hold (0 = unlimited).</param>
/// <param name="syncInterval">How often to broadcast a sync/pack request to peers.</param>
/// <param name="deleteType">Controls whether deletes are soft- or hard-deleted.</param>
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
public DirAccountResolver(
string path,
long limit,
TimeSpan syncInterval,
JwtDeleteType deleteType,
params DirResOption[] opts)
{
if (limit == 0)
{
limit = long.MaxValue;
}
if (syncInterval <= TimeSpan.Zero)
{
syncInterval = DefaultSyncInterval;
}
Store = DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
deleteType,
expireCheck: TimeSpan.Zero,
limit,
evictOnLimit: false,
ttl: TimeSpan.Zero,
changeNotification: null);
SyncInterval = syncInterval;
FetchTimeout = DefaultFetchTimeout;
Apply(opts);
}
// Internal constructor used by CacheDirAccountResolver which supplies its own store.
internal DirAccountResolver(
DirJwtStore store,
TimeSpan syncInterval,
TimeSpan fetchTimeout)
{
Store = store;
SyncInterval = syncInterval;
FetchTimeout = fetchTimeout;
}
/// <summary>
/// Applies a sequence of functional options to this resolver.
/// Mirrors Go <c>DirAccResolver.apply</c>.
/// </summary>
protected void Apply(IEnumerable<DirResOption> opts)
{
foreach (var opt in opts)
{
opt(this);
}
}
// -------------------------------------------------------------------------
// IAccountResolver overrides
// -------------------------------------------------------------------------
/// <summary>
/// DirAccountResolver is not read-only.
/// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false
/// from the concrete behaviour (store is writable).
/// </summary>
public override bool IsReadOnly() => false;
/// <summary>
/// Tracks updates (reacts to JWT change events).
/// Mirrors Go <c>DirAccResolver.IsTrackingUpdate</c>.
/// </summary>
public override bool IsTrackingUpdate() => true;
/// <summary>
/// Reloads state from the backing <see cref="DirJwtStore"/>.
/// Mirrors Go <c>DirAccResolver.Reload</c>.
/// </summary>
public override void Reload() => Store.Reload();
/// <summary>
/// Fetches the JWT for <paramref name="name"/> from the local <see cref="DirJwtStore"/>.
/// Throws <see cref="InvalidOperationException"/> when the account is not found locally.
/// <para>
/// Note: the Go implementation falls back to <c>srv.fetch</c> (a cluster-wide lookup) when
/// the local store misses. That fallback requires system subscriptions and is deferred to
/// session 12. For now this method only consults the local store.
/// </para>
/// Mirrors Go <c>DirAccResolver.Fetch</c> (local path only).
/// </summary>
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
{
var theJwt = Store.LoadAcc(name);
if (!string.IsNullOrEmpty(theJwt))
{
return Task.FromResult(theJwt);
}
throw new InvalidOperationException($"Account not found: {name}");
}
/// <summary>
/// Stores <paramref name="jwt"/> under <paramref name="name"/>, keeping the newer JWT
/// when a conflicting entry already exists.
/// Mirrors Go <c>DirAccResolver.Store</c> (delegates to <c>saveIfNewer</c>).
/// </summary>
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
{
// SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation.
Store.SaveAcc(name, jwt);
return Task.CompletedTask;
}
/// <summary>
/// Starts background system subscriptions and the periodic sync timer.
/// <para>
/// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack
/// requests, cluster synchronisation, and the periodic pack broadcast goroutine.
/// </para>
/// Mirrors Go <c>DirAccResolver.Start</c>.
/// </summary>
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions and periodic sync timer.
}
/// <summary>
/// Stops background processing and closes the <see cref="DirJwtStore"/>.
/// Mirrors Go <c>AccountResolver.Close</c> (no explicit Go override; store is closed
/// by the server shutdown path).
/// </summary>
public override void Close() => Store.Close();
/// <inheritdoc/>
public void Dispose() => Store.Dispose();
}
// ============================================================================
// CacheDirAccountResolver (stub)
// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594).
// ============================================================================
/// <summary>
/// A caching variant of <see cref="DirAccountResolver"/> that uses a TTL-based expiring
/// store so that fetched JWTs are automatically evicted after <see cref="Ttl"/>.
/// <para>
/// The Start override that wires up system subscriptions is a stub in this session;
/// full implementation requires system subscription support (session 12+).
/// </para>
/// Mirrors Go <c>CacheDirAccResolver</c>.
/// </summary>
public sealed class CacheDirAccountResolver : DirAccountResolver
{
// Default cache limit — mirrors Go's fallback of 1 000 entries.
private const long DefaultCacheLimit = 1_000;
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
/// <summary>The TTL applied to each cached JWT entry. Mirrors Go <c>CacheDirAccResolver.ttl</c>.</summary>
public TimeSpan Ttl { get; }
/// <summary>
/// Creates a new caching directory account resolver.
/// <para>
/// When <paramref name="limit"/> is zero or negative it defaults to 1 000.
/// </para>
/// Mirrors Go <c>NewCacheDirAccResolver</c>.
/// </summary>
/// <param name="path">Directory path for the JWT store.</param>
/// <param name="limit">Maximum number of JWTs to cache (0 = 1 000).</param>
/// <param name="ttl">Time-to-live for each cached JWT.</param>
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
public CacheDirAccountResolver(
string path,
long limit,
TimeSpan ttl,
params DirResOption[] opts)
: base(
store: DirJwtStore.NewExpiringDirJwtStore(
path,
shard: false,
create: true,
JwtDeleteType.HardDelete,
expireCheck: TimeSpan.Zero,
limit: limit <= 0 ? DefaultCacheLimit : limit,
evictOnLimit: true,
ttl: ttl,
changeNotification: null),
syncInterval: TimeSpan.Zero,
fetchTimeout: DefaultFetchTimeout)
{
Ttl = ttl;
Apply(opts);
}
/// <summary>
/// Starts background system subscriptions for cached JWT update notifications.
/// <para>
/// TODO (session 12): wire up system subscriptions for account JWT update events
/// (cache variant — does not include pack/list/delete handling).
/// </para>
/// Mirrors Go <c>CacheDirAccResolver.Start</c>.
/// </summary>
public override void Start(object server)
{
Server = server;
// TODO (session 12): set up system subscriptions for cache-update notifications.
}
}

View File

@@ -0,0 +1,737 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/accounts.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// AccountLimits — account-based limits
// Mirrors Go `limits` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Per-account connection and payload limits.
/// Mirrors Go <c>limits</c> struct in server/accounts.go.
/// </summary>
internal sealed class AccountLimits
{
/// <summary>Maximum payload size (-1 = unlimited). Mirrors Go <c>mpay</c>.</summary>
public int MaxPayload { get; set; } = -1;
/// <summary>Maximum subscriptions (-1 = unlimited). Mirrors Go <c>msubs</c>.</summary>
public int MaxSubscriptions { get; set; } = -1;
/// <summary>Maximum connections (-1 = unlimited). Mirrors Go <c>mconns</c>.</summary>
public int MaxConnections { get; set; } = -1;
/// <summary>Maximum leaf nodes (-1 = unlimited). Mirrors Go <c>mleafs</c>.</summary>
public int MaxLeafNodes { get; set; } = -1;
/// <summary>When true, bearer tokens are not allowed. Mirrors Go <c>disallowBearer</c>.</summary>
public bool DisallowBearer { get; set; }
}
// ============================================================================
// SConns — remote server connection/leafnode counters
// Mirrors Go `sconns` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks the number of client connections and leaf nodes for a remote server.
/// Mirrors Go <c>sconns</c> struct in server/accounts.go.
/// </summary>
internal sealed class SConns
{
/// <summary>Number of client connections from the remote server. Mirrors Go <c>conns</c>.</summary>
public int Conns;
/// <summary>Number of leaf nodes from the remote server. Mirrors Go <c>leafs</c>.</summary>
public int Leafs;
}
// ============================================================================
// ServiceRespType — service response type enum
// Mirrors Go `ServiceRespType` and its iota constants in server/accounts.go.
// ============================================================================
/// <summary>
/// The response type for an exported service.
/// Mirrors Go <c>ServiceRespType</c> in server/accounts.go.
/// </summary>
public enum ServiceRespType : byte
{
/// <summary>A single response is expected. Default. Mirrors Go <c>Singleton</c>.</summary>
Singleton = 0,
/// <summary>Multiple responses are streamed. Mirrors Go <c>Streamed</c>.</summary>
Streamed = 1,
/// <summary>Responses are sent in chunks. Mirrors Go <c>Chunked</c>.</summary>
Chunked = 2,
}
/// <summary>
/// Extension methods for <see cref="ServiceRespType"/>.
/// </summary>
public static class ServiceRespTypeExtensions
{
/// <summary>
/// Returns the string representation of the response type.
/// Mirrors Go <c>ServiceRespType.String()</c>.
/// </summary>
public static string ToNatsString(this ServiceRespType rt) => rt switch
{
ServiceRespType.Singleton => "Singleton",
ServiceRespType.Streamed => "Streamed",
ServiceRespType.Chunked => "Chunked",
_ => "Unknown ServiceResType",
};
}
// ============================================================================
// ExportAuth — export authorization configuration
// Mirrors Go `exportAuth` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Holds configured approvals or a flag indicating that an auth token is
/// required for import.
/// Mirrors Go <c>exportAuth</c> struct in server/accounts.go.
/// </summary>
internal class ExportAuth
{
/// <summary>When true, an auth token is required to import this export. Mirrors Go <c>tokenReq</c>.</summary>
public bool TokenRequired { get; set; }
/// <summary>
/// Position in the subject token where the account name appears (for
/// public exports that embed the importing account name).
/// Mirrors Go <c>accountPos</c>.
/// </summary>
public uint AccountPosition { get; set; }
/// <summary>
/// Accounts explicitly approved to import this export.
/// Key is the account name. Mirrors Go <c>approved</c>.
/// </summary>
public Dictionary<string, Account>? Approved { get; set; }
/// <summary>
/// Accounts whose activations have been revoked.
/// Key is the account name, value is the revocation timestamp (Unix ns).
/// Mirrors Go <c>actsRevoked</c>.
/// </summary>
public Dictionary<string, long>? ActivationsRevoked { get; set; }
}
// ============================================================================
// StreamExport — exported stream descriptor
// Mirrors Go `streamExport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a stream exported by an account.
/// Mirrors Go <c>streamExport</c> struct in server/accounts.go.
/// </summary>
internal sealed class StreamExport : ExportAuth
{
// No additional fields beyond ExportAuth for now.
// Full implementation in session 11 (accounts.go).
}
// ============================================================================
// InternalServiceLatency — service latency tracking configuration
// Mirrors Go `serviceLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Configuration for service latency tracking on an exported service.
/// Mirrors Go <c>serviceLatency</c> struct in server/accounts.go.
/// </summary>
internal sealed class InternalServiceLatency
{
/// <summary>
/// Sampling percentage (1100), or 0 to indicate triggered by header.
/// Mirrors Go <c>sampling int8</c>.
/// </summary>
public int Sampling { get; set; }
/// <summary>Subject to publish latency metrics to. Mirrors Go <c>subject</c>.</summary>
public string Subject { get; set; } = string.Empty;
}
// ============================================================================
// ServiceExportEntry — exported service descriptor
// Mirrors Go `serviceExport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a service exported by an account with additional configuration
/// for response type, latency tracking, and timers.
/// Mirrors Go <c>serviceExport</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceExportEntry : ExportAuth
{
/// <summary>Account that owns this export. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>Response type (Singleton, Streamed, Chunked). Mirrors Go <c>respType</c>.</summary>
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
/// <summary>Latency tracking configuration, or null if disabled. Mirrors Go <c>latency</c>.</summary>
public InternalServiceLatency? Latency { get; set; }
/// <summary>
/// Timer used to collect response-latency measurements.
/// Mirrors Go <c>rtmr *time.Timer</c>.
/// </summary>
public Timer? ResponseTimer { get; set; }
/// <summary>
/// Threshold duration for service responses.
/// Mirrors Go <c>respThresh time.Duration</c>.
/// </summary>
public TimeSpan ResponseThreshold { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary for this export.
/// Mirrors Go <c>atrc</c> (allow_trace).
/// </summary>
public bool AllowTrace { get; set; }
}
// ============================================================================
// ExportMap — tracks exported streams and services for an account
// Mirrors Go `exportMap` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks all stream exports, service exports, and response mappings for an account.
/// Mirrors Go <c>exportMap</c> struct in server/accounts.go.
/// </summary>
internal sealed class ExportMap
{
/// <summary>
/// Exported streams keyed by subject pattern.
/// Mirrors Go <c>streams map[string]*streamExport</c>.
/// </summary>
public Dictionary<string, StreamExport>? Streams { get; set; }
/// <summary>
/// Exported services keyed by subject pattern.
/// Mirrors Go <c>services map[string]*serviceExport</c>.
/// </summary>
public Dictionary<string, ServiceExportEntry>? Services { get; set; }
/// <summary>
/// In-flight response service imports keyed by reply subject.
/// Mirrors Go <c>responses map[string]*serviceImport</c>.
/// </summary>
public Dictionary<string, ServiceImportEntry>? Responses { get; set; }
}
// ============================================================================
// ImportMap — tracks imported streams and services for an account
// Mirrors Go `importMap` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Tracks all stream imports, service imports, and reverse-response maps.
/// Mirrors Go <c>importMap</c> struct in server/accounts.go.
/// </summary>
internal sealed class ImportMap
{
/// <summary>
/// Imported streams (ordered list).
/// Mirrors Go <c>streams []*streamImport</c>.
/// </summary>
public List<StreamImportEntry>? Streams { get; set; }
/// <summary>
/// Imported services keyed by subject pattern; each key may have
/// multiple import entries (e.g. fan-out imports).
/// Mirrors Go <c>services map[string][]*serviceImport</c>.
/// </summary>
public Dictionary<string, List<ServiceImportEntry>>? Services { get; set; }
/// <summary>
/// Reverse-response map used to clean up singleton service imports.
/// Mirrors Go <c>rrMap map[string][]*serviceRespEntry</c>.
/// </summary>
public Dictionary<string, List<ServiceRespEntry>>? ReverseResponseMap { get; set; }
}
// ============================================================================
// StreamImportEntry — an imported stream mapping
// Mirrors Go `streamImport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// An imported stream from another account, with optional subject remapping.
/// Mirrors Go <c>streamImport</c> struct in server/accounts.go.
/// </summary>
internal sealed class StreamImportEntry
{
/// <summary>Account providing the stream. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>Source subject on the exporting account. Mirrors Go <c>from</c>.</summary>
public string From { get; set; } = string.Empty;
/// <summary>Destination subject on the importing account. Mirrors Go <c>to</c>.</summary>
public string To { get; set; } = string.Empty;
/// <summary>
/// Subject transform applied to the source subject.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// Stubbed as <see cref="ISubjectTransformer"/> until the transform
/// engine is wired in.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Reverse transform for reply subjects.
/// Mirrors Go <c>rtr *subjectTransform</c>.
/// </summary>
public ISubjectTransformer? ReverseTransform { get; set; }
/// <summary>
/// JWT import claim that authorized this import.
/// Mirrors Go <c>claim *jwt.Import</c>.
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
/// </summary>
public object? Claim { get; set; }
/// <summary>
/// When true, use the published subject instead of <see cref="To"/>.
/// Mirrors Go <c>usePub</c>.
/// </summary>
public bool UsePublishedSubject { get; set; }
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
public bool Invalid { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary.
/// Mirrors Go <c>atrc</c> (allow_trace).
/// </summary>
public bool AllowTrace { get; set; }
}
// ============================================================================
// ServiceImportEntry — an imported service mapping
// Mirrors Go `serviceImport` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// An imported service from another account, with response routing and
/// latency tracking state.
/// Mirrors Go <c>serviceImport</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceImportEntry
{
/// <summary>Account providing the service. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>
/// JWT import claim that authorized this import.
/// Mirrors Go <c>claim *jwt.Import</c>.
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
/// </summary>
public object? Claim { get; set; }
/// <summary>Parent service export entry. Mirrors Go <c>se *serviceExport</c>.</summary>
public ServiceExportEntry? ServiceExport { get; set; }
/// <summary>
/// Subscription ID byte slice for cleanup.
/// Mirrors Go <c>sid []byte</c>.
/// </summary>
public byte[]? SubscriptionId { get; set; }
/// <summary>Source subject on the importing account. Mirrors Go <c>from</c>.</summary>
public string From { get; set; } = string.Empty;
/// <summary>Destination subject on the exporting account. Mirrors Go <c>to</c>.</summary>
public string To { get; set; } = string.Empty;
/// <summary>
/// Subject transform applied when routing requests.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// Stubbed as <see cref="ISubjectTransformer"/> until transform engine is wired in.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Timestamp (Unix nanoseconds) when the import request was created.
/// Used for latency tracking. Mirrors Go <c>ts int64</c>.
/// </summary>
public long Timestamp { get; set; }
/// <summary>Response type for this service import. Mirrors Go <c>rt ServiceRespType</c>.</summary>
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
/// <summary>Latency tracking configuration. Mirrors Go <c>latency *serviceLatency</c>.</summary>
public InternalServiceLatency? Latency { get; set; }
/// <summary>
/// First-leg latency measurement (requestor side).
/// Mirrors Go <c>m1 *ServiceLatency</c>.
/// </summary>
public ServiceLatency? M1 { get; set; }
/// <summary>
/// Client connection that sent the original request.
/// Mirrors Go <c>rc *client</c>.
/// </summary>
public ClientConnection? RequestingClient { get; set; }
/// <summary>
/// When true, use the published subject instead of <see cref="To"/>.
/// Mirrors Go <c>usePub</c>.
/// </summary>
public bool UsePublishedSubject { get; set; }
/// <summary>
/// When true, this import entry represents a pending response rather
/// than an originating request.
/// Mirrors Go <c>response</c>.
/// </summary>
public bool IsResponse { get; set; }
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
public bool Invalid { get; set; }
/// <summary>
/// When true, the requestor's <see cref="ClientInfo"/> is shared with
/// the responder. Mirrors Go <c>share</c>.
/// </summary>
public bool Share { get; set; }
/// <summary>Whether latency tracking is active. Mirrors Go <c>tracking</c>.</summary>
public bool Tracking { get; set; }
/// <summary>Whether a response was delivered to the requestor. Mirrors Go <c>didDeliver</c>.</summary>
public bool DidDeliver { get; set; }
/// <summary>
/// When true, tracing is allowed past the account boundary (inherited
/// from the service export). Mirrors Go <c>atrc</c>.
/// </summary>
public bool AllowTrace { get; set; }
/// <summary>
/// Headers from the original request, used when latency is triggered by
/// a header. Mirrors Go <c>trackingHdr http.Header</c>.
/// </summary>
public Dictionary<string, string[]>? TrackingHeader { get; set; }
}
// ============================================================================
// ServiceRespEntry — reverse-response map entry
// Mirrors Go `serviceRespEntry` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Records a service import mapping for reverse-response-map cleanup.
/// Mirrors Go <c>serviceRespEntry</c> struct in server/accounts.go.
/// </summary>
internal sealed class ServiceRespEntry
{
/// <summary>Account that owns the service import. Mirrors Go <c>acc</c>.</summary>
public Account? Account { get; set; }
/// <summary>
/// The mapped subscription subject used for the response.
/// Mirrors Go <c>msub</c>.
/// </summary>
public string MappedSubject { get; set; } = string.Empty;
}
// ============================================================================
// MapDest — public API for weighted subject mappings
// Mirrors Go `MapDest` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Describes a weighted mapping destination for published subjects.
/// Mirrors Go <c>MapDest</c> struct in server/accounts.go.
/// </summary>
public sealed class MapDest
{
[JsonPropertyName("subject")]
public string Subject { get; set; } = string.Empty;
[JsonPropertyName("weight")]
public byte Weight { get; set; }
[JsonPropertyName("cluster")]
public string Cluster { get; set; } = string.Empty;
/// <summary>
/// Creates a new <see cref="MapDest"/> with the given subject and weight.
/// Mirrors Go <c>NewMapDest</c>.
/// </summary>
public static MapDest New(string subject, byte weight) =>
new() { Subject = subject, Weight = weight };
}
// ============================================================================
// Destination — internal weighted mapped destination
// Mirrors Go `destination` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Internal representation of a weighted mapped destination, holding a
/// transform and a weight.
/// Mirrors Go <c>destination</c> struct in server/accounts.go.
/// </summary>
internal sealed class Destination
{
/// <summary>
/// Transform that converts the source subject to the destination subject.
/// Mirrors Go <c>tr *subjectTransform</c>.
/// </summary>
public ISubjectTransformer? Transform { get; set; }
/// <summary>
/// Relative weight (0100). Mirrors Go <c>weight uint8</c>.
/// </summary>
public byte Weight { get; set; }
}
// ============================================================================
// SubjectMapping — internal subject mapping entry
// Mirrors Go `mapping` struct in server/accounts.go.
// Renamed from `mapping` to avoid collision with the C# keyword context.
// ============================================================================
/// <summary>
/// An internal entry describing how a source subject is remapped to one or
/// more weighted destinations, optionally scoped to specific clusters.
/// Mirrors Go <c>mapping</c> struct in server/accounts.go.
/// </summary>
internal sealed class SubjectMapping
{
/// <summary>Source subject pattern. Mirrors Go <c>src</c>.</summary>
public string Source { get; set; } = string.Empty;
/// <summary>
/// Whether the source contains wildcards.
/// Mirrors Go <c>wc</c>.
/// </summary>
public bool HasWildcard { get; set; }
/// <summary>
/// Weighted destinations with no cluster scope.
/// Mirrors Go <c>dests []*destination</c>.
/// </summary>
public List<Destination> Destinations { get; set; } = [];
/// <summary>
/// Per-cluster weighted destinations.
/// Key is the cluster name. Mirrors Go <c>cdests map[string][]*destination</c>.
/// </summary>
public Dictionary<string, List<Destination>>? ClusterDestinations { get; set; }
}
// ============================================================================
// TypedEvent — base for server advisory events
// Mirrors Go `TypedEvent` struct in server/events.go.
// Included here because ServiceLatency embeds it.
// ============================================================================
/// <summary>
/// Base fields for a NATS typed event or advisory.
/// Mirrors Go <c>TypedEvent</c> struct in server/events.go.
/// </summary>
public class TypedEvent
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("timestamp")]
public DateTime Time { get; set; }
}
// ============================================================================
// ServiceLatency — public latency measurement event
// Mirrors Go `ServiceLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// The JSON message published to a latency-tracking subject when a service
/// request completes. Includes requestor and responder timing breakdowns.
/// Mirrors Go <c>ServiceLatency</c> struct in server/accounts.go.
/// </summary>
public sealed class ServiceLatency : TypedEvent
{
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("description")]
public string Error { get; set; } = string.Empty;
[JsonPropertyName("requestor")]
public ClientInfo? Requestor { get; set; }
[JsonPropertyName("responder")]
public ClientInfo? Responder { get; set; }
/// <summary>
/// Headers from the original request that triggered latency measurement.
/// Mirrors Go <c>RequestHeader http.Header</c>.
/// </summary>
[JsonPropertyName("header")]
public Dictionary<string, string[]>? RequestHeader { get; set; }
[JsonPropertyName("start")]
public DateTime RequestStart { get; set; }
/// <summary>Mirrors Go <c>ServiceLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("service")]
public TimeSpan ServiceLatencyDuration { get; set; }
/// <summary>Mirrors Go <c>SystemLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("system")]
public TimeSpan SystemLatency { get; set; }
/// <summary>Mirrors Go <c>TotalLatency time.Duration</c> (nanoseconds).</summary>
[JsonPropertyName("total")]
public TimeSpan TotalLatency { get; set; }
/// <summary>
/// Returns the sum of requestor RTT, responder RTT, and system latency.
/// Mirrors Go <c>ServiceLatency.NATSTotalTime()</c>.
/// </summary>
public TimeSpan NATSTotalTime()
{
var requestorRtt = Requestor?.Rtt ?? TimeSpan.Zero;
var responderRtt = Responder?.Rtt ?? TimeSpan.Zero;
return requestorRtt + responderRtt + SystemLatency;
}
}
// ============================================================================
// RemoteLatency — cross-server latency transport message
// Mirrors Go `remoteLatency` struct in server/accounts.go.
// ============================================================================
/// <summary>
/// Used to transport a responder-side latency measurement to the
/// requestor's server so the two halves can be merged.
/// Mirrors Go <c>remoteLatency</c> struct in server/accounts.go.
/// </summary>
internal sealed class RemoteLatency
{
[JsonPropertyName("account")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("req_id")]
public string RequestId { get; set; } = string.Empty;
[JsonPropertyName("m2")]
public ServiceLatency M2 { get; set; } = new();
/// <summary>
/// Private: response latency threshold used when deciding whether to
/// send the remote measurement.
/// Mirrors Go <c>respThresh time.Duration</c>.
/// </summary>
public TimeSpan ResponseThreshold { get; set; }
}
// ============================================================================
// RsiReason — reason for removing a response service import
// Mirrors Go `rsiReason` and its iota constants in server/accounts.go.
// ============================================================================
/// <summary>
/// The reason a response service import entry is being removed.
/// Mirrors Go <c>rsiReason</c> and its iota constants in server/accounts.go.
/// </summary>
internal enum RsiReason
{
/// <summary>Normal completion. Mirrors Go <c>rsiOk</c>.</summary>
Ok = 0,
/// <summary>Response was never delivered. Mirrors Go <c>rsiNoDelivery</c>.</summary>
NoDelivery = 1,
/// <summary>Response timed out. Mirrors Go <c>rsiTimeout</c>.</summary>
Timeout = 2,
}
// ============================================================================
// Account-level constants
// Mirrors the const blocks in server/accounts.go.
// ============================================================================
/// <summary>
/// Constants related to account route-pool indexing and search depth.
/// </summary>
internal static class AccountConstants
{
/// <summary>
/// Sentinel value indicating the account has a dedicated route connection.
/// Mirrors Go <c>accDedicatedRoute = -1</c>.
/// </summary>
public const int DedicatedRoute = -1;
/// <summary>
/// Sentinel value indicating the account is in the process of transitioning
/// to a dedicated route.
/// Mirrors Go <c>accTransitioningToDedicatedRoute = -2</c>.
/// </summary>
public const int TransitioningToDedicatedRoute = -2;
/// <summary>
/// Maximum depth for account cycle detection when following import chains.
/// Mirrors Go <c>MaxAccountCycleSearchDepth = 1024</c>.
/// </summary>
public const int MaxCycleSearchDepth = 1024;
}
/// <summary>
/// Well-known header names and event type identifiers used by the account
/// service-latency and client-info subsystems.
/// </summary>
public static class AccountEventConstants
{
/// <summary>
/// Header name used to pass client metadata into a service request.
/// Mirrors Go <c>ClientInfoHdr = "Nats-Request-Info"</c>.
/// </summary>
public const string ClientInfoHeader = "Nats-Request-Info";
/// <summary>
/// The default threshold (in nanoseconds, as a <see cref="TimeSpan"/>) below
/// which a subscription-limit report is suppressed.
/// Mirrors Go <c>defaultMaxSubLimitReportThreshold = int64(2 * time.Second)</c>.
/// </summary>
public static readonly TimeSpan DefaultMaxSubLimitReportThreshold = TimeSpan.FromSeconds(2);
/// <summary>
/// NATS event type identifier for <see cref="ServiceLatency"/> messages.
/// Mirrors Go <c>ServiceLatencyType = "io.nats.server.metric.v1.service_latency"</c>.
/// </summary>
public const string ServiceLatencyType = "io.nats.server.metric.v1.service_latency";
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,93 @@
// Copyright 2022-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/auth_callout.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// External auth callout support.
/// Mirrors Go <c>auth_callout.go</c>.
/// </summary>
internal static class AuthCallout
{
/// <summary>
/// Publishes an auth request to the configured callout account and awaits
/// a signed JWT response that authorises or rejects the connecting client.
/// Mirrors Go <c>processClientOrLeafCallout</c> in auth_callout.go.
/// </summary>
public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
{
// Full implementation requires internal NATS pub/sub with async request/reply.
// This is intentionally left as a stub until the internal NATS connection layer is available.
throw new NotImplementedException(
"Auth callout requires internal NATS pub/sub — implement when connection layer is available.");
}
/// <summary>
/// Populates an authorization request payload with client connection info.
/// Mirrors Go <c>client.fillClientInfo</c> in auth_callout.go.
/// </summary>
public static void FillClientInfo(AuthorizationRequest req, ClientConnection c)
{
req.ClientInfoObj = new AuthorizationClientInfo
{
Host = c.Host,
Id = c.Cid,
Kind = c.Kind.ToString().ToLowerInvariant(),
Type = "client",
};
}
/// <summary>
/// Populates an authorization request payload with connect options.
/// Mirrors Go <c>client.fillConnectOpts</c> in auth_callout.go.
/// </summary>
public static void FillConnectOpts(AuthorizationRequest req, ClientConnection c)
{
req.ConnectOptions = new AuthorizationConnectOpts
{
Username = c.GetUsername(),
Password = c.GetPassword(),
AuthToken = c.GetAuthToken(),
Nkey = c.GetNkey(),
};
}
}
/// <summary>Authorization request sent to auth callout service.</summary>
public sealed class AuthorizationRequest
{
public string ServerId { get; set; } = string.Empty;
public string UserNkey { get; set; } = string.Empty;
public AuthorizationClientInfo? ClientInfoObj { get; set; }
public AuthorizationConnectOpts? ConnectOptions { get; set; }
}
/// <summary>Client info portion of an authorization request.</summary>
public sealed class AuthorizationClientInfo
{
public string Host { get; set; } = string.Empty;
public ulong Id { get; set; }
public string Kind { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
}
/// <summary>Connect options portion of an authorization request.</summary>
public sealed class AuthorizationConnectOpts
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string Nkey { get; set; } = string.Empty;
}

View File

@@ -16,6 +16,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
@@ -270,4 +271,104 @@ public static partial class AuthHandler
{
buf.Fill((byte)'x');
}
/// <summary>
/// Returns the closed-client state for an auth error.
/// Mirrors Go <c>getAuthErrClosedState</c> in server/auth.go.
/// </summary>
public static ClosedState GetAuthErrClosedState(Exception? err)
{
return err switch
{
AuthProxyNotTrustedException => ClosedState.ProxyNotTrusted,
AuthProxyRequiredException => ClosedState.ProxyRequired,
_ => ClosedState.AuthenticationViolation,
};
}
/// <summary>
/// Validates that proxy protocol configuration is consistent.
/// If <see cref="ServerOptions.ProxyRequired"/> is set, <see cref="ServerOptions.ProxyProtocol"/> must also be enabled.
/// Note: Full NKey-format validation of trusted proxy keys is deferred until proxy auth is fully implemented.
/// Partially mirrors Go <c>validateProxies</c> in server/auth.go.
/// </summary>
public static Exception? ValidateProxies(ServerOptions opts)
{
if (opts.ProxyRequired && !opts.ProxyProtocol)
return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
return null;
}
/// <summary>
/// Extracts the DC= attribute values from a certificate's distinguished name.
/// Mirrors Go <c>getTLSAuthDCs</c> in server/auth.go.
/// </summary>
public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
var subject = cert.Subject;
var dcs = new System.Text.StringBuilder();
foreach (var part in subject.Split(','))
{
var trimmed = part.Trim();
if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
{
if (dcs.Length > 0) dcs.Append('.');
dcs.Append(trimmed[3..]);
}
}
return dcs.ToString();
}
/// <summary>
/// Checks whether a client's TLS certificate subject matches using the provided matcher function.
/// Mirrors Go <c>checkClientTLSCertSubject</c> in server/auth.go.
/// </summary>
public static bool CheckClientTlsCertSubject(
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
Func<string, bool> matcher)
{
if (cert == null) return false;
return matcher(cert.Subject);
}
/// <summary>
/// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
/// Mirrors Go <c>processUserPermissionsTemplate</c> in server/auth.go.
/// </summary>
public static (Permissions Result, Exception? Error) ProcessUserPermissionsTemplate(
Permissions lim,
string accountName,
Dictionary<string, string>? tags)
{
ExpandSubjectList(lim.Publish?.Allow, accountName, tags);
ExpandSubjectList(lim.Publish?.Deny, accountName, tags);
ExpandSubjectList(lim.Subscribe?.Allow, accountName, tags);
ExpandSubjectList(lim.Subscribe?.Deny, accountName, tags);
return (lim, null);
}
private static readonly Regex TemplateVar =
new(@"\{\{(\w+(?:\.\w+)*)\}\}", RegexOptions.Compiled);
private static void ExpandSubjectList(List<string>? subjects, string accountName, Dictionary<string, string>? tags)
{
if (subjects == null) return;
for (var i = 0; i < subjects.Count; i++)
subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
}
private static string ExpandTemplate(string subject, string accountName, Dictionary<string, string>? tags)
{
return TemplateVar.Replace(subject, m =>
{
var key = m.Groups[1].Value;
if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
{
var tagKey = key[4..];
return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
}
return m.Value;
});
}
}

View File

@@ -13,6 +13,8 @@
//
// Adapted from server/auth.go (type definitions) in the NATS server Go source.
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
@@ -166,11 +168,23 @@ public class RoutePermissions
public SubjectPermission? Export { get; set; }
}
// Account stub removed — full implementation is in Accounts/Account.cs
// in the ZB.MOM.NatsNet.Server namespace.
/// <summary>
/// Stub for Account type. Full implementation in later sessions.
/// Mirrors Go <c>Account</c> struct.
/// Sentinel exception representing a proxy-auth "not trusted" error.
/// Mirrors Go <c>ErrAuthProxyNotTrusted</c> in server/auth.go.
/// </summary>
public class Account
public sealed class AuthProxyNotTrustedException : InvalidOperationException
{
public string Name { get; set; } = string.Empty;
public AuthProxyNotTrustedException() : base("proxy not trusted") { }
}
/// <summary>
/// Sentinel exception representing a proxy-auth "required" error.
/// Mirrors Go <c>ErrAuthProxyRequired</c> in server/auth.go.
/// </summary>
public sealed class AuthProxyRequiredException : InvalidOperationException
{
public AuthProxyRequiredException() : base("proxy required") { }
}

View File

@@ -14,6 +14,7 @@
// Adapted from server/jwt.go in the NATS server Go source.
using System.Net;
using ZB.MOM.NatsNet.Server;
namespace ZB.MOM.NatsNet.Server.Auth;
@@ -30,15 +31,6 @@ public static class JwtProcessor
/// </summary>
public const string JwtPrefix = "eyJ";
/// <summary>
/// Wipes a byte slice by filling with 'x', for clearing nkey seed data.
/// Mirrors Go <c>wipeSlice</c>.
/// </summary>
public static void WipeSlice(Span<byte> buf)
{
buf.Fill((byte)'x');
}
/// <summary>
/// Validates that the given IP host address is allowed by the user claims source CIDRs.
/// Returns true if the host is within any of the allowed CIDRs, or if no CIDRs are specified.
@@ -179,6 +171,58 @@ public static class JwtProcessor
return true;
}
/// <summary>
/// Reads an operator JWT from a file path. Returns (claims, error).
/// Mirrors Go <c>ReadOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (object? Claims, Exception? Error) ReadOperatorJwt(string path)
{
if (string.IsNullOrEmpty(path))
return (null, new ArgumentException("operator JWT path is empty"));
string jwtString;
try
{
jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
}
catch (Exception ex)
{
return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
}
return ReadOperatorJwtInternal(jwtString);
}
/// <summary>
/// Decodes an operator JWT string. Returns (claims, error).
/// Mirrors Go <c>readOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (object? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
{
if (string.IsNullOrEmpty(jwtString))
return (null, new ArgumentException("operator JWT string is empty"));
if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));
// Full NATS JWT parsing would require a dedicated JWT library.
// At this level, we validate the prefix and structure.
return (null, new FormatException("operator JWT parsing not fully implemented — requires NATS JWT library"));
}
/// <summary>
/// Validates the trusted operator JWTs in options.
/// Mirrors Go <c>validateTrustedOperators</c> in server/jwt.go.
/// </summary>
public static Exception? ValidateTrustedOperators(ServerOptions opts)
{
if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
return null;
// TODO: Full trusted operator JWT validation requires a NATS JWT library.
// Each operator JWT should be decoded and its signing key chain verified.
// For now, we accept any non-empty operator list and validate at connect time.
return null;
}
}
/// <summary>

View File

@@ -0,0 +1,172 @@
// Copyright 2021-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/ocsp.go, server/ocsp_peer.go, server/ocsp_responsecache.go
// in the NATS server Go source.
using System.Security.Cryptography.X509Certificates;
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
/// <summary>
/// Controls how OCSP stapling behaves for a TLS certificate.
/// Mirrors Go <c>OCSPMode uint8</c> in server/ocsp.go.
/// </summary>
public enum OcspMode : byte
{
/// <summary>
/// Staple only if the "status_request" OID is present in the certificate.
/// Mirrors Go <c>OCSPModeAuto</c>.
/// </summary>
Auto = 0,
/// <summary>
/// Must staple — honors the Must-Staple flag and shuts down on revocation.
/// Mirrors Go <c>OCSPModeMust</c>.
/// </summary>
MustStaple = 1,
/// <summary>
/// Always obtain OCSP status, regardless of certificate flags.
/// Mirrors Go <c>OCSPModeAlways</c>.
/// </summary>
Always = 2,
/// <summary>
/// Never check OCSP, even if the certificate has the Must-Staple flag.
/// Mirrors Go <c>OCSPModeNever</c>.
/// </summary>
Never = 3,
}
/// <summary>
/// Holds a cached OCSP staple response and its expiry information.
/// </summary>
internal sealed class OcspStaple
{
/// <summary>The raw DER-encoded OCSP response bytes.</summary>
public byte[]? Response { get; set; }
/// <summary>When the OCSP response next needs to be refreshed.</summary>
public DateTime NextUpdate { get; set; }
}
/// <summary>
/// Orchestrates OCSP stapling for a single TLS certificate.
/// Monitors certificate validity and refreshes the staple on a background timer.
/// Mirrors Go <c>OCSPMonitor</c> struct in server/ocsp.go.
/// Replaces the stub in NatsServerTypes.cs.
/// </summary>
internal sealed class OcspMonitor
{
private readonly Lock _mu = new();
/// <summary>Path to the TLS certificate file being monitored.</summary>
public string? CertFile { get; set; }
/// <summary>Path to the CA certificate file used to verify OCSP responses.</summary>
public string? CaFile { get; set; }
/// <summary>Path to a persisted OCSP staple file (optional).</summary>
public string? OcspStapleFile { get; set; }
/// <summary>The OCSP stapling mode for this monitor.</summary>
public OcspMode Mode { get; set; }
/// <summary>How often to check for a fresh OCSP response.</summary>
public TimeSpan CheckInterval { get; set; } = TimeSpan.FromHours(24);
/// <summary>The owning server instance.</summary>
public NatsServer? Server { get; set; }
/// <summary>The synchronisation lock for this monitor's mutable state.</summary>
public Lock Mu => _mu;
/// <summary>Starts the background OCSP refresh timer.</summary>
public void Start()
=> throw new NotImplementedException("TODO: session 23 — ocsp");
/// <summary>Stops the background OCSP refresh timer.</summary>
public void Stop()
=> throw new NotImplementedException("TODO: session 23 — ocsp");
/// <summary>Returns the current cached OCSP staple bytes, or <c>null</c> if none.</summary>
public byte[]? GetStaple()
=> throw new NotImplementedException("TODO: session 23 — ocsp");
}
/// <summary>
/// Interface for caching raw OCSP response bytes keyed by certificate fingerprint.
/// Mirrors Go <c>OCSPResponseCache</c> interface in server/ocsp_responsecache.go.
/// Replaces the stub in NatsServerTypes.cs.
/// </summary>
public interface IOcspResponseCache
{
/// <summary>Returns the cached OCSP response for <paramref name="key"/>, or <c>null</c>.</summary>
byte[]? Get(string key);
/// <summary>Stores an OCSP response under <paramref name="key"/>.</summary>
void Put(string key, byte[] response);
/// <summary>Removes the cached entry for <paramref name="key"/>.</summary>
void Remove(string key);
}
/// <summary>
/// A no-op OCSP cache that never stores anything.
/// Mirrors Go <c>NoOpCache</c> in server/ocsp_responsecache.go.
/// </summary>
internal sealed class NoOpCache : IOcspResponseCache
{
public byte[]? Get(string key) => null;
public void Put(string key, byte[] response) { }
public void Remove(string key) { }
}
/// <summary>
/// An OCSP cache backed by a local directory on disk.
/// Mirrors Go <c>LocalCache</c> in server/ocsp_responsecache.go.
/// Full implementation is deferred to session 23.
/// </summary>
internal sealed class LocalDirCache : IOcspResponseCache
{
private readonly string _dir;
public LocalDirCache(string dir)
{
_dir = dir;
}
public byte[]? Get(string key)
=> throw new NotImplementedException("TODO: session 23 — ocsp");
public void Put(string key, byte[] response)
=> throw new NotImplementedException("TODO: session 23 — ocsp");
public void Remove(string key)
=> throw new NotImplementedException("TODO: session 23 — ocsp");
}
/// <summary>
/// Payload for the OCSP peer certificate rejection advisory event.
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> fields in server/events.go
/// and the OCSP peer reject logic in server/ocsp_peer.go.
/// </summary>
public sealed class OcspPeerRejectInfo
{
[System.Text.Json.Serialization.JsonPropertyName("peer")]
public string Peer { get; set; } = string.Empty;
[System.Text.Json.Serialization.JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}

View File

@@ -842,6 +842,43 @@ public sealed partial class ClientConnection
internal void SetAuthError(Exception err) { lock (_mu) { AuthErr = err; } }
internal Exception? GetAuthError() { lock (_mu) { return AuthErr; } }
// Auth credential accessors (used by NatsServer.Auth.cs)
internal string GetAuthToken() { lock (_mu) { return Opts.Token; } }
internal string GetNkey() { lock (_mu) { return Opts.Nkey; } }
internal string GetNkeySig() { lock (_mu) { return Opts.Sig; } }
internal string GetUsername() { lock (_mu) { return Opts.Username; } }
internal string GetPassword() { lock (_mu) { return Opts.Password; } }
internal X509Certificate2? GetTlsCertificate()
{
lock (_mu)
{
if (_nc is SslStream ssl)
{
var cert = ssl.RemoteCertificate;
if (cert is X509Certificate2 cert2) return cert2;
if (cert != null) return new X509Certificate2(cert);
}
return null;
}
}
internal void SetAccount(INatsAccount? acc)
{
lock (_mu) { Account = acc; }
}
internal void SetAccount(Account? acc) => SetAccount(acc as INatsAccount);
internal void SetPermissions(Auth.Permissions? perms)
{
lock (_mu)
{
if (perms != null)
Perms = BuildPermissions(perms);
}
}
// =========================================================================
// Timer helpers (features 523-531)
// =========================================================================
@@ -1077,6 +1114,21 @@ public sealed partial class ClientConnection
// features 425-427: writeLoop / flushClients / readLoop
internal void WriteLoop() { /* TODO session 09 */ }
internal void FlushClients(long budget) { /* TODO session 09 */ }
internal void ReadLoop(byte[]? pre) { /* TODO session 09 */ }
/// <summary>
/// Generates the INFO JSON bytes sent to the client on connect.
/// Stub — full implementation in session 09.
/// Mirrors Go <c>client.generateClientInfoJSON()</c>.
/// </summary>
internal ReadOnlyMemory<byte> GenerateClientInfoJSON(ServerInfo info, bool includeClientIp)
=> ReadOnlyMemory<byte>.Empty;
/// <summary>
/// Sets the auth-timeout timer to the specified duration.
/// Mirrors Go <c>client.setAuthTimer(d)</c>.
/// </summary>
internal void SetAuthTimer(TimeSpan d) { /* TODO session 09 */ }
// features 428-432: closedStateForErr, collapsePtoNB, flushOutbound, handleWriteTimeout, markConnAsClosed
internal static ClosedState ClosedStateForErr(Exception err) =>

View File

@@ -273,6 +273,13 @@ public sealed class ClientInfo
public bool Disconnect { get; set; }
public string[]? Cluster { get; set; }
public bool Service { get; set; }
/// <summary>
/// Round-trip time to the client.
/// Mirrors Go <c>RTT time.Duration</c> in events.go.
/// Added here to support <see cref="ServiceLatency.NATSTotalTime"/>.
/// </summary>
public TimeSpan Rtt { get; set; }
}
// ============================================================================

View File

@@ -0,0 +1,169 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from parse utility functions in server/opts.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
/// </summary>
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
{
private static readonly Regex Pattern = new(
@"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
=> writer.WriteStringValue(FormatDuration(value));
/// <summary>
/// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
/// </summary>
public static TimeSpan Parse(string s)
{
if (string.IsNullOrWhiteSpace(s))
throw new FormatException("Duration string is empty");
// Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
var m = Pattern.Match(s);
if (m.Success && m.Value.Length > 0)
{
var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
return new TimeSpan(0, hours, minutes, seconds, ms)
+ TimeSpan.FromMicroseconds(us)
+ TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
}
// Try .NET TimeSpan.Parse (handles "hh:mm:ss")
if (TimeSpan.TryParse(s, out var ts)) return ts;
throw new FormatException($"Cannot parse duration string: \"{s}\"");
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns";
if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s";
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
}
}
/// <summary>
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
/// </summary>
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
{
public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString()?.Trim() ?? string.Empty;
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
{
"1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
"1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
_ => throw new FormatException($"Unknown TLS version: \"{s}\""),
};
}
/// <summary>
/// Validates and normalises a NATS URL string (nats://host:port).
/// Mirrors Go <c>parseURL</c> in server/opts.go.
/// </summary>
public sealed class NatsUrlJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? string.Empty;
return Normalise(raw);
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
=> writer.WriteStringValue(value);
public static string Normalise(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
url = url.Trim();
if (!url.Contains("://")) url = "nats://" + url;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new FormatException($"Invalid NATS URL: \"{url}\"");
return uri.ToString().TrimEnd('/');
}
}
/// <summary>
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
/// </summary>
public sealed class StorageSizeJsonConverter : JsonConverter<long>
{
private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
{
return reader.GetInt64();
}
var raw = reader.GetString() ?? "0";
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
public static long Parse(string s)
{
if (long.TryParse(s, out var n)) return n;
var m = Pattern.Match(s.Trim());
if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
var num = double.Parse(m.Groups[1].Value);
var suffix = m.Groups[2].Value.ToUpperInvariant();
return suffix switch
{
"K" or "KB" => (long)(num * 1024),
"M" or "MB" => (long)(num * 1024 * 1024),
"G" or "GB" => (long)(num * 1024 * 1024 * 1024),
"T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
_ => (long)num,
};
}
}

View File

@@ -0,0 +1,996 @@
// Copyright 2017-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/reload.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
// =============================================================================
// IReloadOption — mirrors Go `option` interface in reload.go
// =============================================================================
/// <summary>
/// Represents a hot-swappable configuration setting that can be applied to a
/// running server. Mirrors Go <c>option</c> interface in server/reload.go.
/// </summary>
public interface IReloadOption
{
/// <summary>Apply this option to the running server.</summary>
void Apply(NatsServer server);
/// <summary>Returns true if this option requires reloading the logger.</summary>
bool IsLoggingChange();
/// <summary>
/// Returns true if this option requires reloading the cached trace level.
/// Clients store trace level separately.
/// </summary>
bool IsTraceLevelChange();
/// <summary>Returns true if this option requires reloading authorization.</summary>
bool IsAuthChange();
/// <summary>Returns true if this option requires reloading TLS.</summary>
bool IsTlsChange();
/// <summary>Returns true if this option requires reloading cluster permissions.</summary>
bool IsClusterPermsChange();
/// <summary>
/// Returns true if this option requires special handling for changes in
/// cluster pool size or accounts list.
/// </summary>
bool IsClusterPoolSizeOrAccountsChange();
/// <summary>
/// Returns true if this option indicates a change in the server's JetStream config.
/// Account changes are handled separately in reloadAuthorization.
/// </summary>
bool IsJetStreamChange();
/// <summary>Returns true if this change requires publishing the server's statz.</summary>
bool IsStatszChange();
}
// =============================================================================
// NoopReloadOption — mirrors Go `noopOption` struct in reload.go
// =============================================================================
/// <summary>
/// Base class providing no-op implementations for all <see cref="IReloadOption"/>
/// methods. Concrete option types override only the methods relevant to them.
/// Mirrors Go <c>noopOption</c> struct in server/reload.go.
/// </summary>
public abstract class NoopReloadOption : IReloadOption
{
/// <inheritdoc/>
public virtual void Apply(NatsServer server) { }
/// <inheritdoc/>
public virtual bool IsLoggingChange() => false;
/// <inheritdoc/>
public virtual bool IsTraceLevelChange() => false;
/// <inheritdoc/>
public virtual bool IsAuthChange() => false;
/// <inheritdoc/>
public virtual bool IsTlsChange() => false;
/// <inheritdoc/>
public virtual bool IsClusterPermsChange() => false;
/// <inheritdoc/>
public virtual bool IsClusterPoolSizeOrAccountsChange() => false;
/// <inheritdoc/>
public virtual bool IsJetStreamChange() => false;
/// <inheritdoc/>
public virtual bool IsStatszChange() => false;
}
// =============================================================================
// Intermediate base classes (mirrors Go loggingOption / traceLevelOption)
// =============================================================================
/// <summary>
/// Base for all logging-related reload options.
/// Mirrors Go <c>loggingOption</c> struct.
/// </summary>
internal abstract class LoggingReloadOption : NoopReloadOption
{
public override bool IsLoggingChange() => true;
}
/// <summary>
/// Base for all trace-level reload options.
/// Mirrors Go <c>traceLevelOption</c> struct.
/// </summary>
internal abstract class TraceLevelReloadOption : LoggingReloadOption
{
public override bool IsTraceLevelChange() => true;
}
/// <summary>
/// Base for all authorization-related reload options.
/// Mirrors Go <c>authOption</c> struct.
/// </summary>
internal abstract class AuthReloadOption : NoopReloadOption
{
public override bool IsAuthChange() => true;
}
/// <summary>
/// Base for TLS reload options.
/// Mirrors Go <c>tlsOption</c> (as a base, not the concrete type).
/// </summary>
internal abstract class TlsBaseReloadOption : NoopReloadOption
{
public override bool IsTlsChange() => true;
}
// =============================================================================
// Logging & Trace option types
// =============================================================================
/// <summary>
/// Reload option for the <c>trace</c> setting.
/// Mirrors Go <c>traceOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>trace_verbose</c> setting.
/// Mirrors Go <c>traceVerboseOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceVerboseReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceVerboseReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace_verbose = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>trace_headers</c> setting.
/// Mirrors Go <c>traceHeadersOption</c> struct in reload.go.
/// </summary>
internal sealed class TraceHeadersReloadOption : TraceLevelReloadOption
{
private readonly bool _newValue;
public TraceHeadersReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: trace_headers = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>debug</c> setting.
/// Mirrors Go <c>debugOption</c> struct in reload.go.
/// </summary>
internal sealed class DebugReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public DebugReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
server.Noticef("Reloaded: debug = {0}", _newValue);
// TODO: session 13 — call server.ReloadDebugRaftNodes(_newValue)
}
}
/// <summary>
/// Reload option for the <c>logtime</c> setting.
/// Mirrors Go <c>logtimeOption</c> struct in reload.go.
/// </summary>
internal sealed class LogtimeReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public LogtimeReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: logtime = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>logtime_utc</c> setting.
/// Mirrors Go <c>logtimeUTCOption</c> struct in reload.go.
/// </summary>
internal sealed class LogtimeUtcReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public LogtimeUtcReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: logtime_utc = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>log_file</c> setting.
/// Mirrors Go <c>logfileOption</c> struct in reload.go.
/// </summary>
internal sealed class LogFileReloadOption : LoggingReloadOption
{
private readonly string _newValue;
public LogFileReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: log_file = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>syslog</c> setting.
/// Mirrors Go <c>syslogOption</c> struct in reload.go.
/// </summary>
internal sealed class SyslogReloadOption : LoggingReloadOption
{
private readonly bool _newValue;
public SyslogReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: syslog = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>remote_syslog</c> setting.
/// Mirrors Go <c>remoteSyslogOption</c> struct in reload.go.
/// </summary>
internal sealed class RemoteSyslogReloadOption : LoggingReloadOption
{
private readonly string _newValue;
public RemoteSyslogReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: remote_syslog = {0}", _newValue);
}
// =============================================================================
// TLS option types
// =============================================================================
/// <summary>
/// Reload option for the <c>tls</c> setting.
/// Mirrors Go <c>tlsOption</c> struct in reload.go.
/// The TLS config is stored as <c>object?</c> because the full
/// <c>TlsConfig</c> type is not yet ported.
/// TODO: session 13 — replace object? with the ported TlsConfig type.
/// </summary>
internal sealed class TlsReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object? with ported TlsConfig type
private readonly object? _newValue;
public TlsReloadOption(object? newValue) => _newValue = newValue;
public override bool IsTlsChange() => true;
public override void Apply(NatsServer server)
{
var message = _newValue is null ? "disabled" : "enabled";
server.Noticef("Reloaded: tls = {0}", message);
// TODO: session 13 — update server.Info.TLSRequired / TLSVerify
}
}
/// <summary>
/// Reload option for the TLS <c>timeout</c> setting.
/// Mirrors Go <c>tlsTimeoutOption</c> struct in reload.go.
/// </summary>
internal sealed class TlsTimeoutReloadOption : NoopReloadOption
{
private readonly double _newValue;
public TlsTimeoutReloadOption(double newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: tls timeout = {0}", _newValue);
}
/// <summary>
/// Reload option for the TLS <c>pinned_certs</c> setting.
/// Mirrors Go <c>tlsPinnedCertOption</c> struct in reload.go.
/// The pinned cert set is stored as <c>object?</c> pending the port
/// of the PinnedCertSet type.
/// TODO: session 13 — replace object? with ported PinnedCertSet type.
/// </summary>
internal sealed class TlsPinnedCertReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object? with ported PinnedCertSet type
private readonly object? _newValue;
public TlsPinnedCertReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: pinned_certs");
}
/// <summary>
/// Reload option for the TLS <c>handshake_first</c> setting.
/// Mirrors Go <c>tlsHandshakeFirst</c> struct in reload.go.
/// </summary>
internal sealed class TlsHandshakeFirstReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public TlsHandshakeFirstReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: Client TLS handshake first: {0}", _newValue);
}
/// <summary>
/// Reload option for the TLS <c>handshake_first_fallback</c> delay setting.
/// Mirrors Go <c>tlsHandshakeFirstFallback</c> struct in reload.go.
/// </summary>
internal sealed class TlsHandshakeFirstFallbackReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public TlsHandshakeFirstFallbackReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: Client TLS handshake first fallback delay: {0}", _newValue);
}
// =============================================================================
// Authorization option types
// =============================================================================
/// <summary>
/// Reload option for the <c>username</c> authorization setting.
/// Mirrors Go <c>usernameOption</c> struct in reload.go.
/// </summary>
internal sealed class UsernameReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization username");
}
/// <summary>
/// Reload option for the <c>password</c> authorization setting.
/// Mirrors Go <c>passwordOption</c> struct in reload.go.
/// </summary>
internal sealed class PasswordReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization password");
}
/// <summary>
/// Reload option for the <c>token</c> authorization setting.
/// Mirrors Go <c>authorizationOption</c> struct in reload.go.
/// </summary>
internal sealed class AuthorizationReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization token");
}
/// <summary>
/// Reload option for the authorization <c>timeout</c> setting.
/// Note: this is a NoopReloadOption (not auth) because authorization
/// will be reloaded with options separately.
/// Mirrors Go <c>authTimeoutOption</c> struct in reload.go.
/// </summary>
internal sealed class AuthTimeoutReloadOption : NoopReloadOption
{
private readonly double _newValue;
public AuthTimeoutReloadOption(double newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization timeout = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>tags</c> setting.
/// Mirrors Go <c>tagsOption</c> struct in reload.go.
/// </summary>
internal sealed class TagsReloadOption : NoopReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: tags");
public override bool IsStatszChange() => true;
}
/// <summary>
/// Reload option for the <c>metadata</c> setting.
/// Mirrors Go <c>metadataOption</c> struct in reload.go.
/// </summary>
internal sealed class MetadataReloadOption : NoopReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: metadata");
public override bool IsStatszChange() => true;
}
/// <summary>
/// Reload option for the authorization <c>users</c> setting.
/// Mirrors Go <c>usersOption</c> struct in reload.go.
/// </summary>
internal sealed class UsersReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization users");
}
/// <summary>
/// Reload option for the authorization <c>nkeys</c> setting.
/// Mirrors Go <c>nkeysOption</c> struct in reload.go.
/// </summary>
internal sealed class NkeysReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: authorization nkey users");
}
/// <summary>
/// Reload option for the <c>accounts</c> setting.
/// Mirrors Go <c>accountsOption</c> struct in reload.go.
/// </summary>
internal sealed class AccountsReloadOption : AuthReloadOption
{
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: accounts");
}
// =============================================================================
// Cluster option types
// =============================================================================
/// <summary>
/// Reload option for the <c>cluster</c> setting.
/// Stores cluster options as <c>object?</c> pending the port of <c>ClusterOpts</c>.
/// Mirrors Go <c>clusterOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported ClusterOpts type.
/// </summary>
internal sealed class ClusterReloadOption : AuthReloadOption
{
// TODO: session 13 — replace object? with ported ClusterOpts type
private readonly object? _newValue;
private readonly bool _permsChanged;
private readonly bool _poolSizeChanged;
private readonly bool _compressChanged;
private readonly string[] _accsAdded;
private readonly string[] _accsRemoved;
public ClusterReloadOption(
object? newValue,
bool permsChanged,
bool poolSizeChanged,
bool compressChanged,
string[] accsAdded,
string[] accsRemoved)
{
_newValue = newValue;
_permsChanged = permsChanged;
_poolSizeChanged = poolSizeChanged;
_compressChanged = compressChanged;
_accsAdded = accsAdded;
_accsRemoved = accsRemoved;
}
public override bool IsClusterPermsChange()
=> _permsChanged;
public override bool IsClusterPoolSizeOrAccountsChange()
=> _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0;
public override void Apply(NatsServer server)
{
// TODO: session 13 — full cluster apply logic (TLS, route info, compression)
server.Noticef("Reloaded: cluster");
}
}
/// <summary>
/// Reload option for the cluster <c>routes</c> setting.
/// Routes to add/remove are stored as <c>object[]</c> pending the port of URL handling.
/// Mirrors Go <c>routesOption</c> struct in reload.go.
/// TODO: session 13 — replace object[] with Uri[] when route types are ported.
/// </summary>
internal sealed class RoutesReloadOption : NoopReloadOption
{
// TODO: session 13 — replace object[] with Uri[] when route URL types are ported
private readonly object[] _add;
private readonly object[] _remove;
public RoutesReloadOption(object[] add, object[] remove)
{
_add = add;
_remove = remove;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — add/remove routes, update varzUpdateRouteURLs
server.Noticef("Reloaded: cluster routes");
}
}
// =============================================================================
// Connection limit & network option types
// =============================================================================
/// <summary>
/// Reload option for the <c>max_connections</c> setting.
/// Mirrors Go <c>maxConnOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxConnReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxConnReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — close random connections if over limit
server.Noticef("Reloaded: max_connections = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>pid_file</c> setting.
/// Mirrors Go <c>pidFileOption</c> struct in reload.go.
/// </summary>
internal sealed class PidFileReloadOption : NoopReloadOption
{
private readonly string _newValue;
public PidFileReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
if (string.IsNullOrEmpty(_newValue))
return;
// TODO: session 13 — call server.LogPid()
server.Noticef("Reloaded: pid_file = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>ports_file_dir</c> setting.
/// Mirrors Go <c>portsFileDirOption</c> struct in reload.go.
/// </summary>
internal sealed class PortsFileDirReloadOption : NoopReloadOption
{
private readonly string _oldValue;
private readonly string _newValue;
public PortsFileDirReloadOption(string oldValue, string newValue)
{
_oldValue = oldValue;
_newValue = newValue;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts()
server.Noticef("Reloaded: ports_file_dir = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>max_control_line</c> setting.
/// Mirrors Go <c>maxControlLineOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxControlLineReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxControlLineReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update mcl on each connected client
server.Noticef("Reloaded: max_control_line = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>max_payload</c> setting.
/// Mirrors Go <c>maxPayloadOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxPayloadReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxPayloadReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update server info and mpay on each client
server.Noticef("Reloaded: max_payload = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the <c>ping_interval</c> setting.
/// Mirrors Go <c>pingIntervalOption</c> struct in reload.go.
/// </summary>
internal sealed class PingIntervalReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public PingIntervalReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: ping_interval = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>ping_max</c> setting.
/// Mirrors Go <c>maxPingsOutOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxPingsOutReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxPingsOutReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: ping_max = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>write_deadline</c> setting.
/// Mirrors Go <c>writeDeadlineOption</c> struct in reload.go.
/// </summary>
internal sealed class WriteDeadlineReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public WriteDeadlineReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: write_deadline = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>client_advertise</c> setting.
/// Mirrors Go <c>clientAdvertiseOption</c> struct in reload.go.
/// </summary>
internal sealed class ClientAdvertiseReloadOption : NoopReloadOption
{
private readonly string _newValue;
public ClientAdvertiseReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.SetInfoHostPort()
server.Noticef("Reload: client_advertise = {0}", _newValue);
}
}
// =============================================================================
// JetStream option type
// =============================================================================
/// <summary>
/// Reload option for the <c>jetstream</c> setting.
/// Mirrors Go <c>jetStreamOption</c> struct in reload.go.
/// </summary>
internal sealed class JetStreamReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public JetStreamReloadOption(bool newValue) => _newValue = newValue;
public override bool IsJetStreamChange() => true;
public override bool IsStatszChange() => true;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: JetStream");
}
// =============================================================================
// Miscellaneous option types
// =============================================================================
/// <summary>
/// Reload option for the <c>default_sentinel</c> setting.
/// Mirrors Go <c>defaultSentinelOption</c> struct in reload.go.
/// </summary>
internal sealed class DefaultSentinelReloadOption : NoopReloadOption
{
private readonly string _newValue;
public DefaultSentinelReloadOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: default_sentinel = {0}", _newValue);
}
/// <summary>
/// Reload option for the OCSP setting.
/// The new value is stored as <c>object?</c> pending the port of <c>OCSPConfig</c>.
/// Mirrors Go <c>ocspOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported OcspConfig type.
/// </summary>
internal sealed class OcspReloadOption : TlsBaseReloadOption
{
// TODO: session 13 — replace object? with ported OcspConfig type
private readonly object? _newValue;
public OcspReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: OCSP");
}
/// <summary>
/// Reload option for the OCSP response cache setting.
/// The new value is stored as <c>object?</c> pending the port of
/// <c>OCSPResponseCacheConfig</c>.
/// Mirrors Go <c>ocspResponseCacheOption</c> struct in reload.go.
/// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type.
/// </summary>
internal sealed class OcspResponseCacheReloadOption : TlsBaseReloadOption
{
// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type
private readonly object? _newValue;
public OcspResponseCacheReloadOption(object? newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded OCSP peer cache");
}
/// <summary>
/// Reload option for the <c>connect_error_reports</c> setting.
/// Mirrors Go <c>connectErrorReports</c> struct in reload.go.
/// </summary>
internal sealed class ConnectErrorReportsReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ConnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: connect_error_reports = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>reconnect_error_reports</c> setting.
/// Mirrors Go <c>reconnectErrorReports</c> struct in reload.go.
/// </summary>
internal sealed class ReconnectErrorReportsReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ReconnectErrorReportsReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: reconnect_error_reports = {0}", _newValue);
}
/// <summary>
/// Reload option for the <c>max_traced_msg_len</c> setting.
/// Mirrors Go <c>maxTracedMsgLenOption</c> struct in reload.go.
/// </summary>
internal sealed class MaxTracedMsgLenReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MaxTracedMsgLenReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — update server.Opts.MaxTracedMsgLen under lock
server.Noticef("Reloaded: max_traced_msg_len = {0}", _newValue);
}
}
// =============================================================================
// MQTT option types
// =============================================================================
/// <summary>
/// Reload option for the MQTT <c>ack_wait</c> setting.
/// Mirrors Go <c>mqttAckWaitReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttAckWaitReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public MqttAckWaitReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT ack_wait = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>max_ack_pending</c> setting.
/// Mirrors Go <c>mqttMaxAckPendingReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttMaxAckPendingReloadOption : NoopReloadOption
{
private readonly ushort _newValue;
public MqttMaxAckPendingReloadOption(ushort newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.MqttUpdateMaxAckPending(_newValue)
server.Noticef("Reloaded: MQTT max_ack_pending = {0}", _newValue);
}
}
/// <summary>
/// Reload option for the MQTT <c>stream_replicas</c> setting.
/// Mirrors Go <c>mqttStreamReplicasReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttStreamReplicasReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MqttStreamReplicasReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT stream_replicas = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_replicas</c> setting.
/// Mirrors Go <c>mqttConsumerReplicasReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttConsumerReplicasReloadOption : NoopReloadOption
{
private readonly int _newValue;
public MqttConsumerReplicasReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_replicas = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_memory_storage</c> setting.
/// Mirrors Go <c>mqttConsumerMemoryStorageReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttConsumerMemoryStorageReloadOption : NoopReloadOption
{
private readonly bool _newValue;
public MqttConsumerMemoryStorageReloadOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_memory_storage = {0}", _newValue);
}
/// <summary>
/// Reload option for the MQTT <c>consumer_inactive_threshold</c> setting.
/// Mirrors Go <c>mqttInactiveThresholdReload</c> struct in reload.go.
/// </summary>
internal sealed class MqttInactiveThresholdReloadOption : NoopReloadOption
{
private readonly TimeSpan _newValue;
public MqttInactiveThresholdReloadOption(TimeSpan newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: MQTT consumer_inactive_threshold = {0}", _newValue);
}
// =============================================================================
// Profiling option type
// =============================================================================
/// <summary>
/// Reload option for the <c>prof_block_rate</c> setting.
/// Mirrors Go <c>profBlockRateReload</c> struct in reload.go.
/// </summary>
internal sealed class ProfBlockRateReloadOption : NoopReloadOption
{
private readonly int _newValue;
public ProfBlockRateReloadOption(int newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
{
// TODO: session 13 — call server.SetBlockProfileRate(_newValue)
server.Noticef("Reloaded: prof_block_rate = {0}", _newValue);
}
}
// =============================================================================
// LeafNode option type
// =============================================================================
/// <summary>
/// Reload option for leaf-node settings (TLS handshake-first, compression, disabled).
/// Mirrors Go <c>leafNodeOption</c> struct in reload.go.
/// </summary>
internal sealed class LeafNodeReloadOption : NoopReloadOption
{
private readonly bool _tlsFirstChanged;
private readonly bool _compressionChanged;
private readonly bool _disabledChanged;
public LeafNodeReloadOption(bool tlsFirstChanged, bool compressionChanged, bool disabledChanged)
{
_tlsFirstChanged = tlsFirstChanged;
_compressionChanged = compressionChanged;
_disabledChanged = disabledChanged;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply()
if (_tlsFirstChanged)
server.Noticef("Reloaded: LeafNode TLS HandshakeFirst settings");
if (_compressionChanged)
server.Noticef("Reloaded: LeafNode compression settings");
if (_disabledChanged)
server.Noticef("Reloaded: LeafNode disabled/enabled state");
}
}
// =============================================================================
// NoFastProducerStall option type
// =============================================================================
/// <summary>
/// Reload option for the <c>no_fast_producer_stall</c> setting.
/// Mirrors Go <c>noFastProdStallReload</c> struct in reload.go.
/// </summary>
internal sealed class NoFastProducerStallReloadOption : NoopReloadOption
{
private readonly bool _noStall;
public NoFastProducerStallReloadOption(bool noStall) => _noStall = noStall;
public override void Apply(NatsServer server)
{
var not = _noStall ? "not " : string.Empty;
server.Noticef("Reloaded: fast producers will {0}be stalled", not);
}
}
// =============================================================================
// Proxies option type
// =============================================================================
/// <summary>
/// Reload option for the <c>proxies</c> trusted keys setting.
/// Mirrors Go <c>proxiesReload</c> struct in reload.go.
/// </summary>
internal sealed class ProxiesReloadOption : NoopReloadOption
{
private readonly string[] _add;
private readonly string[] _del;
public ProxiesReloadOption(string[] add, string[] del)
{
_add = add;
_del = del;
}
public override void Apply(NatsServer server)
{
// TODO: session 13 — disconnect proxied clients for removed keys,
// call server.ProcessProxiesTrustedKeys()
if (_del.Length > 0)
server.Noticef("Reloaded: proxies trusted keys {0} were removed", string.Join(", ", _del));
if (_add.Length > 0)
server.Noticef("Reloaded: proxies trusted keys {0} were added", string.Join(", ", _add));
}
}
// =============================================================================
// ConfigReloader — stub for server/reload.go Reload() / ReloadOptions()
// =============================================================================
/// <summary>
/// Stub for the configuration reloader.
/// Full reload logic (diffOptions, applyOptions, recheckPinnedCerts) will be
/// implemented in a future session.
/// Mirrors Go <c>Server.Reload()</c> and <c>Server.ReloadOptions()</c> in
/// server/reload.go.
/// </summary>
internal sealed class ConfigReloader
{
// TODO: session 13 — full reload logic
// Mirrors Go server.Reload() / server.ReloadOptions() in server/reload.go
/// <summary>
/// Stub: read and apply the server config file.
/// Returns null on success; a non-null Exception describes the failure.
/// </summary>
public Exception? Reload(NatsServer server) => null;
}

View File

@@ -0,0 +1,132 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/opts.go in the NATS server Go source.
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
/// and all <c>parse*</c> helper functions in server/opts.go.
/// </summary>
public static class ServerOptionsConfiguration
{
/// <summary>
/// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
/// NATS-specific JSON converters.
/// </summary>
public static JsonSerializerOptions CreateJsonOptions()
{
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opts.Converters.Add(new NatsDurationJsonConverter());
opts.Converters.Add(new TlsVersionJsonConverter());
opts.Converters.Add(new StorageSizeJsonConverter());
return opts;
}
/// <summary>
/// Reads a JSON file at <paramref name="path"/> and returns a bound
/// <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
/// </summary>
public static ServerOptions ProcessConfigFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Configuration file not found: {path}", path);
var json = File.ReadAllText(path, Encoding.UTF8);
return ProcessConfigString(json);
}
/// <summary>
/// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>Options.ProcessConfigString</c>.
/// </summary>
public static ServerOptions ProcessConfigString(string json)
{
ArgumentNullException.ThrowIfNullOrEmpty(json);
var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
?? new ServerOptions();
PostProcess(opts);
return opts;
}
/// <summary>
/// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
/// to a <see cref="ServerOptions"/> instance.
/// The configuration section should be the root or a named section such as "NatsServer".
/// </summary>
public static void BindConfiguration(IConfiguration config, ServerOptions target)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(target);
config.Bind(target);
PostProcess(target);
}
// -------------------------------------------------------------------------
// Post-processing
// -------------------------------------------------------------------------
/// <summary>
/// Applies defaults and cross-field validation after loading.
/// Mirrors the end of <c>Options.processConfigFile</c> and
/// <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void PostProcess(ServerOptions opts)
{
// Apply default port if not set.
if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;
// Apply default host if not set.
if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;
// Apply default max payload.
if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;
// Apply default auth timeout.
if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout;
// Apply default max control line size.
if (opts.MaxControlLine == 0) opts.MaxControlLine = ServerConstants.MaxControlLineSize;
// Ensure SystemAccount defaults if not set.
ConfigureSystemAccount(opts);
}
/// <summary>
/// Sets up the system account name from options.
/// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void ConfigureSystemAccount(ServerOptions opts)
{
// If system account already set, nothing to do.
if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
// Respect explicit opt-out.
if (opts.NoSystemAccount) return;
// Default to "$SYS" if not explicitly disabled.
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
}
}

View File

@@ -0,0 +1,778 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/events.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// System subject constants
// Mirrors the const block at the top of server/events.go.
// ============================================================================
/// <summary>
/// System-account subject templates and constants used for internal NATS server
/// event routing. All format-string fields use <see cref="string.Format"/> with
/// the appropriate server/account ID substituted at call time.
/// Mirrors the const block in server/events.go.
/// </summary>
public static class SystemSubjects
{
// Account lookup / claims
public const string AccLookupReqSubj = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.LOOKUP";
public const string AccPackReqSubj = "$SYS.REQ.CLAIMS.PACK";
public const string AccListReqSubj = "$SYS.REQ.CLAIMS.LIST";
public const string AccClaimsReqSubj = "$SYS.REQ.CLAIMS.UPDATE";
public const string AccDeleteReqSubj = "$SYS.REQ.CLAIMS.DELETE";
// Connection events
public const string ConnectEventSubj = "$SYS.ACCOUNT.{0}.CONNECT";
public const string DisconnectEventSubj = "$SYS.ACCOUNT.{0}.DISCONNECT";
// Direct request routing
public const string AccDirectReqSubj = "$SYS.REQ.ACCOUNT.{0}.{1}";
public const string AccPingReqSubj = "$SYS.REQ.ACCOUNT.PING.{0}";
// Account update events (both old and new forms kept for backward compatibility)
public const string AccUpdateEventSubjOld = "$SYS.ACCOUNT.{0}.CLAIMS.UPDATE";
public const string AccUpdateEventSubjNew = "$SYS.REQ.ACCOUNT.{0}.CLAIMS.UPDATE";
public const string ConnsRespSubj = "$SYS._INBOX_.{0}";
public const string AccConnsEventSubjNew = "$SYS.ACCOUNT.{0}.SERVER.CONNS";
public const string AccConnsEventSubjOld = "$SYS.SERVER.ACCOUNT.{0}.CONNS"; // backward compat
// Server lifecycle events
public const string LameDuckEventSubj = "$SYS.SERVER.{0}.LAMEDUCK";
public const string ShutdownEventSubj = "$SYS.SERVER.{0}.SHUTDOWN";
// Client control
public const string ClientKickReqSubj = "$SYS.REQ.SERVER.{0}.KICK";
public const string ClientLdmReqSubj = "$SYS.REQ.SERVER.{0}.LDM";
// Auth error events
public const string AuthErrorEventSubj = "$SYS.SERVER.{0}.CLIENT.AUTH.ERR";
public const string AuthErrorAccountEventSubj = "$SYS.ACCOUNT.CLIENT.AUTH.ERR";
// Stats
public const string ServerStatsSubj = "$SYS.SERVER.{0}.STATSZ";
public const string ServerDirectReqSubj = "$SYS.REQ.SERVER.{0}.{1}";
public const string ServerPingReqSubj = "$SYS.REQ.SERVER.PING.{0}";
public const string ServerStatsPingReqSubj = "$SYS.REQ.SERVER.PING"; // deprecated; use STATSZ variant
public const string ServerReloadReqSubj = "$SYS.REQ.SERVER.{0}.RELOAD";
// Leaf node
public const string LeafNodeConnectEventSubj = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT"; // internal only
// Latency
public const string RemoteLatencyEventSubj = "$SYS.LATENCY.M2.{0}";
public const string InboxRespSubj = "$SYS._INBOX.{0}.{1}";
// User info
public const string UserDirectInfoSubj = "$SYS.REQ.USER.INFO";
public const string UserDirectReqSubj = "$SYS.REQ.USER.{0}.INFO";
// Subscription count
public const string AccNumSubsReqSubj = "$SYS.REQ.ACCOUNT.NSUBS";
// Debug
public const string AccSubsSubj = "$SYS.DEBUG.SUBSCRIBERS";
// OCSP peer events
public const string OcspPeerRejectEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT";
public const string OcspPeerChainlinkInvalidEventSubj = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID";
// Parsing constants (token indexes / counts)
public const int AccLookupReqTokens = 6;
public const int ShutdownEventTokens = 4;
public const int ServerSubjectIndex = 2;
public const int AccUpdateTokensNew = 6;
public const int AccUpdateTokensOld = 5;
public const int AccUpdateAccIdxOld = 2;
public const int AccReqTokens = 5;
public const int AccReqAccIndex = 3;
}
// ============================================================================
// Advisory message type schema URI constants
// Mirrors the const string variables near each struct in server/events.go.
// ============================================================================
public static class EventMsgTypes
{
public const string ConnectEventMsgType = "io.nats.server.advisory.v1.client_connect";
public const string DisconnectEventMsgType = "io.nats.server.advisory.v1.client_disconnect";
public const string OcspPeerRejectEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_reject";
public const string OcspPeerChainlinkInvalidEventMsgType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid";
public const string AccountNumConnsMsgType = "io.nats.server.advisory.v1.account_connections";
}
// ============================================================================
// Heartbeat / rate-limit intervals (mirrors package-level vars in events.go)
// ============================================================================
/// <summary>
/// Default timing constants for server event heartbeats and rate limiting.
/// Mirrors Go package-level <c>var</c> declarations in events.go.
/// </summary>
public static class EventIntervals
{
/// <summary>Default HB interval for events. Mirrors Go <c>eventsHBInterval = 30s</c>.</summary>
public static readonly TimeSpan EventsHbInterval = TimeSpan.FromSeconds(30);
/// <summary>Default HB interval for stats. Mirrors Go <c>statsHBInterval = 10s</c>.</summary>
public static readonly TimeSpan StatsHbInterval = TimeSpan.FromSeconds(10);
/// <summary>Minimum interval between statsz publishes. Mirrors Go <c>defaultStatszRateLimit = 1s</c>.</summary>
public static readonly TimeSpan DefaultStatszRateLimit = TimeSpan.FromSeconds(1);
}
// ============================================================================
// SysMsgHandler — delegate for internal system message dispatch
// Mirrors Go <c>sysMsgHandler</c> func type in events.go.
// ============================================================================
/// <summary>
/// Callback invoked when an internal system message is dispatched.
/// Mirrors Go <c>sysMsgHandler</c> in server/events.go.
/// </summary>
public delegate void SysMsgHandler(
Subscription sub,
NatsClient client,
Account acc,
string subject,
string reply,
byte[] hdr,
byte[] msg);
// ============================================================================
// InSysMsg — queued internal system message
// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Holds a system message queued for internal delivery, avoiding the
/// route/gateway path.
/// Mirrors Go <c>inSysMsg</c> struct in server/events.go.
/// </summary>
internal sealed class InSysMsg
{
public Subscription? Sub { get; set; }
public NatsClient? Client { get; set; }
public Account? Acc { get; set; }
public string Subject { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public byte[]? Hdr { get; set; }
public byte[]? Msg { get; set; }
public SysMsgHandler? Cb { get; set; }
}
// ============================================================================
// InternalState — server internal/sys state
// Mirrors Go <c>internal</c> struct in server/events.go.
// Uses Monitor lock (lock(this)) in place of Go's embedded sync.Mutex.
// ============================================================================
/// <summary>
/// Holds all internal state used by the server's system-account event
/// machinery: account reference, client, send/receive queues, timers,
/// reply handlers, and heartbeat configuration.
/// Mirrors Go <c>internal</c> struct in server/events.go.
/// </summary>
internal sealed class InternalState
{
// ---- identity / sequencing ----
public Account? Account { get; set; }
public NatsClient? Client { get; set; }
public ulong Seq { get; set; }
public int Sid { get; set; }
// ---- remote server tracking ----
/// <summary>Map of server ID → serverUpdate. Mirrors Go <c>servers map[string]*serverUpdate</c>.</summary>
public Dictionary<string, ServerUpdate> Servers { get; set; } = new();
// ---- timers ----
/// <summary>Sweeper timer. Mirrors Go <c>sweeper *time.Timer</c>.</summary>
public System.Threading.Timer? Sweeper { get; set; }
/// <summary>Stats heartbeat timer. Mirrors Go <c>stmr *time.Timer</c>.</summary>
public System.Threading.Timer? StatsMsgTimer { get; set; }
// ---- reply handlers ----
/// <summary>
/// Pending reply subject → handler map.
/// Mirrors Go <c>replies map[string]msgHandler</c>.
/// </summary>
public Dictionary<string, Action<Subscription, NatsClient, Account, string, string, byte[], byte[]>> Replies { get; set; } = new();
// ---- queues ----
/// <summary>Outbound message send queue. Mirrors Go <c>sendq *ipQueue[*pubMsg]</c>.</summary>
public IpQueue<PubMsg>? SendQueue { get; set; }
/// <summary>Inbound receive queue. Mirrors Go <c>recvq *ipQueue[*inSysMsg]</c>.</summary>
public IpQueue<InSysMsg>? RecvQueue { get; set; }
/// <summary>Priority receive queue for STATSZ/Pings. Mirrors Go <c>recvqp *ipQueue[*inSysMsg]</c>.</summary>
public IpQueue<InSysMsg>? RecvQueuePriority { get; set; }
/// <summary>Reset channel used to restart the send loop. Mirrors Go <c>resetCh chan struct{}</c>.</summary>
public System.Threading.Channels.Channel<bool>? ResetChannel { get; set; }
// ---- durations ----
/// <summary>Maximum time before an orphaned server entry is removed. Mirrors Go <c>orphMax</c>.</summary>
public TimeSpan OrphanMax { get; set; }
/// <summary>Interval at which orphan checks run. Mirrors Go <c>chkOrph</c>.</summary>
public TimeSpan CheckOrphan { get; set; }
/// <summary>Interval between statsz publishes. Mirrors Go <c>statsz</c>.</summary>
public TimeSpan StatszInterval { get; set; }
/// <summary>Client-facing statsz interval. Mirrors Go <c>cstatsz</c>.</summary>
public TimeSpan ClientStatszInterval { get; set; }
// ---- misc ----
/// <summary>Short hash used for shared-inbox routing. Mirrors Go <c>shash string</c>.</summary>
public string ShortHash { get; set; } = string.Empty;
/// <summary>Inbox prefix for this server's internal client. Mirrors Go <c>inboxPre string</c>.</summary>
public string InboxPrefix { get; set; } = string.Empty;
/// <summary>Subscription for remote stats. Mirrors Go <c>remoteStatsSub *subscription</c>.</summary>
public Subscription? RemoteStatsSub { get; set; }
/// <summary>Time of the last statsz publish. Mirrors Go <c>lastStatsz time.Time</c>.</summary>
public DateTime LastStatsz { get; set; }
}
// ============================================================================
// ServerUpdate — remote server heartbeat tracking
// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Tracks the sequence number and last-seen timestamp of a remote server's
/// system heartbeat. Used to detect orphaned servers.
/// Mirrors Go <c>serverUpdate</c> struct in server/events.go.
/// </summary>
internal sealed class ServerUpdate
{
/// <summary>Last sequence number received from the remote server.</summary>
public ulong Seq { get; set; }
/// <summary>Wall-clock time of the last heartbeat.</summary>
public DateTime LTime { get; set; }
}
// ============================================================================
// PubMsg — internally-queued outbound publish message
// Mirrors Go <c>pubMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Holds an outbound message that the server wants to publish via the internal
/// send loop, avoiding direct route/gateway writes.
/// Mirrors Go <c>pubMsg</c> struct in server/events.go.
/// </summary>
internal sealed class PubMsg
{
public NatsClient? Client { get; set; }
public string Subject { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public ServerInfo? Si { get; set; }
public byte[]? Hdr { get; set; }
public object? Msg { get; set; }
/// <summary>Compression type. TODO: session 12 — wire up compressionType enum.</summary>
public int Oct { get; set; }
public bool Echo { get; set; }
public bool Last { get; set; }
// TODO: session 12 — add pool return helper (returnToPool).
}
// ============================================================================
// DataStats — message/byte counter pair (sent or received)
// Mirrors Go <c>DataStats</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Reports how many messages and bytes were sent or received.
/// Optionally breaks out gateway, route, and leaf-node traffic.
/// Mirrors Go <c>DataStats</c> struct in server/events.go.
/// </summary>
public sealed class DataStats
{
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("bytes")]
public long Bytes { get; set; }
[JsonPropertyName("gateways")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Gateways { get; set; }
[JsonPropertyName("routes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Routes { get; set; }
[JsonPropertyName("leafs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public MsgBytes? Leafs { get; set; }
}
// ============================================================================
// MsgBytes — simple message+byte pair used inside DataStats
// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// A simple pair of message and byte counts, used as a nested breakdown
/// inside <see cref="DataStats"/>.
/// Mirrors Go <c>MsgBytes</c> struct in server/events.go.
/// </summary>
public sealed class MsgBytes
{
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("bytes")]
public long Bytes { get; set; }
}
// ============================================================================
// RouteStat / GatewayStat — per-route and per-gateway stat snapshots
// Mirrors Go <c>RouteStat</c> and <c>GatewayStat</c> in server/events.go.
// ============================================================================
/// <summary>
/// Statistics snapshot for a single cluster route connection.
/// Mirrors Go <c>RouteStat</c> in server/events.go.
/// </summary>
public sealed class RouteStat
{
[JsonPropertyName("rid")]
public ulong Id { get; set; }
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("pending")]
public int Pending { get; set; }
}
/// <summary>
/// Statistics snapshot for a gateway connection.
/// Mirrors Go <c>GatewayStat</c> in server/events.go.
/// </summary>
public sealed class GatewayStat
{
[JsonPropertyName("gwid")]
public ulong Id { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("inbound_connections")]
public int NumInbound { get; set; }
}
// ============================================================================
// ServerStatsMsg — periodic stats advisory published on $SYS.SERVER.{id}.STATSZ
// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
// ============================================================================
/// <summary>
/// Periodic advisory message containing the current server statistics.
/// Mirrors Go <c>ServerStatsMsg</c> struct in server/events.go.
/// </summary>
public sealed class ServerStatsMsg
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("statsz")]
public ServerStatsAdvisory Stats { get; set; } = new();
}
// ============================================================================
// ServerStatsAdvisory — the statsz payload inside ServerStatsMsg
// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
// NOTE: distinct from the internal ServerStats in NatsServerTypes.cs.
// ============================================================================
/// <summary>
/// The JSON-serialisable statistics payload included inside <see cref="ServerStatsMsg"/>.
/// Mirrors Go <c>ServerStats</c> struct (advisory form) in server/events.go.
/// </summary>
public sealed class ServerStatsAdvisory
{
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("mem")]
public long Mem { get; set; }
[JsonPropertyName("cores")]
public int Cores { get; set; }
[JsonPropertyName("cpu")]
public double Cpu { get; set; }
[JsonPropertyName("connections")]
public int Connections { get; set; }
[JsonPropertyName("total_connections")]
public ulong TotalConnections { get; set; }
[JsonPropertyName("active_accounts")]
public int ActiveAccounts { get; set; }
[JsonPropertyName("subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
[JsonPropertyName("slow_consumer_stats")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public SlowConsumersStats? SlowConsumersStats { get; set; }
[JsonPropertyName("stale_connections")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long StaleConnections { get; set; }
[JsonPropertyName("stale_connection_stats")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public StaleConnectionStats? StaleConnectionStats { get; set; }
[JsonPropertyName("stalled_clients")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long StalledClients { get; set; }
[JsonPropertyName("routes")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<RouteStat>? Routes { get; set; }
[JsonPropertyName("gateways")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<GatewayStat>? Gateways { get; set; }
[JsonPropertyName("active_servers")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int ActiveServers { get; set; }
/// <summary>JetStream stats. TODO: session 19 — wire JetStreamVarz type.</summary>
[JsonPropertyName("jetstream")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public object? JetStream { get; set; }
[JsonPropertyName("gomemlimit")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long MemLimit { get; set; }
[JsonPropertyName("gomaxprocs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int MaxProcs { get; set; }
}
// ============================================================================
// SlowConsumersStats / StaleConnectionStats — advisory-layer per-kind counters
// These are the JSON-serialisable variants used in ServerStatsAdvisory.
// The internal atomic counters live in NatsServerTypes.cs (SlowConsumerStats /
// StaleConnectionStats with different casing).
// ============================================================================
/// <summary>
/// Per-kind slow-consumer counters included in stats advisories.
/// Mirrors Go <c>SlowConsumersStats</c> in server/monitor.go.
/// </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>
/// Per-kind stale-connection counters included in stats advisories.
/// Mirrors Go <c>StaleConnectionStats</c> in server/monitor.go.
/// </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; }
}
// ============================================================================
// ConnectEventMsg / DisconnectEventMsg — client lifecycle advisories
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.CONNECT</c> when a new
/// client connection is established within a tracked account.
/// Mirrors Go <c>ConnectEventMsg</c> in server/events.go.
/// </summary>
public sealed class ConnectEventMsg : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public ClientInfo Client { get; set; } = new();
}
/// <summary>
/// Advisory published on <c>$SYS.ACCOUNT.{acc}.DISCONNECT</c> when a
/// previously-tracked client connection closes.
/// Mirrors Go <c>DisconnectEventMsg</c> in server/events.go.
/// </summary>
public sealed class DisconnectEventMsg : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("client")]
public ClientInfo 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;
}
// ============================================================================
// OCSPPeerRejectEventMsg / OCSPPeerChainlinkInvalidEventMsg
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory published when a peer TLS handshake is rejected due to OCSP
/// invalidation of the peer's leaf certificate.
/// Mirrors Go <c>OCSPPeerRejectEventMsg</c> in server/events.go.
/// </summary>
public sealed class OcspPeerRejectEventMsg : TypedEvent
{
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("peer")]
public CertInfo Peer { get; set; } = new();
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Advisory published when a certificate in a valid TLS chain is found to be
/// OCSP-invalid during a peer handshake. Both the invalid link and the
/// peer's leaf cert are included.
/// Mirrors Go <c>OCSPPeerChainlinkInvalidEventMsg</c> in server/events.go.
/// </summary>
public sealed class OcspPeerChainlinkInvalidEventMsg : TypedEvent
{
[JsonPropertyName("link")]
public CertInfo Link { get; set; } = new();
[JsonPropertyName("peer")]
public CertInfo Peer { get; set; } = new();
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
// ============================================================================
// AccountNumConns / AccountStat — account connection count advisories
// Mirrors Go structs in server/events.go.
// ============================================================================
/// <summary>
/// Advisory heartbeat published when the connection count for a tracked
/// account changes, or on a periodic schedule.
/// Mirrors Go <c>AccountNumConns</c> struct in server/events.go.
/// </summary>
public sealed class AccountNumConns : TypedEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
// Embedded AccountStat fields are inlined via composition.
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("conns")]
public int Conns { get; set; }
[JsonPropertyName("leafnodes")]
public int LeafNodes { get; set; }
[JsonPropertyName("total_conns")]
public int TotalConns { get; set; }
[JsonPropertyName("num_subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
}
/// <summary>
/// Statistic data common to <see cref="AccountNumConns"/> and account-level
/// monitoring responses.
/// Mirrors Go <c>AccountStat</c> struct in server/events.go.
/// </summary>
public sealed class AccountStat
{
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("conns")]
public int Conns { get; set; }
[JsonPropertyName("leafnodes")]
public int LeafNodes { get; set; }
[JsonPropertyName("total_conns")]
public int TotalConns { get; set; }
[JsonPropertyName("num_subscriptions")]
public uint NumSubs { get; set; }
[JsonPropertyName("sent")]
public DataStats Sent { get; set; } = new();
[JsonPropertyName("received")]
public DataStats Received { get; set; } = new();
[JsonPropertyName("slow_consumers")]
public long SlowConsumers { get; set; }
}
/// <summary>
/// Internal request payload sent when this server first starts tracking an
/// account, asking peer servers for their local connection counts.
/// Mirrors Go <c>accNumConnsReq</c> struct in server/events.go.
/// </summary>
internal sealed class AccNumConnsReq
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
}
// ============================================================================
// ServerCapability / ServerID — server identity and capability flags
// Mirrors Go types in server/events.go.
// ============================================================================
/// <summary>
/// Bit-flag capability set for a remote server.
/// Mirrors Go <c>ServerCapability uint64</c> in server/events.go.
/// </summary>
[Flags]
public enum ServerCapability : ulong
{
/// <summary>No capabilities.</summary>
None = 0,
/// <summary>Server has JetStream enabled. Mirrors Go <c>JetStreamEnabled</c>.</summary>
JetStreamEnabled = 1UL << 0,
/// <summary>New stream snapshot capability. Mirrors Go <c>BinaryStreamSnapshot</c>.</summary>
BinaryStreamSnapshot = 1UL << 1,
/// <summary>Move NRG traffic out of system account. Mirrors Go <c>AccountNRG</c>.</summary>
AccountNrg = 1UL << 2,
}
/// <summary>
/// Minimal static identity for a remote server (name, host, ID).
/// Mirrors Go <c>ServerID</c> struct in server/events.go.
/// </summary>
public sealed class ServerIdentity
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("host")]
public string Host { get; set; } = string.Empty;
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,384 @@
// Copyright 2018-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/gateway.go in the NATS server Go source.
using System.Threading;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Session 16: Gateways
// ============================================================================
/// <summary>
/// Represents the interest mode for a given account on a gateway connection.
/// Mirrors Go <c>GatewayInterestMode</c> byte iota in gateway.go.
/// Do not change values — they are part of the wire-level gossip protocol.
/// </summary>
public enum GatewayInterestMode : byte
{
/// <summary>
/// Default mode: the cluster sends to a gateway unless told there is no
/// interest (applies to plain subscribers only).
/// </summary>
Optimistic = 0,
/// <summary>
/// Transitioning: the gateway has been sending too many no-interest signals
/// and is switching to <see cref="InterestOnly"/> mode for this account.
/// </summary>
Transitioning = 1,
/// <summary>
/// Interest-only mode: the cluster has sent all its subscription interest;
/// the gateway only forwards messages when explicit interest is known.
/// </summary>
InterestOnly = 2,
/// <summary>
/// Internal sentinel used after a cache flush; not part of the public wire enum.
/// </summary>
CacheFlushed = 3,
}
/// <summary>
/// Server-level gateway state kept on the <see cref="NatsServer"/> instance.
/// Replaces the stub that was in <c>NatsServerTypes.cs</c>.
/// Mirrors Go <c>srvGateway</c> struct in gateway.go.
/// </summary>
internal sealed class SrvGateway
{
/// <summary>
/// Total number of queue subs across all remote gateways.
/// Accessed via <c>Interlocked</c> — must be 64-bit aligned.
/// </summary>
public long TotalQSubs;
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>
/// True if both a gateway name and port are configured (immutable after init).
/// </summary>
public bool Enabled { get; set; }
/// <summary>Name of this server's gateway cluster.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Outbound gateway connections keyed by remote gateway name.</summary>
public Dictionary<string, ClientConnection> Out { get; set; } = new();
/// <summary>
/// Outbound gateway connections in RTT order, used for message routing.
/// </summary>
public List<ClientConnection> Outo { get; set; } = [];
/// <summary>Inbound gateway connections keyed by connection ID.</summary>
public Dictionary<ulong, ClientConnection> In { get; set; } = new();
/// <summary>Per-remote-gateway configuration, keyed by gateway name.</summary>
public Dictionary<string, GatewayCfg> Remotes { get; set; } = new();
/// <summary>Reference-counted set of all gateway URLs in the cluster.</summary>
public RefCountedUrlSet Urls { get; set; } = new();
/// <summary>This server's own gateway URL (after random-port resolution).</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Gateway INFO protocol object.</summary>
public ServerInfo? Info { get; set; }
/// <summary>Pre-marshalled INFO JSON bytes.</summary>
public byte[]? InfoJson { get; set; }
/// <summary>When true, reject connections from gateways not in <see cref="Remotes"/>.</summary>
public bool RejectUnknown { get; set; }
/// <summary>
/// Reply prefix bytes: <c>"$GNR.&lt;reserved&gt;.&lt;clusterHash&gt;.&lt;serverHash&gt;."</c>
/// </summary>
public byte[] ReplyPfx { get; set; } = [];
// Backward-compatibility reply prefix and hash (old "$GR." scheme)
public byte[] OldReplyPfx { get; set; } = [];
public byte[] OldHash { get; set; } = [];
// -------------------------------------------------------------------------
// pasi — per-account subject interest tally (protected by its own mutex)
// -------------------------------------------------------------------------
/// <summary>
/// Per-account subject-interest tally.
/// Outer key = account name; inner key = subject (or "subject queue" pair);
/// value = tally struct.
/// Mirrors Go's anonymous <c>pasi</c> embedded struct in <c>srvGateway</c>.
/// </summary>
private readonly Lock _pasiLock = new();
public Dictionary<string, Dictionary<string, SitAlly>> Pasi { get; set; } = new();
public Lock PasiLock => _pasiLock;
// -------------------------------------------------------------------------
// Recent subscription tracking (thread-safe map)
// -------------------------------------------------------------------------
/// <summary>
/// Recent subscriptions for a given account (subject → expiry ticks).
/// Mirrors Go's <c>rsubs sync.Map</c>.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, long> RSubs { get; set; } = new();
// -------------------------------------------------------------------------
// Other server-level gateway fields
// -------------------------------------------------------------------------
/// <summary>DNS resolver used before dialling gateway connections.</summary>
public INetResolver? Resolver { get; set; }
/// <summary>Max buffer size for sending queue-sub protocol (used in tests).</summary>
public int SqbSz { get; set; }
/// <summary>How long to look for a subscription match for a reply message.</summary>
public TimeSpan RecSubExp { get; set; }
/// <summary>Server ID hash (6 bytes) for routing mapped replies.</summary>
public byte[] SIdHash { get; set; } = [];
/// <summary>
/// Map from a route server's hashed ID (6 bytes) to the route client.
/// Mirrors Go's <c>routesIDByHash sync.Map</c>.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, ClientConnection> RoutesIdByHash { get; set; } = new();
/// <summary>
/// Gateway URLs from this server's own entry in the Gateways config block,
/// used for monitoring reports.
/// </summary>
public List<string> OwnCfgUrls { get; set; } = [];
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Subject-interest tally entry. Indicates whether the key in the map is a
/// queue subscription and how many matching subscriptions exist.
/// Mirrors Go <c>sitally</c> struct in gateway.go.
/// </summary>
internal sealed class SitAlly
{
/// <summary>Number of subscriptions directly matching the subject/queue key.</summary>
public int N { get; set; }
/// <summary>True if this entry represents a queue subscription.</summary>
public bool Q { get; set; }
}
/// <summary>
/// Runtime configuration for a single remote gateway.
/// Wraps <see cref="RemoteGatewayOpts"/> with connection-attempt state and a lock.
/// Mirrors Go <c>gatewayCfg</c> struct in gateway.go.
/// </summary>
internal sealed class GatewayCfg
{
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>The raw remote-gateway options this cfg was built from.</summary>
public RemoteGatewayOpts? RemoteOpts { get; set; }
/// <summary>6-byte cluster hash used for reply routing.</summary>
public byte[] Hash { get; set; } = [];
/// <summary>4-byte old-style hash for backward compatibility.</summary>
public byte[] OldHash { get; set; } = [];
/// <summary>Map of URL string → parsed URL for this remote gateway.</summary>
public Dictionary<string, Uri> Urls { get; set; } = new();
/// <summary>Number of connection attempts made so far.</summary>
public int ConnAttempts { get; set; }
/// <summary>TLS server name override for SNI.</summary>
public string TlsName { get; set; } = string.Empty;
/// <summary>True if this remote was discovered via gossip (not configured).</summary>
public bool Implicit { get; set; }
/// <summary>When true, monitoring should refresh the URL list on next varz inspection.</summary>
public bool VarzUpdateUrls { get; set; }
// Forwarded properties from RemoteGatewayOpts
public string Name { get => RemoteOpts?.Name ?? string.Empty; }
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Per-connection gateway state embedded in <see cref="ClientConnection"/>
/// when the connection kind is <c>Gateway</c>.
/// Mirrors Go <c>gateway</c> struct in gateway.go.
/// </summary>
internal sealed class Gateway
{
/// <summary>Name of the remote gateway cluster.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Configuration block for the remote gateway.</summary>
public GatewayCfg? Cfg { get; set; }
/// <summary>URL used for CONNECT after receiving the remote INFO (outbound only).</summary>
public Uri? ConnectUrl { get; set; }
/// <summary>
/// Per-account subject interest (outbound connection).
/// Maps account name → <see cref="OutSide"/> for that account.
/// Uses a thread-safe map because it is read from multiple goroutines.
/// </summary>
public System.Collections.Concurrent.ConcurrentDictionary<string, OutSide>? OutSim { get; set; }
/// <summary>
/// Per-account no-interest subjects or interest-only mode (inbound connection).
/// </summary>
public Dictionary<string, InSide>? InSim { get; set; }
/// <summary>True if this is an outbound gateway connection.</summary>
public bool Outbound { get; set; }
/// <summary>
/// Set in the read loop without locking to record that the inbound side
/// sent its CONNECT protocol.
/// </summary>
public bool Connected { get; set; }
/// <summary>
/// True if the remote server only understands the old <c>$GR.</c> prefix,
/// not the newer <c>$GNR.</c> scheme.
/// </summary>
public bool UseOldPrefix { get; set; }
/// <summary>
/// When true the inbound side switches accounts to interest-only mode
/// immediately, so the outbound side can disregard optimistic mode.
/// </summary>
public bool InterestOnlyMode { get; set; }
/// <summary>Name of the remote server on this gateway connection.</summary>
public string RemoteName { get; set; } = string.Empty;
}
/// <summary>
/// Outbound subject-interest entry for a single account on a gateway connection.
/// Mirrors Go <c>outsie</c> struct in gateway.go.
/// </summary>
internal sealed class OutSide
{
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
/// <summary>Current interest mode for this account on the outbound gateway.</summary>
public GatewayInterestMode Mode { get; set; }
/// <summary>
/// Set of subjects for which the remote has signalled no-interest.
/// Null when the remote has sent all its subscriptions (interest-only mode).
/// </summary>
public HashSet<string>? Ni { get; set; }
/// <summary>
/// Subscription index: contains queue subs in optimistic mode,
/// or all subs when <see cref="Mode"/> has been switched.
/// </summary>
public SubscriptionIndex? Sl { get; set; }
/// <summary>Number of queue subscriptions tracked in <see cref="Sl"/>.</summary>
public int Qsubs { get; set; }
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// Inbound subject-interest entry for a single account on a gateway connection.
/// Tracks subjects for which an RS- was sent to the remote, and the current mode.
/// Mirrors Go <c>insie</c> struct in gateway.go.
/// </summary>
internal sealed class InSide
{
/// <summary>
/// Subjects for which RS- was sent to the remote (null when in interest-only mode).
/// </summary>
public HashSet<string>? Ni { get; set; }
/// <summary>Current interest mode for this account on the inbound gateway.</summary>
public GatewayInterestMode Mode { get; set; }
}
/// <summary>
/// A single gateway reply-mapping entry: the mapped subject and its expiry.
/// Mirrors Go <c>gwReplyMap</c> struct in gateway.go.
/// </summary>
internal sealed class GwReplyMap
{
/// <summary>The mapped (routed) subject string.</summary>
public string Ms { get; set; } = string.Empty;
/// <summary>Expiry expressed as <see cref="DateTime.Ticks"/> (UTC).</summary>
public long Exp { get; set; }
}
/// <summary>
/// Gateway reply routing table and a fast-path check flag.
/// Mirrors Go <c>gwReplyMapping</c> struct in gateway.go.
/// </summary>
internal sealed class GwReplyMapping
{
/// <summary>
/// Non-zero when the mapping table should be consulted while processing
/// inbound messages. Accessed via <c>Interlocked</c> — must be 32-bit aligned.
/// </summary>
public int Check;
/// <summary>Active reply-subject → GwReplyMap entries.</summary>
public Dictionary<string, GwReplyMap> Mapping { get; set; } = new();
/// <summary>
/// Returns the routed subject for <paramref name="subject"/> if a mapping
/// exists, otherwise returns the original subject and <c>false</c>.
/// Caller must hold any required lock before invoking.
/// </summary>
public (byte[] Subject, bool Found) Get(byte[] subject)
{
// TODO: session 16 — implement mapping lookup
return (subject, false);
}
}

View File

@@ -0,0 +1,64 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// A Go-like WaitGroup: tracks a set of in-flight operations and lets callers
/// block until all of them complete.
/// </summary>
internal sealed class WaitGroup
{
private int _count;
private volatile TaskCompletionSource<bool> _tcs;
public WaitGroup()
{
_tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_tcs.SetResult(true); // starts at zero, so "done" immediately
}
/// <summary>
/// Increment the counter by <paramref name="delta"/> (usually 1).
/// Must be called before starting the goroutine it tracks.
/// </summary>
public void Add(int delta = 1)
{
var newCount = Interlocked.Add(ref _count, delta);
if (newCount < 0)
throw new InvalidOperationException("WaitGroup counter went negative");
if (newCount == 0)
{
// All goroutines done — signal any waiters.
Volatile.Read(ref _tcs).TrySetResult(true);
}
else if (delta > 0 && newCount == delta)
{
// Transitioning from 0 to positive — replace the completed TCS
// with a fresh unsignaled one so Wait() will block correctly.
Volatile.Write(ref _tcs,
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously));
}
}
/// <summary>Decrement the counter by 1. Called when a goroutine finishes.</summary>
public void Done() => Add(-1);
/// <summary>Block synchronously until the counter reaches 0.</summary>
public void Wait()
{
if (Volatile.Read(ref _count) == 0) return;
Volatile.Read(ref _tcs).Task.GetAwaiter().GetResult();
}
}

View File

@@ -0,0 +1,400 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/memstore.go (consumerMemStore)
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// In-memory implementation of <see cref="IConsumerStore"/>.
/// Stores consumer delivery and ack state in memory only.
/// </summary>
public sealed class ConsumerMemStore : IConsumerStore
{
// -----------------------------------------------------------------------
// Fields
// -----------------------------------------------------------------------
private readonly object _mu = new();
private readonly JetStreamMemStore _ms;
private ConsumerConfig _cfg;
private ConsumerState _state = new();
private bool _closed;
// -----------------------------------------------------------------------
// Constructor
// -----------------------------------------------------------------------
/// <summary>
/// Creates a new consumer memory store backed by the given stream store.
/// </summary>
public ConsumerMemStore(JetStreamMemStore ms, ConsumerConfig cfg)
{
_ms = ms;
_cfg = cfg;
}
// -----------------------------------------------------------------------
// IConsumerStore — starting sequence
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void SetStarting(ulong sseq)
{
lock (_mu)
{
_state.Delivered.Stream = sseq;
_state.AckFloor.Stream = sseq;
}
}
/// <inheritdoc/>
public void UpdateStarting(ulong sseq)
{
lock (_mu)
{
if (sseq > _state.Delivered.Stream)
{
_state.Delivered.Stream = sseq;
// For AckNone just update delivered and ackfloor at the same time.
if (_cfg.AckPolicy == AckPolicy.AckNone)
_state.AckFloor.Stream = sseq;
}
}
}
/// <inheritdoc/>
public void Reset(ulong sseq)
{
lock (_mu)
{
_state = new ConsumerState();
}
SetStarting(sseq);
}
// -----------------------------------------------------------------------
// IConsumerStore — state query
// -----------------------------------------------------------------------
/// <inheritdoc/>
public bool HasState()
{
lock (_mu)
{
return _state.Delivered.Consumer != 0 || _state.Delivered.Stream != 0;
}
}
// -----------------------------------------------------------------------
// IConsumerStore — delivery tracking
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void UpdateDelivered(ulong dseq, ulong sseq, ulong dc, long ts)
{
lock (_mu)
{
if (dc != 1 && _cfg.AckPolicy == AckPolicy.AckNone)
throw StoreErrors.ErrNoAckPolicy;
// Replay from old leader — ignore outdated updates.
if (dseq <= _state.AckFloor.Consumer)
return;
if (_cfg.AckPolicy != AckPolicy.AckNone)
{
_state.Pending ??= new Dictionary<ulong, Pending>();
if (sseq <= _state.Delivered.Stream)
{
// Update to a previously delivered message.
if (_state.Pending.TryGetValue(sseq, out var p) && p != null)
p.Timestamp = ts;
}
else
{
_state.Pending[sseq] = new Pending { Sequence = dseq, Timestamp = ts };
}
if (dseq > _state.Delivered.Consumer)
_state.Delivered.Consumer = dseq;
if (sseq > _state.Delivered.Stream)
_state.Delivered.Stream = sseq;
if (dc > 1)
{
var maxdc = (ulong)_cfg.MaxDeliver;
if (maxdc > 0 && dc > maxdc)
_state.Pending.Remove(sseq);
_state.Redelivered ??= new Dictionary<ulong, ulong>();
if (!_state.Redelivered.TryGetValue(sseq, out var cur) || cur < dc - 1)
_state.Redelivered[sseq] = dc - 1;
}
}
else
{
// AckNone — update delivered and ackfloor together.
if (dseq > _state.Delivered.Consumer)
{
_state.Delivered.Consumer = dseq;
_state.AckFloor.Consumer = dseq;
}
if (sseq > _state.Delivered.Stream)
{
_state.Delivered.Stream = sseq;
_state.AckFloor.Stream = sseq;
}
}
}
}
/// <inheritdoc/>
public void UpdateAcks(ulong dseq, ulong sseq)
{
lock (_mu)
{
if (_cfg.AckPolicy == AckPolicy.AckNone)
throw StoreErrors.ErrNoAckPolicy;
// Ignore outdated acks.
if (dseq <= _state.AckFloor.Consumer)
return;
if (_state.Pending == null || !_state.Pending.ContainsKey(sseq))
{
_state.Redelivered?.Remove(sseq);
throw StoreErrors.ErrStoreMsgNotFound;
}
if (_cfg.AckPolicy == AckPolicy.AckAll)
{
var sgap = sseq - _state.AckFloor.Stream;
_state.AckFloor.Consumer = dseq;
_state.AckFloor.Stream = sseq;
if (sgap > (ulong)_state.Pending.Count)
{
var toRemove = new List<ulong>();
foreach (var kv in _state.Pending)
if (kv.Key <= sseq)
toRemove.Add(kv.Key);
foreach (var k in toRemove)
{
_state.Pending.Remove(k);
_state.Redelivered?.Remove(k);
}
}
else
{
for (var seq = sseq; seq > sseq - sgap && _state.Pending.Count > 0; seq--)
{
_state.Pending.Remove(seq);
_state.Redelivered?.Remove(seq);
if (seq == 0) break;
}
}
return;
}
// AckExplicit
if (_state.Pending.TryGetValue(sseq, out var pending) && pending != null)
{
_state.Pending.Remove(sseq);
if (dseq > pending.Sequence && pending.Sequence > 0)
dseq = pending.Sequence; // Use the original delivery sequence.
}
if (_state.Pending.Count == 0)
{
_state.AckFloor.Consumer = _state.Delivered.Consumer;
_state.AckFloor.Stream = _state.Delivered.Stream;
}
else if (dseq == _state.AckFloor.Consumer + 1)
{
_state.AckFloor.Consumer = dseq;
_state.AckFloor.Stream = sseq;
if (_state.Delivered.Consumer > dseq)
{
for (var ss = sseq + 1; ss <= _state.Delivered.Stream; ss++)
{
if (_state.Pending.TryGetValue(ss, out var pp) && pp != null)
{
if (pp.Sequence > 0)
{
_state.AckFloor.Consumer = pp.Sequence - 1;
_state.AckFloor.Stream = ss - 1;
}
break;
}
}
}
}
_state.Redelivered?.Remove(sseq);
}
}
// -----------------------------------------------------------------------
// IConsumerStore — config update
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void UpdateConfig(ConsumerConfig cfg)
{
lock (_mu)
{
_cfg = cfg;
}
}
// -----------------------------------------------------------------------
// IConsumerStore — update state
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void Update(ConsumerState state)
{
if (state.AckFloor.Consumer > state.Delivered.Consumer)
throw new InvalidOperationException("bad ack floor for consumer");
if (state.AckFloor.Stream > state.Delivered.Stream)
throw new InvalidOperationException("bad ack floor for stream");
Dictionary<ulong, Pending>? pending = null;
Dictionary<ulong, ulong>? redelivered = null;
if (state.Pending?.Count > 0)
{
pending = new Dictionary<ulong, Pending>(state.Pending.Count);
foreach (var kv in state.Pending)
{
if (kv.Key <= state.AckFloor.Stream || kv.Key > state.Delivered.Stream)
throw new InvalidOperationException($"bad pending entry, sequence [{kv.Key}] out of range");
pending[kv.Key] = new Pending { Sequence = kv.Value.Sequence, Timestamp = kv.Value.Timestamp };
}
}
if (state.Redelivered?.Count > 0)
{
redelivered = new Dictionary<ulong, ulong>(state.Redelivered);
}
lock (_mu)
{
// Ignore outdated updates.
if (state.Delivered.Consumer < _state.Delivered.Consumer ||
state.AckFloor.Stream < _state.AckFloor.Stream)
throw new InvalidOperationException("old update ignored");
_state.Delivered = new SequencePair { Consumer = state.Delivered.Consumer, Stream = state.Delivered.Stream };
_state.AckFloor = new SequencePair { Consumer = state.AckFloor.Consumer, Stream = state.AckFloor.Stream };
_state.Pending = pending;
_state.Redelivered = redelivered;
}
}
// -----------------------------------------------------------------------
// IConsumerStore — state retrieval
// -----------------------------------------------------------------------
/// <inheritdoc/>
public (ConsumerState? State, Exception? Error) State() => StateWithCopy(doCopy: true);
/// <inheritdoc/>
public (ConsumerState? State, Exception? Error) BorrowState() => StateWithCopy(doCopy: false);
private (ConsumerState? State, Exception? Error) StateWithCopy(bool doCopy)
{
lock (_mu)
{
if (_closed)
return (null, StoreErrors.ErrStoreClosed);
var state = new ConsumerState
{
Delivered = new SequencePair { Consumer = _state.Delivered.Consumer, Stream = _state.Delivered.Stream },
AckFloor = new SequencePair { Consumer = _state.AckFloor.Consumer, Stream = _state.AckFloor.Stream },
};
if (_state.Pending?.Count > 0)
{
state.Pending = doCopy ? CopyPending() : _state.Pending;
}
if (_state.Redelivered?.Count > 0)
{
state.Redelivered = doCopy ? CopyRedelivered() : _state.Redelivered;
}
return (state, null);
}
}
// -----------------------------------------------------------------------
// IConsumerStore — encoding
// -----------------------------------------------------------------------
/// <inheritdoc/>
public byte[] EncodedState()
{
lock (_mu)
{
if (_closed)
throw StoreErrors.ErrStoreClosed;
// TODO: session 17 — encode consumer state to binary
return Array.Empty<byte>();
}
}
// -----------------------------------------------------------------------
// IConsumerStore — lifecycle
// -----------------------------------------------------------------------
/// <inheritdoc/>
public StorageType Type() => StorageType.MemoryStorage;
/// <inheritdoc/>
public void Stop()
{
lock (_mu)
{
_closed = true;
}
_ms.RemoveConsumer(this);
}
/// <inheritdoc/>
public void Delete() => Stop();
/// <inheritdoc/>
public void StreamDelete() => Stop();
// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
private Dictionary<ulong, Pending> CopyPending()
{
var pending = new Dictionary<ulong, Pending>(_state.Pending!.Count);
foreach (var kv in _state.Pending!)
pending[kv.Key] = new Pending { Sequence = kv.Value.Sequence, Timestamp = kv.Value.Timestamp };
return pending;
}
private Dictionary<ulong, ulong> CopyRedelivered()
{
return new Dictionary<ulong, ulong>(_state.Redelivered!);
}
}

View File

@@ -0,0 +1,428 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/filestore.go (fileStore struct and methods)
using System.Threading.Channels;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// File-backed implementation of <see cref="IStreamStore"/>.
/// Stores JetStream messages in per-block files on disk with optional
/// encryption and compression.
/// Mirrors the <c>fileStore</c> struct in filestore.go.
/// </summary>
public sealed class JetStreamFileStore : IStreamStore, IDisposable
{
// -----------------------------------------------------------------------
// Fields — mirrors fileStore struct fields
// -----------------------------------------------------------------------
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.NoRecursion);
// State
private StreamState _state = new();
private List<ulong>? _tombs;
private LostStreamData? _ld;
// Callbacks
private StorageUpdateHandler? _scb;
private StorageRemoveMsgHandler? _rmcb;
private ProcessJetStreamMsgHandler? _pmsgcb;
// Age-check timer
private Timer? _ageChk;
private bool _ageChkRun;
private long _ageChkTime;
// Background sync timer
private Timer? _syncTmr;
// Configuration
private FileStreamInfo _cfg;
private FileStoreConfig _fcfg;
// Message block list and index
private MessageBlock? _lmb; // last (active write) block
private List<MessageBlock> _blks = [];
private Dictionary<uint, MessageBlock> _bim = [];
// Per-subject index map
private SubjectTree<Psi>? _psim;
// Total subject-list length (sum of subject-string lengths)
private int _tsl;
// writeFullState concurrency guard
private readonly object _wfsmu = new();
private long _wfsrun; // Interlocked: is writeFullState running?
private int _wfsadml; // Average dmap length (protected by _wfsmu)
// Quit / load-done channels (Channel<byte> mimics chan struct{})
private Channel<byte>? _qch;
private Channel<byte>? _fsld;
// Consumer list
private readonly ReaderWriterLockSlim _cmu = new(LockRecursionPolicy.NoRecursion);
private List<IConsumerStore> _cfs = [];
// Snapshot-in-progress count
private int _sips;
// Dirty-write counter (incremented when writes are pending flush)
private int _dirty;
// Lifecycle flags
private bool _closing;
private volatile bool _closed;
// Flush-in-progress flag
private bool _fip;
// Whether the store has ever received a message
private bool _receivedAny;
// Whether the first sequence has been moved forward
private bool _firstMoved;
// Last PurgeEx call time (for throttle logic)
private DateTime _lpex;
// -----------------------------------------------------------------------
// Constructor
// -----------------------------------------------------------------------
/// <summary>
/// Initialises a file-backed stream store using the supplied file-store
/// configuration and stream information.
/// </summary>
/// <param name="fcfg">File-store configuration (block size, cipher, paths, etc.).</param>
/// <param name="cfg">Stream metadata (created time and stream config).</param>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="fcfg"/> or <paramref name="cfg"/> is null.
/// </exception>
public JetStreamFileStore(FileStoreConfig fcfg, FileStreamInfo cfg)
{
ArgumentNullException.ThrowIfNull(fcfg);
ArgumentNullException.ThrowIfNull(cfg);
_fcfg = fcfg;
_cfg = cfg;
// Apply defaults (mirrors newFileStoreWithCreated in filestore.go).
if (_fcfg.BlockSize == 0)
_fcfg.BlockSize = FileStoreDefaults.DefaultLargeBlockSize;
if (_fcfg.CacheExpire == TimeSpan.Zero)
_fcfg.CacheExpire = FileStoreDefaults.DefaultCacheBufferExpiration;
if (_fcfg.SubjectStateExpire == TimeSpan.Zero)
_fcfg.SubjectStateExpire = FileStoreDefaults.DefaultFssExpiration;
if (_fcfg.SyncInterval == TimeSpan.Zero)
_fcfg.SyncInterval = FileStoreDefaults.DefaultSyncInterval;
_psim = new SubjectTree<Psi>();
_bim = new Dictionary<uint, MessageBlock>();
_qch = Channel.CreateUnbounded<byte>();
_fsld = Channel.CreateUnbounded<byte>();
}
// -----------------------------------------------------------------------
// IStreamStore — type / state
// -----------------------------------------------------------------------
/// <inheritdoc/>
public StorageType Type() => StorageType.FileStorage;
/// <inheritdoc/>
public StreamState State()
{
_mu.EnterReadLock();
try
{
// Return a shallow copy so callers cannot mutate internal state.
return new StreamState
{
Msgs = _state.Msgs,
Bytes = _state.Bytes,
FirstSeq = _state.FirstSeq,
FirstTime = _state.FirstTime,
LastSeq = _state.LastSeq,
LastTime = _state.LastTime,
NumSubjects = _state.NumSubjects,
NumDeleted = _state.NumDeleted,
Deleted = _state.Deleted,
Lost = _state.Lost,
Consumers = _state.Consumers,
};
}
finally
{
_mu.ExitReadLock();
}
}
/// <inheritdoc/>
public void FastState(StreamState state)
{
_mu.EnterReadLock();
try
{
state.Msgs = _state.Msgs;
state.Bytes = _state.Bytes;
state.FirstSeq = _state.FirstSeq;
state.FirstTime = _state.FirstTime;
state.LastSeq = _state.LastSeq;
state.LastTime = _state.LastTime;
state.NumDeleted = _state.NumDeleted;
state.Consumers = _state.Consumers;
}
finally
{
_mu.ExitReadLock();
}
}
// -----------------------------------------------------------------------
// IStreamStore — callback registration
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void RegisterStorageUpdates(StorageUpdateHandler cb)
{
_mu.EnterWriteLock();
try { _scb = cb; }
finally { _mu.ExitWriteLock(); }
}
/// <inheritdoc/>
public void RegisterStorageRemoveMsg(StorageRemoveMsgHandler cb)
{
_mu.EnterWriteLock();
try { _rmcb = cb; }
finally { _mu.ExitWriteLock(); }
}
/// <inheritdoc/>
public void RegisterProcessJetStreamMsg(ProcessJetStreamMsgHandler cb)
{
_mu.EnterWriteLock();
try { _pmsgcb = cb; }
finally { _mu.ExitWriteLock(); }
}
// -----------------------------------------------------------------------
// IStreamStore — lifecycle
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void Stop()
{
_mu.EnterWriteLock();
try
{
if (_closing) return;
_closing = true;
}
finally
{
_mu.ExitWriteLock();
}
_ageChk?.Dispose();
_ageChk = null;
_syncTmr?.Dispose();
_syncTmr = null;
_closed = true;
}
/// <inheritdoc/>
public void Dispose() => Stop();
// -----------------------------------------------------------------------
// IStreamStore — store / load (all stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public (ulong Seq, long Ts) StoreMsg(string subject, byte[]? hdr, byte[]? msg, long ttl)
=> throw new NotImplementedException("TODO: session 18 — filestore StoreMsg");
/// <inheritdoc/>
public void StoreRawMsg(string subject, byte[]? hdr, byte[]? msg, ulong seq, long ts, long ttl, bool discardNewCheck)
=> throw new NotImplementedException("TODO: session 18 — filestore StoreRawMsg");
/// <inheritdoc/>
public (ulong Seq, Exception? Error) SkipMsg(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore SkipMsg");
/// <inheritdoc/>
public void SkipMsgs(ulong seq, ulong num)
=> throw new NotImplementedException("TODO: session 18 — filestore SkipMsgs");
/// <inheritdoc/>
public void FlushAllPending()
=> throw new NotImplementedException("TODO: session 18 — filestore FlushAllPending");
/// <inheritdoc/>
public StoreMsg? LoadMsg(ulong seq, StoreMsg? sm)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadMsg");
/// <inheritdoc/>
public (StoreMsg? Sm, ulong Skip) LoadNextMsg(string filter, bool wc, ulong start, StoreMsg? smp)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadNextMsg");
/// <inheritdoc/>
public (StoreMsg? Sm, ulong Skip) LoadNextMsgMulti(object? sl, ulong start, StoreMsg? smp)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadNextMsgMulti");
/// <inheritdoc/>
public StoreMsg? LoadLastMsg(string subject, StoreMsg? sm)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadLastMsg");
/// <inheritdoc/>
public (StoreMsg? Sm, Exception? Error) LoadPrevMsg(ulong start, StoreMsg? smp)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadPrevMsg");
/// <inheritdoc/>
public (StoreMsg? Sm, ulong Skip, Exception? Error) LoadPrevMsgMulti(object? sl, ulong start, StoreMsg? smp)
=> throw new NotImplementedException("TODO: session 18 — filestore LoadPrevMsgMulti");
/// <inheritdoc/>
public (bool Removed, Exception? Error) RemoveMsg(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore RemoveMsg");
/// <inheritdoc/>
public (bool Removed, Exception? Error) EraseMsg(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore EraseMsg");
/// <inheritdoc/>
public (ulong Purged, Exception? Error) Purge()
=> throw new NotImplementedException("TODO: session 18 — filestore Purge");
/// <inheritdoc/>
public (ulong Purged, Exception? Error) PurgeEx(string subject, ulong seq, ulong keep)
=> throw new NotImplementedException("TODO: session 18 — filestore PurgeEx");
/// <inheritdoc/>
public (ulong Purged, Exception? Error) Compact(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore Compact");
/// <inheritdoc/>
public void Truncate(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore Truncate");
// -----------------------------------------------------------------------
// IStreamStore — query methods (all stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public ulong GetSeqFromTime(DateTime t)
=> throw new NotImplementedException("TODO: session 18 — filestore GetSeqFromTime");
/// <inheritdoc/>
public SimpleState FilteredState(ulong seq, string subject)
=> throw new NotImplementedException("TODO: session 18 — filestore FilteredState");
/// <inheritdoc/>
public Dictionary<string, SimpleState> SubjectsState(string filterSubject)
=> throw new NotImplementedException("TODO: session 18 — filestore SubjectsState");
/// <inheritdoc/>
public Dictionary<string, ulong> SubjectsTotals(string filterSubject)
=> throw new NotImplementedException("TODO: session 18 — filestore SubjectsTotals");
/// <inheritdoc/>
public (ulong[] Seqs, Exception? Error) AllLastSeqs()
=> throw new NotImplementedException("TODO: session 18 — filestore AllLastSeqs");
/// <inheritdoc/>
public (ulong[] Seqs, Exception? Error) MultiLastSeqs(string[] filters, ulong maxSeq, int maxAllowed)
=> throw new NotImplementedException("TODO: session 18 — filestore MultiLastSeqs");
/// <inheritdoc/>
public (string Subject, Exception? Error) SubjectForSeq(ulong seq)
=> throw new NotImplementedException("TODO: session 18 — filestore SubjectForSeq");
/// <inheritdoc/>
public (ulong Total, ulong ValidThrough, Exception? Error) NumPending(ulong sseq, string filter, bool lastPerSubject)
=> throw new NotImplementedException("TODO: session 18 — filestore NumPending");
/// <inheritdoc/>
public (ulong Total, ulong ValidThrough, Exception? Error) NumPendingMulti(ulong sseq, object? sl, bool lastPerSubject)
=> throw new NotImplementedException("TODO: session 18 — filestore NumPendingMulti");
// -----------------------------------------------------------------------
// IStreamStore — stream state encoding (stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public (byte[] Enc, Exception? Error) EncodedStreamState(ulong failed)
=> throw new NotImplementedException("TODO: session 18 — filestore EncodedStreamState");
/// <inheritdoc/>
public void SyncDeleted(DeleteBlocks dbs)
=> throw new NotImplementedException("TODO: session 18 — filestore SyncDeleted");
// -----------------------------------------------------------------------
// IStreamStore — config / admin (stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public void UpdateConfig(StreamConfig cfg)
=> throw new NotImplementedException("TODO: session 18 — filestore UpdateConfig");
/// <inheritdoc/>
public void Delete(bool inline)
=> throw new NotImplementedException("TODO: session 18 — filestore Delete");
/// <inheritdoc/>
public void ResetState()
=> throw new NotImplementedException("TODO: session 18 — filestore ResetState");
// -----------------------------------------------------------------------
// IStreamStore — consumer management (stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public IConsumerStore ConsumerStore(string name, DateTime created, ConsumerConfig cfg)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerStore");
/// <inheritdoc/>
public void AddConsumer(IConsumerStore o)
{
_cmu.EnterWriteLock();
try { _cfs.Add(o); }
finally { _cmu.ExitWriteLock(); }
}
/// <inheritdoc/>
public void RemoveConsumer(IConsumerStore o)
{
_cmu.EnterWriteLock();
try { _cfs.Remove(o); }
finally { _cmu.ExitWriteLock(); }
}
// -----------------------------------------------------------------------
// IStreamStore — snapshot / utilization (stubs)
// -----------------------------------------------------------------------
/// <inheritdoc/>
public (SnapshotResult? Result, Exception? Error) Snapshot(TimeSpan deadline, bool includeConsumers, bool checkMsgs)
=> throw new NotImplementedException("TODO: session 18 — filestore Snapshot");
/// <inheritdoc/>
public (ulong Total, ulong Reported, Exception? Error) Utilization()
=> throw new NotImplementedException("TODO: session 18 — filestore Utilization");
}

View File

@@ -0,0 +1,416 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/filestore.go
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// FileStoreConfig
// ---------------------------------------------------------------------------
/// <summary>
/// Configuration for a file-backed JetStream stream store.
/// Mirrors <c>FileStoreConfig</c> in filestore.go.
/// </summary>
public sealed class FileStoreConfig
{
/// <summary>Parent directory for all storage.</summary>
public string StoreDir { get; set; } = string.Empty;
/// <summary>
/// File block size. Also represents the maximum per-block overhead.
/// Defaults to <see cref="FileStoreDefaults.DefaultBlockSize"/>.
/// </summary>
public ulong BlockSize { get; set; }
/// <summary>How long with no activity until the in-memory cache is expired.</summary>
public TimeSpan CacheExpire { get; set; }
/// <summary>How long with no activity until a message block's subject state is expired.</summary>
public TimeSpan SubjectStateExpire { get; set; }
/// <summary>How often the store syncs data to disk in the background.</summary>
public TimeSpan SyncInterval { get; set; }
/// <summary>When true, every write is immediately synced to disk.</summary>
public bool SyncAlways { get; set; }
/// <summary>When true, write operations may be batched and flushed asynchronously.</summary>
public bool AsyncFlush { get; set; }
/// <summary>Encryption cipher used when encrypting blocks.</summary>
public StoreCipher Cipher { get; set; }
/// <summary>Compression algorithm applied to stored blocks.</summary>
public StoreCompression Compression { get; set; }
// Internal reference to the owning server — not serialised.
// Equivalent to srv *Server in Go; kept as object to avoid circular project deps.
internal object? Server { get; set; }
}
// ---------------------------------------------------------------------------
// FileStreamInfo
// ---------------------------------------------------------------------------
/// <summary>
/// Remembers the creation time alongside the stream configuration.
/// Mirrors <c>FileStreamInfo</c> in filestore.go.
/// </summary>
public sealed class FileStreamInfo
{
/// <summary>UTC time at which the stream was created.</summary>
public DateTime Created { get; set; }
/// <summary>Stream configuration.</summary>
public StreamConfig Config { get; set; } = new();
}
// ---------------------------------------------------------------------------
// FileConsumerInfo
// ---------------------------------------------------------------------------
/// <summary>
/// Used for creating and restoring consumer stores from disk.
/// Mirrors <c>FileConsumerInfo</c> in filestore.go.
/// </summary>
public sealed class FileConsumerInfo
{
/// <summary>UTC time at which the consumer was created.</summary>
public DateTime Created { get; set; }
/// <summary>Durable consumer name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Consumer configuration.</summary>
public ConsumerConfig Config { get; set; } = new();
}
// ---------------------------------------------------------------------------
// Psi — per-subject index entry (internal)
// ---------------------------------------------------------------------------
/// <summary>
/// Per-subject index entry stored in the subject tree.
/// Mirrors the <c>psi</c> struct in filestore.go.
/// </summary>
internal sealed class Psi
{
/// <summary>Total messages for this subject across all blocks.</summary>
public ulong Total { get; set; }
/// <summary>Index of the first block that holds messages for this subject.</summary>
public uint Fblk { get; set; }
/// <summary>Index of the last block that holds messages for this subject.</summary>
public uint Lblk { get; set; }
}
// ---------------------------------------------------------------------------
// Cache — write-through and load cache (internal)
// ---------------------------------------------------------------------------
/// <summary>
/// Write-through caching layer also used when loading messages from disk.
/// Mirrors the <c>cache</c> struct in filestore.go.
/// </summary>
internal sealed class Cache
{
/// <summary>Raw message data buffer.</summary>
public byte[] Buf { get; set; } = Array.Empty<byte>();
/// <summary>Write position into <see cref="Buf"/>.</summary>
public int Wp { get; set; }
/// <summary>Per-sequence byte offsets into <see cref="Buf"/>.</summary>
public uint[] Idx { get; set; } = Array.Empty<uint>();
/// <summary>First sequence number this cache covers.</summary>
public ulong Fseq { get; set; }
/// <summary>No-random-access flag: when true sequential access is assumed.</summary>
public bool Nra { get; set; }
}
// ---------------------------------------------------------------------------
// MsgId — sequence + timestamp pair (internal)
// ---------------------------------------------------------------------------
/// <summary>
/// Pairs a message sequence number with its nanosecond timestamp.
/// Mirrors the <c>msgId</c> struct in filestore.go.
/// </summary>
internal struct MsgId
{
/// <summary>Sequence number.</summary>
public ulong Seq;
/// <summary>Nanosecond Unix timestamp.</summary>
public long Ts;
}
// ---------------------------------------------------------------------------
// CompressionInfo — compression metadata
// ---------------------------------------------------------------------------
/// <summary>
/// Compression metadata attached to a message block.
/// Mirrors <c>CompressionInfo</c> in filestore.go.
/// </summary>
public sealed class CompressionInfo
{
/// <summary>Compression algorithm in use.</summary>
public StoreCompression Type { get; set; }
/// <summary>Original (uncompressed) size in bytes.</summary>
public ulong Original { get; set; }
/// <summary>Compressed size in bytes.</summary>
public ulong Compressed { get; set; }
/// <summary>
/// Serialises compression metadata as a compact binary prefix.
/// Format: 'c' 'm' 'p' &lt;algorithmByte&gt; &lt;uvarint originalSize&gt;
/// </summary>
public byte[] MarshalMetadata()
{
// TODO: session 18 — implement varint encoding
throw new NotImplementedException("TODO: session 18 — filestore CompressionInfo.MarshalMetadata");
}
/// <summary>
/// Deserialises compression metadata from a binary buffer.
/// Returns the number of bytes consumed, or 0 if the buffer does not start with the expected prefix.
/// </summary>
public int UnmarshalMetadata(byte[] b)
{
// TODO: session 18 — implement varint decoding
throw new NotImplementedException("TODO: session 18 — filestore CompressionInfo.UnmarshalMetadata");
}
}
// ---------------------------------------------------------------------------
// ErrBadMsg — corrupt/malformed message error (internal)
// ---------------------------------------------------------------------------
/// <summary>
/// Indicates a malformed or corrupt message was detected in a block file.
/// Mirrors the <c>errBadMsg</c> type in filestore.go.
/// </summary>
internal sealed class ErrBadMsg : Exception
{
/// <summary>Path to the block file that contained the bad message.</summary>
public string FileName { get; }
/// <summary>Optional additional detail about the corruption.</summary>
public string Detail { get; }
public ErrBadMsg(string fileName, string detail = "")
: base(BuildMessage(fileName, detail))
{
FileName = fileName;
Detail = detail;
}
private static string BuildMessage(string fileName, string detail)
{
var baseName = Path.GetFileName(fileName);
return string.IsNullOrEmpty(detail)
? $"malformed or corrupt message in {baseName}"
: $"malformed or corrupt message in {baseName}: {detail}";
}
}
// ---------------------------------------------------------------------------
// FileStoreDefaults — well-known constants
// ---------------------------------------------------------------------------
/// <summary>
/// Well-known constants from filestore.go, exposed for cross-assembly use.
/// </summary>
public static class FileStoreDefaults
{
// Magic / version markers written into block files.
/// <summary>Magic byte used to identify file-store block files.</summary>
public const byte FileStoreMagic = 22;
/// <summary>Current block file version.</summary>
public const byte FileStoreVersion = 1;
/// <summary>New-format index version.</summary>
internal const byte NewVersion = 2;
/// <summary>Header length in bytes for block records.</summary>
internal const int HdrLen = 2;
// Directory names
/// <summary>Top-level directory that holds per-stream subdirectories.</summary>
public const string StreamsDir = "streams";
/// <summary>Directory that holds in-flight batch data for a stream.</summary>
public const string BatchesDir = "batches";
/// <summary>Directory that holds message block files.</summary>
public const string MsgDir = "msgs";
/// <summary>Temporary directory name used during a full purge.</summary>
public const string PurgeDir = "__msgs__";
/// <summary>Temporary directory name for the new message block during purge.</summary>
public const string NewMsgDir = "__new_msgs__";
/// <summary>Directory name that holds per-consumer state.</summary>
public const string ConsumerDir = "obs";
// File name patterns
/// <summary>Format string for block file names (<c>{index}.blk</c>).</summary>
public const string BlkScan = "{0}.blk";
/// <summary>Suffix for active block files.</summary>
public const string BlkSuffix = ".blk";
/// <summary>Format string for compacted-block staging files (<c>{index}.new</c>).</summary>
public const string NewScan = "{0}.new";
/// <summary>Format string for index files (<c>{index}.idx</c>).</summary>
public const string IndexScan = "{0}.idx";
/// <summary>Format string for per-block encryption-key files (<c>{index}.key</c>).</summary>
public const string KeyScan = "{0}.key";
/// <summary>Glob pattern used to find orphaned key files.</summary>
public const string KeyScanAll = "*.key";
/// <summary>Suffix for temporary rewrite/compression staging files.</summary>
public const string BlkTmpSuffix = ".tmp";
// Meta files
/// <summary>Stream / consumer metadata file name.</summary>
public const string JetStreamMetaFile = "meta.inf";
/// <summary>Checksum file for the metadata file.</summary>
public const string JetStreamMetaFileSum = "meta.sum";
/// <summary>Encrypted metadata key file name.</summary>
public const string JetStreamMetaFileKey = "meta.key";
/// <summary>Full stream-state snapshot file name.</summary>
public const string StreamStateFile = "index.db";
/// <summary>Encoded TTL hash-wheel persistence file name.</summary>
public const string TtlStreamStateFile = "thw.db";
/// <summary>Encoded message-scheduling persistence file name.</summary>
public const string MsgSchedulingStreamStateFile = "sched.db";
/// <summary>Consumer state file name inside a consumer directory.</summary>
public const string ConsumerState = "o.dat";
// Block size defaults (bytes)
/// <summary>Default block size for large (limits-based) streams: 8 MB.</summary>
public const ulong DefaultLargeBlockSize = 8 * 1024 * 1024;
/// <summary>Default block size for work-queue / interest streams: 4 MB.</summary>
public const ulong DefaultMediumBlockSize = 4 * 1024 * 1024;
/// <summary>Default block size used by mirrors/sources: 1 MB.</summary>
public const ulong DefaultSmallBlockSize = 1 * 1024 * 1024;
/// <summary>Tiny pool block size (256 KB) — avoids large allocations at low write rates.</summary>
public const ulong DefaultTinyBlockSize = 256 * 1024;
/// <summary>Maximum encrypted-head block size: 2 MB.</summary>
public const ulong MaximumEncryptedBlockSize = 2 * 1024 * 1024;
/// <summary>Default block size for KV-based streams (same as medium).</summary>
public const ulong DefaultKvBlockSize = DefaultMediumBlockSize;
/// <summary>Hard upper limit on block size.</summary>
public const ulong MaxBlockSize = DefaultLargeBlockSize;
/// <summary>Minimum allowed block size: 32 KiB.</summary>
public const ulong FileStoreMinBlkSize = 32 * 1000;
/// <summary>Maximum allowed block size (same as <see cref="MaxBlockSize"/>).</summary>
public const ulong FileStoreMaxBlkSize = MaxBlockSize;
/// <summary>
/// Default block size exposed publicly; resolves to <see cref="DefaultSmallBlockSize"/> (1 MB)
/// to match the spec note in the porting plan.
/// </summary>
public const ulong DefaultBlockSize = DefaultSmallBlockSize;
// Timing defaults
/// <summary>Default duration before an idle cache buffer is expired: 10 seconds.</summary>
public static readonly TimeSpan DefaultCacheBufferExpiration = TimeSpan.FromSeconds(10);
/// <summary>Default interval for background disk sync: 2 minutes.</summary>
public static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(2);
/// <summary>Default idle timeout before file descriptors are closed: 30 seconds.</summary>
public static readonly TimeSpan CloseFdsIdle = TimeSpan.FromSeconds(30);
/// <summary>Default expiration time for idle per-block subject state: 2 minutes.</summary>
public static readonly TimeSpan DefaultFssExpiration = TimeSpan.FromMinutes(2);
// Thresholds
/// <summary>Minimum coalesce size for write batching: 16 KiB.</summary>
public const int CoalesceMinimum = 16 * 1024;
/// <summary>Maximum wait time when gathering messages to flush: 8 ms.</summary>
public static readonly TimeSpan MaxFlushWait = TimeSpan.FromMilliseconds(8);
/// <summary>Minimum block size before compaction is attempted: 2 MB.</summary>
public const int CompactMinimum = 2 * 1024 * 1024;
/// <summary>Threshold above which a record length is considered corrupt: 32 MB.</summary>
public const int RlBadThresh = 32 * 1024 * 1024;
/// <summary>Size of the per-record hash checksum in bytes.</summary>
public const int RecordHashSize = 8;
// Encryption key size minimums
/// <summary>Minimum size of a metadata encryption key: 64 bytes.</summary>
internal const int MinMetaKeySize = 64;
/// <summary>Minimum size of a block encryption key: 64 bytes.</summary>
internal const int MinBlkKeySize = 64;
// Cache-index bit flags
/// <summary>Bit set in a cache index slot to mark that the checksum has been validated.</summary>
internal const uint Cbit = 1u << 31;
/// <summary>Bit set in a cache index slot to mark the message as deleted.</summary>
internal const uint Dbit = 1u << 30;
/// <summary>Bit set in a record length field to indicate the record has headers.</summary>
internal const uint Hbit = 1u << 31;
/// <summary>Bit set in a sequence number to mark an erased message.</summary>
internal const ulong Ebit = 1UL << 63;
/// <summary>Bit set in a sequence number to mark a tombstone.</summary>
internal const ulong Tbit = 1UL << 62;
}

View File

@@ -0,0 +1,614 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_api.go in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// Forward stubs for types defined in later sessions
// ---------------------------------------------------------------------------
/// <summary>Stub: stored message type — full definition in session 20.</summary>
public sealed class StoredMsg { }
/// <summary>Priority group for pull consumers — full definition in session 20.</summary>
public sealed class PriorityGroup { }
// ---------------------------------------------------------------------------
// API subject constants
// ---------------------------------------------------------------------------
/// <summary>
/// JetStream API subject constants.
/// Mirrors the const block at the top of server/jetstream_api.go.
/// </summary>
public static class JsApiSubjects
{
public const string JsAllApi = "$JS.API.>";
public const string JsApiPrefix = "$JS.API";
public const string JsApiAccountInfo = "$JS.API.INFO";
public const string JsApiStreamCreate = "$JS.API.STREAM.CREATE.*";
public const string JsApiStreamCreateT = "$JS.API.STREAM.CREATE.{0}";
public const string JsApiStreamUpdate = "$JS.API.STREAM.UPDATE.*";
public const string JsApiStreamUpdateT = "$JS.API.STREAM.UPDATE.{0}";
public const string JsApiStreams = "$JS.API.STREAM.NAMES";
public const string JsApiStreamList = "$JS.API.STREAM.LIST";
public const string JsApiStreamInfo = "$JS.API.STREAM.INFO.*";
public const string JsApiStreamInfoT = "$JS.API.STREAM.INFO.{0}";
public const string JsApiStreamDelete = "$JS.API.STREAM.DELETE.*";
public const string JsApiStreamDeleteT = "$JS.API.STREAM.DELETE.{0}";
public const string JsApiStreamPurge = "$JS.API.STREAM.PURGE.*";
public const string JsApiStreamPurgeT = "$JS.API.STREAM.PURGE.{0}";
public const string JsApiStreamSnapshot = "$JS.API.STREAM.SNAPSHOT.*";
public const string JsApiStreamSnapshotT = "$JS.API.STREAM.SNAPSHOT.{0}";
public const string JsApiStreamRestore = "$JS.API.STREAM.RESTORE.*";
public const string JsApiStreamRestoreT = "$JS.API.STREAM.RESTORE.{0}";
public const string JsApiMsgDelete = "$JS.API.STREAM.MSG.DELETE.*";
public const string JsApiMsgDeleteT = "$JS.API.STREAM.MSG.DELETE.{0}";
public const string JsApiMsgGet = "$JS.API.STREAM.MSG.GET.*";
public const string JsApiMsgGetT = "$JS.API.STREAM.MSG.GET.{0}";
public const string JsDirectMsgGet = "$JS.API.DIRECT.GET.*";
public const string JsDirectMsgGetT = "$JS.API.DIRECT.GET.{0}";
public const string JsDirectGetLastBySubject = "$JS.API.DIRECT.GET.*.>";
public const string JsDirectGetLastBySubjectT = "$JS.API.DIRECT.GET.{0}.{1}";
public const string JsApiConsumerCreate = "$JS.API.CONSUMER.CREATE.*";
public const string JsApiConsumerCreateT = "$JS.API.CONSUMER.CREATE.{0}";
public const string JsApiConsumerCreateEx = "$JS.API.CONSUMER.CREATE.*.>";
public const string JsApiConsumerCreateExT = "$JS.API.CONSUMER.CREATE.{0}.{1}.{2}";
public const string JsApiDurableCreate = "$JS.API.CONSUMER.DURABLE.CREATE.*.*";
public const string JsApiDurableCreateT = "$JS.API.CONSUMER.DURABLE.CREATE.{0}.{1}";
public const string JsApiConsumers = "$JS.API.CONSUMER.NAMES.*";
public const string JsApiConsumersT = "$JS.API.CONSUMER.NAMES.{0}";
public const string JsApiConsumerList = "$JS.API.CONSUMER.LIST.*";
public const string JsApiConsumerListT = "$JS.API.CONSUMER.LIST.{0}";
public const string JsApiConsumerInfo = "$JS.API.CONSUMER.INFO.*.*";
public const string JsApiConsumerInfoT = "$JS.API.CONSUMER.INFO.{0}.{1}";
public const string JsApiConsumerDelete = "$JS.API.CONSUMER.DELETE.*.*";
public const string JsApiConsumerDeleteT = "$JS.API.CONSUMER.DELETE.{0}.{1}";
public const string JsApiConsumerPause = "$JS.API.CONSUMER.PAUSE.*.*";
public const string JsApiConsumerPauseT = "$JS.API.CONSUMER.PAUSE.{0}.{1}";
public const string JsApiRequestNextT = "$JS.API.CONSUMER.MSG.NEXT.{0}.{1}";
public const string JsApiConsumerResetT = "$JS.API.CONSUMER.RESET.{0}.{1}";
public const string JsApiConsumerUnpin = "$JS.API.CONSUMER.UNPIN.*.*";
public const string JsApiConsumerUnpinT = "$JS.API.CONSUMER.UNPIN.{0}.{1}";
public const string JsApiStreamRemovePeer = "$JS.API.STREAM.PEER.REMOVE.*";
public const string JsApiStreamRemovePeerT = "$JS.API.STREAM.PEER.REMOVE.{0}";
public const string JsApiStreamLeaderStepDown = "$JS.API.STREAM.LEADER.STEPDOWN.*";
public const string JsApiStreamLeaderStepDownT = "$JS.API.STREAM.LEADER.STEPDOWN.{0}";
public const string JsApiConsumerLeaderStepDown = "$JS.API.CONSUMER.LEADER.STEPDOWN.*.*";
public const string JsApiConsumerLeaderStepDownT = "$JS.API.CONSUMER.LEADER.STEPDOWN.{0}.{1}";
public const string JsApiLeaderStepDown = "$JS.API.META.LEADER.STEPDOWN";
public const string JsApiRemoveServer = "$JS.API.SERVER.REMOVE";
public const string JsApiAccountPurge = "$JS.API.ACCOUNT.PURGE.*";
public const string JsApiAccountPurgeT = "$JS.API.ACCOUNT.PURGE.{0}";
public const string JsApiServerStreamMove = "$JS.API.ACCOUNT.STREAM.MOVE.*.*";
public const string JsApiServerStreamMoveT = "$JS.API.ACCOUNT.STREAM.MOVE.{0}.{1}";
public const string JsApiServerStreamCancelMove = "$JS.API.ACCOUNT.STREAM.CANCEL_MOVE.*.*";
public const string JsApiServerStreamCancelMoveT = "$JS.API.ACCOUNT.STREAM.CANCEL_MOVE.{0}.{1}";
// Advisory/metric subjects
public const string JsAdvisoryPrefix = "$JS.EVENT.ADVISORY";
public const string JsMetricPrefix = "$JS.EVENT.METRIC";
public const string JsMetricConsumerAckPre = "$JS.EVENT.METRIC.CONSUMER.ACK";
public const string JsAdvisoryConsumerMaxDelivery = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES";
public const string JsAdvisoryConsumerMsgNak = "$JS.EVENT.ADVISORY.CONSUMER.MSG_NAKED";
public const string JsAdvisoryConsumerMsgTerminated = "$JS.EVENT.ADVISORY.CONSUMER.MSG_TERMINATED";
public const string JsAdvisoryStreamCreated = "$JS.EVENT.ADVISORY.STREAM.CREATED";
public const string JsAdvisoryStreamDeleted = "$JS.EVENT.ADVISORY.STREAM.DELETED";
public const string JsAdvisoryStreamUpdated = "$JS.EVENT.ADVISORY.STREAM.UPDATED";
public const string JsAdvisoryConsumerCreated = "$JS.EVENT.ADVISORY.CONSUMER.CREATED";
public const string JsAdvisoryConsumerDeleted = "$JS.EVENT.ADVISORY.CONSUMER.DELETED";
public const string JsAdvisoryConsumerPause = "$JS.EVENT.ADVISORY.CONSUMER.PAUSE";
public const string JsAdvisoryConsumerPinned = "$JS.EVENT.ADVISORY.CONSUMER.PINNED";
public const string JsAdvisoryConsumerUnpinned = "$JS.EVENT.ADVISORY.CONSUMER.UNPINNED";
public const string JsAdvisoryStreamSnapshotCreate = "$JS.EVENT.ADVISORY.STREAM.SNAPSHOT_CREATE";
public const string JsAdvisoryStreamSnapshotComplete = "$JS.EVENT.ADVISORY.STREAM.SNAPSHOT_COMPLETE";
public const string JsAdvisoryStreamRestoreCreate = "$JS.EVENT.ADVISORY.STREAM.RESTORE_CREATE";
public const string JsAdvisoryStreamRestoreComplete = "$JS.EVENT.ADVISORY.STREAM.RESTORE_COMPLETE";
public const string JsAdvisoryDomainLeaderElected = "$JS.EVENT.ADVISORY.DOMAIN.LEADER_ELECTED";
public const string JsAdvisoryStreamLeaderElected = "$JS.EVENT.ADVISORY.STREAM.LEADER_ELECTED";
public const string JsAdvisoryStreamQuorumLost = "$JS.EVENT.ADVISORY.STREAM.QUORUM_LOST";
public const string JsAdvisoryStreamBatchAbandoned = "$JS.EVENT.ADVISORY.STREAM.BATCH_ABANDONED";
public const string JsAdvisoryConsumerLeaderElected = "$JS.EVENT.ADVISORY.CONSUMER.LEADER_ELECTED";
public const string JsAdvisoryConsumerQuorumLost = "$JS.EVENT.ADVISORY.CONSUMER.QUORUM_LOST";
public const string JsAdvisoryServerOutOfStorage = "$JS.EVENT.ADVISORY.SERVER.OUT_OF_STORAGE";
public const string JsAdvisoryServerRemoved = "$JS.EVENT.ADVISORY.SERVER.REMOVED";
public const string JsAdvisoryApiLimitReached = "$JS.EVENT.ADVISORY.API.LIMIT_REACHED";
public const string JsAuditAdvisory = "$JS.EVENT.ADVISORY.API";
// Response type strings
public const string JsApiSystemResponseType = "io.nats.jetstream.api.v1.system_response";
public const string JsApiOverloadedType = "io.nats.jetstream.api.v1.system_overloaded";
public const string JsApiAccountInfoResponseType = "io.nats.jetstream.api.v1.account_info_response";
public const string JsApiStreamCreateResponseType = "io.nats.jetstream.api.v1.stream_create_response";
public const string JsApiStreamDeleteResponseType = "io.nats.jetstream.api.v1.stream_delete_response";
public const string JsApiStreamInfoResponseType = "io.nats.jetstream.api.v1.stream_info_response";
public const string JsApiStreamNamesResponseType = "io.nats.jetstream.api.v1.stream_names_response";
public const string JsApiStreamListResponseType = "io.nats.jetstream.api.v1.stream_list_response";
public const string JsApiStreamPurgeResponseType = "io.nats.jetstream.api.v1.stream_purge_response";
public const string JsApiStreamUpdateResponseType = "io.nats.jetstream.api.v1.stream_update_response";
public const string JsApiMsgDeleteResponseType = "io.nats.jetstream.api.v1.stream_msg_delete_response";
public const string JsApiStreamSnapshotResponseType = "io.nats.jetstream.api.v1.stream_snapshot_response";
public const string JsApiStreamRestoreResponseType = "io.nats.jetstream.api.v1.stream_restore_response";
public const string JsApiStreamRemovePeerResponseType = "io.nats.jetstream.api.v1.stream_remove_peer_response";
public const string JsApiStreamLeaderStepDownResponseType = "io.nats.jetstream.api.v1.stream_leader_stepdown_response";
public const string JsApiConsumerLeaderStepDownResponseType = "io.nats.jetstream.api.v1.consumer_leader_stepdown_response";
public const string JsApiLeaderStepDownResponseType = "io.nats.jetstream.api.v1.meta_leader_stepdown_response";
public const string JsApiMetaServerRemoveResponseType = "io.nats.jetstream.api.v1.meta_server_remove_response";
public const string JsApiAccountPurgeResponseType = "io.nats.jetstream.api.v1.account_purge_response";
public const string JsApiMsgGetResponseType = "io.nats.jetstream.api.v1.stream_msg_get_response";
public const string JsApiConsumerCreateResponseType = "io.nats.jetstream.api.v1.consumer_create_response";
public const string JsApiConsumerDeleteResponseType = "io.nats.jetstream.api.v1.consumer_delete_response";
public const string JsApiConsumerPauseResponseType = "io.nats.jetstream.api.v1.consumer_pause_response";
public const string JsApiConsumerInfoResponseType = "io.nats.jetstream.api.v1.consumer_info_response";
public const string JsApiConsumerNamesResponseType = "io.nats.jetstream.api.v1.consumer_names_response";
public const string JsApiConsumerListResponseType = "io.nats.jetstream.api.v1.consumer_list_response";
public const string JsApiConsumerUnpinResponseType = "io.nats.jetstream.api.v1.consumer_unpin_response";
public const string JsApiConsumerResetResponseType = "io.nats.jetstream.api.v1.consumer_reset_response";
// Limits
public const int JsApiNamesLimit = 1024;
public const int JsApiListLimit = 256;
public const int JsMaxSubjectDetails = 100_000;
public const int JsWaitQueueDefaultMax = 512;
public const int JsMaxDescriptionLen = 4 * 1024;
public const int JsMaxMetadataLen = 128 * 1024;
public const int JsMaxNameLen = 255;
public const int JsDefaultRequestQueueLimit = 10_000;
// Request headers
public const string JsRequiredApiLevel = "Nats-Required-Api-Level";
}
// ---------------------------------------------------------------------------
// Base API types
// ---------------------------------------------------------------------------
/// <summary>
/// Standard base response from the JetStream JSON API.
/// Mirrors <c>ApiResponse</c> in server/jetstream_api.go.
/// </summary>
public sealed class ApiResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
/// <summary>Returns the <see cref="Error"/> as an exception, or null if none.</summary>
public Exception? ToError() =>
Error is null ? null : new InvalidOperationException($"{Error.Description} ({Error.ErrCode})");
}
/// <summary>
/// Paged response metadata included in list responses.
/// Mirrors <c>ApiPaged</c> in server/jetstream_api.go.
/// </summary>
public sealed class ApiPaged
{
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
}
/// <summary>
/// Request parameters for paged API responses.
/// Mirrors <c>ApiPagedRequest</c> in server/jetstream_api.go.
/// </summary>
public sealed class ApiPagedRequest
{
[JsonPropertyName("offset")] public int Offset { get; set; }
}
// ---------------------------------------------------------------------------
// Account
// ---------------------------------------------------------------------------
/// <summary>Account info response.</summary>
public sealed class JsApiAccountInfoResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
// JetStreamAccountStats fields (embedded in Go)
[JsonPropertyName("memory")] public ulong Memory { get; set; }
[JsonPropertyName("storage")] public ulong Store { get; set; }
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
[JsonPropertyName("streams")] public int Streams { get; set; }
[JsonPropertyName("consumers")] public int Consumers { get; set; }
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
[JsonPropertyName("tiers")] public Dictionary<string, JetStreamTier>? Tiers { get; set; }
}
// ---------------------------------------------------------------------------
// Stream API types
// ---------------------------------------------------------------------------
/// <summary>Stream creation response.</summary>
public sealed class JsApiStreamCreateResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("config")] public StreamConfig? Config { get; set; }
[JsonPropertyName("state")] public StreamState? State { get; set; }
[JsonPropertyName("did_create")] public bool DidCreate { get; set; }
}
/// <summary>Stream deletion response.</summary>
public sealed class JsApiStreamDeleteResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Stream info request with optional filtering.</summary>
public sealed class JsApiStreamInfoRequest
{
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("deleted_details")] public bool DeletedDetails { get; set; }
[JsonPropertyName("subjects_filter")] public string? SubjectsFilter { get; set; }
}
/// <summary>Stream info response.</summary>
public sealed class JsApiStreamInfoResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
// StreamInfo fields embedded — delegated to StreamInfo stub for now
public StreamInfo? Info { get; set; }
}
/// <summary>Stream names list request.</summary>
public sealed class JsApiStreamNamesRequest
{
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
}
/// <summary>Stream names list response.</summary>
public sealed class JsApiStreamNamesResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("streams")] public List<string>? Streams { get; set; }
}
/// <summary>Detailed stream list request.</summary>
public sealed class JsApiStreamListRequest
{
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
}
/// <summary>Detailed stream list response.</summary>
public sealed class JsApiStreamListResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("streams")] public List<StreamInfo>? Streams { get; set; }
[JsonPropertyName("missing")] public List<string>? Missing { get; set; }
[JsonPropertyName("offline")] public Dictionary<string, string>? Offline { get; set; }
}
/// <summary>Stream purge request.</summary>
public sealed class JsApiStreamPurgeRequest
{
[JsonPropertyName("seq")] public ulong Sequence { get; set; }
[JsonPropertyName("filter")] public string? Subject { get; set; }
[JsonPropertyName("keep")] public ulong Keep { get; set; }
}
/// <summary>Stream purge response.</summary>
public sealed class JsApiStreamPurgeResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("purged")] public ulong Purged { get; set; }
}
/// <summary>Stream update response.</summary>
public sealed class JsApiStreamUpdateResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
public StreamInfo? Info { get; set; }
}
/// <summary>Message deletion request.</summary>
public sealed class JsApiMsgDeleteRequest
{
[JsonPropertyName("seq")] public ulong Seq { get; set; }
[JsonPropertyName("no_erase")] public bool NoErase { get; set; }
}
/// <summary>Message deletion response.</summary>
public sealed class JsApiMsgDeleteResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Stream snapshot request.</summary>
public sealed class JsApiStreamSnapshotRequest
{
[JsonPropertyName("deliver_subject")] public string DeliverSubject { get; set; } = string.Empty;
[JsonPropertyName("no_consumers")] public bool NoConsumers { get; set; }
[JsonPropertyName("chunk_size")] public int ChunkSize { get; set; }
[JsonPropertyName("window_size")] public int WindowSize { get; set; }
[JsonPropertyName("jsck")] public bool CheckMsgs { get; set; }
}
/// <summary>Direct stream snapshot response.</summary>
public sealed class JsApiStreamSnapshotResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("config")] public StreamConfig? Config { get; set; }
[JsonPropertyName("state")] public StreamState? State { get; set; }
}
/// <summary>Stream restore request.</summary>
public sealed class JsApiStreamRestoreRequest
{
[JsonPropertyName("config")] public StreamConfig Config { get; set; } = new();
[JsonPropertyName("state")] public StreamState State { get; set; } = new();
}
/// <summary>Stream restore response.</summary>
public sealed class JsApiStreamRestoreResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("deliver_subject")] public string DeliverSubject { get; set; } = string.Empty;
}
/// <summary>Remove a peer from a stream.</summary>
public sealed class JsApiStreamRemovePeerRequest
{
[JsonPropertyName("peer")] public string Peer { get; set; } = string.Empty;
}
/// <summary>Response to remove peer request.</summary>
public sealed class JsApiStreamRemovePeerResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Response to stream leader step-down.</summary>
public sealed class JsApiStreamLeaderStepDownResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Response to consumer leader step-down.</summary>
public sealed class JsApiConsumerLeaderStepDownResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Meta-leader step-down request with optional placement.</summary>
public sealed class JsApiLeaderStepdownRequest
{
[JsonPropertyName("placement")] public Placement? Placement { get; set; }
}
/// <summary>Response to meta-leader step-down.</summary>
public sealed class JsApiLeaderStepDownResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Remove a peer server from the meta group.</summary>
public sealed class JsApiMetaServerRemoveRequest
{
[JsonPropertyName("peer")] public string Server { get; set; } = string.Empty;
[JsonPropertyName("peer_id")] public string? PeerId { get; set; }
}
/// <summary>Response to peer removal.</summary>
public sealed class JsApiMetaServerRemoveResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Request to move a stream off a server.</summary>
public sealed class JsApiMetaServerStreamMoveRequest
{
[JsonPropertyName("server")] public string? Server { get; set; }
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("tags")] public string[]? Tags { get; set; }
}
/// <summary>Account purge response.</summary>
public sealed class JsApiAccountPurgeResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("initiated")] public bool Initiated { get; set; }
}
// ---------------------------------------------------------------------------
// Message get
// ---------------------------------------------------------------------------
/// <summary>
/// Direct message get request (by sequence, last-for-subject, next-for-subject, or batch).
/// Mirrors <c>JSApiMsgGetRequest</c> in server/jetstream_api.go.
/// </summary>
public sealed class JsApiMsgGetRequest
{
[JsonPropertyName("seq")] public ulong Seq { get; set; }
[JsonPropertyName("last_by_subj")] public string? LastFor { get; set; }
[JsonPropertyName("next_by_subj")] public string? NextFor { get; set; }
[JsonPropertyName("batch")] public int Batch { get; set; }
[JsonPropertyName("max_bytes")] public int MaxBytes { get; set; }
[JsonPropertyName("start_time")] public DateTime? StartTime { get; set; }
[JsonPropertyName("multi_last")] public string[]? MultiLastFor { get; set; }
[JsonPropertyName("up_to_seq")] public ulong UpToSeq { get; set; }
[JsonPropertyName("up_to_time")] public DateTime? UpToTime { get; set; }
[JsonPropertyName("no_hdr")] public bool NoHeaders { get; set; }
}
/// <summary>Message get response.</summary>
public sealed class JsApiMsgGetResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("message")] public StoredMsg? Message { get; set; }
}
// ---------------------------------------------------------------------------
// Consumer API types
// ---------------------------------------------------------------------------
/// <summary>Consumer create/update response.</summary>
public sealed class JsApiConsumerCreateResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
public ConsumerInfo? Info { get; set; }
}
/// <summary>Consumer delete response.</summary>
public sealed class JsApiConsumerDeleteResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("success")] public bool Success { get; set; }
}
/// <summary>Consumer pause request.</summary>
public sealed class JsApiConsumerPauseRequest
{
[JsonPropertyName("pause_until")] public DateTime PauseUntil { get; set; }
}
/// <summary>Consumer pause response.</summary>
public sealed class JsApiConsumerPauseResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("paused")] public bool Paused { get; set; }
[JsonPropertyName("pause_until")] public DateTime PauseUntil { get; set; }
[JsonPropertyName("pause_remaining")] public TimeSpan PauseRemaining { get; set; }
}
/// <summary>Consumer info response.</summary>
public sealed class JsApiConsumerInfoResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
public ConsumerInfo? Info { get; set; }
}
/// <summary>Consumer names request (paged).</summary>
public sealed class JsApiConsumersRequest
{
[JsonPropertyName("offset")] public int Offset { get; set; }
}
/// <summary>Consumer names list response.</summary>
public sealed class JsApiConsumerNamesResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("consumers")] public List<string>? Consumers { get; set; }
}
/// <summary>Consumer list response.</summary>
public sealed class JsApiConsumerListResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("total")] public int Total { get; set; }
[JsonPropertyName("offset")] public int Offset { get; set; }
[JsonPropertyName("limit")] public int Limit { get; set; }
[JsonPropertyName("consumers")] public List<ConsumerInfo>? Consumers { get; set; }
[JsonPropertyName("missing")] public List<string>? Missing { get; set; }
[JsonPropertyName("offline")] public Dictionary<string, string>? Offline { get; set; }
}
/// <summary>
/// Pull consumer next-message request.
/// Mirrors <c>JSApiConsumerGetNextRequest</c> in server/jetstream_api.go.
/// </summary>
public sealed class JsApiConsumerGetNextRequest
{
[JsonPropertyName("expires")] public TimeSpan Expires { get; set; }
[JsonPropertyName("batch")] public int Batch { get; set; }
[JsonPropertyName("max_bytes")] public int MaxBytes { get; set; }
[JsonPropertyName("no_wait")] public bool NoWait { get; set; }
[JsonPropertyName("idle_heartbeat")] public TimeSpan Heartbeat { get; set; }
public PriorityGroup? Priority { get; set; }
}
/// <summary>Consumer reset (seek to sequence) request.</summary>
public sealed class JsApiConsumerResetRequest
{
[JsonPropertyName("seq")] public ulong Seq { get; set; }
}
/// <summary>Consumer reset response.</summary>
public sealed class JsApiConsumerResetResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
[JsonPropertyName("reset_seq")] public ulong ResetSeq { get; set; }
public ConsumerInfo? Info { get; set; }
}
/// <summary>Consumer unpin (priority group) request.</summary>
public sealed class JsApiConsumerUnpinRequest
{
[JsonPropertyName("group")] public string Group { get; set; } = string.Empty;
}
/// <summary>Consumer unpin response.</summary>
public sealed class JsApiConsumerUnpinResponse
{
[JsonPropertyName("type")] public string? Type { get; set; }
[JsonPropertyName("error")] public JsApiError? Error { get; set; }
}

View File

@@ -0,0 +1,132 @@
// Copyright 2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_batching.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// Batching types
// ---------------------------------------------------------------------------
/// <summary>
/// Tracks in-progress atomic publish batch groups for a stream.
/// Mirrors the <c>batching</c> struct in server/jetstream_batching.go.
/// </summary>
internal sealed class Batching
{
private readonly Lock _mu = new();
private readonly Dictionary<string, BatchGroup> _group = new(StringComparer.Ordinal);
public Lock Mu => _mu;
public Dictionary<string, BatchGroup> Group => _group;
}
/// <summary>
/// A single in-progress atomic batch: its temporary store and cleanup timer.
/// Mirrors <c>batchGroup</c> in server/jetstream_batching.go.
/// </summary>
internal sealed class BatchGroup
{
/// <summary>Last proposed stream sequence for this batch.</summary>
public ulong LastSeq { get; set; }
/// <summary>Temporary backing store for the batch's messages.</summary>
public object? Store { get; set; } // IStreamStore — session 20
/// <summary>Timer that abandons the batch after the configured timeout.</summary>
public Timer? BatchTimer { get; set; }
/// <summary>
/// Stops the cleanup timer and flushes pending writes so the batch is
/// ready to be committed.
/// Mirrors <c>batchGroup.readyForCommit</c>.
/// </summary>
public bool ReadyForCommit()
{
// Stub — full implementation requires IStreamStore.FlushAllPending (session 20).
return BatchTimer?.Change(Timeout.Infinite, Timeout.Infinite) != null;
}
}
/// <summary>
/// Stages consistency-check data for a single atomic batch before it is committed.
/// Mirrors <c>batchStagedDiff</c> in server/jetstream_batching.go.
/// </summary>
internal sealed class BatchStagedDiff
{
/// <summary>Message IDs seen in this batch, for duplicate detection.</summary>
public Dictionary<string, object?>? MsgIds { get; set; }
/// <summary>Running counter totals, keyed by subject.</summary>
public Dictionary<string, object?>? Counter { get; set; } // map[string]*msgCounterRunningTotal
/// <summary>Inflight subject byte/op totals for DiscardNew checks.</summary>
public Dictionary<string, object?>? Inflight { get; set; } // map[string]*inflightSubjectRunningTotal
/// <summary>Expected-last-seq-per-subject checks staged in this batch.</summary>
public Dictionary<string, BatchExpectedPerSubject>? ExpectedPerSubject { get; set; }
}
/// <summary>
/// Cached expected-last-sequence-per-subject result for a single subject within a batch.
/// Mirrors <c>batchExpectedPerSubject</c> in server/jetstream_batching.go.
/// </summary>
internal sealed class BatchExpectedPerSubject
{
/// <summary>Stream sequence of the last message on this subject at proposal time.</summary>
public ulong SSeq { get; set; }
/// <summary>Clustered proposal sequence at which this check was computed.</summary>
public ulong ClSeq { get; set; }
}
/// <summary>
/// Tracks the in-progress application of a committed batch on the Raft apply path.
/// Mirrors <c>batchApply</c> in server/jetstream_batching.go.
/// </summary>
internal sealed class BatchApply
{
private readonly Lock _mu = new();
/// <summary>ID of the current batch.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Number of entries expected in the batch (for consistency checks).</summary>
public ulong Count { get; set; }
/// <summary>Raft committed entries that make up this batch.</summary>
public List<object?>? Entries { get; set; } // []*CommittedEntry — session 20+
/// <summary>Index within an entry indicating the first message of the batch.</summary>
public int EntryStart { get; set; }
/// <summary>Applied value before the entry containing the first batch message.</summary>
public ulong MaxApplied { get; set; }
public Lock Mu => _mu;
/// <summary>
/// Clears in-memory apply-batch state.
/// Mirrors <c>batchApply.clearBatchStateLocked</c>.
/// Lock should be held.
/// </summary>
public void ClearBatchStateLocked()
{
Id = string.Empty;
Count = 0;
Entries = null;
EntryStart = 0;
MaxApplied = 0;
}
}

View File

@@ -0,0 +1,524 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_cluster.go in the NATS server Go source.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// JetStreamCluster
// ============================================================================
/// <summary>
/// Holds cluster-level JetStream state on each server: the meta-controller Raft node,
/// stream/consumer assignment maps, and inflight proposal tracking.
/// Mirrors Go <c>jetStreamCluster</c> struct in server/jetstream_cluster.go lines 44-84.
/// </summary>
internal sealed class JetStreamCluster
{
/// <summary>The meta-controller Raft node.</summary>
public IRaftNode? Meta { get; set; }
/// <summary>
/// All known stream assignments. Key: account name → stream name → assignment.
/// </summary>
public Dictionary<string, Dictionary<string, StreamAssignment>> Streams { get; set; } = new();
/// <summary>
/// Inflight stream create/update/delete proposals. Key: account → stream name.
/// </summary>
public Dictionary<string, Dictionary<string, InflightStreamInfo>> InflightStreams { get; set; } = new();
/// <summary>
/// Inflight consumer create/update/delete proposals. Key: account → stream → consumer.
/// </summary>
public Dictionary<string, Dictionary<string, Dictionary<string, InflightConsumerInfo>>> InflightConsumers { get; set; } = new();
/// <summary>
/// Peer-remove reply subjects pending quorum. Key: peer ID.
/// </summary>
public Dictionary<string, PeerRemoveInfo> PeerRemoveReply { get; set; } = new();
/// <summary>Signals meta-leader should re-check stream assignments.</summary>
public bool StreamsCheck { get; set; }
/// <summary>Reference to the top-level server (object to avoid circular dep).</summary>
public object? Server { get; set; }
/// <summary>Internal client for cluster messaging (object to avoid circular dep).</summary>
public object? Client { get; set; }
/// <summary>Subscription that receives stream assignment results (object to avoid session dep).</summary>
public object? StreamResults { get; set; }
/// <summary>Subscription that receives consumer assignment results (object to avoid session dep).</summary>
public object? ConsumerResults { get; set; }
/// <summary>Subscription for meta-leader step-down requests.</summary>
public object? Stepdown { get; set; }
/// <summary>Subscription for peer-remove requests.</summary>
public object? PeerRemove { get; set; }
/// <summary>Subscription for stream-move requests.</summary>
public object? PeerStreamMove { get; set; }
/// <summary>Subscription for stream-move cancellation.</summary>
public object? PeerStreamCancelMove { get; set; }
/// <summary>Channel used to pop out monitorCluster before the raft layer.</summary>
public System.Threading.Channels.Channel<bool>? Qch { get; set; }
/// <summary>Notifies that monitorCluster has actually stopped.</summary>
public System.Threading.Channels.Channel<bool>? Stopped { get; set; }
/// <summary>Last meta-snapshot time (Unix nanoseconds).</summary>
public long LastMetaSnapTime { get; set; }
/// <summary>Duration of last meta-snapshot (nanoseconds).</summary>
public long LastMetaSnapDuration { get; set; }
}
// ============================================================================
// InflightStreamInfo
// ============================================================================
/// <summary>
/// Tracks inflight stream create/update/delete proposals.
/// Mirrors Go <c>inflightStreamInfo</c> in server/jetstream_cluster.go lines 87-91.
/// </summary>
internal sealed class InflightStreamInfo
{
/// <summary>Number of inflight operations.</summary>
public ulong Ops { get; set; }
/// <summary>Whether the stream has been deleted.</summary>
public bool Deleted { get; set; }
public StreamAssignment? Assignment { get; set; }
}
// ============================================================================
// InflightConsumerInfo
// ============================================================================
/// <summary>
/// Tracks inflight consumer create/update/delete proposals.
/// Mirrors Go <c>inflightConsumerInfo</c> in server/jetstream_cluster.go lines 94-98.
/// </summary>
internal sealed class InflightConsumerInfo
{
/// <summary>Number of inflight operations.</summary>
public ulong Ops { get; set; }
/// <summary>Whether the consumer has been deleted.</summary>
public bool Deleted { get; set; }
public ConsumerAssignment? Assignment { get; set; }
}
// ============================================================================
// PeerRemoveInfo
// ============================================================================
/// <summary>
/// Holds the reply information for a peer-remove request pending quorum.
/// Mirrors Go <c>peerRemoveInfo</c> in server/jetstream_cluster.go lines 101-106.
/// </summary>
internal sealed class PeerRemoveInfo
{
/// <summary>Client info from the request (object to avoid session dep).</summary>
public ClientInfo? ClientInfo { get; set; }
public string Subject { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public string Request { get; set; } = string.Empty;
}
// ============================================================================
// EntryOp enum
// ============================================================================
/// <summary>
/// Operation type encoded in a JetStream cluster Raft entry.
/// Mirrors Go <c>entryOp</c> iota in server/jetstream_cluster.go lines 116-150.
/// ONLY ADD TO THE END — inserting values breaks server interoperability.
/// </summary>
internal enum EntryOp : byte
{
// Meta ops
AssignStreamOp = 0,
AssignConsumerOp = 1,
RemoveStreamOp = 2,
RemoveConsumerOp = 3,
// Stream ops
StreamMsgOp = 4,
PurgeStreamOp = 5,
DeleteMsgOp = 6,
// Consumer ops
UpdateDeliveredOp = 7,
UpdateAcksOp = 8,
// Compressed consumer assignments
AssignCompressedConsumerOp = 9,
// Filtered consumer skip
UpdateSkipOp = 10,
// Update stream
UpdateStreamOp = 11,
// Pending pull requests
AddPendingRequest = 12,
RemovePendingRequest = 13,
// Compressed streams (RAFT or catchup)
CompressedStreamMsgOp = 14,
// Deleted gaps on catchups for replicas
DeleteRangeOp = 15,
// Batch stream ops
BatchMsgOp = 16,
BatchCommitMsgOp = 17,
// Consumer reset to specific starting sequence
ResetSeqOp = 18,
}
// ============================================================================
// RaftGroup
// ============================================================================
/// <summary>
/// Describes a Raft consensus group that houses streams and consumers.
/// Controlled by the meta-group controller.
/// Mirrors Go <c>raftGroup</c> struct in server/jetstream_cluster.go lines 154-163.
/// </summary>
internal sealed class RaftGroup
{
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("peers")] public string[] Peers { get; set; } = [];
[JsonPropertyName("store")] public StorageType Storage { get; set; }
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
[JsonPropertyName("preferred")] public string? Preferred { get; set; }
[JsonPropertyName("scale_up")] public bool ScaleUp { get; set; }
/// <summary>Internal Raft node — not serialized.</summary>
[JsonIgnore]
public IRaftNode? Node { get; set; }
}
// ============================================================================
// StreamAssignment
// ============================================================================
/// <summary>
/// What the meta controller uses to assign streams to peers.
/// Mirrors Go <c>streamAssignment</c> struct in server/jetstream_cluster.go lines 166-184.
/// </summary>
internal sealed class StreamAssignment
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("created")] public DateTime Created { get; set; }
[JsonPropertyName("stream")] public JsonElement ConfigJson { get; set; }
[JsonIgnore] public StreamConfig? Config { get; set; }
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
[JsonPropertyName("sync")] public string Sync { get; set; } = string.Empty;
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("reply")] public string? Reply { get; set; }
[JsonPropertyName("restore_state")] public StreamState? Restore { get; set; }
// Internal (not serialized)
[JsonIgnore] public Dictionary<string, ConsumerAssignment>? Consumers { get; set; }
[JsonIgnore] public bool Responded { get; set; }
[JsonIgnore] public bool Recovering { get; set; }
[JsonIgnore] public bool Reassigning { get; set; }
[JsonIgnore] public bool Resetting { get; set; }
[JsonIgnore] public Exception? Error { get; set; }
[JsonIgnore] public UnsupportedStreamAssignment? Unsupported { get; set; }
}
// ============================================================================
// UnsupportedStreamAssignment
// ============================================================================
/// <summary>
/// Holds state for a stream assignment that this server cannot run,
/// so that it can still respond to cluster info requests.
/// Mirrors Go <c>unsupportedStreamAssignment</c> in server/jetstream_cluster.go lines 186-191.
/// </summary>
internal sealed class UnsupportedStreamAssignment
{
public string Reason { get; set; } = string.Empty;
public StreamInfo Info { get; set; } = new();
/// <summary>Internal system client (object to avoid session dep).</summary>
public object? SysClient { get; set; }
/// <summary>Info subscription (object to avoid session dep).</summary>
public object? InfoSub { get; set; }
}
// ============================================================================
// ConsumerAssignment
// ============================================================================
/// <summary>
/// What the meta controller uses to assign consumers to streams.
/// Mirrors Go <c>consumerAssignment</c> struct in server/jetstream_cluster.go lines 250-266.
/// </summary>
internal sealed class ConsumerAssignment
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("created")] public DateTime Created { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
[JsonPropertyName("consumer")] public JsonElement ConfigJson { get; set; }
[JsonIgnore] public ConsumerConfig? Config { get; set; }
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("reply")] public string? Reply { get; set; }
[JsonPropertyName("state")] public ConsumerState? State { get; set; }
// Internal (not serialized)
[JsonIgnore] public bool Responded { get; set; }
[JsonIgnore] public bool Recovering { get; set; }
[JsonIgnore] public Exception? Error { get; set; }
[JsonIgnore] public UnsupportedConsumerAssignment? Unsupported { get; set; }
}
// ============================================================================
// UnsupportedConsumerAssignment
// ============================================================================
/// <summary>
/// Holds state for a consumer assignment that this server cannot run.
/// Mirrors Go <c>unsupportedConsumerAssignment</c> in server/jetstream_cluster.go lines 268-273.
/// </summary>
internal sealed class UnsupportedConsumerAssignment
{
public string Reason { get; set; } = string.Empty;
public ConsumerInfo Info { get; set; } = new();
/// <summary>Internal system client (object to avoid session dep).</summary>
public object? SysClient { get; set; }
/// <summary>Info subscription (object to avoid session dep).</summary>
public object? InfoSub { get; set; }
}
// ============================================================================
// WriteableConsumerAssignment
// ============================================================================
/// <summary>
/// Serialisable form of a consumer assignment used in meta-snapshots.
/// Mirrors Go <c>writeableConsumerAssignment</c> in server/jetstream_cluster.go lines 332-340.
/// </summary>
internal sealed class WriteableConsumerAssignment
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("created")] public DateTime Created { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
[JsonPropertyName("consumer")] public JsonElement ConfigJson { get; set; }
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
[JsonPropertyName("state")] public ConsumerState? State { get; set; }
}
// ============================================================================
// StreamPurge
// ============================================================================
/// <summary>
/// What the stream leader replicates when purging a stream.
/// Mirrors Go <c>streamPurge</c> struct in server/jetstream_cluster.go lines 343-350.
/// </summary>
internal sealed class StreamPurge
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
[JsonPropertyName("reply")] public string Reply { get; set; } = string.Empty;
[JsonPropertyName("request")] public JSApiStreamPurgeRequest? Request { get; set; }
}
// ============================================================================
// StreamMsgDelete
// ============================================================================
/// <summary>
/// What the stream leader replicates when deleting a message.
/// Mirrors Go <c>streamMsgDelete</c> struct in server/jetstream_cluster.go lines 353-360.
/// </summary>
internal sealed class StreamMsgDelete
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
[JsonPropertyName("seq")] public ulong Seq { get; set; }
[JsonPropertyName("no_erase")] public bool NoErase { get; set; }
[JsonPropertyName("subject")] public string Subject { get; set; } = string.Empty;
[JsonPropertyName("reply")] public string Reply { get; set; } = string.Empty;
}
// ============================================================================
// RecoveryUpdates
// ============================================================================
/// <summary>
/// Accumulates stream/consumer changes discovered during meta-recovery so they
/// can be applied after the full snapshot has been processed.
/// Mirrors Go <c>recoveryUpdates</c> struct in server/jetstream_cluster.go lines 1327-1333.
/// </summary>
internal sealed class RecoveryUpdates
{
public Dictionary<string, StreamAssignment> RemoveStreams { get; set; } = new();
public Dictionary<string, Dictionary<string, ConsumerAssignment>> RemoveConsumers { get; set; } = new();
public Dictionary<string, StreamAssignment> AddStreams { get; set; } = new();
public Dictionary<string, StreamAssignment> UpdateStreams { get; set; } = new();
public Dictionary<string, Dictionary<string, ConsumerAssignment>> UpdateConsumers { get; set; } = new();
}
// ============================================================================
// WriteableStreamAssignment
// ============================================================================
/// <summary>
/// Serialisable form of a stream assignment (with its consumers) used in meta-snapshots.
/// Mirrors Go <c>writeableStreamAssignment</c> in server/jetstream_cluster.go lines 1872-1879.
/// </summary>
internal sealed class WriteableStreamAssignment
{
[JsonPropertyName("client")] public ClientInfo? Client { get; set; }
[JsonPropertyName("created")] public DateTime Created { get; set; }
[JsonPropertyName("stream")] public JsonElement ConfigJson { get; set; }
[JsonPropertyName("group")] public RaftGroup? Group { get; set; }
[JsonPropertyName("sync")] public string Sync { get; set; } = string.Empty;
public List<WriteableConsumerAssignment> Consumers { get; set; } = new();
}
// ============================================================================
// ConsumerAssignmentResult
// ============================================================================
/// <summary>
/// Result sent by a member after processing a consumer assignment.
/// Mirrors Go <c>consumerAssignmentResult</c> in server/jetstream_cluster.go lines 5592-5597.
/// </summary>
internal sealed class ConsumerAssignmentResult
{
[JsonPropertyName("account")] public string Account { get; set; } = string.Empty;
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
[JsonPropertyName("consumer")] public string Consumer { get; set; } = string.Empty;
/// <summary>Stub: JSApiConsumerCreateResponse — full type in session 20+.</summary>
[JsonPropertyName("response")] public object? Response { get; set; }
}
// ============================================================================
// StreamAssignmentResult
// ============================================================================
/// <summary>
/// Result sent by a member after processing a stream assignment.
/// Mirrors Go <c>streamAssignmentResult</c> in server/jetstream_cluster.go lines 6779-6785.
/// </summary>
internal sealed class StreamAssignmentResult
{
[JsonPropertyName("account")] public string Account { get; set; } = string.Empty;
[JsonPropertyName("stream")] public string Stream { get; set; } = string.Empty;
/// <summary>Stub: JSApiStreamCreateResponse — full type in session 20+.</summary>
[JsonPropertyName("create_response")] public object? Response { get; set; }
/// <summary>Stub: JSApiStreamRestoreResponse — full type in session 20+.</summary>
[JsonPropertyName("restore_response")] public object? Restore { get; set; }
[JsonPropertyName("is_update")] public bool Update { get; set; }
}
// ============================================================================
// SelectPeerError
// ============================================================================
/// <summary>
/// Collects the reasons why no suitable peer could be found for a placement.
/// Mirrors Go <c>selectPeerError</c> struct in server/jetstream_cluster.go lines 7113-7121.
/// </summary>
internal sealed class SelectPeerError : Exception
{
public bool ExcludeTag { get; set; }
public bool Offline { get; set; }
public bool NoStorage { get; set; }
public bool UniqueTag { get; set; }
public bool Misc { get; set; }
public bool NoJsClust { get; set; }
public HashSet<string>? NoMatchTags { get; set; }
public HashSet<string>? ExcludeTags { get; set; }
public override string Message => BuildMessage();
private string BuildMessage()
{
var b = new System.Text.StringBuilder("no suitable peers for placement");
if (Offline) b.Append(", peer offline");
if (ExcludeTag) b.Append(", exclude tag set");
if (NoStorage) b.Append(", insufficient storage");
if (UniqueTag) b.Append(", server tag not unique");
if (Misc) b.Append(", miscellaneous issue");
if (NoJsClust) b.Append(", jetstream not enabled in cluster");
if (NoMatchTags is { Count: > 0 })
{
b.Append(", tags not matched [");
b.Append(string.Join(", ", NoMatchTags));
b.Append(']');
}
if (ExcludeTags is { Count: > 0 })
{
b.Append(", tags excluded [");
b.Append(string.Join(", ", ExcludeTags));
b.Append(']');
}
return b.ToString();
}
}
// ============================================================================
// StreamSnapshot
// ============================================================================
/// <summary>
/// JSON-serialisable snapshot of a stream's state for cluster catchup.
/// Mirrors Go <c>streamSnapshot</c> struct in server/jetstream_cluster.go lines 9454-9461.
/// </summary>
internal sealed class StreamSnapshot
{
[JsonPropertyName("messages")] public ulong Msgs { get; set; }
[JsonPropertyName("bytes")] public ulong Bytes { get; set; }
[JsonPropertyName("first_seq")] public ulong FirstSeq { get; set; }
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
[JsonPropertyName("clfs")] public ulong Failed { get; set; }
[JsonPropertyName("deleted")] public ulong[]? Deleted { get; set; }
}
// ============================================================================
// StreamSyncRequest
// ============================================================================
/// <summary>
/// Request sent by a lagging follower to trigger stream catch-up from the leader.
/// Mirrors Go <c>streamSyncRequest</c> struct in server/jetstream_cluster.go lines 9707-9713.
/// </summary>
internal sealed class StreamSyncRequest
{
[JsonPropertyName("peer")] public string? Peer { get; set; }
[JsonPropertyName("first_seq")] public ulong FirstSeq { get; set; }
[JsonPropertyName("last_seq")] public ulong LastSeq { get; set; }
[JsonPropertyName("delete_ranges")] public bool DeleteRangesOk { get; set; }
[JsonPropertyName("min_applied")] public ulong MinApplied { get; set; }
}
// ============================================================================
// Stub API request/response types (referenced by cluster types; full impl later)
// ============================================================================
/// <summary>Stub: full definition in session 21 (jetstream_api.go).</summary>
internal sealed class JSApiStreamPurgeRequest
{
[JsonPropertyName("filter")] public string? Filter { get; set; }
[JsonPropertyName("seq")] public ulong? Seq { get; set; }
[JsonPropertyName("keep")] public ulong? Keep { get; set; }
}

View File

@@ -0,0 +1,323 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_errors.go and server/jetstream_errors_generated.go
// in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// JsApiError
// ---------------------------------------------------------------------------
/// <summary>
/// Included in all JetStream API responses when there was an error.
/// Mirrors <c>ApiError</c> in server/jetstream_errors.go.
/// </summary>
public sealed class JsApiError
{
[JsonPropertyName("code")] public int Code { get; set; }
[JsonPropertyName("err_code")] public ushort ErrCode { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
public override string ToString() => $"{Description} ({ErrCode})";
}
// ---------------------------------------------------------------------------
// JsApiErrors — all 203 error code constants
// ---------------------------------------------------------------------------
/// <summary>
/// Pre-built <see cref="JsApiError"/> instances for all JetStream error codes.
/// Mirrors the <c>ApiErrors</c> map in server/jetstream_errors_generated.go.
/// </summary>
public static class JsApiErrors
{
// ---- Account ----
public static readonly JsApiError AccountResourcesExceeded = new() { Code = 400, ErrCode = 10002, Description = "resource limits exceeded for account" };
// ---- Atomic Publish ----
public static readonly JsApiError AtomicPublishContainsDuplicateMessage = new() { Code = 400, ErrCode = 10201, Description = "atomic publish batch contains duplicate message id" };
public static readonly JsApiError AtomicPublishDisabled = new() { Code = 400, ErrCode = 10174, Description = "atomic publish is disabled" };
public static readonly JsApiError AtomicPublishIncompleteBatch = new() { Code = 400, ErrCode = 10176, Description = "atomic publish batch is incomplete" };
public static readonly JsApiError AtomicPublishInvalidBatchCommit = new() { Code = 400, ErrCode = 10200, Description = "atomic publish batch commit is invalid" };
public static readonly JsApiError AtomicPublishInvalidBatchID = new() { Code = 400, ErrCode = 10179, Description = "atomic publish batch ID is invalid" };
public static readonly JsApiError AtomicPublishMissingSeq = new() { Code = 400, ErrCode = 10175, Description = "atomic publish sequence is missing" };
public static readonly JsApiError AtomicPublishTooLargeBatch = new() { Code = 400, ErrCode = 10199, Description = "atomic publish batch is too large: {size}" };
public static readonly JsApiError AtomicPublishUnsupportedHeaderBatch = new() { Code = 400, ErrCode = 10177, Description = "atomic publish unsupported header used: {header}" };
// ---- General ----
public static readonly JsApiError BadRequest = new() { Code = 400, ErrCode = 10003, Description = "bad request" };
// ---- Cluster ----
public static readonly JsApiError ClusterIncomplete = new() { Code = 503, ErrCode = 10004, Description = "incomplete results" };
public static readonly JsApiError ClusterNoPeers = new() { Code = 400, ErrCode = 10005, Description = "{err}" };
public static readonly JsApiError ClusterNotActive = new() { Code = 500, ErrCode = 10006, Description = "JetStream not in clustered mode" };
public static readonly JsApiError ClusterNotAssigned = new() { Code = 500, ErrCode = 10007, Description = "JetStream cluster not assigned to this server" };
public static readonly JsApiError ClusterNotAvail = new() { Code = 503, ErrCode = 10008, Description = "JetStream system temporarily unavailable" };
public static readonly JsApiError ClusterNotLeader = new() { Code = 500, ErrCode = 10009, Description = "JetStream cluster can not handle request" };
public static readonly JsApiError ClusterPeerNotMember = new() { Code = 400, ErrCode = 10040, Description = "peer not a member" };
public static readonly JsApiError ClusterRequired = new() { Code = 503, ErrCode = 10010, Description = "JetStream clustering support required" };
public static readonly JsApiError ClusterServerMemberChangeInflight = new() { Code = 400, ErrCode = 10202, Description = "cluster member change is in progress" };
public static readonly JsApiError ClusterServerNotMember = new() { Code = 400, ErrCode = 10044, Description = "server is not a member of the cluster" };
public static readonly JsApiError ClusterTags = new() { Code = 400, ErrCode = 10011, Description = "tags placement not supported for operation" };
public static readonly JsApiError ClusterUnSupportFeature = new() { Code = 503, ErrCode = 10036, Description = "not currently supported in clustered mode" };
// ---- Consumer ----
public static readonly JsApiError ConsumerAckPolicyInvalid = new() { Code = 400, ErrCode = 10181, Description = "consumer ack policy invalid" };
public static readonly JsApiError ConsumerAckWaitNegative = new() { Code = 400, ErrCode = 10183, Description = "consumer ack wait needs to be positive" };
public static readonly JsApiError ConsumerAlreadyExists = new() { Code = 400, ErrCode = 10148, Description = "consumer already exists" };
public static readonly JsApiError ConsumerBackOffNegative = new() { Code = 400, ErrCode = 10184, Description = "consumer backoff needs to be positive" };
public static readonly JsApiError ConsumerBadDurableName = new() { Code = 400, ErrCode = 10103, Description = "durable name can not contain '.', '*', '>'" };
public static readonly JsApiError ConsumerConfigRequired = new() { Code = 400, ErrCode = 10078, Description = "consumer config required" };
public static readonly JsApiError ConsumerCreateDurableAndNameMismatch = new() { Code = 400, ErrCode = 10132, Description = "Consumer Durable and Name have to be equal if both are provided" };
public static readonly JsApiError ConsumerCreateErr = new() { Code = 500, ErrCode = 10012, Description = "{err}" };
public static readonly JsApiError ConsumerCreateFilterSubjectMismatch = new() { Code = 400, ErrCode = 10131, Description = "Consumer create request did not match filtered subject from create subject" };
public static readonly JsApiError ConsumerDeliverCycle = new() { Code = 400, ErrCode = 10081, Description = "consumer deliver subject forms a cycle" };
public static readonly JsApiError ConsumerDeliverToWildcards = new() { Code = 400, ErrCode = 10079, Description = "consumer deliver subject has wildcards" };
public static readonly JsApiError ConsumerDescriptionTooLong = new() { Code = 400, ErrCode = 10107, Description = "consumer description is too long, maximum allowed is {max}" };
public static readonly JsApiError ConsumerDirectRequiresEphemeral = new() { Code = 400, ErrCode = 10091, Description = "consumer direct requires an ephemeral consumer" };
public static readonly JsApiError ConsumerDirectRequiresPush = new() { Code = 400, ErrCode = 10090, Description = "consumer direct requires a push based consumer" };
public static readonly JsApiError ConsumerDoesNotExist = new() { Code = 400, ErrCode = 10149, Description = "consumer does not exist" };
public static readonly JsApiError ConsumerDuplicateFilterSubjects = new() { Code = 400, ErrCode = 10136, Description = "consumer cannot have both FilterSubject and FilterSubjects specified" };
public static readonly JsApiError ConsumerDurableNameNotInSubject = new() { Code = 400, ErrCode = 10016, Description = "consumer expected to be durable but no durable name set in subject" };
public static readonly JsApiError ConsumerDurableNameNotMatchSubject = new() { Code = 400, ErrCode = 10017, Description = "consumer name in subject does not match durable name in request" };
public static readonly JsApiError ConsumerDurableNameNotSet = new() { Code = 400, ErrCode = 10018, Description = "consumer expected to be durable but a durable name was not set" };
public static readonly JsApiError ConsumerEmptyFilter = new() { Code = 400, ErrCode = 10139, Description = "consumer filter in FilterSubjects cannot be empty" };
public static readonly JsApiError ConsumerEmptyGroupName = new() { Code = 400, ErrCode = 10161, Description = "Group name cannot be an empty string" };
public static readonly JsApiError ConsumerEphemeralWithDurableInSubject = new() { Code = 400, ErrCode = 10019, Description = "consumer expected to be ephemeral but detected a durable name set in subject" };
public static readonly JsApiError ConsumerEphemeralWithDurableName = new() { Code = 400, ErrCode = 10020, Description = "consumer expected to be ephemeral but a durable name was set in request" };
public static readonly JsApiError ConsumerExistingActive = new() { Code = 400, ErrCode = 10105, Description = "consumer already exists and is still active" };
public static readonly JsApiError ConsumerFCRequiresPush = new() { Code = 400, ErrCode = 10089, Description = "consumer flow control requires a push based consumer" };
public static readonly JsApiError ConsumerFilterNotSubset = new() { Code = 400, ErrCode = 10093, Description = "consumer filter subject is not a valid subset of the interest subjects" };
public static readonly JsApiError ConsumerHBRequiresPush = new() { Code = 400, ErrCode = 10088, Description = "consumer idle heartbeat requires a push based consumer" };
public static readonly JsApiError ConsumerInactiveThresholdExcess = new() { Code = 400, ErrCode = 10153, Description = "consumer inactive threshold exceeds system limit of {limit}" };
public static readonly JsApiError ConsumerInvalidDeliverSubject = new() { Code = 400, ErrCode = 10112, Description = "invalid push consumer deliver subject" };
public static readonly JsApiError ConsumerInvalidGroupName = new() { Code = 400, ErrCode = 10162, Description = "Valid priority group name must match A-Z, a-z, 0-9, -_/=)+ and may not exceed 16 characters" };
public static readonly JsApiError ConsumerInvalidPolicy = new() { Code = 400, ErrCode = 10094, Description = "{err}" };
public static readonly JsApiError ConsumerInvalidPriorityGroup = new() { Code = 400, ErrCode = 10160, Description = "Provided priority group does not exist for this consumer" };
public static readonly JsApiError ConsumerInvalidReset = new() { Code = 400, ErrCode = 10204, Description = "invalid reset: {err}" };
public static readonly JsApiError ConsumerInvalidSampling = new() { Code = 400, ErrCode = 10095, Description = "failed to parse consumer sampling configuration: {err}" };
public static readonly JsApiError ConsumerMaxDeliverBackoff = new() { Code = 400, ErrCode = 10116, Description = "max deliver is required to be > length of backoff values" };
public static readonly JsApiError ConsumerMaxPendingAckExcess = new() { Code = 400, ErrCode = 10121, Description = "consumer max ack pending exceeds system limit of {limit}" };
public static readonly JsApiError ConsumerMaxPendingAckPolicyRequired = new() { Code = 400, ErrCode = 10082, Description = "consumer requires ack policy for max ack pending" };
public static readonly JsApiError ConsumerMaxRequestBatchExceeded = new() { Code = 400, ErrCode = 10125, Description = "consumer max request batch exceeds server limit of {limit}" };
public static readonly JsApiError ConsumerMaxRequestBatchNegative = new() { Code = 400, ErrCode = 10114, Description = "consumer max request batch needs to be > 0" };
public static readonly JsApiError ConsumerMaxRequestExpiresTooSmall = new() { Code = 400, ErrCode = 10115, Description = "consumer max request expires needs to be >= 1ms" };
public static readonly JsApiError ConsumerMaxWaitingNegative = new() { Code = 400, ErrCode = 10087, Description = "consumer max waiting needs to be positive" };
public static readonly JsApiError ConsumerMetadataLength = new() { Code = 400, ErrCode = 10135, Description = "consumer metadata exceeds maximum size of {limit}" };
public static readonly JsApiError ConsumerMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10137, Description = "consumer with multiple subject filters cannot use subject based API" };
public static readonly JsApiError ConsumerNameContainsPathSeparators = new() { Code = 400, ErrCode = 10127, Description = "Consumer name can not contain path separators" };
public static readonly JsApiError ConsumerNameExist = new() { Code = 400, ErrCode = 10013, Description = "consumer name already in use" };
public static readonly JsApiError ConsumerNameTooLong = new() { Code = 400, ErrCode = 10102, Description = "consumer name is too long, maximum allowed is {max}" };
public static readonly JsApiError ConsumerNotFound = new() { Code = 404, ErrCode = 10014, Description = "consumer not found" };
public static readonly JsApiError ConsumerOffline = new() { Code = 500, ErrCode = 10119, Description = "consumer is offline" };
public static readonly JsApiError ConsumerOfflineReason = new() { Code = 500, ErrCode = 10195, Description = "consumer is offline: {err}" };
public static readonly JsApiError ConsumerOnMapped = new() { Code = 400, ErrCode = 10092, Description = "consumer direct on a mapped consumer" };
public static readonly JsApiError ConsumerOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10138, Description = "consumer subject filters cannot overlap" };
public static readonly JsApiError ConsumerPinnedTTLWithoutPriorityPolicyNone = new() { Code = 400, ErrCode = 10197, Description = "PinnedTTL cannot be set when PriorityPolicy is none" };
public static readonly JsApiError ConsumerPriorityGroupWithPolicyNone = new() { Code = 400, ErrCode = 10196, Description = "consumer can not have priority groups when policy is none" };
public static readonly JsApiError ConsumerPriorityPolicyWithoutGroup = new() { Code = 400, ErrCode = 10159, Description = "Setting PriorityPolicy requires at least one PriorityGroup to be set" };
public static readonly JsApiError ConsumerPullNotDurable = new() { Code = 400, ErrCode = 10085, Description = "consumer in pull mode requires a durable name" };
public static readonly JsApiError ConsumerPullRequiresAck = new() { Code = 400, ErrCode = 10084, Description = "consumer in pull mode requires explicit ack policy on workqueue stream" };
public static readonly JsApiError ConsumerPullWithRateLimit = new() { Code = 400, ErrCode = 10086, Description = "consumer in pull mode can not have rate limit set" };
public static readonly JsApiError ConsumerPushMaxWaiting = new() { Code = 400, ErrCode = 10080, Description = "consumer in push mode can not set max waiting" };
public static readonly JsApiError ConsumerPushWithPriorityGroup = new() { Code = 400, ErrCode = 10178, Description = "priority groups can not be used with push consumers" };
public static readonly JsApiError ConsumerReplacementWithDifferentName = new() { Code = 400, ErrCode = 10106, Description = "consumer replacement durable config not the same" };
public static readonly JsApiError ConsumerReplayPolicyInvalid = new() { Code = 400, ErrCode = 10182, Description = "consumer replay policy invalid" };
public static readonly JsApiError ConsumerReplicasExceedsStream = new() { Code = 400, ErrCode = 10126, Description = "consumer config replica count exceeds parent stream" };
public static readonly JsApiError ConsumerReplicasShouldMatchStream = new() { Code = 400, ErrCode = 10134, Description = "consumer config replicas must match interest retention stream's replicas" };
public static readonly JsApiError ConsumerSmallHeartbeat = new() { Code = 400, ErrCode = 10083, Description = "consumer idle heartbeat needs to be >= 100ms" };
public static readonly JsApiError ConsumerStoreFailed = new() { Code = 500, ErrCode = 10104, Description = "error creating store for consumer: {err}" };
public static readonly JsApiError ConsumerWQConsumerNotDeliverAll = new() { Code = 400, ErrCode = 10101, Description = "consumer must be deliver all on workqueue stream" };
public static readonly JsApiError ConsumerWQConsumerNotUnique = new() { Code = 400, ErrCode = 10100, Description = "filtered consumer not unique on workqueue stream" };
public static readonly JsApiError ConsumerWQMultipleUnfiltered = new() { Code = 400, ErrCode = 10099, Description = "multiple non-filtered consumers not allowed on workqueue stream" };
public static readonly JsApiError ConsumerWQRequiresExplicitAck = new() { Code = 400, ErrCode = 10098, Description = "workqueue stream requires explicit ack" };
public static readonly JsApiError ConsumerWithFlowControlNeedsHeartbeats = new() { Code = 400, ErrCode = 10108, Description = "consumer with flow control also needs heartbeats" };
// ---- Resources ----
public static readonly JsApiError InsufficientResources = new() { Code = 503, ErrCode = 10023, Description = "insufficient resources" };
public static readonly JsApiError InvalidJSON = new() { Code = 400, ErrCode = 10025, Description = "invalid JSON: {err}" };
public static readonly JsApiError MaximumConsumersLimit = new() { Code = 400, ErrCode = 10026, Description = "maximum consumers limit reached" };
public static readonly JsApiError MaximumStreamsLimit = new() { Code = 400, ErrCode = 10027, Description = "maximum number of streams reached" };
public static readonly JsApiError MemoryResourcesExceeded = new() { Code = 500, ErrCode = 10028, Description = "insufficient memory resources available" };
// ---- Message Counter ----
public static readonly JsApiError MessageCounterBroken = new() { Code = 400, ErrCode = 10172, Description = "message counter is broken" };
public static readonly JsApiError MessageIncrDisabled = new() { Code = 400, ErrCode = 10168, Description = "message counters is disabled" };
public static readonly JsApiError MessageIncrInvalid = new() { Code = 400, ErrCode = 10171, Description = "message counter increment is invalid" };
public static readonly JsApiError MessageIncrMissing = new() { Code = 400, ErrCode = 10169, Description = "message counter increment is missing" };
public static readonly JsApiError MessageIncrPayload = new() { Code = 400, ErrCode = 10170, Description = "message counter has payload" };
// ---- Message Schedules ----
public static readonly JsApiError MessageSchedulesDisabled = new() { Code = 400, ErrCode = 10188, Description = "message schedules is disabled" };
public static readonly JsApiError MessageSchedulesPatternInvalid = new() { Code = 400, ErrCode = 10189, Description = "message schedules pattern is invalid" };
public static readonly JsApiError MessageSchedulesRollupInvalid = new() { Code = 400, ErrCode = 10192, Description = "message schedules invalid rollup" };
public static readonly JsApiError MessageSchedulesSourceInvalid = new() { Code = 400, ErrCode = 10203, Description = "message schedules source is invalid" };
public static readonly JsApiError MessageSchedulesTTLInvalid = new() { Code = 400, ErrCode = 10191, Description = "message schedules invalid per-message TTL" };
public static readonly JsApiError MessageSchedulesTargetInvalid = new() { Code = 400, ErrCode = 10190, Description = "message schedules target is invalid" };
// ---- Message TTL ----
public static readonly JsApiError MessageTTLDisabled = new() { Code = 400, ErrCode = 10166, Description = "per-message TTL is disabled" };
public static readonly JsApiError MessageTTLInvalid = new() { Code = 400, ErrCode = 10165, Description = "invalid per-message TTL" };
// ---- Mirror ----
public static readonly JsApiError MirrorConsumerSetupFailed = new() { Code = 500, ErrCode = 10029, Description = "{err}" };
public static readonly JsApiError MirrorInvalidStreamName = new() { Code = 400, ErrCode = 10142, Description = "mirrored stream name is invalid" };
public static readonly JsApiError MirrorInvalidSubjectFilter = new() { Code = 400, ErrCode = 10151, Description = "mirror transform source: {err}" };
public static readonly JsApiError MirrorInvalidTransformDestination = new() { Code = 400, ErrCode = 10154, Description = "mirror transform: {err}" };
public static readonly JsApiError MirrorMaxMessageSizeTooBig = new() { Code = 400, ErrCode = 10030, Description = "stream mirror must have max message size >= source" };
public static readonly JsApiError MirrorMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10150, Description = "mirror with multiple subject transforms cannot also have a single subject filter" };
public static readonly JsApiError MirrorOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10152, Description = "mirror subject filters can not overlap" };
public static readonly JsApiError MirrorWithAtomicPublish = new() { Code = 400, ErrCode = 10198, Description = "stream mirrors can not also use atomic publishing" };
public static readonly JsApiError MirrorWithCounters = new() { Code = 400, ErrCode = 10173, Description = "stream mirrors can not also calculate counters" };
public static readonly JsApiError MirrorWithFirstSeq = new() { Code = 400, ErrCode = 10143, Description = "stream mirrors can not have first sequence configured" };
public static readonly JsApiError MirrorWithMsgSchedules = new() { Code = 400, ErrCode = 10186, Description = "stream mirrors can not also schedule messages" };
public static readonly JsApiError MirrorWithSources = new() { Code = 400, ErrCode = 10031, Description = "stream mirrors can not also contain other sources" };
public static readonly JsApiError MirrorWithStartSeqAndTime = new() { Code = 400, ErrCode = 10032, Description = "stream mirrors can not have both start seq and start time configured" };
public static readonly JsApiError MirrorWithSubjectFilters = new() { Code = 400, ErrCode = 10033, Description = "stream mirrors can not contain filtered subjects" };
public static readonly JsApiError MirrorWithSubjects = new() { Code = 400, ErrCode = 10034, Description = "stream mirrors can not contain subjects" };
// ---- Misc ----
public static readonly JsApiError NoAccount = new() { Code = 503, ErrCode = 10035, Description = "account not found" };
public static readonly JsApiError NoLimits = new() { Code = 400, ErrCode = 10120, Description = "no JetStream default or applicable tiered limit present" };
public static readonly JsApiError NoMessageFound = new() { Code = 404, ErrCode = 10037, Description = "no message found" };
public static readonly JsApiError NotEmptyRequest = new() { Code = 400, ErrCode = 10038, Description = "expected an empty request payload" };
public static readonly JsApiError NotEnabled = new() { Code = 503, ErrCode = 10076, Description = "JetStream not enabled" };
public static readonly JsApiError NotEnabledForAccount = new() { Code = 503, ErrCode = 10039, Description = "JetStream not enabled for account" };
public static readonly JsApiError Pedantic = new() { Code = 400, ErrCode = 10157, Description = "pedantic mode: {err}" };
public static readonly JsApiError PeerRemap = new() { Code = 503, ErrCode = 10075, Description = "peer remap failed" };
// ---- RAFT ----
public static readonly JsApiError RaftGeneralErr = new() { Code = 500, ErrCode = 10041, Description = "{err}" };
// ---- Replicas ----
public static readonly JsApiError ReplicasCountCannotBeNegative = new() { Code = 400, ErrCode = 10133, Description = "replicas count cannot be negative" };
public static readonly JsApiError RequiredApiLevel = new() { Code = 412, ErrCode = 10185, Description = "JetStream minimum api level required" };
// ---- Restore ----
public static readonly JsApiError RestoreSubscribeFailed = new() { Code = 500, ErrCode = 10042, Description = "JetStream unable to subscribe to restore snapshot {subject}: {err}" };
// ---- Sequence ----
public static readonly JsApiError SequenceNotFound = new() { Code = 400, ErrCode = 10043, Description = "sequence {seq} not found" };
public static readonly JsApiError SnapshotDeliverSubjectInvalid = new() { Code = 400, ErrCode = 10015, Description = "deliver subject not valid" };
// ---- Source ----
public static readonly JsApiError SourceConsumerSetupFailed = new() { Code = 500, ErrCode = 10045, Description = "{err}" };
public static readonly JsApiError SourceDuplicateDetected = new() { Code = 400, ErrCode = 10140, Description = "duplicate source configuration detected" };
public static readonly JsApiError SourceInvalidStreamName = new() { Code = 400, ErrCode = 10141, Description = "sourced stream name is invalid" };
public static readonly JsApiError SourceInvalidSubjectFilter = new() { Code = 400, ErrCode = 10145, Description = "source transform source: {err}" };
public static readonly JsApiError SourceInvalidTransformDestination = new() { Code = 400, ErrCode = 10146, Description = "source transform: {err}" };
public static readonly JsApiError SourceMaxMessageSizeTooBig = new() { Code = 400, ErrCode = 10046, Description = "stream source must have max message size >= target" };
public static readonly JsApiError SourceMultipleFiltersNotAllowed = new() { Code = 400, ErrCode = 10144, Description = "source with multiple subject transforms cannot also have a single subject filter" };
public static readonly JsApiError SourceOverlappingSubjectFilters = new() { Code = 400, ErrCode = 10147, Description = "source filters can not overlap" };
public static readonly JsApiError SourceWithMsgSchedules = new() { Code = 400, ErrCode = 10187, Description = "stream source can not also schedule messages" };
// ---- Storage ----
public static readonly JsApiError StorageResourcesExceeded = new() { Code = 500, ErrCode = 10047, Description = "insufficient storage resources available" };
// ---- Stream ----
public static readonly JsApiError StreamAssignment = new() { Code = 500, ErrCode = 10048, Description = "{err}" };
public static readonly JsApiError StreamCreate = new() { Code = 500, ErrCode = 10049, Description = "{err}" };
public static readonly JsApiError StreamDelete = new() { Code = 500, ErrCode = 10050, Description = "{err}" };
public static readonly JsApiError StreamDuplicateMessageConflict = new() { Code = 409, ErrCode = 10158, Description = "duplicate message id is in process" };
public static readonly JsApiError StreamExpectedLastSeqPerSubjectInvalid = new() { Code = 400, ErrCode = 10193, Description = "missing sequence for expected last sequence per subject" };
public static readonly JsApiError StreamExpectedLastSeqPerSubjectNotReady = new() { Code = 503, ErrCode = 10163, Description = "expected last sequence per subject temporarily unavailable" };
public static readonly JsApiError StreamExternalApiOverlap = new() { Code = 400, ErrCode = 10021, Description = "stream external api prefix {prefix} must not overlap with {subject}" };
public static readonly JsApiError StreamExternalDelPrefixOverlaps = new() { Code = 400, ErrCode = 10022, Description = "stream external delivery prefix {prefix} overlaps with stream subject {subject}" };
public static readonly JsApiError StreamGeneralError = new() { Code = 500, ErrCode = 10051, Description = "{err}" };
public static readonly JsApiError StreamHeaderExceedsMaximum = new() { Code = 400, ErrCode = 10097, Description = "header size exceeds maximum allowed of 64k" };
public static readonly JsApiError StreamInfoMaxSubjects = new() { Code = 500, ErrCode = 10117, Description = "subject details would exceed maximum allowed" };
public static readonly JsApiError StreamInvalidConfig = new() { Code = 500, ErrCode = 10052, Description = "{err}" };
public static readonly JsApiError StreamInvalid = new() { Code = 500, ErrCode = 10096, Description = "stream not valid" };
public static readonly JsApiError StreamInvalidExternalDeliverySubj = new() { Code = 400, ErrCode = 10024, Description = "stream external delivery prefix {prefix} must not contain wildcards" };
public static readonly JsApiError StreamLimits = new() { Code = 500, ErrCode = 10053, Description = "{err}" };
public static readonly JsApiError StreamMaxBytesRequired = new() { Code = 400, ErrCode = 10113, Description = "account requires a stream config to have max bytes set" };
public static readonly JsApiError StreamMaxStreamBytesExceeded = new() { Code = 400, ErrCode = 10122, Description = "stream max bytes exceeds account limit max stream bytes" };
public static readonly JsApiError StreamMessageExceedsMaximum = new() { Code = 400, ErrCode = 10054, Description = "message size exceeds maximum allowed" };
public static readonly JsApiError StreamMinLastSeq = new() { Code = 412, ErrCode = 10180, Description = "min last sequence" };
public static readonly JsApiError StreamMirrorNotUpdatable = new() { Code = 400, ErrCode = 10055, Description = "stream mirror configuration can not be updated" };
public static readonly JsApiError StreamMismatch = new() { Code = 400, ErrCode = 10056, Description = "stream name in subject does not match request" };
public static readonly JsApiError StreamMoveAndScale = new() { Code = 400, ErrCode = 10123, Description = "can not move and scale a stream in a single update" };
public static readonly JsApiError StreamMoveInProgress = new() { Code = 400, ErrCode = 10124, Description = "stream move already in progress: {msg}" };
public static readonly JsApiError StreamMoveNotInProgress = new() { Code = 400, ErrCode = 10129, Description = "stream move not in progress" };
public static readonly JsApiError StreamMsgDeleteFailed = new() { Code = 500, ErrCode = 10057, Description = "{err}" };
public static readonly JsApiError StreamNameContainsPathSeparators = new() { Code = 400, ErrCode = 10128, Description = "Stream name can not contain path separators" };
public static readonly JsApiError StreamNameExist = new() { Code = 400, ErrCode = 10058, Description = "stream name already in use with a different configuration" };
public static readonly JsApiError StreamNameExistRestoreFailed = new() { Code = 400, ErrCode = 10130, Description = "stream name already in use, cannot restore" };
public static readonly JsApiError StreamNotFound = new() { Code = 404, ErrCode = 10059, Description = "stream not found" };
public static readonly JsApiError StreamNotMatch = new() { Code = 400, ErrCode = 10060, Description = "expected stream does not match" };
public static readonly JsApiError StreamOffline = new() { Code = 500, ErrCode = 10118, Description = "stream is offline" };
public static readonly JsApiError StreamOfflineReason = new() { Code = 500, ErrCode = 10194, Description = "stream is offline: {err}" };
public static readonly JsApiError StreamPurgeFailed = new() { Code = 500, ErrCode = 10110, Description = "{err}" };
public static readonly JsApiError StreamReplicasNotSupported = new() { Code = 500, ErrCode = 10074, Description = "replicas > 1 not supported in non-clustered mode" };
public static readonly JsApiError StreamReplicasNotUpdatable = new() { Code = 400, ErrCode = 10061, Description = "Replicas configuration can not be updated" };
public static readonly JsApiError StreamRestore = new() { Code = 500, ErrCode = 10062, Description = "restore failed: {err}" };
public static readonly JsApiError StreamRollupFailed = new() { Code = 500, ErrCode = 10111, Description = "{err}" };
public static readonly JsApiError StreamSealed = new() { Code = 400, ErrCode = 10109, Description = "invalid operation on sealed stream" };
public static readonly JsApiError StreamSequenceNotMatch = new() { Code = 503, ErrCode = 10063, Description = "expected stream sequence does not match" };
public static readonly JsApiError StreamSnapshot = new() { Code = 500, ErrCode = 10064, Description = "snapshot failed: {err}" };
public static readonly JsApiError StreamStoreFailed = new() { Code = 503, ErrCode = 10077, Description = "{err}" };
public static readonly JsApiError StreamSubjectOverlap = new() { Code = 400, ErrCode = 10065, Description = "subjects overlap with an existing stream" };
public static readonly JsApiError StreamTemplateCreate = new() { Code = 500, ErrCode = 10066, Description = "{err}" };
public static readonly JsApiError StreamTemplateDelete = new() { Code = 500, ErrCode = 10067, Description = "{err}" };
public static readonly JsApiError StreamTemplateNotFound = new() { Code = 404, ErrCode = 10068, Description = "template not found" };
public static readonly JsApiError StreamTooManyRequests = new() { Code = 429, ErrCode = 10167, Description = "too many requests" };
public static readonly JsApiError StreamTransformInvalidDestination = new() { Code = 400, ErrCode = 10156, Description = "stream transform: {err}" };
public static readonly JsApiError StreamTransformInvalidSource = new() { Code = 400, ErrCode = 10155, Description = "stream transform source: {err}" };
public static readonly JsApiError StreamUpdate = new() { Code = 500, ErrCode = 10069, Description = "{err}" };
public static readonly JsApiError StreamWrongLastMsgID = new() { Code = 400, ErrCode = 10070, Description = "wrong last msg ID: {id}" };
public static readonly JsApiError StreamWrongLastSequenceConstant = new() { Code = 400, ErrCode = 10164, Description = "wrong last sequence" };
public static readonly JsApiError StreamWrongLastSequence = new() { Code = 400, ErrCode = 10071, Description = "wrong last sequence: {seq}" };
// ---- Temp storage ----
public static readonly JsApiError TempStorageFailed = new() { Code = 500, ErrCode = 10072, Description = "JetStream unable to open temp storage for restore" };
public static readonly JsApiError TemplateNameNotMatchSubject = new() { Code = 400, ErrCode = 10073, Description = "template name in subject does not match request" };
// ---------------------------------------------------------------------------
// Lookup by ErrCode
// ---------------------------------------------------------------------------
private static readonly Dictionary<ushort, JsApiError> _byErrCode;
static JsApiErrors()
{
_byErrCode = new Dictionary<ushort, JsApiError>();
foreach (var field in typeof(JsApiErrors).GetFields(
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
{
if (field.GetValue(null) is JsApiError e)
_byErrCode.TryAdd(e.ErrCode, e);
}
}
/// <summary>
/// Returns the pre-built <see cref="JsApiError"/> for the given err_code, or null if not found.
/// </summary>
public static JsApiError? ForErrCode(ushort errCode) =>
_byErrCode.TryGetValue(errCode, out var e) ? e : null;
/// <summary>
/// Returns true if the given <see cref="JsApiError"/> matches one or more of the supplied err_codes.
/// Mirrors <c>IsNatsErr</c> in server/jetstream_errors.go.
/// </summary>
public static bool IsNatsError(JsApiError? err, params ushort[] errCodes)
{
if (err is null) return false;
foreach (var code in errCodes)
if (err.ErrCode == code) return true;
return false;
}
}

View File

@@ -0,0 +1,293 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream.go in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// JetStreamConfig
// ---------------------------------------------------------------------------
/// <summary>
/// Configuration for the JetStream subsystem.
/// Mirrors <c>JetStreamConfig</c> in server/jetstream.go.
/// </summary>
public sealed class JetStreamConfig
{
/// <summary>Maximum size of memory-backed streams.</summary>
[JsonPropertyName("max_memory")]
public long MaxMemory { get; set; }
/// <summary>Maximum size of file-backed streams.</summary>
[JsonPropertyName("max_storage")]
public long MaxStore { get; set; }
/// <summary>Directory where storage files are stored.</summary>
[JsonPropertyName("store_dir")]
public string StoreDir { get; set; } = string.Empty;
/// <summary>How frequently we sync to disk in the background via fsync.</summary>
[JsonPropertyName("sync_interval")]
public TimeSpan SyncInterval { get; set; }
/// <summary>If true, flushes are done after every write.</summary>
[JsonPropertyName("sync_always")]
public bool SyncAlways { get; set; }
/// <summary>The JetStream domain for this server.</summary>
[JsonPropertyName("domain")]
public string Domain { get; set; } = string.Empty;
/// <summary>Whether compression is supported.</summary>
[JsonPropertyName("compress_ok")]
public bool CompressOK { get; set; }
/// <summary>Unique tag assigned to this server instance.</summary>
[JsonPropertyName("unique_tag")]
public string UniqueTag { get; set; } = string.Empty;
/// <summary>Whether strict JSON parsing is performed.</summary>
[JsonPropertyName("strict")]
public bool Strict { get; set; }
}
// ---------------------------------------------------------------------------
// JetStreamApiStats
// ---------------------------------------------------------------------------
/// <summary>
/// Statistics about the JetStream API usage for this server.
/// Mirrors <c>JetStreamAPIStats</c> in server/jetstream.go.
/// </summary>
public sealed class JetStreamApiStats
{
/// <summary>Active API level implemented by this server.</summary>
[JsonPropertyName("level")]
public int Level { get; set; }
/// <summary>Total API requests received since server start.</summary>
[JsonPropertyName("total")]
public ulong Total { get; set; }
/// <summary>Total API requests that resulted in error responses.</summary>
[JsonPropertyName("errors")]
public ulong Errors { get; set; }
/// <summary>Number of API requests currently being served.</summary>
[JsonPropertyName("inflight")]
public ulong Inflight { get; set; }
}
// ---------------------------------------------------------------------------
// JetStreamStats
// ---------------------------------------------------------------------------
/// <summary>
/// Statistics about JetStream for this server.
/// Mirrors <c>JetStreamStats</c> in server/jetstream.go.
/// </summary>
public sealed class JetStreamStats
{
[JsonPropertyName("memory")] public ulong Memory { get; set; }
[JsonPropertyName("storage")] public ulong Store { get; set; }
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
[JsonPropertyName("reserved_storage")] public ulong ReservedStore { 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();
}
// ---------------------------------------------------------------------------
// JetStreamAccountLimits
// ---------------------------------------------------------------------------
/// <summary>
/// Per-account JetStream limits.
/// Mirrors <c>JetStreamAccountLimits</c> in server/jetstream.go.
/// </summary>
public sealed class JetStreamAccountLimits
{
[JsonPropertyName("max_memory")] public long MaxMemory { get; set; }
[JsonPropertyName("max_storage")] public long MaxStore { get; set; }
[JsonPropertyName("max_streams")] public int MaxStreams { get; set; }
[JsonPropertyName("max_consumers")] public int MaxConsumers { get; set; }
[JsonPropertyName("max_ack_pending")] public int MaxAckPending { get; set; }
[JsonPropertyName("memory_max_stream_bytes")] public long MemoryMaxStreamBytes { get; set; }
[JsonPropertyName("storage_max_stream_bytes")] public long StoreMaxStreamBytes { get; set; }
[JsonPropertyName("max_bytes_required")] public bool MaxBytesRequired { get; set; }
}
// ---------------------------------------------------------------------------
// JetStreamTier
// ---------------------------------------------------------------------------
/// <summary>
/// Per-tier JetStream usage and limits.
/// Mirrors <c>JetStreamTier</c> in server/jetstream.go.
/// </summary>
public sealed class JetStreamTier
{
[JsonPropertyName("memory")] public ulong Memory { get; set; }
[JsonPropertyName("storage")] public ulong Store { get; set; }
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
[JsonPropertyName("streams")] public int Streams { get; set; }
[JsonPropertyName("consumers")] public int Consumers { get; set; }
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
}
// ---------------------------------------------------------------------------
// JetStreamAccountStats
// ---------------------------------------------------------------------------
/// <summary>
/// Current statistics about an account's JetStream usage.
/// Mirrors <c>JetStreamAccountStats</c> in server/jetstream.go.
/// Embeds <see cref="JetStreamTier"/> for totals.
/// </summary>
public sealed class JetStreamAccountStats
{
// Embedded JetStreamTier fields (Go struct embedding)
[JsonPropertyName("memory")] public ulong Memory { get; set; }
[JsonPropertyName("storage")] public ulong Store { get; set; }
[JsonPropertyName("reserved_memory")] public ulong ReservedMemory { get; set; }
[JsonPropertyName("reserved_storage")]public ulong ReservedStore { get; set; }
[JsonPropertyName("streams")] public int Streams { get; set; }
[JsonPropertyName("consumers")] public int Consumers { get; set; }
[JsonPropertyName("limits")] public JetStreamAccountLimits Limits { get; set; } = new();
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
[JsonPropertyName("tiers")] public Dictionary<string, JetStreamTier>? Tiers { get; set; }
}
// ---------------------------------------------------------------------------
// Internal JetStream engine types
// ---------------------------------------------------------------------------
/// <summary>
/// The main JetStream engine, one per server.
/// Mirrors <c>jetStream</c> struct in server/jetstream.go.
/// </summary>
internal sealed class JetStream
{
// Atomic counters (use Interlocked for thread-safety)
public long ApiInflight;
public long ApiTotal;
public long ApiErrors;
public long MemReserved;
public long StoreReserved;
public long MemUsed;
public long StoreUsed;
public long QueueLimit;
public int Clustered; // atomic int32
private readonly ReaderWriterLockSlim _mu = new();
public object? Server { get; set; } // *Server — set at runtime
public JetStreamConfig Config { get; set; } = new();
public object? Cluster { get; set; } // *jetStreamCluster — session 20+
public Dictionary<string, JsAccount> Accounts { get; } = new(StringComparer.Ordinal);
public DateTime Started { get; set; }
// State booleans
public bool MetaRecovering { get; set; }
public bool StandAlone { get; set; }
public bool Oos { get; set; }
public bool ShuttingDown { get; set; }
public int Disabled; // atomic bool (0=false, 1=true)
public ReaderWriterLockSlim Lock => _mu;
}
/// <summary>
/// Tracks remote per-tier usage for a JetStream account.
/// Mirrors <c>remoteUsage</c> in server/jetstream.go.
/// </summary>
internal sealed class RemoteUsage
{
public Dictionary<string, JsaUsage> Tiers { get; } = new(StringComparer.Ordinal);
public ulong Api;
public ulong Err;
}
/// <summary>
/// Per-tier storage accounting (total + local split).
/// Mirrors <c>jsaStorage</c> in server/jetstream.go.
/// </summary>
internal sealed class JsaStorage
{
public JsaUsage Total { get; set; } = new();
public JsaUsage Local { get; set; } = new();
}
/// <summary>
/// A JetStream-enabled account, holding streams, limits and usage tracking.
/// Mirrors <c>jsAccount</c> in server/jetstream.go.
/// </summary>
internal sealed class JsAccount
{
private readonly ReaderWriterLockSlim _mu = new();
public object? Js { get; set; } // *jetStream
public object? Account { get; set; } // *Account
public string StoreDir { get; set; } = string.Empty;
// Concurrent inflight map (mirrors sync.Map)
public readonly System.Collections.Concurrent.ConcurrentDictionary<string, object?> Inflight = new();
// Streams keyed by stream name
public Dictionary<string, object?> Streams { get; } = new(StringComparer.Ordinal); // *stream
// Send queue (mirrors *ipQueue[*pubMsg])
public object? SendQ { get; set; }
// Atomic sync flag (0=false, 1=true)
public int Sync;
// Usage/limits (protected by UsageMu)
private readonly ReaderWriterLockSlim _usageMu = new();
public Dictionary<string, JetStreamAccountLimits> Limits { get; } = new(StringComparer.Ordinal);
public Dictionary<string, JsaStorage> Usage { get; } = new(StringComparer.Ordinal);
public Dictionary<string, RemoteUsage> RUsage { get; } = new(StringComparer.Ordinal);
public ulong ApiTotal { get; set; }
public ulong ApiErrors { get; set; }
public ulong UsageApi { get; set; }
public ulong UsageErr { get; set; }
public string UpdatesPub { get; set; } = string.Empty;
public object? UpdatesSub { get; set; } // *subscription
public DateTime LUpdate { get; set; }
public ReaderWriterLockSlim Lock => _mu;
public ReaderWriterLockSlim UsageLock => _usageMu;
}
/// <summary>
/// Memory and store byte usage.
/// Mirrors <c>jsaUsage</c> in server/jetstream.go.
/// </summary>
internal sealed class JsaUsage
{
public long Mem { get; set; }
public long Store { get; set; }
}
/// <summary>
/// Delegate for a function that generates a key-encryption key from a context byte array.
/// Mirrors <c>keyGen</c> in server/jetstream.go.
/// </summary>
public delegate byte[] KeyGen(byte[] context);

View File

@@ -0,0 +1,106 @@
// Copyright 2024-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/jetstream_versioning.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// JetStream API level versioning constants and helpers.
/// Mirrors server/jetstream_versioning.go.
/// </summary>
public static class JetStreamVersioning
{
/// <summary>Maximum supported JetStream API level for this server.</summary>
public const int JsApiLevel = 3;
/// <summary>Metadata key that carries the required API level for a stream or consumer asset.</summary>
public const string JsRequiredLevelMetadataKey = "_nats.req.level";
/// <summary>Metadata key that carries the server version that created/updated the asset.</summary>
public const string JsServerVersionMetadataKey = "_nats.ver";
/// <summary>Metadata key that carries the server API level that created/updated the asset.</summary>
public const string JsServerLevelMetadataKey = "_nats.level";
// ---- API level feature gates ----
// These document which API level each feature requires.
// They correspond to the requires() calls in setStaticStreamMetadata / setStaticConsumerMetadata.
/// <summary>API level required for per-message TTL and SubjectDeleteMarkerTTL (v2.11).</summary>
public const int ApiLevelForTTL = 1;
/// <summary>API level required for consumer PauseUntil (v2.11).</summary>
public const int ApiLevelForConsumerPause = 1;
/// <summary>API level required for priority groups (v2.11).</summary>
public const int ApiLevelForPriorityGroups = 1;
/// <summary>API level required for counter CRDTs (v2.12).</summary>
public const int ApiLevelForCounters = 2;
/// <summary>API level required for atomic batch publishing (v2.12).</summary>
public const int ApiLevelForAtomicPublish = 2;
/// <summary>API level required for message scheduling (v2.12).</summary>
public const int ApiLevelForMsgSchedules = 2;
/// <summary>API level required for async persist mode (v2.12).</summary>
public const int ApiLevelForAsyncPersist = 2;
// ---- Helper methods ----
/// <summary>
/// Returns the required API level string from stream or consumer metadata,
/// or an empty string if not set.
/// Mirrors <c>getRequiredApiLevel</c>.
/// </summary>
public static string GetRequiredApiLevel(IDictionary<string, string>? metadata)
{
if (metadata is not null && metadata.TryGetValue(JsRequiredLevelMetadataKey, out var l) && l.Length > 0)
return l;
return string.Empty;
}
/// <summary>
/// Returns whether this server supports the required API level encoded in the asset's metadata.
/// Mirrors <c>supportsRequiredApiLevel</c>.
/// </summary>
public static bool SupportsRequiredApiLevel(IDictionary<string, string>? metadata)
{
var l = GetRequiredApiLevel(metadata);
if (l.Length == 0) return true;
return int.TryParse(l, out var level) && level <= JsApiLevel;
}
/// <summary>
/// Removes dynamic (per-response) versioning fields from metadata.
/// These should never be stored; only added in API responses.
/// Mirrors <c>deleteDynamicMetadata</c>.
/// </summary>
public static void DeleteDynamicMetadata(IDictionary<string, string> metadata)
{
metadata.Remove(JsServerVersionMetadataKey);
metadata.Remove(JsServerLevelMetadataKey);
}
/// <summary>
/// Returns whether a request should be rejected based on the Nats-Required-Api-Level header value.
/// Mirrors <c>errorOnRequiredApiLevel</c>.
/// </summary>
public static bool ErrorOnRequiredApiLevel(string? reqApiLevelHeader)
{
if (string.IsNullOrEmpty(reqApiLevelHeader)) return false;
return !int.TryParse(reqApiLevelHeader, out var minLevel) || JsApiLevel < minLevel;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/filestore.go (msgBlock struct and consumerFileStore struct)
using System.Threading.Channels;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ---------------------------------------------------------------------------
// MessageBlock
// ---------------------------------------------------------------------------
/// <summary>
/// Represents a single on-disk message block file together with its
/// in-memory cache and index state.
/// Mirrors the <c>msgBlock</c> struct in filestore.go.
/// </summary>
internal sealed class MessageBlock
{
// ------------------------------------------------------------------
// Identity fields — first/last use volatile-style access in Go via
// atomic.LoadUint64 on the embedded msgId structs.
// We replicate those as plain fields; callers must acquire _mu before
// reading/writing unless using the Interlocked helpers on Seq/Ts.
// ------------------------------------------------------------------
/// <summary>First message in this block (sequence + nanosecond timestamp).</summary>
public MsgId First;
/// <summary>Last message in this block (sequence + nanosecond timestamp).</summary>
public MsgId Last;
// ------------------------------------------------------------------
// Lock
// ------------------------------------------------------------------
/// <summary>Guards all mutable fields in this block.</summary>
public readonly ReaderWriterLockSlim Mu = new(LockRecursionPolicy.NoRecursion);
// ------------------------------------------------------------------
// Back-reference
// ------------------------------------------------------------------
/// <summary>Owning file store.</summary>
public JetStreamFileStore? Fs { get; set; }
// ------------------------------------------------------------------
// File I/O
// ------------------------------------------------------------------
/// <summary>Path to the <c>.blk</c> message data file.</summary>
public string Mfn { get; set; } = string.Empty;
/// <summary>Open file stream for the block data file (null when closed/idle).</summary>
public FileStream? Mfd { get; set; }
/// <summary>Path to the per-block encryption key file.</summary>
public string Kfn { get; set; } = string.Empty;
// ------------------------------------------------------------------
// Compression
// ------------------------------------------------------------------
/// <summary>Effective compression algorithm when the block was last loaded.</summary>
public StoreCompression Cmp { get; set; }
/// <summary>
/// Last index write size in bytes; used to detect whether re-compressing
/// the block would save meaningful space.
/// </summary>
public long Liwsz { get; set; }
// ------------------------------------------------------------------
// Block identity
// ------------------------------------------------------------------
/// <summary>Monotonically increasing block index number (used in file names).</summary>
public uint Index { get; set; }
// ------------------------------------------------------------------
// Counters
// ------------------------------------------------------------------
/// <summary>User-visible byte count (excludes deleted-message bytes).</summary>
public ulong Bytes { get; set; }
/// <summary>
/// Total raw byte count including deleted messages.
/// Used to decide when to roll to a new block.
/// </summary>
public ulong RBytes { get; set; }
/// <summary>Byte count captured at the last compaction (0 if never compacted).</summary>
public ulong CBytes { get; set; }
/// <summary>User-visible message count (excludes deleted messages).</summary>
public ulong Msgs { get; set; }
// ------------------------------------------------------------------
// Per-subject state
// ------------------------------------------------------------------
/// <summary>
/// Optional per-subject state tree for this block.
/// Lazily populated and expired when idle.
/// Mirrors <c>mb.fss</c> in filestore.go.
/// </summary>
public SubjectTree<SimpleState>? Fss { get; set; }
// ------------------------------------------------------------------
// Deleted-sequence tracking
// ------------------------------------------------------------------
/// <summary>
/// Set of deleted sequence numbers within this block.
/// Uses the AVL-backed <see cref="SequenceSet"/> to match Go's <c>avl.SequenceSet</c>.
/// </summary>
public SequenceSet Dmap { get; set; } = new();
// ------------------------------------------------------------------
// Timestamps (nanosecond Unix times, matches Go int64)
// ------------------------------------------------------------------
/// <summary>Nanosecond timestamp of the last write to this block.</summary>
public long Lwts { get; set; }
/// <summary>Nanosecond timestamp of the last load (cache fill) of this block.</summary>
public long Llts { get; set; }
/// <summary>Nanosecond timestamp of the last read from this block.</summary>
public long Lrts { get; set; }
/// <summary>Nanosecond timestamp of the last subject-state (fss) access.</summary>
public long Lsts { get; set; }
/// <summary>Last sequence that was looked up; used to detect linear scans.</summary>
public ulong Llseq { get; set; }
// ------------------------------------------------------------------
// Cache
// ------------------------------------------------------------------
/// <summary>
/// Active in-memory cache. May be null when evicted.
/// Mirrors <c>mb.cache</c>; the elastic-pointer field (<c>mb.ecache</c>) is
/// not ported — a plain nullable field is sufficient here.
/// </summary>
public Cache? CacheData { get; set; }
/// <summary>Number of times the cache has been (re)loaded from disk.</summary>
public ulong Cloads { get; set; }
/// <summary>Cache buffer expiration duration for this block.</summary>
public TimeSpan Cexp { get; set; }
/// <summary>Per-block subject-state (fss) expiration duration.</summary>
public TimeSpan Fexp { get; set; }
/// <summary>Timer used to expire the cache buffer when idle.</summary>
public Timer? Ctmr { get; set; }
// ------------------------------------------------------------------
// State flags
// ------------------------------------------------------------------
/// <summary>Whether the in-memory cache is currently populated.</summary>
public bool HaveCache => CacheData != null;
/// <summary>Whether this block has been closed and must not be written to.</summary>
public bool Closed { get; set; }
/// <summary>Whether this block has unflushed data that must be synced to disk.</summary>
public bool NeedSync { get; set; }
/// <summary>When true every write is immediately synced (SyncAlways mode).</summary>
public bool SyncAlways { get; set; }
/// <summary>When true compaction is suppressed for this block.</summary>
public bool NoCompact { get; set; }
/// <summary>
/// When true the block's messages are not tracked in the per-subject index.
/// Used for blocks that only contain tombstone or deleted markers.
/// </summary>
public bool NoTrack { get; set; }
/// <summary>Whether a background flusher goroutine equivalent is running.</summary>
public bool Flusher { get; set; }
/// <summary>Whether a cache-load is currently in progress.</summary>
public bool Loading { get; set; }
// ------------------------------------------------------------------
// Write error
// ------------------------------------------------------------------
/// <summary>Captured write error; non-null means the block is in a bad state.</summary>
public Exception? Werr { get; set; }
// ------------------------------------------------------------------
// TTL / scheduling counters
// ------------------------------------------------------------------
/// <summary>Number of messages in this block that have a TTL set.</summary>
public ulong Ttls { get; set; }
/// <summary>Number of messages in this block that have a schedule set.</summary>
public ulong Schedules { get; set; }
// ------------------------------------------------------------------
// Channels (equivalent to Go chan struct{})
// ------------------------------------------------------------------
/// <summary>Flush-request channel: signals the flusher to run.</summary>
public Channel<byte>? Fch { get; set; }
/// <summary>Quit channel: closing signals background goroutine equivalents to stop.</summary>
public Channel<byte>? Qch { get; set; }
// ------------------------------------------------------------------
// Checksum
// ------------------------------------------------------------------
/// <summary>
/// Last-check hash: 8-byte rolling checksum of the last validated record.
/// Mirrors <c>mb.lchk [8]byte</c>.
/// </summary>
public byte[] Lchk { get; set; } = new byte[8];
// ------------------------------------------------------------------
// Test hook
// ------------------------------------------------------------------
/// <summary>When true, simulates a write failure. Used by unit tests only.</summary>
public bool MockWriteErr { get; set; }
}
// ---------------------------------------------------------------------------
// ConsumerFileStore
// ---------------------------------------------------------------------------
/// <summary>
/// File-backed implementation of <see cref="IConsumerStore"/>.
/// Persists consumer delivery and ack state to a directory under the stream's
/// <c>obs/</c> subdirectory.
/// Mirrors the <c>consumerFileStore</c> struct in filestore.go.
/// </summary>
public sealed class ConsumerFileStore : IConsumerStore
{
// ------------------------------------------------------------------
// Fields — mirrors consumerFileStore struct
// ------------------------------------------------------------------
private readonly object _mu = new();
/// <summary>Back-reference to the owning file store.</summary>
private readonly JetStreamFileStore _fs;
/// <summary>Consumer metadata (name, created time, config).</summary>
private FileConsumerInfo _cfg;
/// <summary>Durable consumer name.</summary>
private readonly string _name;
/// <summary>Path to the consumer's state directory.</summary>
private readonly string _odir;
/// <summary>Path to the consumer state file (<c>o.dat</c>).</summary>
private readonly string _ifn;
/// <summary>Consumer delivery/ack state.</summary>
private ConsumerState _state = new();
/// <summary>Flush-request channel.</summary>
private Channel<byte>? _fch;
/// <summary>Quit channel.</summary>
private Channel<byte>? _qch;
/// <summary>Whether a background flusher is running.</summary>
private bool _flusher;
/// <summary>Whether a write is currently in progress.</summary>
private bool _writing;
/// <summary>Whether the state is dirty (pending flush).</summary>
private bool _dirty;
/// <summary>Whether this consumer store is closed.</summary>
private bool _closed;
// ------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------
/// <summary>
/// Creates a new file-backed consumer store.
/// </summary>
public ConsumerFileStore(JetStreamFileStore fs, FileConsumerInfo cfg, string name, string odir)
{
_fs = fs;
_cfg = cfg;
_name = name;
_odir = odir;
_ifn = Path.Combine(odir, FileStoreDefaults.ConsumerState);
}
// ------------------------------------------------------------------
// IConsumerStore — all methods stubbed
// ------------------------------------------------------------------
/// <inheritdoc/>
public void SetStarting(ulong sseq)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.SetStarting");
/// <inheritdoc/>
public void UpdateStarting(ulong sseq)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.UpdateStarting");
/// <inheritdoc/>
public void Reset(ulong sseq)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.Reset");
/// <inheritdoc/>
public bool HasState()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.HasState");
/// <inheritdoc/>
public void UpdateDelivered(ulong dseq, ulong sseq, ulong dc, long ts)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.UpdateDelivered");
/// <inheritdoc/>
public void UpdateAcks(ulong dseq, ulong sseq)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.UpdateAcks");
/// <inheritdoc/>
public void UpdateConfig(ConsumerConfig cfg)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.UpdateConfig");
/// <inheritdoc/>
public void Update(ConsumerState state)
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.Update");
/// <inheritdoc/>
public (ConsumerState? State, Exception? Error) State()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.State");
/// <inheritdoc/>
public (ConsumerState? State, Exception? Error) BorrowState()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.BorrowState");
/// <inheritdoc/>
public byte[] EncodedState()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.EncodedState");
/// <inheritdoc/>
public StorageType Type() => StorageType.FileStorage;
/// <inheritdoc/>
public void Stop()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.Stop");
/// <inheritdoc/>
public void Delete()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.Delete");
/// <inheritdoc/>
public void StreamDelete()
=> throw new NotImplementedException("TODO: session 18 — filestore ConsumerFileStore.StreamDelete");
}

View File

@@ -0,0 +1,151 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/consumer.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Represents a JetStream consumer, managing message delivery, ack tracking, and lifecycle.
/// Mirrors the <c>consumer</c> struct in server/consumer.go.
/// </summary>
internal sealed class NatsConsumer : IDisposable
{
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
public string Name { get; private set; } = string.Empty;
public string Stream { get; private set; } = string.Empty;
public ConsumerConfig Config { get; private set; } = new();
public DateTime Created { get; private set; }
// Atomic counters — use Interlocked for thread-safe access
internal long Delivered;
internal long AckFloor;
internal long NumAckPending;
internal long NumRedelivered;
private bool _closed;
/// <summary>IRaftNode — stored as object to avoid cross-dependency on Raft session.</summary>
private object? _node;
private CancellationTokenSource? _quitCts;
public NatsConsumer(string stream, ConsumerConfig config, DateTime created)
{
Stream = stream;
Name = (config.Name is { Length: > 0 } name) ? name
: (config.Durable ?? string.Empty);
Config = config;
Created = created;
_quitCts = new CancellationTokenSource();
}
// -------------------------------------------------------------------------
// Factory
// -------------------------------------------------------------------------
/// <summary>
/// Creates a new <see cref="NatsConsumer"/> for the given stream.
/// Returns null if the consumer cannot be created (stub: always throws).
/// Mirrors <c>newConsumer</c> / <c>consumer.create</c> in server/consumer.go.
/// </summary>
public static NatsConsumer? Create(
NatsStream stream,
ConsumerConfig cfg,
ConsumerAction action,
ConsumerAssignment? sa)
{
throw new NotImplementedException("TODO: session 21 — consumer");
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/// <summary>
/// Stops processing and tears down goroutines / timers.
/// Mirrors <c>consumer.stop</c> in server/consumer.go.
/// </summary>
public void Stop() =>
throw new NotImplementedException("TODO: session 21 — consumer");
/// <summary>
/// Deletes the consumer and all associated state permanently.
/// Mirrors <c>consumer.delete</c> in server/consumer.go.
/// </summary>
public void Delete() =>
throw new NotImplementedException("TODO: session 21 — consumer");
// -------------------------------------------------------------------------
// Info / State
// -------------------------------------------------------------------------
/// <summary>
/// Returns a snapshot of consumer info including config and delivery state.
/// Mirrors <c>consumer.info</c> in server/consumer.go.
/// </summary>
public ConsumerInfo GetInfo() =>
throw new NotImplementedException("TODO: session 21 — consumer");
/// <summary>
/// Returns the current consumer configuration.
/// Mirrors <c>consumer.config</c> in server/consumer.go.
/// </summary>
public ConsumerConfig GetConfig() =>
throw new NotImplementedException("TODO: session 21 — consumer");
/// <summary>
/// Applies an updated configuration to the consumer.
/// Mirrors <c>consumer.update</c> in server/consumer.go.
/// </summary>
public void UpdateConfig(ConsumerConfig config) =>
throw new NotImplementedException("TODO: session 21 — consumer");
/// <summary>
/// Returns the current durable consumer state (delivered, ack_floor, pending, redelivered).
/// Mirrors <c>consumer.state</c> in server/consumer.go.
/// </summary>
public ConsumerState GetConsumerState() =>
throw new NotImplementedException("TODO: session 21 — consumer");
// -------------------------------------------------------------------------
// Leadership
// -------------------------------------------------------------------------
/// <summary>
/// Returns true if this server is the current consumer leader.
/// Mirrors <c>consumer.isLeader</c> in server/consumer.go.
/// </summary>
public bool IsLeader() =>
throw new NotImplementedException("TODO: session 21 — consumer");
/// <summary>
/// Transitions this consumer into or out of the leader role.
/// Mirrors <c>consumer.setLeader</c> in server/consumer.go.
/// </summary>
public void SetLeader(bool isLeader, ulong term) =>
throw new NotImplementedException("TODO: session 21 — consumer");
// -------------------------------------------------------------------------
// IDisposable
// -------------------------------------------------------------------------
public void Dispose()
{
_quitCts?.Cancel();
_quitCts?.Dispose();
_quitCts = null;
_mu.Dispose();
}
}

View File

@@ -0,0 +1,197 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/stream.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Represents a JetStream stream, managing message storage, replication, and lifecycle.
/// Mirrors the <c>stream</c> struct in server/stream.go.
/// </summary>
internal sealed class NatsStream : IDisposable
{
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
public Account Account { get; private set; }
public string Name { get; private set; } = string.Empty;
public StreamConfig Config { get; private set; } = new();
public DateTime Created { get; private set; }
internal IStreamStore? Store { get; private set; }
// Atomic counters — use Interlocked for thread-safe access
internal long Msgs;
internal long Bytes;
internal long FirstSeq;
internal long LastSeq;
internal bool IsMirror;
private bool _closed;
private CancellationTokenSource? _quitCts;
/// <summary>IRaftNode — stored as object to avoid cross-dependency on Raft session.</summary>
private object? _node;
public NatsStream(Account account, StreamConfig config, DateTime created)
{
Account = account;
Name = config.Name ?? string.Empty;
Config = config;
Created = created;
_quitCts = new CancellationTokenSource();
}
// -------------------------------------------------------------------------
// Factory
// -------------------------------------------------------------------------
/// <summary>
/// Creates a new <see cref="NatsStream"/> after validating the configuration.
/// Returns null if the stream cannot be created (stub: always throws).
/// Mirrors <c>newStream</c> / <c>stream.create</c> in server/stream.go.
/// </summary>
public static NatsStream? Create(
Account acc,
StreamConfig cfg,
object? jsacc,
IStreamStore? store,
StreamAssignment? sa,
object? server)
{
throw new NotImplementedException("TODO: session 21 — stream");
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/// <summary>
/// Stops processing and tears down goroutines / timers.
/// Mirrors <c>stream.stop</c> in server/stream.go.
/// </summary>
public void Stop() =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Deletes the stream and all stored messages permanently.
/// Mirrors <c>stream.delete</c> in server/stream.go.
/// </summary>
public void Delete() =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Purges messages from the stream according to the optional request filter.
/// Mirrors <c>stream.purge</c> in server/stream.go.
/// </summary>
public void Purge(StreamPurgeRequest? req = null) =>
throw new NotImplementedException("TODO: session 21 — stream");
// -------------------------------------------------------------------------
// Info / State
// -------------------------------------------------------------------------
/// <summary>
/// Returns a snapshot of stream info including config, state, and cluster information.
/// Mirrors <c>stream.info</c> in server/stream.go.
/// </summary>
public StreamInfo GetInfo(bool includeDeleted = false) =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Asynchronously returns a snapshot of stream info.
/// Mirrors <c>stream.info</c> (async path) in server/stream.go.
/// </summary>
public Task<StreamInfo> GetInfoAsync(bool includeDeleted = false, CancellationToken ct = default) =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Returns the current stream state (message counts, byte totals, sequences).
/// Mirrors <c>stream.state</c> in server/stream.go.
/// </summary>
public StreamState State() =>
throw new NotImplementedException("TODO: session 21 — stream");
// -------------------------------------------------------------------------
// Leadership
// -------------------------------------------------------------------------
/// <summary>
/// Transitions this stream into or out of the leader role.
/// Mirrors <c>stream.setLeader</c> in server/stream.go.
/// </summary>
public void SetLeader(bool isLeader, ulong term) =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Returns true if this server is the current stream leader.
/// Mirrors <c>stream.isLeader</c> in server/stream.go.
/// </summary>
public bool IsLeader() =>
throw new NotImplementedException("TODO: session 21 — stream");
// -------------------------------------------------------------------------
// Configuration
// -------------------------------------------------------------------------
/// <summary>
/// Returns the owning account.
/// Mirrors <c>stream.account</c> in server/stream.go.
/// </summary>
public Account GetAccount() =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Returns the current stream configuration.
/// Mirrors <c>stream.config</c> in server/stream.go.
/// </summary>
public StreamConfig GetConfig() =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Applies an updated configuration to the stream.
/// Mirrors <c>stream.update</c> in server/stream.go.
/// </summary>
public void UpdateConfig(StreamConfig config) =>
throw new NotImplementedException("TODO: session 21 — stream");
// -------------------------------------------------------------------------
// Sealed state
// -------------------------------------------------------------------------
/// <summary>
/// Returns true if the stream is sealed (no new messages accepted).
/// Mirrors <c>stream.isSealed</c> in server/stream.go.
/// </summary>
public bool IsSealed() =>
throw new NotImplementedException("TODO: session 21 — stream");
/// <summary>
/// Seals the stream so that no new messages can be stored.
/// Mirrors <c>stream.seal</c> in server/stream.go.
/// </summary>
public void Seal() =>
throw new NotImplementedException("TODO: session 21 — stream");
// -------------------------------------------------------------------------
// IDisposable
// -------------------------------------------------------------------------
public void Dispose()
{
_quitCts?.Cancel();
_quitCts?.Dispose();
_quitCts = null;
_mu.Dispose();
}
}

View File

@@ -0,0 +1,622 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/raft.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Threading.Channels;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// RaftNode interface
// ============================================================================
/// <summary>
/// Primary interface for a NATS Consensus Group (NRG) Raft node.
/// Mirrors Go <c>RaftNode</c> interface in server/raft.go lines 40-92.
/// Replaces the stub declared in NatsServerTypes.cs.
/// </summary>
public interface IRaftNode
{
// --- Proposal ---
void Propose(byte[] entry);
void ProposeMulti(IReadOnlyList<Entry> entries);
void ForwardProposal(byte[] entry);
// --- Snapshot ---
void InstallSnapshot(byte[] snap, bool force);
/// <remarks>Returns <see cref="IRaftNodeCheckpoint"/>; typed as object to keep the public interface free of internal types.</remarks>
object CreateSnapshotCheckpoint(bool force);
void SendSnapshot(byte[] snap);
bool NeedSnapshot();
// --- State queries ---
(ulong Entries, ulong Bytes) Applied(ulong index);
(ulong Entries, ulong Bytes) Processed(ulong index, ulong applied);
RaftState State();
(ulong Entries, ulong Bytes) Size();
(ulong Index, ulong Commit, ulong Applied) Progress();
bool Leader();
DateTime? LeaderSince();
bool Quorum();
bool Current();
bool Healthy();
ulong Term();
bool Leaderless();
string GroupLeader();
bool HadPreviousLeader();
// --- Leadership / observer ---
void StepDown(params string[] preferred);
void SetObserver(bool isObserver);
bool IsObserver();
void Campaign();
void CampaignImmediately();
// --- Identity ---
string ID();
string Group();
// --- Peer management ---
IReadOnlyList<Peer> Peers();
void ProposeKnownPeers(IReadOnlyList<string> knownPeers);
void UpdateKnownPeers(IReadOnlyList<string> knownPeers);
void ProposeAddPeer(string peer);
void ProposeRemovePeer(string peer);
bool MembershipChangeInProgress();
void AdjustClusterSize(int csz);
void AdjustBootClusterSize(int csz);
int ClusterSize();
// --- Apply queue ---
IpQueue<CommittedEntry> ApplyQ();
void PauseApply();
void ResumeApply();
bool DrainAndReplaySnapshot();
// --- Channels / lifecycle ---
ChannelReader<bool> LeadChangeC();
ChannelReader<bool> QuitC();
DateTime Created();
void Stop();
void WaitForStop();
void Delete();
bool IsDeleted();
void RecreateInternalSubs();
bool IsSystemAccount();
string GetTrafficAccountName();
}
// ============================================================================
// RaftNodeCheckpoint interface
// ============================================================================
/// <summary>
/// Allows asynchronous snapshot installation from a checkpoint.
/// Mirrors Go <c>RaftNodeCheckpoint</c> interface in server/raft.go lines 98-103.
/// Internal because it references internal types (AppendEntry).
/// </summary>
internal interface IRaftNodeCheckpoint
{
byte[] LoadLastSnapshot();
IEnumerable<(AppendEntry Entry, Exception? Error)> AppendEntriesSeq();
void Abort();
ulong InstallSnapshot(byte[] data);
}
// ============================================================================
// IWal interface
// ============================================================================
/// <summary>
/// Write-ahead log abstraction used by the Raft implementation.
/// Mirrors Go <c>WAL</c> interface in server/raft.go lines 105-118.
/// </summary>
internal interface IWal
{
StorageType Type();
(ulong Seq, long TimeStamp) StoreMsg(string subj, byte[]? hdr, byte[] msg, long ttl);
StoreMsg? LoadMsg(ulong index, StoreMsg? sm);
bool RemoveMsg(ulong index);
ulong Compact(ulong index);
ulong Purge();
ulong PurgeEx(string subject, ulong seq, ulong keep);
void Truncate(ulong seq);
StreamState State();
void FastState(ref StreamState state);
void Stop();
void Delete(bool inline);
}
// ============================================================================
// Peer
// ============================================================================
/// <summary>
/// Represents a peer in a Raft group.
/// Mirrors Go <c>Peer</c> struct in server/raft.go lines 120-125.
/// </summary>
public sealed class Peer
{
public string Id { get; set; } = string.Empty;
public bool Current { get; set; }
public DateTime Last { get; set; }
public ulong Lag { get; set; }
}
// ============================================================================
// RaftState enum
// ============================================================================
/// <summary>
/// Allowable states for a NATS Consensus Group node.
/// Mirrors Go <c>RaftState</c> iota in server/raft.go lines 128-135.
/// </summary>
public enum RaftState : byte
{
Follower = 0,
Leader = 1,
Candidate = 2,
Closed = 3,
}
// ============================================================================
// RaftConfig
// ============================================================================
/// <summary>
/// Configuration for creating a Raft group.
/// Mirrors Go <c>RaftConfig</c> struct in server/raft.go lines 301-317.
/// </summary>
public sealed class RaftConfig
{
public string Name { get; set; } = string.Empty;
public string Store { get; set; } = string.Empty;
/// <summary>WAL store — typed as object to keep public API free of internal IWal.</summary>
public object? Log { get; set; }
public bool Track { get; set; }
public bool Observer { get; set; }
/// <summary>
/// Must be set for a Raft group that's recovering after a restart, or if first
/// seen after a catchup from another server.
/// </summary>
public bool Recovering { get; set; }
/// <summary>
/// Identifies the Raft peer set is being scaled up; prevents an empty-log node
/// from becoming leader prematurely.
/// </summary>
public bool ScaleUp { get; set; }
}
// ============================================================================
// Internal Raft state types
// ============================================================================
/// <summary>
/// Main Raft node implementation.
/// Mirrors Go <c>raft</c> struct in server/raft.go lines 151-251.
/// All algorithm methods are stubbed — full implementation is session 20+.
/// </summary>
internal sealed class Raft : IRaftNode
{
// Identity / location
internal DateTime Created_ { get; set; }
internal string AccName { get; set; } = string.Empty;
internal string GroupName { get; set; } = string.Empty;
internal string StoreDir { get; set; } = string.Empty;
internal string Id { get; set; } = string.Empty;
// WAL (IWal is internal; store as object here to keep the field in the class without accessibility issues)
internal object? Wal { get; set; }
internal StorageType WalType { get; set; }
internal ulong WalBytes { get; set; }
internal Exception? WriteErr { get; set; }
// Atomic state
internal int StateValue { get; set; } // RaftState (use Interlocked)
internal long LeaderStateV { get; set; } // 1 = in complete leader state
internal string SnapFile { get; set; } = string.Empty;
// Cluster
internal int Csz { get; set; } // cluster size
internal int Qn { get; set; } // quorum node count
internal Dictionary<string, Lps> Peers_ { get; set; } = new();
// Tracking removed peers
internal Dictionary<string, DateTime> Removed { get; set; } = new();
internal Dictionary<ulong, Dictionary<string, bool>> Acks { get; set; } = new();
internal Dictionary<ulong, AppendEntry> Pae { get; set; } = new();
// Timers / activity
internal System.Threading.Timer? Elect { get; set; }
internal DateTime Active { get; set; }
internal DateTime Llqrt { get; set; }
internal DateTime Lsut { get; set; }
// Term / index tracking
internal ulong Term_ { get; set; }
internal ulong PTerm { get; set; }
internal ulong PIndex { get; set; }
internal ulong Commit { get; set; }
internal ulong Processed_ { get; set; }
internal ulong Applied_ { get; set; }
internal ulong PApplied { get; set; }
internal ulong MembChangeIndex { get; set; }
internal ulong Aflr { get; set; }
internal string LeaderId { get; set; } = string.Empty;
internal string Vote { get; set; } = string.Empty;
// Server references (object to avoid circular deps)
internal object? Server_ { get; set; }
internal object? Client_ { get; set; }
internal object? JetStream_ { get; set; }
// Atomic booleans — must be fields (not auto-properties) for Interlocked
internal long HasLeaderV;
internal long PLeaderV;
private long _isSysAccV;
// NATS subjects
internal string PSubj { get; set; } = string.Empty;
internal string RpSubj { get; set; } = string.Empty;
internal string VSubj { get; set; } = string.Empty;
internal string VReply { get; set; } = string.Empty;
internal string ASubj { get; set; } = string.Empty;
internal string AReply { get; set; } = string.Empty;
// Queues (object placeholder — IpQueue<T> from session 02)
internal object? SendQ { get; set; }
internal object? AeSub { get; set; }
// Write buffers
internal byte[] Wtv { get; set; } = [];
internal byte[] Wps { get; set; } = [];
// Catchup / progress
internal CatchupState? Catchup { get; set; }
internal Dictionary<string, IpQueue<ulong>>? Progress_ { get; set; }
internal ulong HCommit { get; set; }
// Queues (typed as object to avoid pulling IpQueue<T> directly into the struct boundary)
internal IpQueue<ProposedEntry>? PropQ { get; set; }
internal IpQueue<AppendEntry>? EntryQ { get; set; }
internal IpQueue<AppendEntryResponse>? RespQ { get; set; }
internal IpQueue<CommittedEntry>? ApplyQ_ { get; set; }
internal IpQueue<VoteRequest>? Reqs { get; set; }
internal IpQueue<VoteResponse>? Votes_ { get; set; }
internal Channel<bool>? LeadC { get; set; }
internal Channel<bool>? Quit { get; set; }
// Flags
internal bool Lxfer { get; set; }
internal bool HcBehind { get; set; }
internal bool MaybeLeader { get; set; }
internal bool Paused { get; set; }
internal bool Observer_ { get; set; }
internal bool Initializing { get; set; }
internal bool ScaleUp_ { get; set; }
internal bool Deleted_ { get; set; }
internal bool Snapshotting { get; set; }
// Lock
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
// -----------------------------------------------------------------------
// IRaftNode — stub implementations
// -----------------------------------------------------------------------
public void Propose(byte[] entry) => throw new NotImplementedException("TODO: session 20 — raft");
public void ProposeMulti(IReadOnlyList<Entry> entries) => throw new NotImplementedException("TODO: session 20 — raft");
public void ForwardProposal(byte[] entry) => throw new NotImplementedException("TODO: session 20 — raft");
public void InstallSnapshot(byte[] snap, bool force) => throw new NotImplementedException("TODO: session 20 — raft");
public object CreateSnapshotCheckpoint(bool force) => throw new NotImplementedException("TODO: session 20 — raft");
public void SendSnapshot(byte[] snap) => throw new NotImplementedException("TODO: session 20 — raft");
public bool NeedSnapshot() => throw new NotImplementedException("TODO: session 20 — raft");
public (ulong, ulong) Applied(ulong index) => throw new NotImplementedException("TODO: session 20 — raft");
public (ulong, ulong) Processed(ulong index, ulong applied) => throw new NotImplementedException("TODO: session 20 — raft");
public RaftState State() => (RaftState)StateValue;
public (ulong, ulong) Size() => throw new NotImplementedException("TODO: session 20 — raft");
public (ulong, ulong, ulong) Progress() => throw new NotImplementedException("TODO: session 20 — raft");
public bool Leader() => throw new NotImplementedException("TODO: session 20 — raft");
public DateTime? LeaderSince() => throw new NotImplementedException("TODO: session 20 — raft");
public bool Quorum() => throw new NotImplementedException("TODO: session 20 — raft");
public bool Current() => throw new NotImplementedException("TODO: session 20 — raft");
public bool Healthy() => throw new NotImplementedException("TODO: session 20 — raft");
public ulong Term() => Term_;
public bool Leaderless() => throw new NotImplementedException("TODO: session 20 — raft");
public string GroupLeader() => throw new NotImplementedException("TODO: session 20 — raft");
public bool HadPreviousLeader() => throw new NotImplementedException("TODO: session 20 — raft");
public void StepDown(params string[] preferred) => throw new NotImplementedException("TODO: session 20 — raft");
public void SetObserver(bool isObserver) => throw new NotImplementedException("TODO: session 20 — raft");
public bool IsObserver() => throw new NotImplementedException("TODO: session 20 — raft");
public void Campaign() => throw new NotImplementedException("TODO: session 20 — raft");
public void CampaignImmediately() => throw new NotImplementedException("TODO: session 20 — raft");
public string ID() => Id;
public string Group() => GroupName;
public IReadOnlyList<Peer> Peers() => throw new NotImplementedException("TODO: session 20 — raft");
public void ProposeKnownPeers(IReadOnlyList<string> knownPeers) => throw new NotImplementedException("TODO: session 20 — raft");
public void UpdateKnownPeers(IReadOnlyList<string> knownPeers) => throw new NotImplementedException("TODO: session 20 — raft");
public void ProposeAddPeer(string peer) => throw new NotImplementedException("TODO: session 20 — raft");
public void ProposeRemovePeer(string peer) => throw new NotImplementedException("TODO: session 20 — raft");
public bool MembershipChangeInProgress() => throw new NotImplementedException("TODO: session 20 — raft");
public void AdjustClusterSize(int csz) => throw new NotImplementedException("TODO: session 20 — raft");
public void AdjustBootClusterSize(int csz) => throw new NotImplementedException("TODO: session 20 — raft");
public int ClusterSize() => throw new NotImplementedException("TODO: session 20 — raft");
public IpQueue<CommittedEntry> ApplyQ() => ApplyQ_ ?? throw new InvalidOperationException("Apply queue not initialized");
public void PauseApply() => throw new NotImplementedException("TODO: session 20 — raft");
public void ResumeApply() => throw new NotImplementedException("TODO: session 20 — raft");
public bool DrainAndReplaySnapshot() => throw new NotImplementedException("TODO: session 20 — raft");
public ChannelReader<bool> LeadChangeC() => LeadC?.Reader ?? throw new InvalidOperationException("Lead channel not initialized");
public ChannelReader<bool> QuitC() => Quit?.Reader ?? throw new InvalidOperationException("Quit channel not initialized");
public DateTime Created() => Created_;
public void Stop() => throw new NotImplementedException("TODO: session 20 — raft");
public void WaitForStop() => throw new NotImplementedException("TODO: session 20 — raft");
public void Delete() => throw new NotImplementedException("TODO: session 20 — raft");
public bool IsDeleted() => Deleted_;
public void RecreateInternalSubs() => throw new NotImplementedException("TODO: session 20 — raft");
public bool IsSystemAccount() => Interlocked.Read(ref _isSysAccV) != 0;
public string GetTrafficAccountName() => throw new NotImplementedException("TODO: session 20 — raft");
}
// ============================================================================
// ProposedEntry
// ============================================================================
/// <summary>
/// An entry that has been proposed to the leader, with an optional reply subject.
/// Mirrors Go <c>proposedEntry</c> struct in server/raft.go lines 253-256.
/// </summary>
internal sealed class ProposedEntry
{
public Entry? Entry { get; set; }
public string Reply { get; set; } = string.Empty;
}
// ============================================================================
// CatchupState
// ============================================================================
/// <summary>
/// Tracks the state of a follower catch-up operation.
/// Mirrors Go <c>catchupState</c> struct in server/raft.go lines 259-268.
/// </summary>
internal sealed class CatchupState
{
/// <summary>Subscription that catchup messages arrive on (object to avoid session dep).</summary>
public object? Sub { get; set; }
public ulong CTerm { get; set; }
public ulong CIndex { get; set; }
public ulong PTerm { get; set; }
public ulong PIndex { get; set; }
public DateTime Active { get; set; }
public bool Signal { get; set; }
}
// ============================================================================
// Lps — leader peer state
// ============================================================================
/// <summary>
/// Per-peer state tracked by the leader: last timestamp and last index replicated.
/// Mirrors Go <c>lps</c> struct in server/raft.go lines 271-275.
/// </summary>
internal sealed class Lps
{
/// <summary>Last timestamp.</summary>
public DateTime Ts { get; set; }
/// <summary>Last index replicated.</summary>
public ulong Li { get; set; }
/// <summary>Whether this is a known peer.</summary>
public bool Kp { get; set; }
}
// ============================================================================
// Snapshot (internal)
// ============================================================================
/// <summary>
/// An encoded Raft snapshot (on-disk format).
/// Mirrors Go <c>snapshot</c> struct in server/raft.go lines 1243-1248.
/// </summary>
internal sealed class Snapshot
{
public ulong LastTerm { get; set; }
public ulong LastIndex { get; set; }
public byte[] PeerState { get; set; } = [];
public byte[] Data { get; set; } = [];
}
// ============================================================================
// Checkpoint (internal)
// ============================================================================
/// <summary>
/// Checkpoint for asynchronous snapshot installation.
/// Mirrors Go <c>checkpoint</c> struct in server/raft.go lines 1414-1421.
/// Implements <see cref="IRaftNodeCheckpoint"/>.
/// </summary>
internal sealed class Checkpoint : IRaftNodeCheckpoint
{
public Raft? Node { get; set; }
public ulong Term { get; set; }
public ulong Applied { get; set; }
public ulong PApplied { get; set; }
public string SnapFile { get; set; } = string.Empty;
public byte[] PeerState { get; set; } = [];
public byte[] LoadLastSnapshot()
=> throw new NotImplementedException("TODO: session 20 — raft");
public IEnumerable<(AppendEntry Entry, Exception? Error)> AppendEntriesSeq()
=> throw new NotImplementedException("TODO: session 20 — raft");
public void Abort()
=> throw new NotImplementedException("TODO: session 20 — raft");
public ulong InstallSnapshot(byte[] data)
=> throw new NotImplementedException("TODO: session 20 — raft");
}
// ============================================================================
// CommittedEntry
// ============================================================================
/// <summary>
/// A committed Raft entry passed up to the JetStream layer for application.
/// Mirrors Go <c>CommittedEntry</c> struct in server/raft.go lines 2506-2509.
/// </summary>
public sealed class CommittedEntry
{
public ulong Index { get; set; }
public List<Entry> Entries { get; set; } = new();
}
// ============================================================================
// AppendEntry (internal)
// ============================================================================
/// <summary>
/// The main struct used to sync Raft peers.
/// Mirrors Go <c>appendEntry</c> struct in server/raft.go lines 2557-2568.
/// </summary>
internal sealed class AppendEntry
{
public string Leader { get; set; } = string.Empty;
public ulong TermV { get; set; }
public ulong Commit { get; set; }
public ulong PTerm { get; set; }
public ulong PIndex { get; set; }
public List<Entry> Entries { get; set; } = new();
/// <summary>Highest term for catchups (if 0, use Term).</summary>
public ulong LTerm { get; set; }
public string Reply { get; set; } = string.Empty;
/// <summary>Subscription the append entry arrived on (object to avoid session dep).</summary>
public object? Sub { get; set; }
public byte[]? Buf { get; set; }
}
// ============================================================================
// EntryType enum
// ============================================================================
/// <summary>
/// Type of a Raft log entry.
/// Mirrors Go <c>EntryType</c> iota in server/raft.go lines 2605-2619.
/// </summary>
public enum EntryType : byte
{
EntryNormal = 0,
EntryOldSnapshot = 1,
EntryPeerState = 2,
EntryAddPeer = 3,
EntryRemovePeer = 4,
EntryLeaderTransfer = 5,
EntrySnapshot = 6,
/// <summary>Internal signal type — not transmitted between peers or stored in the log.</summary>
EntryCatchup = 7,
}
// ============================================================================
// Entry
// ============================================================================
/// <summary>
/// A single Raft log entry (type + opaque payload).
/// Mirrors Go <c>Entry</c> struct in server/raft.go lines 2641-2643.
/// </summary>
public sealed class Entry
{
public EntryType Type { get; set; }
public byte[] Data { get; set; } = [];
/// <summary>
/// Returns true if this entry changes group membership.
/// Mirrors Go <c>Entry.ChangesMembership</c>.
/// </summary>
public bool ChangesMembership()
=> Type == EntryType.EntryAddPeer || Type == EntryType.EntryRemovePeer;
}
// ============================================================================
// AppendEntryResponse (internal)
// ============================================================================
/// <summary>
/// Response sent by a follower after receiving an append-entry RPC.
/// Mirrors Go <c>appendEntryResponse</c> struct in server/raft.go lines 2760-2766.
/// </summary>
internal sealed class AppendEntryResponse
{
public ulong TermV { get; set; }
public ulong Index { get; set; }
public string Peer { get; set; } = string.Empty;
public string Reply { get; set; } = string.Empty;
public bool Success { get; set; }
}
// ============================================================================
// PeerState (internal)
// ============================================================================
/// <summary>
/// Encoded peer state attached to snapshots and peer-state entries.
/// Mirrors Go <c>peerState</c> struct in server/raft.go lines 4470-4474.
/// </summary>
internal sealed class PeerState
{
public List<string> KnownPeers { get; set; } = new();
public int ClusterSize { get; set; }
/// <summary>Extension / domain state (opaque ushort in Go).</summary>
public ushort DomainExt { get; set; }
}
// ============================================================================
// VoteRequest (internal)
// ============================================================================
/// <summary>
/// A Raft vote request sent during leader election.
/// Mirrors Go <c>voteRequest</c> struct in server/raft.go lines 4549-4556.
/// </summary>
internal sealed class VoteRequest
{
public ulong TermV { get; set; }
public ulong LastTerm { get; set; }
public ulong LastIndex { get; set; }
public string Candidate { get; set; } = string.Empty;
/// <summary>Internal use — reply subject.</summary>
public string Reply { get; set; } = string.Empty;
}
// ============================================================================
// VoteResponse (internal)
// ============================================================================
/// <summary>
/// A response to a <see cref="VoteRequest"/>.
/// Mirrors Go <c>voteResponse</c> struct in server/raft.go lines 4730-4735.
/// </summary>
internal sealed class VoteResponse
{
public ulong TermV { get; set; }
public string Peer { get; set; } = string.Empty;
public bool Granted { get; set; }
/// <summary>Whether this peer's log is empty.</summary>
public bool Empty { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/stream.go and server/consumer.go in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Stream API types (from stream.go)
// ============================================================================
/// <summary>
/// A stream create request that extends <see cref="StreamConfig"/> with a pedantic flag.
/// Mirrors <c>streamConfigRequest</c> in server/stream.go.
/// </summary>
public sealed class StreamConfigRequest
{
[JsonPropertyName("config")]
public StreamConfig Config { get; set; } = new();
/// <summary>If true, strict validation is applied during stream creation/update.</summary>
[JsonPropertyName("pedantic")]
public bool Pedantic { get; set; }
}
/// <summary>
/// Information about a stream, returned from info requests.
/// Mirrors <c>StreamInfo</c> in server/stream.go.
/// </summary>
public sealed class StreamInfo
{
[JsonPropertyName("config")]
public StreamConfig Config { get; set; } = new();
[JsonPropertyName("created")]
public DateTime Created { get; set; }
[JsonPropertyName("state")]
public StreamState State { get; set; } = new();
[JsonPropertyName("mirror")]
public StreamSourceInfo? Mirror { get; set; }
[JsonPropertyName("sources")]
public StreamSourceInfo[]? Sources { get; set; }
[JsonPropertyName("cluster")]
public ClusterInfo? Cluster { get; set; }
[JsonPropertyName("mirror_direct")]
public bool Mirror_Direct { get; set; }
[JsonPropertyName("allow_direct")]
public bool Allow_Direct { get; set; }
/// <summary>Alternate cluster name.</summary>
[JsonPropertyName("alternates")]
public string? Alt { get; set; }
}
/// <summary>
/// Information about a stream mirror or source.
/// Mirrors <c>StreamSourceInfo</c> in server/stream.go.
/// </summary>
public sealed class StreamSourceInfo
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("filter_subject")]
public string? FilterSubject { get; set; }
[JsonPropertyName("lag")]
public ulong Lag { get; set; }
[JsonPropertyName("active")]
public DateTime? Active { get; set; }
[JsonPropertyName("external")]
public StreamSource? External { get; set; }
[JsonPropertyName("error")]
public string? Error { get; set; }
}
/// <summary>
/// Request parameters for stream info, allowing filtering.
/// Mirrors <c>streamInfoRequest</c> in server/stream.go.
/// </summary>
public sealed class StreamInfoRequest
{
[JsonPropertyName("subjects_filter")]
public string? SubjectsFilter { get; set; }
[JsonPropertyName("mirror_check_until")]
public string? MirrorCheckUntil { get; set; }
[JsonPropertyName("deleted_details")]
public bool DeletedDetails { get; set; }
[JsonPropertyName("subjects_detail")]
public bool SubjectsDetail { get; set; }
}
/// <summary>
/// Request parameters for purging a stream.
/// Mirrors <c>StreamPurgeRequest</c> in server/stream.go.
/// </summary>
public sealed class StreamPurgeRequest
{
[JsonPropertyName("filter")]
public string? Filter { get; set; }
[JsonPropertyName("seq")]
public ulong Sequence { get; set; }
[JsonPropertyName("keep")]
public ulong Keep { get; set; }
}
/// <summary>
/// Request for deleting a specific stream message.
/// Mirrors <c>StreamMsgDeleteRequest</c> in server/stream.go.
/// </summary>
public sealed class StreamMsgDeleteRequest
{
[JsonPropertyName("seq")]
public ulong Seq { get; set; }
[JsonPropertyName("no_erase")]
public bool NoErase { get; set; }
}
/// <summary>
/// Request for retrieving a specific stream message.
/// Mirrors <c>StreamGetMsgRequest</c> in server/stream.go.
/// </summary>
public sealed class StreamGetMsgRequest
{
[JsonPropertyName("seq")]
public ulong Seq { get; set; }
[JsonPropertyName("last_by_subj")]
public string? LastBySubject { get; set; }
[JsonPropertyName("next_by_subj")]
public string? NextBySubject { get; set; }
}
/// <summary>
/// Publish acknowledgement response from JetStream.
/// Mirrors <c>JSPubAckResponse</c> in server/stream.go.
/// </summary>
public sealed class JSPubAckResponse
{
[JsonPropertyName("stream")]
public string Stream { get; set; } = string.Empty;
[JsonPropertyName("seq")]
public ulong Seq { get; set; }
[JsonPropertyName("duplicate")]
public bool Duplicate { get; set; }
[JsonPropertyName("domain")]
public string? Domain { get; set; }
[JsonPropertyName("error")]
public JsApiError? PubAckError { get; set; }
/// <summary>
/// Returns an exception if the response contains an error, otherwise null.
/// Mirrors <c>ToError()</c> helper pattern in NATS Go server.
/// </summary>
public Exception? ToError()
{
if (PubAckError is { ErrCode: > 0 })
return new InvalidOperationException($"{PubAckError.Description} (errCode={PubAckError.ErrCode})");
return null;
}
}
/// <summary>
/// A raw published message before JetStream processing.
/// Mirrors <c>pubMsg</c> (JetStream variant) in server/stream.go.
/// Note: renamed <c>JsStreamPubMsg</c> to avoid collision with the server-level
/// <c>PubMsg</c> (events.go) which lives in the same namespace.
/// </summary>
public sealed class JsStreamPubMsg
{
public string Subject { get; set; } = string.Empty;
public string? Reply { get; set; }
public byte[]? Hdr { get; set; }
public byte[]? Msg { get; set; }
public Dictionary<string, string>? Meta { get; set; }
}
/// <summary>
/// A JetStream publish message with sync tracking.
/// Mirrors <c>jsPubMsg</c> in server/stream.go.
/// </summary>
public sealed class JsPubMsg
{
public string Subject { get; set; } = string.Empty;
public string? Reply { get; set; }
public byte[]? Hdr { get; set; }
public byte[]? Msg { get; set; }
/// <summary>Publish argument (opaque, set at runtime).</summary>
public object? Pa { get; set; }
/// <summary>Sync/ack channel (opaque, set at runtime).</summary>
public object? Sync { get; set; }
}
/// <summary>
/// An inbound message to be processed by the JetStream layer.
/// Mirrors <c>inMsg</c> in server/stream.go.
/// </summary>
public sealed class InMsg
{
public string Subject { get; set; } = string.Empty;
public string? Reply { get; set; }
public byte[]? Hdr { get; set; }
public byte[]? Msg { get; set; }
/// <summary>The originating client (opaque, set at runtime).</summary>
public object? Client { get; set; }
}
/// <summary>
/// A cached/clustered message for replication.
/// Mirrors <c>cMsg</c> in server/stream.go.
/// </summary>
public sealed class CMsg
{
public string Subject { get; set; } = string.Empty;
public byte[]? Msg { get; set; }
public ulong Seq { get; set; }
}
// ============================================================================
// Consumer API types (from consumer.go)
// ============================================================================
/// <summary>
/// Information about a consumer, returned from info requests.
/// Mirrors <c>ConsumerInfo</c> in server/consumer.go.
/// </summary>
public sealed class ConsumerInfo
{
[JsonPropertyName("stream_name")]
public string Stream { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("created")]
public DateTime Created { get; set; }
[JsonPropertyName("config")]
public ConsumerConfig? Config { get; set; }
[JsonPropertyName("delivered")]
public SequenceInfo Delivered { get; set; } = new();
[JsonPropertyName("ack_floor")]
public SequenceInfo AckFloor { get; set; } = new();
[JsonPropertyName("num_ack_pending")]
public int NumAckPending { get; set; }
[JsonPropertyName("num_redelivered")]
public int NumRedelivered { get; set; }
[JsonPropertyName("num_waiting")]
public int NumWaiting { get; set; }
[JsonPropertyName("num_pending")]
public ulong NumPending { get; set; }
[JsonPropertyName("cluster")]
public ClusterInfo? Cluster { get; set; }
[JsonPropertyName("push_bound")]
public bool PushBound { get; set; }
[JsonPropertyName("paused")]
public bool Paused { get; set; }
[JsonPropertyName("pause_remaining")]
public TimeSpan PauseRemaining { get; set; }
[JsonPropertyName("ts")]
public DateTime TimeStamp { get; set; }
[JsonPropertyName("priority_groups")]
public PriorityGroupState[]? PriorityGroups { get; set; }
}
/// <summary>
/// State information for a priority group on a pull consumer.
/// Mirrors <c>PriorityGroupState</c> in server/consumer.go.
/// </summary>
public sealed class PriorityGroupState
{
[JsonPropertyName("group")]
public string Group { get; set; } = string.Empty;
[JsonPropertyName("pinned_client_id")]
public string? PinnedClientId { get; set; }
[JsonPropertyName("pinned_ts")]
public DateTime PinnedTs { get; set; }
}
/// <summary>
/// Sequence information for consumer delivered/ack_floor positions.
/// Mirrors <c>SequenceInfo</c> in server/consumer.go.
/// </summary>
public sealed class SequenceInfo
{
[JsonPropertyName("consumer_seq")]
public ulong Consumer { get; set; }
[JsonPropertyName("stream_seq")]
public ulong Stream { get; set; }
[JsonPropertyName("last_active")]
public DateTime? Last { get; set; }
}
/// <summary>
/// Request to create or update a consumer.
/// Mirrors <c>CreateConsumerRequest</c> in server/consumer.go.
/// </summary>
public sealed class CreateConsumerRequest
{
[JsonPropertyName("stream_name")]
public string Stream { get; set; } = string.Empty;
[JsonPropertyName("config")]
public ConsumerConfig Config { get; set; } = new();
[JsonPropertyName("action")]
public ConsumerAction Action { get; set; }
}
/// <summary>
/// Specifies the intended action when creating a consumer.
/// Mirrors <c>ConsumerAction</c> in server/consumer.go.
/// </summary>
public enum ConsumerAction
{
/// <summary>Create a new consumer or update if it already exists.</summary>
CreateOrUpdate = 0,
/// <summary>Create a new consumer; fail if it already exists.</summary>
Create = 1,
/// <summary>Update an existing consumer; fail if it does not exist.</summary>
Update = 2,
}
/// <summary>
/// Response for a consumer deletion request.
/// Mirrors <c>ConsumerDeleteResponse</c> in server/consumer.go.
/// </summary>
public sealed class ConsumerDeleteResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
}
/// <summary>
/// A pending pull request waiting in the wait queue.
/// Mirrors <c>waitingRequest</c> in server/consumer.go.
/// </summary>
public sealed class WaitingRequest
{
public string Subject { get; set; } = string.Empty;
public string? Reply { get; set; }
/// <summary>Number of messages requested.</summary>
public int N { get; set; }
/// <summary>Number of messages delivered so far.</summary>
public int D { get; set; }
/// <summary>No-wait flag (1 = no wait).</summary>
public int NoWait { get; set; }
public DateTime? Expires { get; set; }
/// <summary>Max byte limit for this batch.</summary>
public int MaxBytes { get; set; }
/// <summary>Bytes accumulated so far.</summary>
public int B { get; set; }
}
/// <summary>
/// A circular wait queue for pending pull requests.
/// Mirrors <c>waitQueue</c> in server/consumer.go.
/// </summary>
public sealed class WaitQueue
{
private readonly List<WaitingRequest> _reqs = new();
private int _head;
private int _tail;
/// <summary>Number of pending requests in the queue.</summary>
public int Len => _reqs.Count;
/// <summary>Add a waiting request to the tail of the queue.</summary>
public void Add(WaitingRequest req) =>
throw new NotImplementedException("TODO: session 21");
/// <summary>Peek at the head request without removing it.</summary>
public WaitingRequest? Peek() =>
throw new NotImplementedException("TODO: session 21");
/// <summary>Remove and return the head request.</summary>
public WaitingRequest? Pop() =>
throw new NotImplementedException("TODO: session 21");
/// <summary>Compact the internal backing list to reclaim removed slots.</summary>
public void Compress() =>
throw new NotImplementedException("TODO: session 21");
/// <summary>Returns true if the queue is at capacity (head == tail when full).</summary>
public bool IsFull(int max) =>
throw new NotImplementedException("TODO: session 21");
}
/// <summary>
/// Cluster membership and leadership information for a stream or consumer.
/// Mirrors <c>ClusterInfo</c> in server/consumer.go and server/stream.go.
/// </summary>
public sealed class ClusterInfo
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("leader")]
public string? Leader { get; set; }
[JsonPropertyName("replicas")]
public PeerInfo[]? Replicas { get; set; }
}
/// <summary>
/// Information about a peer in a JetStream Raft group.
/// Mirrors <c>PeerInfo</c> in server/consumer.go and server/stream.go.
/// </summary>
public sealed class PeerInfo
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("current")]
public bool Current { get; set; }
[JsonPropertyName("offline")]
public bool Offline { get; set; }
[JsonPropertyName("active")]
public TimeSpan Active { get; set; }
[JsonPropertyName("lag")]
public ulong Lag { get; set; }
}

View File

@@ -0,0 +1,202 @@
// Copyright 2019-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/leafnode.go in the NATS server Go source.
using System.Text.Json.Serialization;
using System.Threading;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Session 15: Leaf Nodes
// ============================================================================
/// <summary>
/// Per-connection leaf-node state embedded in <see cref="ClientConnection"/>
/// when the connection kind is <c>Leaf</c>.
/// Mirrors Go <c>leaf</c> struct in leafnode.go.
/// </summary>
internal sealed class Leaf
{
/// <summary>
/// Config for solicited (outbound) leaf connections; null for accepted connections.
/// </summary>
public LeafNodeCfg? Remote { get; set; }
/// <summary>
/// True when we are the spoke side of a hub/spoke leaf pair.
/// </summary>
public bool IsSpoke { get; set; }
/// <summary>
/// Cluster name of the remote server when we are a hub and the spoke is
/// part of a cluster.
/// </summary>
public string RemoteCluster { get; set; } = string.Empty;
/// <summary>Remote server name or ID.</summary>
public string RemoteServer { get; set; } = string.Empty;
/// <summary>Domain name of the remote server.</summary>
public string RemoteDomain { get; set; } = string.Empty;
/// <summary>Account name of the remote server.</summary>
public string RemoteAccName { get; set; } = string.Empty;
/// <summary>
/// When true, suppresses propagation of east-west interest from other leaf nodes.
/// </summary>
public bool Isolated { get; set; }
/// <summary>
/// Subject-interest suppression map shared with the remote side.
/// Key = subject, Value = interest count (positive = subscribe, negative = unsubscribe delta).
/// </summary>
public Dictionary<string, int> Smap { get; set; } = new();
/// <summary>
/// Short-lived set of subscriptions added during <c>initLeafNodeSmapAndSendSubs</c>
/// to detect and avoid double-counting races.
/// </summary>
public HashSet<Subscription>? Tsub { get; set; }
/// <summary>Timer that clears <see cref="Tsub"/> after the initialization window.</summary>
public Timer? Tsubt { get; set; }
/// <summary>
/// Selected compression mode, which may differ from the server-configured mode.
/// </summary>
public string Compression { get; set; } = string.Empty;
/// <summary>
/// Gateway-mapped reply subscription used for GW reply routing via leaf nodes.
/// </summary>
public Subscription? GwSub { get; set; }
}
/// <summary>
/// Runtime configuration for a remote (solicited) leaf-node connection.
/// Wraps <see cref="RemoteLeafOpts"/> with connection-attempt state and a
/// reader-writer lock for concurrent access.
/// Mirrors Go <c>leafNodeCfg</c> struct in leafnode.go.
/// Replaces the stub that was in <c>NatsServerTypes.cs</c>.
/// </summary>
public sealed class LeafNodeCfg
{
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion);
// -------------------------------------------------------------------------
// Embedded RemoteLeafOpts fields
// -------------------------------------------------------------------------
/// <summary>The raw remote options this cfg was constructed from.</summary>
public RemoteLeafOpts? RemoteOpts { get; set; }
// -------------------------------------------------------------------------
// Runtime connection-attempt fields
// -------------------------------------------------------------------------
/// <summary>Resolved URLs to attempt connections to.</summary>
public List<Uri> Urls { get; set; } = [];
/// <summary>Currently selected URL from <see cref="Urls"/>.</summary>
public Uri? CurUrl { get; set; }
/// <summary>TLS server name override for SNI.</summary>
public string TlsName { get; set; } = string.Empty;
/// <summary>Username for authentication (resolved from credentials or options).</summary>
public string Username { get; set; } = string.Empty;
/// <summary>Password for authentication (resolved from credentials or options).</summary>
public string Password { get; set; } = string.Empty;
/// <summary>Publish/subscribe permission overrides for this connection.</summary>
public Permissions? Perms { get; set; }
/// <summary>
/// Delay before the next connection attempt (e.g. during loop-detection back-off).
/// </summary>
public TimeSpan ConnDelay { get; set; }
/// <summary>
/// Timer used to trigger JetStream account migration for this leaf.
/// </summary>
public Timer? JsMigrateTimer { get; set; }
// -------------------------------------------------------------------------
// Forwarded properties from RemoteLeafOpts
// -------------------------------------------------------------------------
public string LocalAccount { get => RemoteOpts?.LocalAccount ?? string.Empty; }
public bool NoRandomize { get => RemoteOpts?.NoRandomize ?? false; }
public string Credentials { get => RemoteOpts?.Credentials ?? string.Empty; }
public bool Disabled { get => RemoteOpts?.Disabled ?? false; }
// -------------------------------------------------------------------------
// Lock helpers
// -------------------------------------------------------------------------
public void AcquireReadLock() => _lock.EnterReadLock();
public void ReleaseReadLock() => _lock.ExitReadLock();
public void AcquireWriteLock() => _lock.EnterWriteLock();
public void ReleaseWriteLock() => _lock.ExitWriteLock();
}
/// <summary>
/// CONNECT protocol payload sent by a leaf-node connection.
/// Fields map 1-to-1 with the JSON tags in Go's <c>leafConnectInfo</c>.
/// Mirrors Go <c>leafConnectInfo</c> struct in leafnode.go.
/// </summary>
internal sealed class LeafConnectInfo
{
[JsonPropertyName("version")] public string Version { get; set; } = string.Empty;
[JsonPropertyName("nkey")] public string Nkey { get; set; } = string.Empty;
[JsonPropertyName("jwt")] public string Jwt { get; set; } = string.Empty;
[JsonPropertyName("sig")] public string Sig { get; set; } = string.Empty;
[JsonPropertyName("user")] public string User { get; set; } = string.Empty;
[JsonPropertyName("pass")] public string Pass { get; set; } = string.Empty;
[JsonPropertyName("auth_token")] public string Token { get; set; } = string.Empty;
[JsonPropertyName("server_id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("domain")] public string Domain { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("is_hub")] public bool Hub { get; set; }
[JsonPropertyName("cluster")] public string Cluster { get; set; } = string.Empty;
[JsonPropertyName("headers")] public bool Headers { get; set; }
[JsonPropertyName("jetstream")] public bool JetStream { get; set; }
[JsonPropertyName("deny_pub")] public string[] DenyPub { get; set; } = [];
[JsonPropertyName("isolate")] public bool Isolate { get; set; }
/// <summary>
/// Compression mode string. The legacy boolean field was never used; this
/// string field uses a different JSON tag to avoid conflicts.
/// </summary>
[JsonPropertyName("compress_mode")] public string Compression { get; set; } = string.Empty;
/// <summary>
/// Used only to detect wrong-port connections (client connecting to leaf port).
/// </summary>
[JsonPropertyName("gateway")] public string Gateway { get; set; } = string.Empty;
/// <summary>Account name the remote is binding to on the accept side.</summary>
[JsonPropertyName("remote_account")] public string RemoteAccount { get; set; } = string.Empty;
/// <summary>
/// Protocol version sent by the soliciting side so the accepting side knows
/// which features are supported (e.g. message tracing).
/// </summary>
[JsonPropertyName("protocol")] public int Proto { get; set; }
}

View File

@@ -0,0 +1,465 @@
// Copyright 2024-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/msgtrace.go in the NATS server Go source.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Message-trace header name constants
// Mirrors Go const block at top of server/msgtrace.go.
// ============================================================================
/// <summary>
/// NATS message-trace header names and special sentinel values.
/// Mirrors Go const block in server/msgtrace.go.
/// </summary>
public static class MsgTraceHeaders
{
/// <summary>Header that carries the trace destination subject. Mirrors Go <c>MsgTraceDest</c>.</summary>
public const string MsgTraceDest = "Nats-Trace-Dest";
/// <summary>
/// Sentinel value placed in the trace-dest header to disable tracing
/// (must be an invalid NATS subject). Mirrors Go <c>MsgTraceDestDisabled</c>.
/// </summary>
public const string MsgTraceDestDisabled = "trace disabled";
/// <summary>Header used for hop-count tracking across servers. Mirrors Go <c>MsgTraceHop</c>.</summary>
public const string MsgTraceHop = "Nats-Trace-Hop";
/// <summary>Header that carries the originating account name. Mirrors Go <c>MsgTraceOriginAccount</c>.</summary>
public const string MsgTraceOriginAccount = "Nats-Trace-Origin-Account";
/// <summary>
/// When set to a truthy value, the message is consumed only for tracing
/// and not delivered to subscribers. Mirrors Go <c>MsgTraceOnly</c>.
/// </summary>
public const string MsgTraceOnly = "Nats-Trace-Only";
/// <summary>
/// W3C trace-context parent header. NATS no longer lower-cases this but
/// accepts it in any case. Mirrors Go <c>traceParentHdr</c> (internal).
/// </summary>
public const string TraceParentHdr = "traceparent";
}
// ============================================================================
// MsgTraceType — discriminator string for polymorphic trace event lists
// Mirrors Go <c>MsgTraceType string</c> in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Discriminator string identifying the concrete type of a trace event
/// within a <see cref="MsgTraceEvents"/> list.
/// Mirrors Go <c>MsgTraceType string</c> and its constants in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceType
{
private readonly string _value;
private MsgTraceType(string value) => _value = value;
/// <inheritdoc/>
public override string ToString() => _value;
public static implicit operator MsgTraceType(string value) => new(value);
public static implicit operator string(MsgTraceType t) => t._value;
public override bool Equals(object? obj) =>
obj is MsgTraceType other && _value == other._value;
public override int GetHashCode() => _value.GetHashCode();
// ---- Well-known type constants (mirror Go const block) ----
/// <summary>Ingress event. Mirrors Go <c>MsgTraceIngressType = "in"</c>.</summary>
public static readonly MsgTraceType Ingress = new("in");
/// <summary>Subject-mapping event. Mirrors Go <c>MsgTraceSubjectMappingType = "sm"</c>.</summary>
public static readonly MsgTraceType SubjectMapping = new("sm");
/// <summary>Stream-export event. Mirrors Go <c>MsgTraceStreamExportType = "se"</c>.</summary>
public static readonly MsgTraceType StreamExport = new("se");
/// <summary>Service-import event. Mirrors Go <c>MsgTraceServiceImportType = "si"</c>.</summary>
public static readonly MsgTraceType ServiceImport = new("si");
/// <summary>JetStream storage event. Mirrors Go <c>MsgTraceJetStreamType = "js"</c>.</summary>
public static readonly MsgTraceType JetStream = new("js");
/// <summary>Egress (delivery) event. Mirrors Go <c>MsgTraceEgressType = "eg"</c>.</summary>
public static readonly MsgTraceType Egress = new("eg");
}
// ============================================================================
// IMsgTrace — interface for polymorphic trace events
// Mirrors Go <c>MsgTrace interface</c> in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Marker interface implemented by all concrete message-trace event types.
/// Enables polymorphic handling of the <see cref="MsgTraceEvents"/> list.
/// Mirrors Go <c>MsgTrace interface</c> in server/msgtrace.go.
/// </summary>
public interface IMsgTrace
{
/// <summary>Returns the discriminator type string for this trace event.</summary>
string Typ();
}
// ============================================================================
// MsgTraceBase — shared fields present in every trace event
// Mirrors Go <c>MsgTraceBase</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Common base fields shared by all concrete message-trace event types.
/// Mirrors Go <c>MsgTraceBase</c> struct in server/msgtrace.go.
/// </summary>
public class MsgTraceBase : IMsgTrace
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
[JsonPropertyName("ts")]
public DateTime Timestamp { get; set; }
/// <inheritdoc/>
public virtual string Typ() => Type;
}
// ============================================================================
// MsgTraceIngress — client / route / gateway / leaf connection ingress event
// Mirrors Go <c>MsgTraceIngress</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records the point at which a message was received by the server from a
/// client, route, gateway, or leaf connection.
/// Mirrors Go <c>MsgTraceIngress</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceIngress : MsgTraceBase
{
[JsonPropertyName("kind")]
public int Kind { get; set; }
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("subj")]
public string Subject { get; set; } = string.Empty;
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; set; }
}
// ============================================================================
// MsgTraceSubjectMapping — subject-mapping rewrite event
// Mirrors Go <c>MsgTraceSubjectMapping</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records a subject-mapping rewrite applied to an in-flight message.
/// Mirrors Go <c>MsgTraceSubjectMapping</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceSubjectMapping : MsgTraceBase
{
[JsonPropertyName("to")]
public string MappedTo { get; set; } = string.Empty;
}
// ============================================================================
// MsgTraceStreamExport — stream export / cross-account delivery event
// Mirrors Go <c>MsgTraceStreamExport</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records delivery of a message to a stream-export destination account.
/// Mirrors Go <c>MsgTraceStreamExport</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceStreamExport : MsgTraceBase
{
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("to")]
public string To { get; set; } = string.Empty;
}
// ============================================================================
// MsgTraceServiceImport — service import routing event
// Mirrors Go <c>MsgTraceServiceImport</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records routing of a message via a service-import from one account to
/// another.
/// Mirrors Go <c>MsgTraceServiceImport</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceServiceImport : MsgTraceBase
{
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
[JsonPropertyName("from")]
public string From { get; set; } = string.Empty;
[JsonPropertyName("to")]
public string To { get; set; } = string.Empty;
}
// ============================================================================
// MsgTraceJetStream — JetStream storage event
// Mirrors Go <c>MsgTraceJetStream</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records the attempt (and outcome) of storing or delivering a message
/// to a JetStream stream.
/// Mirrors Go <c>MsgTraceJetStream</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceJetStream : MsgTraceBase
{
[JsonPropertyName("stream")]
public string Stream { get; set; } = string.Empty;
[JsonPropertyName("subject")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Subject { get; set; }
[JsonPropertyName("nointerest")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool NoInterest { get; set; }
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; set; }
}
// ============================================================================
// MsgTraceEgress — outbound delivery event
// Mirrors Go <c>MsgTraceEgress</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Records the outbound delivery of a message to a subscriber, route,
/// gateway, or leaf connection.
/// Mirrors Go <c>MsgTraceEgress</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceEgress : MsgTraceBase
{
[JsonPropertyName("kind")]
public int Kind { get; set; }
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
[JsonPropertyName("name")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Name { get; set; }
[JsonPropertyName("hop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Hop { get; set; }
[JsonPropertyName("acc")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
[JsonPropertyName("sub")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Subscription { get; set; }
[JsonPropertyName("queue")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Queue { get; set; }
[JsonPropertyName("error")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Error { get; set; }
/// <summary>
/// Optional link to the <see cref="MsgTraceEvent"/> produced by the remote
/// server that received this egress message (route/leaf/gateway hop).
/// Not serialised. Mirrors Go <c>Link *MsgTraceEvent</c>.
/// </summary>
[JsonIgnore]
public MsgTraceEvent? Link { get; set; }
}
// ============================================================================
// MsgTraceEvents — polymorphic list with custom JSON deserialiser
// Mirrors Go <c>MsgTraceEvents []MsgTrace</c> and its UnmarshalJSON in msgtrace.go.
// ============================================================================
/// <summary>
/// Custom JSON converter that deserialises a <c>MsgTraceEvents</c> JSON array
/// into the correct concrete <see cref="IMsgTrace"/> subtype, using the
/// <c>"type"</c> discriminator field.
/// Mirrors Go <c>MsgTraceEvents.UnmarshalJSON</c> in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceEventsConverter : JsonConverter<List<IMsgTrace>>
{
private static readonly Dictionary<string, Func<JsonElement, IMsgTrace>> Factories = new()
{
["in"] = e => e.Deserialize<MsgTraceIngress>()!,
["sm"] = e => e.Deserialize<MsgTraceSubjectMapping>()!,
["se"] = e => e.Deserialize<MsgTraceStreamExport>()!,
["si"] = e => e.Deserialize<MsgTraceServiceImport>()!,
["js"] = e => e.Deserialize<MsgTraceJetStream>()!,
["eg"] = e => e.Deserialize<MsgTraceEgress>()!,
};
public override List<IMsgTrace> Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options)
{
var result = new List<IMsgTrace>();
using var doc = JsonDocument.ParseValue(ref reader);
foreach (var element in doc.RootElement.EnumerateArray())
{
if (!element.TryGetProperty("type", out var typeProp))
throw new JsonException("MsgTrace element missing 'type' field.");
var typeStr = typeProp.GetString() ?? string.Empty;
if (!Factories.TryGetValue(typeStr, out var factory))
throw new JsonException($"Unknown MsgTrace type '{typeStr}'.");
result.Add(factory(element));
}
return result;
}
public override void Write(
Utf8JsonWriter writer,
List<IMsgTrace> value,
JsonSerializerOptions options)
{
writer.WriteStartArray();
foreach (var item in value)
JsonSerializer.Serialize(writer, item, item.GetType(), options);
writer.WriteEndArray();
}
}
// ============================================================================
// MsgTraceRequest — the original request metadata included in a trace event
// Mirrors Go <c>MsgTraceRequest</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// Captures the headers and size of the original message that triggered a
/// trace event.
/// Mirrors Go <c>MsgTraceRequest</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceRequest
{
/// <summary>
/// Original message headers, preserving header-name casing.
/// Mirrors Go <c>Header map[string][]string</c> (not http.Header, so casing is preserved).
/// </summary>
[JsonPropertyName("header")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Dictionary<string, List<string>>? Header { get; set; }
[JsonPropertyName("msgsize")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int MsgSize { get; set; }
}
// ============================================================================
// MsgTraceEvent — top-level trace event published to the trace destination
// Mirrors Go <c>MsgTraceEvent</c> struct in server/msgtrace.go.
// ============================================================================
/// <summary>
/// The top-level message-trace advisory published to the trace destination
/// subject. Contains server identity, the original request metadata, the
/// hop count, and the ordered list of trace events.
/// Mirrors Go <c>MsgTraceEvent</c> struct in server/msgtrace.go.
/// </summary>
public sealed class MsgTraceEvent
{
[JsonPropertyName("server")]
public ServerInfo Server { get; set; } = new();
[JsonPropertyName("request")]
public MsgTraceRequest Request { get; set; } = new();
[JsonPropertyName("hops")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int Hops { get; set; }
[JsonPropertyName("events")]
[JsonConverter(typeof(MsgTraceEventsConverter))]
public List<IMsgTrace> Events { get; set; } = [];
// ---- Convenience accessors (mirrors Go helper methods on MsgTraceEvent) ----
/// <summary>
/// Returns the first event if it is a <see cref="MsgTraceIngress"/>, else null.
/// Mirrors Go <c>MsgTraceEvent.Ingress()</c>.
/// </summary>
public MsgTraceIngress? Ingress() =>
Events.Count > 0 ? Events[0] as MsgTraceIngress : null;
/// <summary>
/// Returns the first <see cref="MsgTraceSubjectMapping"/> in the event list, or null.
/// Mirrors Go <c>MsgTraceEvent.SubjectMapping()</c>.
/// </summary>
public MsgTraceSubjectMapping? SubjectMapping() =>
Events.OfType<MsgTraceSubjectMapping>().FirstOrDefault();
/// <summary>
/// Returns all <see cref="MsgTraceStreamExport"/> events.
/// Mirrors Go <c>MsgTraceEvent.StreamExports()</c>.
/// </summary>
public IReadOnlyList<MsgTraceStreamExport> StreamExports() =>
Events.OfType<MsgTraceStreamExport>().ToList();
/// <summary>
/// Returns all <see cref="MsgTraceServiceImport"/> events.
/// Mirrors Go <c>MsgTraceEvent.ServiceImports()</c>.
/// </summary>
public IReadOnlyList<MsgTraceServiceImport> ServiceImports() =>
Events.OfType<MsgTraceServiceImport>().ToList();
/// <summary>
/// Returns the first <see cref="MsgTraceJetStream"/> event, or null.
/// Mirrors Go <c>MsgTraceEvent.JetStream()</c>.
/// </summary>
public MsgTraceJetStream? JetStream() =>
Events.OfType<MsgTraceJetStream>().FirstOrDefault();
/// <summary>
/// Returns all <see cref="MsgTraceEgress"/> events.
/// Mirrors Go <c>MsgTraceEvent.Egresses()</c>.
/// </summary>
public IReadOnlyList<MsgTraceEgress> Egresses() =>
Events.OfType<MsgTraceEgress>().ToList();
}

View File

@@ -0,0 +1,294 @@
// Copyright 2013-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/monitor_sort_opts.go in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// SortOpt — string wrapper type for connection-list sort options
// Mirrors Go <c>SortOpt string</c> in server/monitor_sort_opts.go.
// ============================================================================
/// <summary>
/// A strongly-typed sort option for <see cref="ConnzOptions.Sort"/>.
/// Wraps a raw string value corresponding to the JSON sort key.
/// Mirrors Go <c>SortOpt string</c> in server/monitor_sort_opts.go.
/// </summary>
public sealed class SortOpt
{
private readonly string _value;
private SortOpt(string value) => _value = value;
/// <summary>Returns the raw sort-option string value.</summary>
public override string ToString() => _value;
/// <summary>Allows implicit conversion from a string literal.</summary>
public static implicit operator SortOpt(string value) => new(value);
/// <summary>Allows implicit conversion back to a plain string.</summary>
public static implicit operator string(SortOpt opt) => opt._value;
public override bool Equals(object? obj) =>
obj is SortOpt other && _value == other._value;
public override int GetHashCode() => _value.GetHashCode();
// ---- Well-known sort-option constants ----
// Mirrors Go const block in monitor_sort_opts.go.
/// <summary>Sort by connection ID (ascending). Mirrors Go <c>ByCid = "cid"</c>.</summary>
public static readonly SortOpt ByCid = new("cid");
/// <summary>Sort by connection start time (same as ByCid). Mirrors Go <c>ByStart = "start"</c>.</summary>
public static readonly SortOpt ByStart = new("start");
/// <summary>Sort by number of subscriptions (descending). Mirrors Go <c>BySubs = "subs"</c>.</summary>
public static readonly SortOpt BySubs = new("subs");
/// <summary>Sort by pending bytes waiting to be sent (descending). Mirrors Go <c>ByPending = "pending"</c>.</summary>
public static readonly SortOpt ByPending = new("pending");
/// <summary>Sort by number of outbound messages (descending). Mirrors Go <c>ByOutMsgs = "msgs_to"</c>.</summary>
public static readonly SortOpt ByOutMsgs = new("msgs_to");
/// <summary>Sort by number of inbound messages (descending). Mirrors Go <c>ByInMsgs = "msgs_from"</c>.</summary>
public static readonly SortOpt ByInMsgs = new("msgs_from");
/// <summary>Sort by bytes sent (descending). Mirrors Go <c>ByOutBytes = "bytes_to"</c>.</summary>
public static readonly SortOpt ByOutBytes = new("bytes_to");
/// <summary>Sort by bytes received (descending). Mirrors Go <c>ByInBytes = "bytes_from"</c>.</summary>
public static readonly SortOpt ByInBytes = new("bytes_from");
/// <summary>Sort by last activity time (descending). Mirrors Go <c>ByLast = "last"</c>.</summary>
public static readonly SortOpt ByLast = new("last");
/// <summary>Sort by idle duration (descending). Mirrors Go <c>ByIdle = "idle"</c>.</summary>
public static readonly SortOpt ByIdle = new("idle");
/// <summary>Sort by uptime (descending). Mirrors Go <c>ByUptime = "uptime"</c>.</summary>
public static readonly SortOpt ByUptime = new("uptime");
/// <summary>Sort by stop time — only valid on closed connections. Mirrors Go <c>ByStop = "stop"</c>.</summary>
public static readonly SortOpt ByStop = new("stop");
/// <summary>Sort by close reason — only valid on closed connections. Mirrors Go <c>ByReason = "reason"</c>.</summary>
public static readonly SortOpt ByReason = new("reason");
/// <summary>Sort by round-trip time (descending). Mirrors Go <c>ByRTT = "rtt"</c>.</summary>
public static readonly SortOpt ByRtt = new("rtt");
private static readonly HashSet<string> ValidValues =
[
"", "cid", "start", "subs", "pending",
"msgs_to", "msgs_from", "bytes_to", "bytes_from",
"last", "idle", "uptime", "stop", "reason", "rtt"
];
/// <summary>
/// Returns true if this sort option is a recognised value.
/// Mirrors Go <c>SortOpt.IsValid()</c> in monitor_sort_opts.go.
/// </summary>
public bool IsValid() => ValidValues.Contains(_value);
}
// ============================================================================
// ConnInfos — sortable list wrapper for ConnInfo pointers
// Mirrors Go <c>ConnInfos []*ConnInfo</c> in monitor_sort_opts.go.
// ============================================================================
/// <summary>
/// A list of <see cref="ConnInfo"/> objects that can be sorted using one of
/// the <c>SortBy*</c> comparers defined in this file.
/// Mirrors Go <c>ConnInfos []*ConnInfo</c> in server/monitor_sort_opts.go.
/// </summary>
public sealed class ConnInfos : List<ConnInfo>
{
public ConnInfos() { }
public ConnInfos(IEnumerable<ConnInfo> items) : base(items) { }
}
// ============================================================================
// IComparer<ConnInfo> implementations — one per sort option
// Each class mirrors the corresponding Less() method in monitor_sort_opts.go.
// ============================================================================
/// <summary>Sort by connection ID (ascending). Mirrors Go <c>SortByCid</c>.</summary>
public sealed class SortByCid : IComparer<ConnInfo>
{
public static readonly SortByCid Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.Cid.CompareTo(y.Cid);
}
}
/// <summary>Sort by number of subscriptions (ascending for underlying sort; caller reverses if needed).</summary>
/// Mirrors Go <c>SortBySubs</c>.
public sealed class SortBySubs : IComparer<ConnInfo>
{
public static readonly SortBySubs Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.NumSubs.CompareTo(y.NumSubs);
}
}
/// <summary>Sort by pending bytes. Mirrors Go <c>SortByPending</c>.</summary>
public sealed class SortByPending : IComparer<ConnInfo>
{
public static readonly SortByPending Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.Pending.CompareTo(y.Pending);
}
}
/// <summary>Sort by outbound message count. Mirrors Go <c>SortByOutMsgs</c>.</summary>
public sealed class SortByOutMsgs : IComparer<ConnInfo>
{
public static readonly SortByOutMsgs Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.OutMsgs.CompareTo(y.OutMsgs);
}
}
/// <summary>Sort by inbound message count. Mirrors Go <c>SortByInMsgs</c>.</summary>
public sealed class SortByInMsgs : IComparer<ConnInfo>
{
public static readonly SortByInMsgs Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.InMsgs.CompareTo(y.InMsgs);
}
}
/// <summary>Sort by outbound bytes. Mirrors Go <c>SortByOutBytes</c>.</summary>
public sealed class SortByOutBytes : IComparer<ConnInfo>
{
public static readonly SortByOutBytes Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.OutBytes.CompareTo(y.OutBytes);
}
}
/// <summary>Sort by inbound bytes. Mirrors Go <c>SortByInBytes</c>.</summary>
public sealed class SortByInBytes : IComparer<ConnInfo>
{
public static readonly SortByInBytes Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.InBytes.CompareTo(y.InBytes);
}
}
/// <summary>Sort by last activity timestamp. Mirrors Go <c>SortByLast</c>.</summary>
public sealed class SortByLast : IComparer<ConnInfo>
{
public static readonly SortByLast Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.LastActivity.CompareTo(y.LastActivity);
}
}
/// <summary>
/// Sort by idle duration (time since last activity), relative to a supplied
/// reference time. Mirrors Go <c>SortByIdle</c>.
/// </summary>
public sealed class SortByIdle : IComparer<ConnInfo>
{
private readonly DateTime _now;
public SortByIdle(DateTime now) => _now = now;
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
var idleX = _now - x.LastActivity;
var idleY = _now - y.LastActivity;
return idleX.CompareTo(idleY);
}
}
/// <summary>
/// Sort by uptime (time the connection has been open), relative to a supplied
/// reference time. Mirrors Go <c>SortByUptime</c>.
/// </summary>
public sealed class SortByUptime : IComparer<ConnInfo>
{
private readonly DateTime _now;
public SortByUptime(DateTime now) => _now = now;
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
var uptimeX = (x.Stop is null || x.Stop == default) ? _now - x.Start : x.Stop.Value - x.Start;
var uptimeY = (y.Stop is null || y.Stop == default) ? _now - y.Start : y.Stop.Value - y.Start;
return uptimeX.CompareTo(uptimeY);
}
}
/// <summary>Sort by stop time (closed connections only). Mirrors Go <c>SortByStop</c>.</summary>
public sealed class SortByStop : IComparer<ConnInfo>
{
public static readonly SortByStop Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
// If either stop is null treat as zero (shouldn't happen for closed-only queries)
var stopX = x.Stop ?? DateTime.MinValue;
var stopY = y.Stop ?? DateTime.MinValue;
return stopX.CompareTo(stopY);
}
}
/// <summary>Sort by close reason string. Mirrors Go <c>SortByReason</c>.</summary>
public sealed class SortByReason : IComparer<ConnInfo>
{
public static readonly SortByReason Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return string.Compare(x.Reason, y.Reason, StringComparison.Ordinal);
}
}
/// <summary>
/// Sort by round-trip time (nanoseconds, internal field).
/// Mirrors Go <c>SortByRTT</c>.
/// </summary>
public sealed class SortByRtt : IComparer<ConnInfo>
{
public static readonly SortByRtt Instance = new();
public int Compare(ConnInfo? x, ConnInfo? y)
{
if (x is null || y is null) return 0;
return x.RttNanos.CompareTo(y.RttNanos);
}
}

View File

@@ -0,0 +1,387 @@
// Copyright 2013-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/monitor.go in the NATS server Go source.
using System.Text.Json.Serialization;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Monitor list size defaults
// Mirrors Go const block near top of monitor.go.
// ============================================================================
/// <summary>
/// Default sizes for monitoring API response lists.
/// Mirrors Go constants in server/monitor.go.
/// </summary>
public static class MonitorDefaults
{
/// <summary>Default maximum number of connection entries returned. Mirrors Go <c>DefaultConnListSize = 1024</c>.</summary>
public const int DefaultConnListSize = 1024;
/// <summary>Default maximum number of subscription entries returned. Mirrors Go <c>DefaultSubListSize = 1024</c>.</summary>
public const int DefaultSubListSize = 1024;
}
// ============================================================================
// ConnState — connection state filter for Connz queries
// Mirrors Go <c>ConnState</c> and its iota constants in monitor.go.
// ============================================================================
/// <summary>
/// Filter applied to connection-list queries to select open, closed, or
/// all connections.
/// Mirrors Go <c>ConnState</c> in server/monitor.go.
/// </summary>
public enum ConnState
{
/// <summary>Only return open (active) connections. Mirrors Go <c>ConnOpen = 0</c>.</summary>
ConnOpen = 0,
/// <summary>Only return closed connections. Mirrors Go <c>ConnClosed</c>.</summary>
ConnClosed = 1,
/// <summary>Return all connections, open or closed. Mirrors Go <c>ConnAll</c>.</summary>
ConnAll = 2,
}
// ============================================================================
// ConnzOptions — query options for the Connz endpoint
// Mirrors Go <c>ConnzOptions</c> struct in server/monitor.go.
// ============================================================================
/// <summary>
/// Options that control the output of a <c>Connz</c> monitoring query.
/// Mirrors Go <c>ConnzOptions</c> struct in server/monitor.go.
/// </summary>
public sealed class ConnzOptions
{
/// <summary>
/// How to sort results. Only <c>ByCid</c> is ascending; all others are
/// descending. Mirrors Go <c>Sort SortOpt</c>.
/// </summary>
[JsonPropertyName("sort")]
public SortOpt Sort { get; set; } = SortOpt.ByCid;
/// <summary>When true, usernames are included in results. Mirrors Go <c>Username bool</c>.</summary>
[JsonPropertyName("auth")]
public bool Username { get; set; }
/// <summary>When true, subscription subjects are listed. Mirrors Go <c>Subscriptions bool</c>.</summary>
[JsonPropertyName("subscriptions")]
public bool Subscriptions { get; set; }
/// <summary>When true, verbose subscription detail is included. Mirrors Go <c>SubscriptionsDetail bool</c>.</summary>
[JsonPropertyName("subscriptions_detail")]
public bool SubscriptionsDetail { get; set; }
/// <summary>Zero-based offset for pagination. Mirrors Go <c>Offset int</c>.</summary>
[JsonPropertyName("offset")]
public int Offset { get; set; }
/// <summary>Maximum number of connections to return. Mirrors Go <c>Limit int</c>.</summary>
[JsonPropertyName("limit")]
public int Limit { get; set; }
/// <summary>Filter for a specific client connection by CID. Mirrors Go <c>CID uint64</c>.</summary>
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
/// <summary>Filter for a specific MQTT client ID. Mirrors Go <c>MQTTClient string</c>.</summary>
[JsonPropertyName("mqtt_client")]
public string MqttClient { get; set; } = string.Empty;
/// <summary>Connection state filter. Mirrors Go <c>State ConnState</c>.</summary>
[JsonPropertyName("state")]
public ConnState State { get; set; } = ConnState.ConnOpen;
/// <summary>Filter by username. Mirrors Go <c>User string</c>.</summary>
[JsonPropertyName("user")]
public string User { get; set; } = string.Empty;
/// <summary>Filter by account name. Mirrors Go <c>Account string</c>.</summary>
[JsonPropertyName("acc")]
public string Account { get; set; } = string.Empty;
/// <summary>Filter by subject interest (requires Account filter). Mirrors Go <c>FilterSubject string</c>.</summary>
[JsonPropertyName("filter_subject")]
public string FilterSubject { get; set; } = string.Empty;
}
// ============================================================================
// Connz — top-level connection list monitoring response
// Mirrors Go <c>Connz</c> struct in server/monitor.go.
// ============================================================================
/// <summary>
/// Top-level response type for the <c>/connz</c> monitoring endpoint.
/// Contains the current connection list and pagination metadata.
/// Mirrors Go <c>Connz</c> struct in server/monitor.go.
/// </summary>
public sealed class Connz
{
[JsonPropertyName("server_id")]
public string Id { get; set; } = string.Empty;
[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 List<ConnInfo> Conns { get; set; } = [];
}
// ============================================================================
// ConnInfo — per-connection detail record
// Mirrors Go <c>ConnInfo</c> struct in server/monitor.go.
// ============================================================================
/// <summary>
/// Detailed information about a single client connection, as returned by the
/// <c>/connz</c> monitoring endpoint.
/// Mirrors Go <c>ConnInfo</c> struct in server/monitor.go.
/// </summary>
public sealed class ConnInfo
{
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
[JsonPropertyName("kind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Kind { get; set; }
[JsonPropertyName("type")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Type { get; set; }
[JsonPropertyName("ip")]
public string Ip { get; set; } = string.Empty;
[JsonPropertyName("port")]
public int Port { get; set; }
[JsonPropertyName("start")]
public DateTime Start { get; set; }
[JsonPropertyName("last_activity")]
public DateTime LastActivity { get; set; }
[JsonPropertyName("stop")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? Stop { get; set; }
[JsonPropertyName("reason")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Reason { get; set; }
[JsonPropertyName("rtt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Rtt { get; set; }
[JsonPropertyName("uptime")]
public string Uptime { get; set; } = string.Empty;
[JsonPropertyName("idle")]
public string Idle { get; set; } = string.Empty;
[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("stalls")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long Stalls { get; set; }
[JsonPropertyName("subscriptions")]
public uint NumSubs { 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("version")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Version { get; set; }
[JsonPropertyName("tls_version")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TlsVersion { get; set; }
[JsonPropertyName("tls_cipher_suite")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? TlsCipher { get; set; }
[JsonPropertyName("tls_peer_certs")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<TlsPeerCert>? TlsPeerCerts { get; set; }
[JsonPropertyName("tls_first")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool TlsFirst { get; set; }
[JsonPropertyName("authorized_user")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AuthorizedUser { get; set; }
[JsonPropertyName("account")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
[JsonPropertyName("subscriptions_list")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<string>? Subs { get; set; }
[JsonPropertyName("subscriptions_list_detail")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public List<SubDetail>? SubsDetail { get; set; }
[JsonPropertyName("jwt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Jwt { get; set; }
[JsonPropertyName("issuer_key")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? IssuerKey { get; set; }
[JsonPropertyName("name_tag")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? NameTag { get; set; }
[JsonPropertyName("tags")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string[]? Tags { get; set; }
[JsonPropertyName("mqtt_client")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? MqttClient { get; set; }
[JsonPropertyName("proxy")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ProxyInfo? Proxy { get; set; }
/// <summary>
/// Internal field used for fast RTT-based sorting.
/// Mirrors Go <c>rtt int64</c> unexported field in ConnInfo.
/// Not serialised.
/// </summary>
[JsonIgnore]
internal long RttNanos { get; set; }
}
// ============================================================================
// ProxyInfo — proxy connection metadata
// Mirrors Go <c>ProxyInfo</c> struct in server/monitor.go.
// ============================================================================
/// <summary>
/// Information about a proxied connection (e.g. HAProxy PROXY protocol).
/// Mirrors Go <c>ProxyInfo</c> struct in server/monitor.go.
/// </summary>
public sealed class ProxyInfo
{
[JsonPropertyName("key")]
public string Key { get; set; } = string.Empty;
}
// ============================================================================
// TlsPeerCert — TLS peer certificate summary
// Mirrors Go <c>TLSPeerCert</c> struct in server/monitor.go.
// ============================================================================
/// <summary>
/// Basic information about a TLS peer certificate.
/// Mirrors Go <c>TLSPeerCert</c> struct in server/monitor.go.
/// </summary>
public sealed class TlsPeerCert
{
[JsonPropertyName("subject")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Subject { get; set; }
[JsonPropertyName("spki_sha256")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? SubjectPkiSha256 { get; set; }
[JsonPropertyName("cert_sha256")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? CertSha256 { get; set; }
}
// ============================================================================
// SubDetail — verbose subscription information
// Mirrors Go <c>SubDetail</c> struct in server/monitor.go (line ~961).
// ============================================================================
/// <summary>
/// Verbose information about a single subscription, included in detailed
/// connection or account monitoring responses.
/// Mirrors Go <c>SubDetail</c> struct in server/monitor.go.
/// </summary>
public sealed class SubDetail
{
[JsonPropertyName("account")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Account { get; set; }
[JsonPropertyName("account_tag")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? AccountTag { get; set; }
[JsonPropertyName("subject")]
public string Subject { get; set; } = string.Empty;
[JsonPropertyName("qgroup")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Queue { get; set; }
[JsonPropertyName("sid")]
public string Sid { get; set; } = string.Empty;
[JsonPropertyName("msgs")]
public long Msgs { get; set; }
[JsonPropertyName("max")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public long Max { get; set; }
[JsonPropertyName("cid")]
public ulong Cid { get; set; }
}

View File

@@ -0,0 +1,271 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Mqtt;
// References to "spec" here are from https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.pdf
/// <summary>
/// MQTT control packet type byte values.
/// Mirrors the <c>mqttPacket*</c> constants in server/mqtt.go.
/// </summary>
internal static class MqttPacket
{
public const byte Connect = 0x10;
public const byte ConnectAck = 0x20;
public const byte Pub = 0x30;
public const byte PubAck = 0x40;
public const byte PubRec = 0x50;
public const byte PubRel = 0x60;
public const byte PubComp = 0x70;
public const byte Sub = 0x80;
public const byte SubAck = 0x90;
public const byte Unsub = 0xA0;
public const byte UnsubAck = 0xB0;
public const byte Ping = 0xC0;
public const byte PingResp = 0xD0;
public const byte Disconnect = 0xE0;
public const byte Mask = 0xF0;
public const byte FlagMask = 0x0F;
}
/// <summary>
/// MQTT CONNECT packet flag byte values.
/// Mirrors the <c>mqttConnFlag*</c> constants in server/mqtt.go.
/// </summary>
internal static class MqttConnectFlag
{
public const byte Reserved = 0x01;
public const byte CleanSession = 0x02;
public const byte WillFlag = 0x04;
public const byte WillQoS = 0x18;
public const byte WillRetain = 0x20;
public const byte PasswordFlag = 0x40;
public const byte UsernameFlag = 0x80;
}
/// <summary>
/// MQTT PUBLISH packet flag byte values.
/// Mirrors the <c>mqttPubFlag*</c> and <c>mqttPubQoS*</c> constants in server/mqtt.go.
/// </summary>
internal static class MqttPubFlag
{
public const byte Retain = 0x01;
public const byte QoS = 0x06;
public const byte Dup = 0x08;
public const byte QoS1 = 0x1 << 1;
public const byte QoS2 = 0x2 << 1;
}
/// <summary>
/// MQTT CONNACK return codes.
/// Mirrors the <c>mqttConnAckRC*</c> constants in server/mqtt.go.
/// </summary>
internal static class MqttConnAckRc
{
public const byte Accepted = 0x00;
public const byte UnacceptableProtocol = 0x01;
public const byte IdentifierRejected = 0x02;
public const byte ServerUnavailable = 0x03;
public const byte BadUserOrPassword = 0x04;
public const byte NotAuthorized = 0x05;
public const byte QoS2WillRejected = 0x10;
}
/// <summary>
/// Miscellaneous MQTT protocol constants.
/// Mirrors the remaining scalar constants in server/mqtt.go.
/// </summary>
internal static class MqttConst
{
/// <summary>Maximum control packet payload size (0xFFFFFFF).</summary>
public const int MaxPayloadSize = 0xFFFFFFF;
/// <summary>MQTT topic level separator character ('/').</summary>
public const char TopicLevelSep = '/';
/// <summary>Single-level wildcard character ('+').</summary>
public const char SingleLevelWildcard = '+';
/// <summary>Multi-level wildcard character ('#').</summary>
public const char MultiLevelWildcard = '#';
/// <summary>Reserved topic prefix character ('$').</summary>
public const char ReservedPrefix = '$';
/// <summary>MQTT protocol level byte (v3.1.1 = 0x04).</summary>
public const byte ProtoLevel = 0x04;
/// <summary>SUBACK failure return code (0x80).</summary>
public const byte SubAckFailure = 0x80;
/// <summary>Fixed flags byte in SUBSCRIBE packets (0x02).</summary>
public const byte SubscribeFlags = 0x02;
/// <summary>Fixed flags byte in UNSUBSCRIBE packets (0x02).</summary>
public const byte UnsubscribeFlags = 0x02;
/// <summary>
/// Suffix appended to the SID of subscriptions created for MQTT '#' wildcard
/// at the upper level. Mirrors <c>mqttMultiLevelSidSuffix</c>.
/// </summary>
public const string MultiLevelSidSuffix = " fwc";
/// <summary>Initial byte allocation for publish headers (overestimate).</summary>
public const int InitialPubHeader = 16;
/// <summary>Default maximum number of pending QoS-1 acks per session.</summary>
public const int DefaultMaxAckPending = 1024;
/// <summary>Absolute upper limit on cumulative MaxAckPending across all session subscriptions.</summary>
public const int MaxAckTotalLimit = 0xFFFF;
/// <summary>WebSocket path for MQTT connections.</summary>
public const string WsPath = "/mqtt";
/// <summary>Marker character for deleted retained messages (used in flag field).</summary>
public const char RetainedFlagDelMarker = '-';
}
/// <summary>
/// MQTT-internal NATS subject / stream / consumer name constants.
/// Mirrors the string constants in server/mqtt.go that define JetStream stream names,
/// subject prefixes, and JSA reply tokens.
/// </summary>
internal static class MqttTopics
{
// -------------------------------------------------------------------------
// Top-level MQTT subject prefix
// -------------------------------------------------------------------------
/// <summary>Prefix used for all internal MQTT subjects.</summary>
public const string Prefix = "$MQTT.";
/// <summary>
/// Prefix for NATS subscriptions used as JS consumer delivery subjects.
/// MQTT clients must not subscribe to subjects starting with this prefix.
/// </summary>
public const string SubPrefix = Prefix + "sub.";
// -------------------------------------------------------------------------
// JetStream stream names
// -------------------------------------------------------------------------
/// <summary>Stream name for MQTT QoS &gt;0 messages on a given account.</summary>
public const string MsgsStreamName = "$MQTT_msgs";
/// <summary>Subject prefix for messages in the MQTT messages stream.</summary>
public const string MsgsStreamSubjectPrefix = Prefix + "msgs.";
/// <summary>Stream name for MQTT retained messages.</summary>
public const string RetainedMsgsStreamName = "$MQTT_rmsgs";
/// <summary>Subject prefix for messages in the retained messages stream.</summary>
public const string RetainedMsgsStreamSubject = Prefix + "rmsgs.";
/// <summary>Stream name for MQTT session state.</summary>
public const string SessStreamName = "$MQTT_sess";
/// <summary>Subject prefix for session state messages.</summary>
public const string SessStreamSubjectPrefix = Prefix + "sess.";
/// <summary>Name prefix used when creating per-account session streams.</summary>
public const string SessionsStreamNamePrefix = "$MQTT_sess_";
/// <summary>Stream name for incoming QoS-2 messages.</summary>
public const string QoS2IncomingMsgsStreamName = "$MQTT_qos2in";
/// <summary>Subject prefix for incoming QoS-2 messages.</summary>
public const string QoS2IncomingMsgsStreamSubjectPrefix = Prefix + "qos2.in.";
/// <summary>Stream name for outgoing MQTT QoS messages (PUBREL).</summary>
public const string OutStreamName = "$MQTT_out";
/// <summary>Subject prefix for outgoing MQTT messages.</summary>
public const string OutSubjectPrefix = Prefix + "out.";
/// <summary>Subject prefix for PUBREL messages.</summary>
public const string PubRelSubjectPrefix = Prefix + "out.pubrel.";
/// <summary>Subject prefix for PUBREL delivery subjects.</summary>
public const string PubRelDeliverySubjectPrefix = Prefix + "deliver.pubrel.";
/// <summary>Durable consumer name prefix for PUBREL.</summary>
public const string PubRelConsumerDurablePrefix = "$MQTT_PUBREL_";
// -------------------------------------------------------------------------
// JSA reply subject prefix and token constants
// -------------------------------------------------------------------------
/// <summary>Prefix of the reply subject for JS API requests.</summary>
public const string JsaRepliesPrefix = Prefix + "JSA.";
// Token position indices within a JSA reply subject.
public const int JsaIdTokenPos = 3;
public const int JsaTokenPos = 4;
public const int JsaClientIdPos = 5;
// JSA operation token values.
public const string JsaStreamCreate = "SC";
public const string JsaStreamUpdate = "SU";
public const string JsaStreamLookup = "SL";
public const string JsaStreamDel = "SD";
public const string JsaConsumerCreate = "CC";
public const string JsaConsumerLookup = "CL";
public const string JsaConsumerDel = "CD";
public const string JsaMsgStore = "MS";
public const string JsaMsgLoad = "ML";
public const string JsaMsgDelete = "MD";
public const string JsaSessPersist = "SP";
public const string JsaRetainedMsgDel = "RD";
public const string JsaStreamNames = "SN";
// -------------------------------------------------------------------------
// NATS header names injected into re-encoded PUBLISH messages
// -------------------------------------------------------------------------
/// <summary>Header that indicates the message originated from MQTT and stores published QoS.</summary>
public const string NatsHeader = "Nmqtt-Pub";
/// <summary>Header storing the original MQTT topic for retained messages.</summary>
public const string NatsRetainedMessageTopic = "Nmqtt-RTopic";
/// <summary>Header storing the origin of a retained message.</summary>
public const string NatsRetainedMessageOrigin = "Nmqtt-ROrigin";
/// <summary>Header storing the flags of a retained message.</summary>
public const string NatsRetainedMessageFlags = "Nmqtt-RFlags";
/// <summary>Header storing the source of a retained message.</summary>
public const string NatsRetainedMessageSource = "Nmqtt-RSource";
/// <summary>Header indicating a PUBREL message and storing the packet identifier.</summary>
public const string NatsPubRelHeader = "Nmqtt-PubRel";
/// <summary>Header storing the original MQTT subject in re-encoded PUBLISH messages.</summary>
public const string NatsHeaderSubject = "Nmqtt-Subject";
/// <summary>Header storing the subject mapping in re-encoded PUBLISH messages.</summary>
public const string NatsHeaderMapped = "Nmqtt-Mapped";
// -------------------------------------------------------------------------
// Sparkplug B constants
// -------------------------------------------------------------------------
public const string SparkbNBirth = "NBIRTH";
public const string SparkbDBirth = "DBIRTH";
public const string SparkbNDeath = "NDEATH";
public const string SparkbDDeath = "DDEATH";
}

View File

@@ -0,0 +1,252 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Mqtt;
// ============================================================================
// Per-client MQTT state
// ============================================================================
/// <summary>
/// Per-client MQTT state attached to every connection established via the MQTT
/// listener or WebSocket upgrade.
/// Mirrors Go <c>mqtt</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttHandler
{
private readonly Lock _mu = new();
// ------------------------------------------------------------------
// Identity
// ------------------------------------------------------------------
/// <summary>MQTT client identifier presented in the CONNECT packet.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>Whether this is a clean session.</summary>
public bool CleanSession { get; set; }
// ------------------------------------------------------------------
// Session / Will
// ------------------------------------------------------------------
/// <summary>Session associated with this connection after a successful CONNECT.</summary>
public MqttSession? Session { get; set; }
/// <summary>
/// Quick reference to the account session manager.
/// Immutable after <c>processConnect()</c> completes.
/// </summary>
public MqttAccountSessionManager? AccountSessionManager { get; set; }
/// <summary>Will message to publish when this connection closes unexpectedly.</summary>
public MqttWill? Will { get; set; }
// ------------------------------------------------------------------
// Keep-alive
// ------------------------------------------------------------------
/// <summary>Keep-alive interval in seconds (0 = disabled).</summary>
public ushort KeepAlive { get; set; }
// ------------------------------------------------------------------
// QoS pending / packet identifiers
// ------------------------------------------------------------------
/// <summary>Next packet identifier to use for QoS &gt;0 outbound messages.</summary>
public ushort NextPi { get; set; }
/// <summary>
/// Pending ack map: packet identifier → pending state.
/// Used for tracking in-flight QoS 1/2 PUBLISH packets.
/// </summary>
public Dictionary<ushort, MqttPending?> Pending { get; } = new();
// ------------------------------------------------------------------
// Protocol flags
// ------------------------------------------------------------------
/// <summary>
/// When <c>true</c>, the server rejects QoS-2 PUBLISH from this client
/// and terminates the connection on receipt of such a packet.
/// Mirrors Go <c>mqtt.rejectQoS2Pub</c>.
/// </summary>
public bool RejectQoS2Pub { get; set; }
/// <summary>
/// When <c>true</c>, QoS-2 SUBSCRIBE requests are silently downgraded to QoS-1.
/// Mirrors Go <c>mqtt.downgradeQoS2Sub</c>.
/// </summary>
public bool DowngradeQoS2Sub { get; set; }
// ------------------------------------------------------------------
// Parse state (used by the read-loop MQTT byte-stream parser)
// ------------------------------------------------------------------
/// <summary>Current state of the fixed-header / remaining-length state machine.</summary>
public byte ParseState { get; set; }
/// <summary>Control packet type byte extracted from the current fixed header.</summary>
public byte PktType { get; set; }
/// <summary>Remaining length of the current control packet (bytes still to read).</summary>
public int RemLen { get; set; }
/// <summary>Buffer accumulating the current packet's variable-header and payload.</summary>
public byte[]? Buf { get; set; }
/// <summary>Multiplier accumulator used during multi-byte remaining-length decoding.</summary>
public int RemLenMult { get; set; }
// ------------------------------------------------------------------
// Thread safety
// ------------------------------------------------------------------
/// <summary>Lock protecting mutable fields on this instance.</summary>
public Lock Mu => _mu;
}
// ============================================================================
// Server-side MQTT extension methods (stubs)
// ============================================================================
/// <summary>
/// Stub extension methods on <see cref="NatsServer"/> for MQTT server operations.
/// Mirrors the server-receiver MQTT functions in server/mqtt.go.
/// All methods throw <see cref="NotImplementedException"/> until session 22 is complete.
/// </summary>
internal static class MqttServerExtensions
{
/// <summary>
/// Start listening for MQTT client connections.
/// Mirrors Go <c>(*Server).startMQTT()</c>.
/// </summary>
public static void StartMqtt(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Configure MQTT authentication overrides from the MQTT options block.
/// Mirrors Go <c>(*Server).mqttConfigAuth()</c>.
/// </summary>
public static void MqttConfigAuth(this NatsServer server, object mqttOpts) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Handle cleanup when an MQTT client connection closes.
/// Mirrors Go <c>(*Server).mqttHandleClosedClient()</c>.
/// </summary>
public static void MqttHandleClosedClient(this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Propagate a change to the maximum ack-pending limit to all MQTT sessions.
/// Mirrors Go <c>(*Server).mqttUpdateMaxAckPending()</c>.
/// </summary>
public static void MqttUpdateMaxAckPending(this NatsServer server, ushort maxp) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Retrieve or lazily-create the JSA for the named account.
/// Mirrors Go <c>(*Server).mqttGetJSAForAccount()</c>.
/// </summary>
public static MqttJsa MqttGetJsaForAccount(this NatsServer server, string account) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Store a QoS message for an account on a (possibly new) NATS subject.
/// Mirrors Go <c>(*Server).mqttStoreQoSMsgForAccountOnNewSubject()</c>.
/// </summary>
public static void MqttStoreQosMsgForAccountOnNewSubject(
this NatsServer server,
int hdr, byte[] msg, string account, string subject) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Get or create the <see cref="MqttAccountSessionManager"/> for the client's account.
/// Mirrors Go <c>(*Server).getOrCreateMQTTAccountSessionManager()</c>.
/// </summary>
public static MqttAccountSessionManager GetOrCreateMqttAccountSessionManager(
this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Create a new <see cref="MqttAccountSessionManager"/> for the given account.
/// Mirrors Go <c>(*Server).mqttCreateAccountSessionManager()</c>.
/// </summary>
public static MqttAccountSessionManager MqttCreateAccountSessionManager(
this NatsServer server, object account, System.Threading.CancellationToken cancel) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Determine how many JetStream replicas to use for MQTT streams.
/// Mirrors Go <c>(*Server).mqttDetermineReplicas()</c>.
/// </summary>
public static int MqttDetermineReplicas(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an MQTT CONNECT packet after parsing.
/// Mirrors Go <c>(*Server).mqttProcessConnect()</c>.
/// </summary>
public static void MqttProcessConnect(
this NatsServer server, object client, MqttConnectProto cp, bool trace) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Send the Will message for a client that disconnected unexpectedly.
/// Mirrors Go <c>(*Server).mqttHandleWill()</c>.
/// </summary>
public static void MqttHandleWill(this NatsServer server, object client) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an inbound MQTT PUBLISH packet.
/// Mirrors Go <c>(*Server).mqttProcessPub()</c>.
/// </summary>
public static void MqttProcessPub(
this NatsServer server, object client, MqttPublishInfo pp, bool trace) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Initiate delivery of a PUBLISH message via JetStream.
/// Mirrors Go <c>(*Server).mqttInitiateMsgDelivery()</c>.
/// </summary>
public static void MqttInitiateMsgDelivery(
this NatsServer server, object client, MqttPublishInfo pp) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Store a QoS-2 PUBLISH exactly once (idempotent).
/// Mirrors Go <c>(*Server).mqttStoreQoS2MsgOnce()</c>.
/// </summary>
public static void MqttStoreQoS2MsgOnce(
this NatsServer server, object client, MqttPublishInfo pp) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Process an inbound MQTT PUBREL packet.
/// Mirrors Go <c>(*Server).mqttProcessPubRel()</c>.
/// </summary>
public static void MqttProcessPubRel(
this NatsServer server, object client, ushort pi, bool trace) =>
throw new NotImplementedException("TODO: session 22");
/// <summary>
/// Audit retained-message permissions after a configuration reload.
/// Mirrors Go <c>(*Server).mqttCheckPubRetainedPerms()</c>.
/// </summary>
public static void MqttCheckPubRetainedPerms(this NatsServer server) =>
throw new NotImplementedException("TODO: session 22");
}

View File

@@ -0,0 +1,391 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/mqtt.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Mqtt;
// ============================================================================
// Enumerations
// ============================================================================
/// <summary>
/// State machine states for parsing incoming MQTT byte streams.
/// Mirrors the <c>mqttParseState*</c> iota in server/mqtt.go (implicit from
/// the read-loop logic).
/// </summary>
internal enum MqttParseState : byte
{
/// <summary>Waiting for the first fixed-header byte.</summary>
MqttStateHeader = 0,
/// <summary>Reading the remaining-length variable-integer bytes.</summary>
MqttStateFixedHeader,
/// <summary>Reading the variable-header + payload bytes of the current packet.</summary>
MqttStateControlPacket,
}
// ============================================================================
// Will
// ============================================================================
/// <summary>
/// MQTT Will message parameters extracted from a CONNECT packet.
/// Mirrors Go <c>mqttWill</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttWill
{
/// <summary>NATS subject derived from the MQTT will topic.</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Original MQTT will topic string.</summary>
public string Topic { get; set; } = string.Empty;
/// <summary>Will message payload bytes, or <c>null</c> if empty.</summary>
public byte[]? Msg { get; set; }
/// <summary>QoS level for the will message (0, 1, or 2).</summary>
public byte Qos { get; set; }
/// <summary>Whether the will message should be retained.</summary>
public bool Retain { get; set; }
}
// ============================================================================
// Connect protocol
// ============================================================================
/// <summary>
/// MQTT CONNECT packet parsed payload.
/// Mirrors Go <c>mqttConnectProto</c> struct in server/mqtt.go (extended with
/// the fields surfaced by the parse helpers).
/// </summary>
internal sealed class MqttConnectProto
{
/// <summary>MQTT client identifier.</summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>Raw CONNECT packet bytes (for forwarding / replay).</summary>
public byte[] Connect { get; set; } = [];
/// <summary>Parsed Will parameters, or <c>null</c> if the Will flag is not set.</summary>
public MqttWill? Will { get; set; }
/// <summary>Username presented in the CONNECT packet.</summary>
public string Username { get; set; } = string.Empty;
/// <summary>Password bytes presented in the CONNECT packet, or <c>null</c> if absent.</summary>
public byte[]? Password { get; set; }
/// <summary>Whether the Clean Session flag was set.</summary>
public bool CleanSession { get; set; }
/// <summary>Keep-alive interval in seconds (0 = disabled).</summary>
public ushort KeepAlive { get; set; }
}
// ============================================================================
// Subscription
// ============================================================================
/// <summary>
/// A single MQTT topic filter subscription entry stored in a session.
/// Mirrors the per-entry semantics of <c>mqttSession.subs</c> map in server/mqtt.go.
/// </summary>
internal sealed class MqttSubscription
{
/// <summary>NATS subject derived from the MQTT topic filter.</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Maximum QoS level granted for this subscription.</summary>
public byte Qos { get; set; }
}
// ============================================================================
// Publish info
// ============================================================================
/// <summary>
/// Parsed metadata for an inbound MQTT PUBLISH packet.
/// Mirrors Go <c>mqttPublish</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttPublishInfo
{
/// <summary>NATS subject derived from the MQTT topic.</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Original MQTT topic string.</summary>
public string Topic { get; set; } = string.Empty;
/// <summary>Message payload bytes, or <c>null</c> if empty.</summary>
public byte[]? Msg { get; set; }
/// <summary>QoS level of the PUBLISH packet.</summary>
public byte Qos { get; set; }
/// <summary>Whether the Retain flag is set.</summary>
public bool Retain { get; set; }
/// <summary>Whether the DUP flag is set (re-delivery of a QoS &gt;0 packet).</summary>
public bool Dup { get; set; }
/// <summary>Packet identifier (only meaningful for QoS 1 and 2).</summary>
public ushort Pi { get; set; }
}
// ============================================================================
// Pending ack
// ============================================================================
/// <summary>
/// Tracks a single in-flight QoS 1 or QoS 2 message pending acknowledgement.
/// Mirrors Go <c>mqttPending</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttPending
{
/// <summary>JetStream stream sequence number for this message.</summary>
public ulong SSeq { get; set; }
/// <summary>JetStream ACK subject to send the acknowledgement to.</summary>
public string JsAckSubject { get; set; } = string.Empty;
/// <summary>JetStream durable consumer name.</summary>
public string JsDur { get; set; } = string.Empty;
}
// ============================================================================
// Retained message
// ============================================================================
/// <summary>
/// A retained MQTT message stored in JetStream.
/// Mirrors Go <c>mqttRetainedMsg</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttRetainedMsg
{
/// <summary>Origin server name.</summary>
public string Origin { get; set; } = string.Empty;
/// <summary>NATS subject for this retained message.</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Original MQTT topic.</summary>
public string Topic { get; set; } = string.Empty;
/// <summary>Message payload bytes.</summary>
public byte[]? Msg { get; set; }
/// <summary>Message flags byte.</summary>
public byte Flags { get; set; }
/// <summary>Source identifier.</summary>
public string Source { get; set; } = string.Empty;
}
// ============================================================================
// Persisted session
// ============================================================================
/// <summary>
/// The JSON-serialisable representation of an MQTT session stored in JetStream.
/// Mirrors Go <c>mqttPersistedSession</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttPersistedSession
{
/// <summary>Server that originally created this session.</summary>
public string Origin { get; set; } = string.Empty;
/// <summary>MQTT client identifier.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Whether this was a clean session.</summary>
public bool Clean { get; set; }
/// <summary>Map of MQTT topic filters to granted QoS levels.</summary>
public Dictionary<string, byte> Subs { get; set; } = new();
}
// ============================================================================
// Session
// ============================================================================
/// <summary>
/// In-memory MQTT session state.
/// Mirrors Go <c>mqttSession</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttSession
{
private readonly Lock _mu = new();
/// <summary>Lock for this session (matches Go <c>sess.mu</c>).</summary>
public Lock Mu => _mu;
// ------------------------------------------------------------------
// Identity
// ------------------------------------------------------------------
/// <summary>MQTT client identifier.</summary>
public string Id { get; set; } = string.Empty;
/// <summary>Hash of the client identifier (used as JetStream key).</summary>
public string IdHash { get; set; } = string.Empty;
/// <summary>Whether this is a clean session.</summary>
public bool Clean { get; set; }
/// <summary>Domain token (domain with trailing '.', or empty).</summary>
public string DomainTk { get; set; } = string.Empty;
// ------------------------------------------------------------------
// Subscriptions
// ------------------------------------------------------------------
/// <summary>
/// Map from MQTT SUBSCRIBE filter to granted QoS level.
/// Mirrors Go <c>mqttSession.subs map[string]byte</c>.
/// </summary>
public Dictionary<string, byte> Subs { get; } = new();
// ------------------------------------------------------------------
// Pending acks
// ------------------------------------------------------------------
/// <summary>Maximum number of in-flight QoS-1/2 PUBLISH acks.</summary>
public ushort MaxPending { get; set; }
/// <summary>
/// In-flight QoS-1 PUBLISH packets pending PUBACK from the client.
/// Key is the packet identifier.
/// </summary>
public Dictionary<ushort, MqttPending> PendingPublish { get; } = new();
/// <summary>
/// In-flight QoS-2 PUBREL packets pending PUBCOMP from the client.
/// Key is the packet identifier.
/// </summary>
public Dictionary<ushort, MqttPending> PendingPubRel { get; } = new();
/// <summary>"Last used" packet identifier; used as the starting point when allocating the next one.</summary>
public ushort LastPi { get; set; }
// ------------------------------------------------------------------
// Constructor
// ------------------------------------------------------------------
/// <summary>Initialises a new session with the given identity.</summary>
public MqttSession(string id, string idHash, bool clean)
{
Id = id;
IdHash = idHash;
Clean = clean;
}
}
// ============================================================================
// JSA stub
// ============================================================================
/// <summary>
/// Stub for the MQTT JetStream API helper.
/// Mirrors Go <c>mqttJSA</c> struct in server/mqtt.go.
/// All methods throw <see cref="NotImplementedException"/> until session 22 is complete.
/// </summary>
internal sealed class MqttJsa
{
/// <summary>Domain (with trailing '.'), or empty.</summary>
public string Domain { get; set; } = string.Empty;
/// <summary>Whether the domain field was explicitly set (even to empty).</summary>
public bool DomainSet { get; set; }
// All methods are stubs — full implementation is deferred to session 22.
public void SendAck(string ackSubject) =>
throw new NotImplementedException("TODO: session 22");
public void SendMsg(string subject, byte[] msg) =>
throw new NotImplementedException("TODO: session 22");
public void StoreMsgNoWait(string subject, int hdrLen, byte[] msg) =>
throw new NotImplementedException("TODO: session 22");
public string PrefixDomain(string subject) =>
throw new NotImplementedException("TODO: session 22");
}
// ============================================================================
// Account session manager stub
// ============================================================================
/// <summary>
/// Per-account MQTT session manager.
/// Mirrors Go <c>mqttAccountSessionManager</c> struct in server/mqtt.go.
/// All mutating methods are stubs.
/// </summary>
internal sealed class MqttAccountSessionManager
{
private readonly Lock _mu = new();
/// <summary>Domain token (domain with trailing '.'), or empty.</summary>
public string DomainTk { get; set; } = string.Empty;
/// <summary>Active sessions keyed by MQTT client ID.</summary>
public Dictionary<string, MqttSession> Sessions { get; } = new();
/// <summary>Sessions keyed by their client ID hash.</summary>
public Dictionary<string, MqttSession> SessionsByHash { get; } = new();
/// <summary>Client IDs that are currently locked (being taken over).</summary>
public HashSet<string> SessionsLocked { get; } = new();
/// <summary>Client IDs that have recently flapped (connected with duplicate ID).</summary>
public Dictionary<string, long> Flappers { get; } = new();
/// <summary>JSA helper for this account.</summary>
public MqttJsa Jsa { get; } = new();
/// <summary>Lock for this manager.</summary>
public Lock Mu => _mu;
// All methods are stubs.
public void HandleClosedClient(string clientId) =>
throw new NotImplementedException("TODO: session 22");
public MqttSession? LookupSession(string clientId) =>
throw new NotImplementedException("TODO: session 22");
public void PersistSession(MqttSession session) =>
throw new NotImplementedException("TODO: session 22");
public void DeleteSession(MqttSession session) =>
throw new NotImplementedException("TODO: session 22");
}
// ============================================================================
// Global session manager stub
// ============================================================================
/// <summary>
/// Server-wide MQTT session manager.
/// Mirrors Go <c>mqttSessionManager</c> struct in server/mqtt.go.
/// </summary>
internal sealed class MqttSessionManager
{
private readonly Lock _mu = new();
/// <summary>Per-account session managers keyed by account name.</summary>
public Dictionary<string, MqttAccountSessionManager> Sessions { get; } = new();
/// <summary>Lock for this manager.</summary>
public Lock Mu => _mu;
}

View File

@@ -0,0 +1,720 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/server.go (account methods) in the NATS server Go source.
// Session 09: account management — configure, register, lookup, fetch.
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
public sealed partial class NatsServer
{
// =========================================================================
// Account-mode helpers (features 30043007)
// =========================================================================
/// <summary>
/// Returns true when only the global ($G) account is defined (pre-NATS 2.0 mode).
/// Mirrors Go <c>Server.globalAccountOnly</c>.
/// </summary>
public bool GlobalAccountOnly()
{
if (_trustedKeys is not null) return false;
bool hasOthers = false;
_mu.EnterReadLock();
try
{
foreach (var kvp in _accounts)
{
var acc = kvp.Value;
// Ignore global and system accounts.
if (acc == _gacc) continue;
var sysAcc = _sysAccAtomic;
if (sysAcc != null && acc == sysAcc) continue;
hasOthers = true;
break;
}
}
finally { _mu.ExitReadLock(); }
return !hasOthers;
}
/// <summary>
/// Returns true when this server has no routes or gateways configured.
/// Mirrors Go <c>Server.standAloneMode</c>.
/// </summary>
public bool StandAloneMode()
{
var opts = GetOpts();
return opts.Cluster.Port == 0 && opts.Gateway.Port == 0;
}
/// <summary>
/// Returns the number of configured routes.
/// Mirrors Go <c>Server.configuredRoutes</c>.
/// </summary>
public int ConfiguredRoutes() => GetOpts().Routes.Count;
/// <summary>
/// Returns online JetStream peer node names from the node-info map.
/// Mirrors Go <c>Server.ActivePeers</c>.
/// </summary>
public List<string> ActivePeers()
{
var peers = new List<string>();
foreach (var kvp in _nodeToInfo)
{
if (kvp.Value is NodeInfo ni && !ni.Offline)
peers.Add(kvp.Key);
}
return peers;
}
// =========================================================================
// ConfigureAccounts (feature 3001)
// =========================================================================
/// <summary>
/// Reads accounts from options and registers/updates them.
/// Returns a set of account names whose stream imports changed (for reload)
/// and any error.
/// Mirrors Go <c>Server.configureAccounts</c>.
/// Server lock must be held on entry.
/// </summary>
public (HashSet<string> ChangedStreamImports, Exception? Error) ConfigureAccounts(bool reloading)
{
var awcsti = new HashSet<string>(StringComparer.Ordinal);
// Create the global ($G) account if not yet present.
if (_gacc == null)
{
_gacc = new Account { Name = ServerConstants.DefaultGlobalAccount };
RegisterAccountNoLock(_gacc);
}
var opts = GetOpts();
// Walk accounts from options.
foreach (var optAcc in opts.Accounts)
{
Account a;
bool create = true;
if (reloading && optAcc.Name != ServerConstants.DefaultGlobalAccount)
{
if (_accounts.TryGetValue(optAcc.Name, out var existing))
{
a = existing;
// Full import/export diffing deferred to session 11 (accounts.go).
create = false;
}
else
{
a = new Account { Name = optAcc.Name };
}
}
else
{
a = optAcc.Name == ServerConstants.DefaultGlobalAccount ? _gacc! : new Account { Name = optAcc.Name };
}
if (create)
{
// Will be a no-op for the global account (already registered).
RegisterAccountNoLock(a);
}
// If an account named $SYS is found, make it the system account.
if (optAcc.Name == ServerConstants.DefaultSystemAccount &&
string.IsNullOrEmpty(opts.SystemAccount))
{
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
}
}
// Resolve system account if configured.
if (!string.IsNullOrEmpty(opts.SystemAccount))
{
// Release server lock for lookupAccount (lock ordering: account → server).
_mu.ExitWriteLock();
var (acc, err) = LookupAccountInternal(opts.SystemAccount);
_mu.EnterWriteLock();
if (err != null)
return (awcsti, new InvalidOperationException($"error resolving system account: {err.Message}", err));
if (acc != null && _sys != null && acc != _sys.Account)
_sys.Account = acc;
if (acc != null)
_sysAccAtomic = acc;
}
return (awcsti, null);
}
// =========================================================================
// Account counts (features 30223023)
// =========================================================================
/// <summary>
/// Returns the total number of registered accounts (slow, test only).
/// Mirrors Go <c>Server.numAccounts</c>.
/// </summary>
public int NumAccounts()
{
_mu.EnterReadLock();
try { return _accounts.Count; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Returns the number of loaded accounts.
/// Mirrors Go <c>Server.NumLoadedAccounts</c>.
/// </summary>
public int NumLoadedAccounts() => NumAccounts();
// =========================================================================
// Account registration (features 30243025)
// =========================================================================
/// <summary>
/// Returns the named account if known, or creates and registers a new one.
/// Mirrors Go <c>Server.LookupOrRegisterAccount</c>.
/// </summary>
public (Account Account, bool IsNew) LookupOrRegisterAccount(string name)
{
_mu.EnterWriteLock();
try
{
if (_accounts.TryGetValue(name, out var existing))
return (existing, false);
var acc = new Account { Name = name };
RegisterAccountNoLock(acc);
return (acc, true);
}
finally { _mu.ExitWriteLock(); }
}
/// <summary>
/// Registers a new account. Returns error if the account already exists.
/// Mirrors Go <c>Server.RegisterAccount</c>.
/// </summary>
public (Account? Account, Exception? Error) RegisterAccount(string name)
{
_mu.EnterWriteLock();
try
{
if (_accounts.ContainsKey(name))
return (null, ServerErrors.ErrAccountExists);
var acc = new Account { Name = name };
RegisterAccountNoLock(acc);
return (acc, null);
}
finally { _mu.ExitWriteLock(); }
}
// =========================================================================
// System account (features 30263030)
// =========================================================================
/// <summary>
/// Sets the named account as the server's system account.
/// Mirrors Go <c>Server.SetSystemAccount</c>.
/// </summary>
public Exception? SetSystemAccount(string accName)
{
if (_accounts.TryGetValue(accName, out var acc))
return SetSystemAccountInternal(acc);
// Not locally known — try resolver.
var (ac, _, fetchErr) = FetchAccountClaims(accName);
if (fetchErr != null) return fetchErr;
var newAcc = BuildInternalAccount(ac);
// Due to race, registerAccount returns the existing one if already registered.
var racc = RegisterAccountInternal(newAcc);
return SetSystemAccountInternal(racc ?? newAcc);
}
/// <summary>
/// Returns the system account, or null if none is set.
/// Mirrors Go <c>Server.SystemAccount</c>.
/// </summary>
public Account? SystemAccount() => _sysAccAtomic;
/// <summary>
/// Returns the global ($G) account.
/// Mirrors Go <c>Server.GlobalAccount</c>.
/// </summary>
public Account? GlobalAccount()
{
_mu.EnterReadLock();
try { return _gacc; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Creates a default system account ($SYS) if one does not already exist.
/// Mirrors Go <c>Server.SetDefaultSystemAccount</c>.
/// </summary>
public Exception? SetDefaultSystemAccount()
{
var (_, isNew) = LookupOrRegisterAccount(ServerConstants.DefaultSystemAccount);
if (!isNew) return null;
Debugf("Created system account: \"{0}\"", ServerConstants.DefaultSystemAccount);
return SetSystemAccount(ServerConstants.DefaultSystemAccount);
}
/// <summary>
/// Assigns <paramref name="acc"/> as the system account and starts internal
/// messaging goroutines.
/// Mirrors Go <c>Server.setSystemAccount</c>.
/// Server lock must NOT be held on entry; this method acquires/releases it.
/// </summary>
public Exception? SetSystemAccountInternal(Account acc)
{
if (acc == null)
return ServerErrors.ErrMissingAccount;
if (acc.IsExpired())
return ServerErrors.ErrAccountExpired;
if (!IsTrustedIssuer(acc.Issuer))
return ServerErrors.ErrAccountValidation;
_mu.EnterWriteLock();
try
{
if (_sys != null)
return ServerErrors.ErrAccountExists;
_sys = new InternalState { Account = acc };
}
finally { _mu.ExitWriteLock(); }
// Store atomically for fast lookup on hot paths.
_sysAccAtomic = acc;
// Full internal-messaging bootstrap (initEventTracking, sendLoop, etc.)
// is deferred to session 12 (events.go).
AddSystemAccountExports(acc);
return null;
}
// =========================================================================
// Internal client factories (features 30313034)
// =========================================================================
/// <summary>Creates an internal system client.</summary>
public ClientConnection CreateInternalSystemClient() =>
CreateInternalClient(ClientKind.System);
/// <summary>Creates an internal JetStream client.</summary>
public ClientConnection CreateInternalJetStreamClient() =>
CreateInternalClient(ClientKind.JetStream);
/// <summary>Creates an internal account client.</summary>
public ClientConnection CreateInternalAccountClient() =>
CreateInternalClient(ClientKind.Account);
/// <summary>
/// Creates an internal client of the given <paramref name="kind"/>.
/// Mirrors Go <c>Server.createInternalClient</c>.
/// </summary>
public ClientConnection CreateInternalClient(ClientKind kind)
{
if (kind != ClientKind.System && kind != ClientKind.JetStream && kind != ClientKind.Account)
throw new InvalidOperationException($"createInternalClient: unsupported kind {kind}");
var c = new ClientConnection(kind, this);
// Mirrors: c.echo = false; c.headers = true; flags.set(noReconnect)
// Full client initialisation deferred to session 10 (client.go).
return c;
}
// =========================================================================
// Subscription tracking / account sublist (features 30353038)
// =========================================================================
/// <summary>
/// Returns true if accounts should track subscriptions for route/gateway propagation.
/// Server lock must be held on entry.
/// Mirrors Go <c>Server.shouldTrackSubscriptions</c>.
/// </summary>
public bool ShouldTrackSubscriptions()
{
var opts = GetOpts();
return opts.Cluster.Port != 0 || opts.Gateway.Port != 0;
}
/// <summary>
/// Invokes <see cref="RegisterAccountNoLock"/> under the server write lock.
/// Returns the already-registered account if a duplicate is detected, or null.
/// Mirrors Go <c>Server.registerAccount</c>.
/// </summary>
public Account? RegisterAccountInternal(Account acc)
{
_mu.EnterWriteLock();
try { return RegisterAccountNoLock(acc); }
finally { _mu.ExitWriteLock(); }
}
/// <summary>
/// Sets the account's subscription index based on the NoSublistCache option.
/// Mirrors Go <c>Server.setAccountSublist</c>.
/// Server lock must be held on entry.
/// </summary>
public void SetAccountSublist(Account acc)
{
if (acc?.Sublist != null) return;
if (acc == null) return;
var opts = GetOpts();
acc.Sublist = opts?.NoSublistCache == true
? SubscriptionIndex.NewSublist(false)
: SubscriptionIndex.NewSublistWithCache();
}
/// <summary>
/// Registers an account in the server's account map.
/// If the account is already registered (race), returns the existing one.
/// Server lock must be held on entry.
/// Mirrors Go <c>Server.registerAccountNoLock</c>.
/// </summary>
public Account? RegisterAccountNoLock(Account acc)
{
// If already registered, return existing.
if (_accounts.TryGetValue(acc.Name, out var existing))
{
_tmpAccounts.TryRemove(acc.Name, out _);
return existing;
}
SetAccountSublist(acc);
SetRouteInfo(acc);
acc.Server = this;
acc.Updated = DateTime.UtcNow;
_accounts[acc.Name] = acc;
_tmpAccounts.TryRemove(acc.Name, out _);
// enableAccountTracking and registerSystemImports deferred to session 12.
EnableAccountTracking(acc);
return null;
}
// =========================================================================
// Route info for accounts (feature 3039)
// =========================================================================
/// <summary>
/// Sets the account's route-pool index based on cluster configuration.
/// Mirrors Go <c>Server.setRouteInfo</c>.
/// Both server and account locks must be held on entry.
/// </summary>
public void SetRouteInfo(Account acc)
{
const int accDedicatedRoute = -1;
if (_accRoutes != null && _accRoutes.ContainsKey(acc.Name))
{
// Dedicated route: store name in hash map; use index -1.
_accRouteByHash.TryAdd(acc.Name, null);
acc.RoutePoolIdx = accDedicatedRoute;
}
else
{
acc.RoutePoolIdx = ComputeRoutePoolIdx(_routesPoolSize, acc.Name);
if (_routesPoolSize > 1)
_accRouteByHash.TryAdd(acc.Name, acc.RoutePoolIdx);
}
}
// =========================================================================
// Account lookup (features 30403042)
// =========================================================================
/// <summary>
/// Returns the account for <paramref name="name"/> if locally known, without
/// fetching from the resolver.
/// Mirrors Go <c>Server.lookupAccountInternal</c> (private helper).
/// Lock must NOT be held on entry.
/// </summary>
public (Account? Account, Exception? Error) LookupAccountInternal(string name)
=> LookupOrFetchAccount(name, fetch: false);
/// <summary>
/// Returns the account for <paramref name="name"/>, optionally fetching from
/// the resolver if not locally known or if expired.
/// Mirrors Go <c>Server.lookupOrFetchAccount</c>.
/// Lock must NOT be held on entry.
/// </summary>
public (Account? Account, Exception? Error) LookupOrFetchAccount(string name, bool fetch)
{
_accounts.TryGetValue(name, out var acc);
if (acc != null)
{
if (acc.IsExpired())
{
Debugf("Requested account [{0}] has expired", name);
if (_accResolver != null && fetch)
{
var updateErr = UpdateAccount(acc);
if (updateErr != null)
return (null, ServerErrors.ErrAccountExpired);
}
else
{
return (null, ServerErrors.ErrAccountExpired);
}
}
return (acc, null);
}
if (_accResolver == null || !fetch)
return (null, ServerErrors.ErrMissingAccount);
return FetchAccountFromResolver(name);
}
/// <summary>
/// Public account lookup — always fetches from resolver if needed.
/// Mirrors Go <c>Server.LookupAccount</c>.
/// </summary>
public (Account? Account, Exception? Error) LookupAccount(string name)
=> LookupOrFetchAccount(name, fetch: true);
// =========================================================================
// Account update (features 30433044)
// =========================================================================
/// <summary>
/// Fetches fresh claims and updates the account if the claims have changed.
/// Mirrors Go <c>Server.updateAccount</c>.
/// Lock must NOT be held on entry.
/// </summary>
public Exception? UpdateAccount(Account acc)
{
// Don't update more than once per second unless the account is incomplete.
if (!acc.Incomplete && (DateTime.UtcNow - acc.Updated) < TimeSpan.FromSeconds(1))
{
Debugf("Requested account update for [{0}] ignored, too soon", acc.Name);
return ServerErrors.ErrAccountResolverUpdateTooSoon;
}
var (claimJwt, err) = FetchRawAccountClaims(acc.Name);
if (err != null) return err;
return UpdateAccountWithClaimJwt(acc, claimJwt);
}
/// <summary>
/// Applies updated JWT claims to the account if they differ from what is stored.
/// Mirrors Go <c>Server.updateAccountWithClaimJWT</c>.
/// Lock must NOT be held on entry.
/// </summary>
public Exception? UpdateAccountWithClaimJwt(Account acc, string claimJwt)
{
if (acc == null) return ServerErrors.ErrMissingAccount;
// If JWT hasn't changed and account is not incomplete, skip.
if (!string.IsNullOrEmpty(acc.ClaimJwt) && acc.ClaimJwt == claimJwt && !acc.Incomplete)
{
Debugf("Requested account update for [{0}], same claims detected", acc.Name);
return null;
}
var (accClaims, _, verifyErr) = VerifyAccountClaims(claimJwt);
if (verifyErr != null) return verifyErr;
if (accClaims == null) return null;
if (acc.Name != accClaims.Subject)
return ServerErrors.ErrAccountValidation;
acc.Issuer = accClaims.Issuer;
// Full UpdateAccountClaims() deferred to session 11.
acc.ClaimJwt = claimJwt;
return null;
}
// =========================================================================
// Account claims fetch / verify (features 30453048)
// =========================================================================
/// <summary>
/// Fetches the raw JWT string for an account from the resolver.
/// Mirrors Go <c>Server.fetchRawAccountClaims</c>.
/// Lock must NOT be held on entry.
/// </summary>
public (string Jwt, Exception? Error) FetchRawAccountClaims(string name)
{
if (_accResolver == null)
return (string.Empty, ServerErrors.ErrNoAccountResolver);
var start = DateTime.UtcNow;
var (jwt, err) = FetchAccountFromResolverRaw(name);
var elapsed = DateTime.UtcNow - start;
if (elapsed > TimeSpan.FromSeconds(1))
Warnf("Account [{0}] fetch took {1}", name, elapsed);
else
Debugf("Account [{0}] fetch took {1}", name, elapsed);
if (err != null)
{
Warnf("Account fetch failed: {0}", err);
return (string.Empty, err);
}
return (jwt, null);
}
/// <summary>
/// Fetches and decodes account JWT claims from the resolver.
/// Mirrors Go <c>Server.fetchAccountClaims</c>.
/// Lock must NOT be held on entry.
/// </summary>
public (AccountClaims? Claims, string Jwt, Exception? Error) FetchAccountClaims(string name)
{
var (claimJwt, err) = FetchRawAccountClaims(name);
if (err != null) return (null, string.Empty, err);
var (claims, verifiedJwt, verifyErr) = VerifyAccountClaims(claimJwt);
if (claims != null && claims.Subject != name)
return (null, string.Empty, ServerErrors.ErrAccountValidation);
return (claims, verifiedJwt, verifyErr);
}
/// <summary>
/// Decodes and validates an account JWT string.
/// Mirrors Go <c>Server.verifyAccountClaims</c>.
/// </summary>
public (AccountClaims? Claims, string Jwt, Exception? Error) VerifyAccountClaims(string claimJwt)
{
// Full JWT decoding deferred to session 06 JWT integration.
// Stub: create a minimal claims object from the raw JWT.
var claims = AccountClaims.TryDecode(claimJwt);
if (claims == null)
return (null, string.Empty, ServerErrors.ErrAccountValidation);
if (!IsTrustedIssuer(claims.Issuer))
return (null, string.Empty, ServerErrors.ErrAccountValidation);
return (claims, claimJwt, null);
}
/// <summary>
/// Fetches an account from the resolver, registers it, and returns it.
/// Mirrors Go <c>Server.fetchAccount</c>.
/// Lock must NOT be held on entry.
/// </summary>
public (Account? Account, Exception? Error) FetchAccountFromResolver(string name)
{
var (accClaims, claimJwt, err) = FetchAccountClaims(name);
if (accClaims == null) return (null, err);
var acc = BuildInternalAccount(accClaims);
// Due to possible race, registerAccount may return an already-registered account.
var racc = RegisterAccountInternal(acc);
if (racc != null)
{
// Update with new claims if changed.
var updateErr = UpdateAccountWithClaimJwt(racc, claimJwt);
return updateErr != null ? (null, updateErr) : (racc, null);
}
acc.ClaimJwt = claimJwt;
return (acc, null);
}
// =========================================================================
// Account helpers
// =========================================================================
/// <summary>
/// Builds an Account from decoded claims.
/// Mirrors Go <c>Server.buildInternalAccount</c>.
/// Full JetStream limits / import / export wiring deferred to session 11.
/// </summary>
internal Account BuildInternalAccount(AccountClaims? claims)
{
var acc = new Account
{
Name = claims?.Subject ?? string.Empty,
Issuer = claims?.Issuer ?? string.Empty,
};
return acc;
}
/// <summary>
/// Fetches the raw JWT directly from the resolver without timing/logging.
/// </summary>
private (string Jwt, Exception? Error) FetchAccountFromResolverRaw(string name)
{
if (_accResolver == null)
return (string.Empty, ServerErrors.ErrNoAccountResolver);
try
{
var jwt = _accResolver.FetchAsync(name).GetAwaiter().GetResult();
return (jwt, null);
}
catch (Exception ex)
{
return (string.Empty, ex);
}
}
/// <summary>
/// Computes the route-pool index for an account name using FNV-32a.
/// Mirrors Go <c>computeRoutePoolIdx</c> (route.go).
/// </summary>
internal static int ComputeRoutePoolIdx(int poolSize, string accountName)
{
if (poolSize <= 1) return 0;
// FNV-32a hash (Go uses fnv.New32a)
uint hash = 2166136261u;
foreach (var b in System.Text.Encoding.UTF8.GetBytes(accountName))
{
hash ^= b;
hash *= 16777619u;
}
return (int)(hash % (uint)poolSize);
}
// =========================================================================
// Stubs for subsystems implemented in later sessions
// =========================================================================
/// <summary>
/// Stub: enables account tracking (session 12 — events.go).
/// </summary>
internal void EnableAccountTracking(Account acc) { /* session 12 */ }
/// <summary>
/// Stub: registers system imports on an account (session 12).
/// </summary>
internal void RegisterSystemImports(Account acc) { /* session 12 */ }
/// <summary>
/// Stub: adds system-account exports (session 12).
/// </summary>
internal void AddSystemAccountExports(Account acc) { /* session 12 */ }
}

View File

@@ -0,0 +1,328 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/auth.go in the NATS server Go source.
using System.Security.Cryptography.X509Certificates;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Authentication logic for <see cref="NatsServer"/>.
/// Mirrors Go auth.go Server methods.
/// </summary>
public sealed partial class NatsServer
{
/// <summary>
/// Wires up auth lookup tables from options.
/// Mirrors Go <c>configureAuthorization</c>.
/// </summary>
internal void ConfigureAuthorization()
{
var opts = GetOpts();
if (opts.CustomClientAuthentication != null)
{
_info.AuthRequired = true;
}
else if (_trustedKeys != null)
{
_info.AuthRequired = true;
}
else if (opts.Nkeys != null || opts.Users != null)
{
(_nkeys, _users) = BuildNkeysAndUsersFromOptions(opts.Nkeys, opts.Users);
_info.AuthRequired = true;
}
else if (!string.IsNullOrEmpty(opts.Username) || !string.IsNullOrEmpty(opts.Authorization))
{
_info.AuthRequired = true;
}
else
{
_users = null;
_nkeys = null;
_info.AuthRequired = false;
}
if (opts.AuthCallout != null && string.IsNullOrEmpty(opts.AuthCallout.Account))
Errorf("Authorization callout account not set");
}
private (Dictionary<string, NkeyUser>? nkeys, Dictionary<string, User>? users) BuildNkeysAndUsersFromOptions(
List<NkeyUser>? nko, List<User>? uo)
{
Dictionary<string, NkeyUser>? nkeys = null;
Dictionary<string, User>? users = null;
if (nko != null)
{
nkeys = new Dictionary<string, NkeyUser>(nko.Count, StringComparer.Ordinal);
foreach (var u in nko)
{
if (u.Permissions != null)
AuthHandler.ValidateResponsePermissions(u.Permissions);
nkeys[u.Nkey] = u;
}
}
if (uo != null)
{
users = new Dictionary<string, User>(uo.Count, StringComparer.Ordinal);
foreach (var u in uo)
{
if (u.Permissions != null)
AuthHandler.ValidateResponsePermissions(u.Permissions);
users[u.Username] = u;
}
}
AssignGlobalAccountToOrphanUsers(nkeys, users);
return (nkeys, users);
}
internal void AssignGlobalAccountToOrphanUsers(
Dictionary<string, NkeyUser>? nkeys,
Dictionary<string, User>? users)
{
if (nkeys != null)
foreach (var u in nkeys.Values)
u.Account ??= _gacc;
if (users != null)
foreach (var u in users.Values)
u.Account ??= _gacc;
}
/// <summary>
/// Entry-point auth check — dispatches by client kind.
/// Mirrors Go <c>checkAuthentication</c>.
/// </summary>
internal bool CheckAuthentication(ClientConnection c)
{
return c.Kind switch
{
ClientKind.Client => IsClientAuthorized(c),
ClientKind.Router => IsRouterAuthorized(c),
ClientKind.Gateway => IsGatewayAuthorized(c),
ClientKind.Leaf => IsLeafNodeAuthorized(c),
_ => false,
};
}
/// <summary>Mirrors Go <c>isClientAuthorized</c>.</summary>
internal bool IsClientAuthorized(ClientConnection c)
=> ProcessClientOrLeafAuthentication(c, GetOpts());
/// <summary>
/// Full authentication dispatch — handles all auth paths.
/// Mirrors Go <c>processClientOrLeafAuthentication</c>.
/// </summary>
internal bool ProcessClientOrLeafAuthentication(ClientConnection c, ServerOptions opts)
{
// Auth callout check
if (opts.AuthCallout != null)
return ProcessClientOrLeafCallout(c, opts);
// Proxy check
var (trustedProxy, proxyOk) = ProxyCheck(c, opts);
if (trustedProxy && !proxyOk)
{
c.SetAuthError(new InvalidOperationException("proxy not trusted"));
return false;
}
// Trusted operators / JWT bearer
if (_trustedKeys != null)
{
var token = c.GetAuthToken();
if (string.IsNullOrEmpty(token))
{
c.SetAuthError(new InvalidOperationException("missing JWT token for trusted operator"));
return false;
}
// TODO: full JWT validation against trusted operators
return true;
}
// NKey authentication
if (_nkeys != null && _nkeys.Count > 0)
{
var nkeyPub = c.GetNkey();
if (!string.IsNullOrEmpty(nkeyPub) && _nkeys.TryGetValue(nkeyPub, out var nkeyUser))
{
var sig = c.GetNkeySig();
var nonce = c.GetNonce(); // byte[]?
if (!string.IsNullOrEmpty(sig) && nonce != null && nonce.Length > 0)
{
try
{
var kp = NATS.NKeys.KeyPair.FromPublicKey(nkeyPub.AsSpan());
// Sig is raw URL-safe base64; convert to standard base64 with padding.
var padded = sig.Replace('-', '+').Replace('_', '/');
var rem = padded.Length % 4;
if (rem == 2) padded += "==";
else if (rem == 3) padded += "=";
var sigBytes = Convert.FromBase64String(padded);
var verified = kp.Verify(
new ReadOnlyMemory<byte>(nonce),
new ReadOnlyMemory<byte>(sigBytes));
if (!verified)
{
c.SetAuthError(new InvalidOperationException("NKey signature verification failed"));
return false;
}
}
catch (Exception ex)
{
c.SetAuthError(ex);
return false;
}
}
c.SetAccount(nkeyUser.Account);
c.SetPermissions(nkeyUser.Permissions);
return true;
}
}
// Username / password
if (_users != null && _users.Count > 0)
{
var username = c.GetUsername();
if (_users.TryGetValue(username, out var user))
{
if (!AuthHandler.ComparePasswords(user.Password, c.GetPassword()))
{
c.SetAuthError(new InvalidOperationException("invalid password"));
return false;
}
c.SetAccount(user.Account);
c.SetPermissions(user.Permissions);
return true;
}
}
// Global username/password (from opts)
if (!string.IsNullOrEmpty(opts.Username))
{
if (c.GetUsername() != opts.Username ||
!AuthHandler.ComparePasswords(opts.Password, c.GetPassword()))
{
c.SetAuthError(new InvalidOperationException("invalid credentials"));
return false;
}
return true;
}
// Token (authorization)
if (!string.IsNullOrEmpty(opts.Authorization))
{
if (!AuthHandler.ComparePasswords(opts.Authorization, c.GetAuthToken()))
{
c.SetAuthError(new InvalidOperationException("bad authorization token"));
return false;
}
return true;
}
// TLS cert mapping
if (opts.TlsMap)
{
var cert = c.GetTlsCertificate();
if (!AuthHandler.CheckClientTlsCertSubject(cert, _ => true))
{
c.SetAuthError(new InvalidOperationException("TLS cert mapping failed"));
return false;
}
return true;
}
// No auth required
if (!_info.AuthRequired) return true;
c.SetAuthError(new InvalidOperationException("no credentials provided"));
return false;
}
/// <summary>Mirrors Go <c>isRouterAuthorized</c>.</summary>
internal bool IsRouterAuthorized(ClientConnection c)
{
var opts = GetOpts();
if (opts.Cluster.Port == 0) return true;
return true; // TODO: full route auth when ClusterOpts is fully typed
}
/// <summary>Mirrors Go <c>isGatewayAuthorized</c>.</summary>
internal bool IsGatewayAuthorized(ClientConnection c)
{
var opts = GetOpts();
if (string.IsNullOrEmpty(opts.Gateway.Name)) return true;
return true;
}
/// <summary>Mirrors Go <c>registerLeafWithAccount</c>.</summary>
internal bool RegisterLeafWithAccount(ClientConnection c, string accountName)
{
var (acc, _) = LookupAccount(accountName);
if (acc == null) return false;
c.SetAccount(acc);
return true;
}
/// <summary>Mirrors Go <c>isLeafNodeAuthorized</c>.</summary>
internal bool IsLeafNodeAuthorized(ClientConnection c)
=> ProcessClientOrLeafAuthentication(c, GetOpts());
/// <summary>Mirrors Go <c>checkAuthforWarnings</c>.</summary>
internal void CheckAuthforWarnings()
{
var opts = GetOpts();
if (opts.Users != null && !string.IsNullOrEmpty(opts.Username))
Warnf("Having a global password along with users/nkeys is not recommended");
}
/// <summary>Mirrors Go <c>proxyCheck</c>.</summary>
internal (bool TrustedProxy, bool Ok) ProxyCheck(ClientConnection c, ServerOptions opts)
{
if (!opts.ProxyProtocol) return (false, false);
// TODO: check remote IP against configured trusted proxy addresses
return (true, true);
}
/// <summary>Mirrors Go <c>processProxiesTrustedKeys</c>.</summary>
internal void ProcessProxiesTrustedKeys()
{
// TODO: parse proxy trusted key strings into _proxyTrustedKeys set
}
/// <summary>
/// Forwards to AuthCallout.ProcessClientOrLeafCallout.
/// Mirrors Go <c>processClientOrLeafCallout</c>.
/// </summary>
internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
=> AuthCallout.ProcessClientOrLeafCallout(this, c, opts);
/// <summary>
/// Config reload stub.
/// Mirrors Go <c>Server.Reload</c>.
/// </summary>
internal void Reload() => throw new NotImplementedException("TODO: config reload — implement in later session");
/// <summary>
/// Returns a Task that shuts the server down asynchronously.
/// Wraps the synchronous <see cref="Shutdown"/> method.
/// </summary>
internal Task ShutdownAsync() => Task.Run(Shutdown);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,849 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/server.go (lines 25774782) in the NATS server Go source.
// Session 10: shutdown, goroutine tracking, query helpers, lame duck mode.
using System.Net;
using System.Net.Sockets;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
namespace ZB.MOM.NatsNet.Server;
public sealed partial class NatsServer
{
// =========================================================================
// Shutdown / WaitForShutdown (features 3051, 3053)
// =========================================================================
/// <summary>
/// Shuts the server down gracefully: closes all listeners, kicks all
/// connections, waits for goroutines, then signals <see cref="WaitForShutdown"/>.
/// Mirrors Go <c>Server.Shutdown()</c>.
/// </summary>
public void Shutdown()
{
// Stubs for JetStream / Raft / eventing — implemented in later sessions.
SignalPullConsumers();
StepdownRaftNodes();
ShutdownEventing();
if (IsShuttingDown()) return;
_mu.EnterWriteLock();
Noticef("Initiating Shutdown...");
var accRes = _accResolver;
GetOpts(); // snapshot opts (not used below but mirrors Go pattern)
Interlocked.Exchange(ref _shutdown, 1);
Interlocked.Exchange(ref _running, 0);
lock (_grMu) { _grRunning = false; }
_mu.ExitWriteLock();
accRes?.Close();
ShutdownJetStream();
ShutdownRaftNodes();
// ---- Collect all connections ----
var conns = new Dictionary<ulong, ClientConnection>();
_mu.EnterWriteLock();
foreach (var kvp in _clients) conns[kvp.Key] = kvp.Value;
lock (_grMu) { foreach (var kvp in _grTmpClients) conns[kvp.Key] = kvp.Value; }
ForEachRoute(r => { conns[r.Cid] = r; });
GetAllGatewayConnections(conns);
foreach (var kvp in _leafs) conns[kvp.Key] = kvp.Value;
// ---- Count & close listeners ----
int doneExpected = 0;
if (_listener != null)
{
doneExpected++;
_listener.Stop();
_listener = null;
}
doneExpected += CloseWebsocketServer();
if (_gateway.Enabled)
{
// mqtt listener managed by session 22
}
if (_leafNodeListener != null)
{
doneExpected++;
_leafNodeListener.Stop();
_leafNodeListener = null;
}
if (_routeListener != null)
{
doneExpected++;
_routeListener.Stop();
_routeListener = null;
}
if (_gatewayListener != null)
{
doneExpected++;
_gatewayListener.Stop();
_gatewayListener = null;
}
if (_http != null)
{
doneExpected++;
_http.Stop();
_http = null;
}
if (_profiler != null)
{
doneExpected++;
_profiler.Stop();
// profiler is not nulled — see Go code: it keeps _profiler ref for ProfilerAddr()
}
_mu.ExitWriteLock();
// Release all goroutines waiting on quitCts.
_quitCts.Cancel();
// Close all client / route / gateway / leaf connections.
foreach (var c in conns.Values)
{
c.SetNoReconnect();
c.CloseConnection(ClosedState.ServerShutdown);
}
// Wait for accept loops to exit.
for (int i = 0; i < doneExpected; i++)
_done.Reader.ReadAsync().GetAwaiter().GetResult();
// Wait for all goroutines.
_grWg.Wait();
var opts = GetOpts();
if (!string.IsNullOrEmpty(opts.PortsFileDir))
DeletePortsFile(opts.PortsFileDir);
Noticef("Server Exiting..");
if (_ocsprc != null) { /* stub — stop OCSP cache in session 23 */ }
DisposeSignalHandlers();
_shutdownComplete.TrySetResult();
}
/// <summary>
/// Blocks until <see cref="Shutdown"/> has fully completed.
/// Mirrors Go <c>Server.WaitForShutdown()</c>.
/// </summary>
public void WaitForShutdown() =>
_shutdownComplete.Task.GetAwaiter().GetResult();
// =========================================================================
// Goroutine tracking (features 31193120)
// =========================================================================
/// <summary>
/// Starts a background Task that counts toward the server wait group.
/// Returns true if the goroutine was started, false if the server is already stopped.
/// Mirrors Go <c>Server.startGoRoutine(f)</c>.
/// </summary>
internal bool StartGoRoutine(Action f)
{
lock (_grMu)
{
if (!_grRunning) return false;
_grWg.Add(1);
Task.Run(() =>
{
try { f(); }
finally { _grWg.Done(); }
});
return true;
}
}
// =========================================================================
// Client / connection management (features 30813084)
// =========================================================================
/// <summary>
/// Removes a client, route, gateway, or leaf from server accounting.
/// Mirrors Go <c>Server.removeClient()</c>.
/// </summary>
internal void RemoveClient(ClientConnection c)
{
switch (c.Kind)
{
case ClientKind.Client:
{
bool updateProto;
string proxyKey;
lock (c)
{
updateProto = c.Kind == ClientKind.Client &&
c.Opts.Protocol >= ClientProtocol.Info;
proxyKey = c.ProxyKey;
}
_mu.EnterWriteLock();
try
{
_clients.Remove(c.Cid);
if (updateProto) _cproto--;
if (!string.IsNullOrEmpty(proxyKey))
RemoveProxiedConn(proxyKey, c.Cid);
}
finally { _mu.ExitWriteLock(); }
break;
}
case ClientKind.Router:
RemoveRoute(c);
break;
case ClientKind.Gateway:
RemoveRemoteGatewayConnection(c);
break;
case ClientKind.Leaf:
RemoveLeafNodeConnection(c);
break;
}
}
/// <summary>
/// Removes a proxied connection entry.
/// Server write lock must be held on entry.
/// Mirrors Go <c>Server.removeProxiedConn()</c>.
/// </summary>
private void RemoveProxiedConn(string key, ulong cid)
{
if (!_proxiedConns.TryGetValue(key, out var conns)) return;
conns.Remove(cid);
if (conns.Count == 0) _proxiedConns.Remove(key);
}
/// <summary>
/// Removes a client from the temporary goroutine client map.
/// Mirrors Go <c>Server.removeFromTempClients()</c>.
/// </summary>
internal void RemoveFromTempClients(ulong cid)
{
lock (_grMu) { _grTmpClients.Remove(cid); }
}
/// <summary>
/// Adds a client to the temporary goroutine client map.
/// Returns false if the server is no longer running goroutines.
/// Mirrors Go <c>Server.addToTempClients()</c>.
/// </summary>
internal bool AddToTempClients(ulong cid, ClientConnection c)
{
lock (_grMu)
{
if (!_grRunning) return false;
_grTmpClients[cid] = c;
return true;
}
}
// =========================================================================
// Query helpers (features 30853118, 31213123)
// =========================================================================
/// <summary>Returns the number of registered routes. Mirrors Go <c>Server.NumRoutes()</c>.</summary>
public int NumRoutes()
{
_mu.EnterReadLock();
try { return NumRoutesInternal(); }
finally { _mu.ExitReadLock(); }
}
private int NumRoutesInternal()
{
int nr = 0;
ForEachRoute(_ => nr++);
return nr;
}
/// <summary>Returns the number of registered remotes. Mirrors Go <c>Server.NumRemotes()</c>.</summary>
public int NumRemotes()
{
_mu.EnterReadLock();
try { return _routes.Count; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the number of leaf-node connections. Mirrors Go <c>Server.NumLeafNodes()</c>.</summary>
public int NumLeafNodes()
{
_mu.EnterReadLock();
try { return _leafs.Count; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the number of registered clients. Mirrors Go <c>Server.NumClients()</c>.</summary>
public int NumClients()
{
_mu.EnterReadLock();
try { return _clients.Count; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the client with the given connection ID. Mirrors Go <c>Server.GetClient()</c>.</summary>
public ClientConnection? GetClient(ulong cid) => GetClientInternal(cid);
private ClientConnection? GetClientInternal(ulong cid)
{
_mu.EnterReadLock();
try
{
_clients.TryGetValue(cid, out var c);
return c;
}
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the leaf node with the given connection ID. Mirrors Go <c>Server.GetLeafNode()</c>.</summary>
public ClientConnection? GetLeafNode(ulong cid)
{
_mu.EnterReadLock();
try
{
_leafs.TryGetValue(cid, out var c);
return c;
}
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Returns total subscriptions across all accounts.
/// Mirrors Go <c>Server.NumSubscriptions()</c>.
/// </summary>
public uint NumSubscriptions()
{
_mu.EnterReadLock();
try { return NumSubscriptionsInternal(); }
finally { _mu.ExitReadLock(); }
}
private uint NumSubscriptionsInternal()
{
int subs = 0;
foreach (var kvp in _accounts)
subs += kvp.Value.TotalSubs();
return (uint)subs;
}
/// <summary>Returns the number of slow consumers. Mirrors Go <c>Server.NumSlowConsumers()</c>.</summary>
public long NumSlowConsumers() => Interlocked.Read(ref _stats.SlowConsumers);
/// <summary>Returns the number of times clients were stalled. Mirrors Go <c>Server.NumStalledClients()</c>.</summary>
public long NumStalledClients() => Interlocked.Read(ref _stats.Stalls);
/// <summary>Mirrors Go <c>Server.NumSlowConsumersClients()</c>.</summary>
public long NumSlowConsumersClients() => Interlocked.Read(ref _scStats.Clients);
/// <summary>Mirrors Go <c>Server.NumSlowConsumersRoutes()</c>.</summary>
public long NumSlowConsumersRoutes() => Interlocked.Read(ref _scStats.Routes);
/// <summary>Mirrors Go <c>Server.NumSlowConsumersGateways()</c>.</summary>
public long NumSlowConsumersGateways() => Interlocked.Read(ref _scStats.Gateways);
/// <summary>Mirrors Go <c>Server.NumSlowConsumersLeafs()</c>.</summary>
public long NumSlowConsumersLeafs() => Interlocked.Read(ref _scStats.Leafs);
/// <summary>Mirrors Go <c>Server.NumStaleConnections()</c>.</summary>
public long NumStaleConnections() => Interlocked.Read(ref _stats.StaleConnections);
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsClients()</c>.</summary>
public long NumStaleConnectionsClients() => Interlocked.Read(ref _staleStats.Clients);
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsRoutes()</c>.</summary>
public long NumStaleConnectionsRoutes() => Interlocked.Read(ref _staleStats.Routes);
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsGateways()</c>.</summary>
public long NumStaleConnectionsGateways() => Interlocked.Read(ref _staleStats.Gateways);
/// <summary>Mirrors Go <c>Server.NumStaleConnectionsLeafs()</c>.</summary>
public long NumStaleConnectionsLeafs() => Interlocked.Read(ref _staleStats.Leafs);
/// <summary>Returns the time the current configuration was loaded. Mirrors Go <c>Server.ConfigTime()</c>.</summary>
public DateTime ConfigTime()
{
_mu.EnterReadLock();
try { return _configTime; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the client listener address. Mirrors Go <c>Server.Addr()</c>.</summary>
public EndPoint? Addr()
{
_mu.EnterReadLock();
try { return _listener?.LocalEndpoint; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the monitoring listener address. Mirrors Go <c>Server.MonitorAddr()</c>.</summary>
public IPEndPoint? MonitorAddr()
{
_mu.EnterReadLock();
try { return _http?.LocalEndpoint as IPEndPoint; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the cluster (route) listener address. Mirrors Go <c>Server.ClusterAddr()</c>.</summary>
public IPEndPoint? ClusterAddr()
{
_mu.EnterReadLock();
try { return _routeListener?.LocalEndpoint as IPEndPoint; }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns the profiler listener address. Mirrors Go <c>Server.ProfilerAddr()</c>.</summary>
public IPEndPoint? ProfilerAddr()
{
_mu.EnterReadLock();
try { return _profiler?.LocalEndpoint as IPEndPoint; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Polls until all expected listeners are up or the deadline expires.
/// Returns an error description if not ready within <paramref name="d"/>.
/// Mirrors Go <c>Server.readyForConnections()</c>.
/// </summary>
public Exception? ReadyForConnectionsError(TimeSpan d)
{
var opts = GetOpts();
var end = DateTime.UtcNow.Add(d);
while (DateTime.UtcNow < end)
{
bool serverOk, routeOk, gatewayOk, leafOk, wsOk;
_mu.EnterReadLock();
serverOk = _listener != null || opts.DontListen;
routeOk = opts.Cluster.Port == 0 || _routeListener != null;
gatewayOk = string.IsNullOrEmpty(opts.Gateway.Name) || _gatewayListener != null;
leafOk = opts.LeafNode.Port == 0 || _leafNodeListener != null;
wsOk = opts.Websocket.Port == 0; // stub — websocket listener not tracked until session 23
_mu.ExitReadLock();
if (serverOk && routeOk && gatewayOk && leafOk && wsOk)
{
if (opts.DontListen)
{
try { _startupComplete.Task.Wait((int)d.TotalMilliseconds); }
catch { /* timeout */ }
}
return null;
}
if (d > TimeSpan.FromMilliseconds(25))
Thread.Sleep(25);
}
return new InvalidOperationException(
$"failed to be ready for connections after {d}");
}
/// <summary>
/// Returns true if the server is ready to accept connections.
/// Mirrors Go <c>Server.ReadyForConnections()</c>.
/// </summary>
public bool ReadyForConnections(TimeSpan dur) =>
ReadyForConnectionsError(dur) == null;
/// <summary>Returns true if the server supports headers. Mirrors Go <c>Server.supportsHeaders()</c>.</summary>
internal bool SupportsHeaders() => !(GetOpts().NoHeaderSupport);
/// <summary>Returns the server ID. Mirrors Go <c>Server.ID()</c>.</summary>
public string ID() => _info.Id;
/// <summary>Returns the JetStream node name (hash of server name). Mirrors Go <c>Server.NodeName()</c>.</summary>
public string NodeName() => GetHash(_info.Name);
/// <summary>Returns the server name. Mirrors Go <c>Server.Name()</c>.</summary>
public string Name() => _info.Name;
/// <summary>Returns the server name as a string. Mirrors Go <c>Server.String()</c>.</summary>
public override string ToString() => _info.Name;
/// <summary>Returns the number of currently-stored closed connections. Mirrors Go <c>Server.numClosedConns()</c>.</summary>
internal int NumClosedConns()
{
_mu.EnterReadLock();
try { return _closed.Len(); }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns total closed connections ever recorded. Mirrors Go <c>Server.totalClosedConns()</c>.</summary>
internal ulong TotalClosedConns()
{
_mu.EnterReadLock();
try { return _closed.TotalConns(); }
finally { _mu.ExitReadLock(); }
}
/// <summary>Returns a snapshot of recently closed connections. Mirrors Go <c>Server.closedClients()</c>.</summary>
internal Internal.ClosedClient?[] ClosedClients()
{
_mu.EnterReadLock();
try { return _closed.ClosedClients(); }
finally { _mu.ExitReadLock(); }
}
// =========================================================================
// Lame duck mode (features 31353139)
// =========================================================================
/// <summary>Returns true if the server is in lame duck mode. Mirrors Go <c>Server.isLameDuckMode()</c>.</summary>
public bool IsLameDuckMode()
{
_mu.EnterReadLock();
try { return _ldm; }
finally { _mu.ExitReadLock(); }
}
/// <summary>
/// Performs a lame-duck shutdown: stops accepting new clients, notifies
/// existing clients to reconnect elsewhere, then shuts down.
/// Mirrors Go <c>Server.LameDuckShutdown()</c>.
/// </summary>
public void LameDuckShutdown() => LameDuckMode();
/// <summary>
/// Core lame-duck implementation.
/// Mirrors Go <c>Server.lameDuckMode()</c>.
/// </summary>
internal void LameDuckMode()
{
_mu.EnterWriteLock();
if (IsShuttingDown() || _ldm || _listener == null)
{
_mu.ExitWriteLock();
return;
}
Noticef("Entering lame duck mode, stop accepting new clients");
_ldm = true;
SendLDMShutdownEventLocked();
int expected = 1;
_listener.Stop();
_listener = null;
expected += CloseWebsocketServer();
_ldmCh = System.Threading.Channels.Channel.CreateBounded<bool>(
new System.Threading.Channels.BoundedChannelOptions(expected)
{ FullMode = System.Threading.Channels.BoundedChannelFullMode.Wait });
var opts = GetOpts();
var gp = opts.LameDuckGracePeriod;
if (gp < TimeSpan.Zero) gp = gp.Negate();
_mu.ExitWriteLock();
// Transfer Raft leaders (stub returns false).
if (TransferRaftLeaders())
Thread.Sleep(1000);
ShutdownJetStream();
ShutdownRaftNodes();
// Wait for accept loops.
for (int i = 0; i < expected; i++)
_ldmCh.Reader.ReadAsync().GetAwaiter().GetResult();
_mu.EnterWriteLock();
var clients = new List<ClientConnection>(_clients.Values);
if (IsShuttingDown() || clients.Count == 0)
{
_mu.ExitWriteLock();
Shutdown();
return;
}
var dur = opts.LameDuckDuration - gp;
if (dur <= TimeSpan.Zero) dur = TimeSpan.FromSeconds(1);
long numClients = clients.Count;
var si = dur / numClients;
int batch = 1;
if (si < TimeSpan.FromTicks(1))
{
si = TimeSpan.FromTicks(1);
batch = (int)(numClients / dur.Ticks);
}
else if (si > TimeSpan.FromSeconds(1))
{
si = TimeSpan.FromSeconds(1);
}
SendLDMToRoutes();
SendLDMToClients();
_mu.ExitWriteLock();
// Grace-period delay.
var token = _quitCts.Token;
try { Task.Delay(gp, token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { return; }
Noticef("Closing existing clients");
for (int i = 0; i < clients.Count; i++)
{
clients[i].CloseConnection(ClosedState.ServerShutdown);
if (i == clients.Count - 1) break;
if (batch == 1 || i % batch == 0)
{
var jitter = (long)(Random.Shared.NextDouble() * si.Ticks);
if (jitter < si.Ticks / 2) jitter = si.Ticks / 2;
try { Task.Delay(TimeSpan.FromTicks(jitter), token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { return; }
}
}
Shutdown();
WaitForShutdown();
}
/// <summary>
/// Sends an LDM INFO to all routes.
/// Server lock must be held on entry.
/// Mirrors Go <c>Server.sendLDMToRoutes()</c>.
/// </summary>
private void SendLDMToRoutes()
{
_routeInfo.LameDuckMode = true;
var infoJson = GenerateInfoJson(_routeInfo);
ForEachRemote(r =>
{
lock (r) { r.EnqueueProto(infoJson); }
});
_routeInfo.LameDuckMode = false;
}
/// <summary>
/// Sends an LDM INFO to all connected clients.
/// Server lock must be held on entry.
/// Mirrors Go <c>Server.sendLDMToClients()</c>.
/// </summary>
private void SendLDMToClients()
{
_info.LameDuckMode = true;
_clientConnectUrls.Clear();
_info.ClientConnectUrls = null;
_info.WsConnectUrls = null;
if (!GetOpts().Cluster.NoAdvertise)
{
var cUrls = _clientConnectUrlsMap.GetAsStringSlice();
_info.ClientConnectUrls = cUrls.Length > 0 ? cUrls : null;
}
SendAsyncInfoToClients(true, true);
_info.LameDuckMode = false;
}
// =========================================================================
// Rate-limit logging (features 31443145)
// =========================================================================
/// <summary>
/// Starts the background goroutine that expires rate-limit log entries.
/// Mirrors Go <c>Server.startRateLimitLogExpiration()</c>.
/// </summary>
internal void StartRateLimitLogExpiration()
{
StartGoRoutine(() =>
{
var interval = TimeSpan.FromSeconds(1);
var token = _quitCts.Token;
while (!token.IsCancellationRequested)
{
try { Task.Delay(interval, token).GetAwaiter().GetResult(); }
catch (OperationCanceledException) { return; }
var now = DateTime.UtcNow;
foreach (var key in _rateLimitLogging.Keys)
{
if (_rateLimitLogging.TryGetValue(key, out var val) &&
val is DateTime ts && now - ts >= interval)
{
_rateLimitLogging.TryRemove(key, out _);
}
}
// Check for a new interval value.
if (_rateLimitLoggingCh.Reader.TryRead(out var newInterval))
interval = newInterval;
}
});
}
/// <summary>
/// Updates the rate-limit logging interval.
/// Mirrors Go <c>Server.changeRateLimitLogInterval()</c>.
/// </summary>
internal void ChangeRateLimitLogInterval(TimeSpan d)
{
if (d <= TimeSpan.Zero) return;
_rateLimitLoggingCh.Writer.TryWrite(d);
}
// =========================================================================
// DisconnectClientByID / LDMClientByID (features 31463147)
// =========================================================================
/// <summary>
/// Forcibly disconnects the client or leaf node with the given ID.
/// Mirrors Go <c>Server.DisconnectClientByID()</c>.
/// </summary>
public Exception? DisconnectClientByID(ulong id)
{
var c = GetClientInternal(id);
if (c != null) { c.CloseConnection(ClosedState.Kicked); return null; }
c = GetLeafNode(id);
if (c != null) { c.CloseConnection(ClosedState.Kicked); return null; }
return new InvalidOperationException("no such client or leafnode id");
}
/// <summary>
/// Sends a Lame Duck Mode INFO message to the specified client.
/// Mirrors Go <c>Server.LDMClientByID()</c>.
/// </summary>
public Exception? LDMClientByID(ulong id)
{
ClientConnection? c;
ServerInfo info;
_mu.EnterReadLock();
_clients.TryGetValue(id, out c);
if (c == null)
{
_mu.ExitReadLock();
return new InvalidOperationException("no such client id");
}
info = CopyInfo();
info.LameDuckMode = true;
_mu.ExitReadLock();
lock (c)
{
if (c.Opts.Protocol >= ClientProtocol.Info &&
(c.Flags & ClientFlags.FirstPongSent) != 0)
{
c.Debugf("Sending Lame Duck Mode info to client");
c.EnqueueProto(c.GenerateClientInfoJSON(info, true).Span);
return null;
}
}
return new InvalidOperationException(
"client does not support Lame Duck Mode or is not ready to receive the notification");
}
// =========================================================================
// updateRemoteSubscription / shouldReportConnectErr (features 31423143)
// =========================================================================
/// <summary>
/// Notifies routes, gateways, and leaf nodes about a subscription change.
/// Mirrors Go <c>Server.updateRemoteSubscription()</c>.
/// </summary>
internal void UpdateRemoteSubscription(Account acc, Subscription sub, int delta)
{
UpdateRouteSubscriptionMap(acc, sub, delta);
if (_gateway.Enabled)
GatewayUpdateSubInterest(acc.Name, sub, delta);
acc.UpdateLeafNodes(sub, delta);
}
/// <summary>
/// Returns true if a connect error at this attempt count should be reported.
/// Mirrors Go <c>Server.shouldReportConnectErr()</c>.
/// </summary>
internal bool ShouldReportConnectErr(bool firstConnect, int attempts)
{
var opts = GetOpts();
int threshold = firstConnect ? opts.ConnectErrorReports : opts.ReconnectErrorReports;
return attempts == 1 || attempts % threshold == 0;
}
// =========================================================================
// Session 10 stubs for cross-session calls
// =========================================================================
/// <summary>Stub — JetStream pull-consumer signalling (session 19).</summary>
private void SignalPullConsumers() { }
/// <summary>Stub — Raft step-down (session 20).</summary>
private void StepdownRaftNodes() { }
/// <summary>Stub — eventing shutdown (session 12).</summary>
private void ShutdownEventing() { }
/// <summary>Stub — JetStream shutdown (session 19).</summary>
private void ShutdownJetStream() { }
/// <summary>Stub — Raft nodes shutdown (session 20).</summary>
private void ShutdownRaftNodes() { }
/// <summary>Stub — Raft leader transfer (session 20). Returns false (no leaders to transfer).</summary>
private bool TransferRaftLeaders() => false;
/// <summary>Stub — LDM shutdown event (session 12).</summary>
private void SendLDMShutdownEventLocked() { }
/// <summary>
/// Stub — closes WebSocket server if running (session 23).
/// Returns the number of done-channel signals to expect.
/// </summary>
private int CloseWebsocketServer() => 0;
/// <summary>
/// Iterates over all route connections. Stub — session 14.
/// Server lock must be held on entry.
/// </summary>
internal void ForEachRoute(Action<ClientConnection> fn) { }
/// <summary>
/// Iterates over all remote (outbound route) connections. Stub — session 14.
/// Server lock must be held on entry.
/// </summary>
private void ForEachRemote(Action<ClientConnection> fn) { }
/// <summary>Stub — collects all gateway connections (session 16).</summary>
private void GetAllGatewayConnections(Dictionary<ulong, ClientConnection> conns) { }
/// <summary>Stub — removes a route connection (session 14).</summary>
private void RemoveRoute(ClientConnection c) { }
/// <summary>Stub — removes a remote gateway connection (session 16).</summary>
private void RemoveRemoteGatewayConnection(ClientConnection c) { }
/// <summary>Stub — removes a leaf-node connection (session 15).</summary>
private void RemoveLeafNodeConnection(ClientConnection c) { }
/// <summary>Stub — sends async INFO to clients (session 10/11). No-op until clients are running.</summary>
private void SendAsyncInfoToClients(bool cliUpdated, bool wsUpdated) { }
/// <summary>Stub — updates route subscription map (session 14).</summary>
private void UpdateRouteSubscriptionMap(Account acc, Subscription sub, int delta) { }
/// <summary>Stub — updates gateway sub interest (session 16).</summary>
private void GatewayUpdateSubInterest(string accName, Subscription sub, int delta) { }
/// <summary>Stub — account disconnect event (session 12).</summary>
private void AccountDisconnectEvent(ClientConnection c, DateTime now, string reason) { }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/signal.go in the NATS server Go source.
using System.Runtime.InteropServices;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// OS signal handling for <see cref="NatsServer"/>.
/// Mirrors Go <c>signal.go</c>.
/// </summary>
public sealed partial class NatsServer
{
private PosixSignalRegistration? _sigHup;
private PosixSignalRegistration? _sigTerm;
private PosixSignalRegistration? _sigInt;
/// <summary>
/// Registers OS signal handlers (SIGHUP, SIGTERM, SIGINT).
/// On Windows, falls back to <see cref="Console.CancelKeyPress"/>.
/// Mirrors Go <c>Server.handleSignals</c>.
/// </summary>
internal void HandleSignals()
{
if (GetOpts()?.NoSigs == true) return;
if (OperatingSystem.IsWindows())
{
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Noticef("Caught interrupt signal, shutting down...");
_ = ShutdownAsync();
};
return;
}
// SIGHUP — reload configuration
_sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGHUP signal, reloading configuration...");
try { Reload(); }
catch (Exception ex) { Errorf("Config reload failed: {0}", ex.Message); }
});
// SIGTERM — graceful shutdown
_sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGTERM signal, shutting down...");
_ = ShutdownAsync();
});
// SIGINT — interrupt (Ctrl+C)
_sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGINT signal, shutting down...");
_ = ShutdownAsync();
});
}
internal void DisposeSignalHandlers()
{
_sigHup?.Dispose();
_sigTerm?.Dispose();
_sigInt?.Dispose();
}
}

View File

@@ -0,0 +1,335 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/server.go in the NATS server Go source.
using System.Collections.Concurrent;
using System.Threading.Channels;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
using ZB.MOM.NatsNet.Server.WebSocket;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// The core NATS server class.
/// Mirrors Go <c>Server</c> struct in server/server.go.
/// Session 09: initialization, configuration, and account management.
/// Sessions 10-23 add further capabilities as partial class files.
/// </summary>
public sealed partial class NatsServer : INatsServer
{
// =========================================================================
// Build-time stamps (mirrors package-level vars in server.go)
// =========================================================================
/// <summary>
/// Binary-stamped trusted operator keys (space-separated NKey public keys).
/// In Go this is a package-level var that can be overridden at build time.
/// In .NET it can be set before constructing any server instance.
/// Mirrors Go package-level <c>trustedKeys</c> var.
/// </summary>
public static string StampedTrustedKeys { get; set; } = string.Empty;
// =========================================================================
// Atomic counters (mirrors fields accessed with atomic operations)
// =========================================================================
private ulong _gcid; // global client id counter
private long _pinnedAccFail; // pinned-account auth failures
private int _activeAccounts; // number of active accounts
// =========================================================================
// Stats (embedded Go structs: stats, scStats, staleStats)
// =========================================================================
private readonly ServerStats _stats = new();
private readonly SlowConsumerStats _scStats = new();
private readonly InternalStaleStats _staleStats = new();
// =========================================================================
// Core identity
// =========================================================================
// kp / xkp are NKey keypairs — represented as byte arrays here.
// Full crypto operations deferred to auth session.
private byte[]? _kpSeed; // server NKey seed
private string _pub = string.Empty; // server public key (server ID)
private byte[]? _xkpSeed; // x25519 key seed
private string _xpub = string.Empty; // x25519 public key
// =========================================================================
// Server info (wire protocol)
// =========================================================================
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.SupportsRecursion);
private readonly ReaderWriterLockSlim _reloadMu = new(LockRecursionPolicy.SupportsRecursion);
internal ServerInfo _info = new();
private string _configFile = string.Empty;
// =========================================================================
// Options (protected by _optsMu)
// =========================================================================
private readonly ReaderWriterLockSlim _optsMu = new(LockRecursionPolicy.NoRecursion);
private ServerOptions _opts;
// =========================================================================
// Running / shutdown state
// =========================================================================
private int _running; // 1 = running, 0 = not (Interlocked)
private int _shutdown; // 1 = shutting down
private readonly CancellationTokenSource _quitCts = new();
private readonly TaskCompletionSource _startupComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _shutdownComplete = new(TaskCreationOptions.RunContinuationsAsynchronously);
private Task? _quitTask;
// =========================================================================
// Listeners (forward-declared stubs — fully wired in session 10)
// =========================================================================
private System.Net.Sockets.TcpListener? _listener;
private Exception? _listenerErr;
// HTTP monitoring listener
private System.Net.Sockets.TcpListener? _http;
// Route listener
private System.Net.Sockets.TcpListener? _routeListener;
private Exception? _routeListenerErr;
// Gateway listener
private System.Net.Sockets.TcpListener? _gatewayListener;
private Exception? _gatewayListenerErr;
// Leaf-node listener
private System.Net.Sockets.TcpListener? _leafNodeListener;
private Exception? _leafNodeListenerErr;
// Profiling listener
private System.Net.Sockets.TcpListener? _profiler;
// Accept-loop done channel — each accept loop sends true when it exits.
private readonly System.Threading.Channels.Channel<bool> _done =
System.Threading.Channels.Channel.CreateUnbounded<bool>();
// Lame-duck channel — created in lameDuckMode, receives one signal per accept loop.
private System.Threading.Channels.Channel<bool>? _ldmCh;
// The no-auth user that only the system account can use (auth session).
private string _sysAccOnlyNoAuthUser = string.Empty;
// =========================================================================
// Accounts
// =========================================================================
private Account? _gacc; // global account
private Account? _sysAccAtomic; // system account (atomic)
private readonly ConcurrentDictionary<string, Account> _accounts = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Account> _tmpAccounts = new(StringComparer.Ordinal);
private IAccountResolver? _accResolver;
private InternalState? _sys; // system messaging state
// =========================================================================
// Client/route/leaf tracking
// =========================================================================
private readonly Dictionary<ulong, ClientConnection> _clients = [];
private readonly Dictionary<ulong, ClientConnection> _leafs = [];
private Dictionary<string, List<ClientConnection>> _routes = [];
private int _routesPoolSize = 1;
private bool _routesReject;
private int _routesNoPool;
private Dictionary<string, Dictionary<string, ClientConnection>>? _accRoutes;
private readonly ConcurrentDictionary<string, object?> _accRouteByHash = new(StringComparer.Ordinal);
private Channel<struct_>? _accAddedCh; // stub
private string _accAddedReqId = string.Empty;
// =========================================================================
// User / nkey maps
// =========================================================================
private Dictionary<string, Auth.User>? _users;
private Dictionary<string, Auth.NkeyUser>? _nkeys;
// =========================================================================
// Connection tracking
// =========================================================================
private ulong _totalClients;
private ClosedRingBuffer _closed = new(0);
private DateTime _start;
private DateTime _configTime;
// =========================================================================
// Goroutine / WaitGroup tracking
// =========================================================================
private readonly object _grMu = new();
private bool _grRunning;
private readonly Dictionary<ulong, ClientConnection> _grTmpClients = [];
private readonly WaitGroup _grWg = new();
// =========================================================================
// Cluster name (separate lock)
// =========================================================================
private readonly ReaderWriterLockSlim _cnMu = new(LockRecursionPolicy.NoRecursion);
private string _cn = string.Empty;
private ServerInfo _routeInfo = new();
private bool _leafNoCluster;
private bool _leafNodeEnabled;
private bool _leafDisableConnect;
private bool _ldm;
// =========================================================================
// Trusted keys
// =========================================================================
private List<string>? _trustedKeys;
private HashSet<string> _strictSigningKeyUsage = [];
// =========================================================================
// Monitoring / stats endpoint
// =========================================================================
private string _httpBasePath = string.Empty;
private readonly Dictionary<string, ulong> _httpReqStats = [];
// =========================================================================
// Client connect URLs
// =========================================================================
private readonly List<string> _clientConnectUrls = [];
private readonly RefCountedUrlSet _clientConnectUrlsMap = new();
// =========================================================================
// Gateway / Websocket / MQTT / OCSP stubs
// =========================================================================
private readonly SrvGateway _gateway = new();
private readonly SrvWebsocket _websocket = new();
private readonly SrvMqtt _mqtt = new();
private OcspMonitor[]? _ocsps;
private bool _ocspPeerVerify;
private IOcspResponseCache? _ocsprc;
// =========================================================================
// Gateway reply map (stub — session 16)
// =========================================================================
private readonly SubscriptionIndex _gwLeafSubs;
// =========================================================================
// NUID event ID generator
// =========================================================================
// Replaced by actual NUID in session 10. Use Guid for now.
private string NextEventId() => Guid.NewGuid().ToString("N");
// =========================================================================
// Various stubs
// =========================================================================
private readonly List<string> _leafRemoteCfgs = []; // stub — session 15
private readonly List<object> _proxiesKeyPairs = []; // stub — session 09 (proxies)
private readonly Dictionary<string, Dictionary<ulong, ClientConnection>> _proxiedConns = [];
private long _cproto; // count of INFO-capable clients
private readonly ConcurrentDictionary<string, object?> _nodeToInfo = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object?> _raftNodes = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _routesToSelf = [];
private INetResolver? _routeResolver;
private readonly ConcurrentDictionary<string, object?> _rateLimitLogging = new();
private readonly Channel<TimeSpan> _rateLimitLoggingCh;
private RateCounter? _connRateCounter;
// GW reply map expiration
private readonly ConcurrentDictionary<string, object?> _gwrm = new();
// Catchup bytes
private readonly ReaderWriterLockSlim _gcbMu = new(LockRecursionPolicy.NoRecursion);
private long _gcbOut;
private long _gcbOutMax;
private readonly Channel<struct_>? _gcbKick; // stub
// Sync-out semaphore
private readonly SemaphoreSlim _syncOutSem;
private const int MaxConcurrentSyncRequests = 16;
// =========================================================================
// Logging
// =========================================================================
private ILogger _logger = NullLogger.Instance;
private int _traceEnabled;
private int _debugEnabled;
private int _traceSysAcc;
// =========================================================================
// INatsServer implementation
// =========================================================================
/// <inheritdoc/>
public ulong NextClientId() => Interlocked.Increment(ref _gcid);
/// <inheritdoc/>
public ServerOptions Options => GetOpts();
/// <inheritdoc/>
public bool TraceEnabled => Interlocked.CompareExchange(ref _traceEnabled, 0, 0) != 0;
/// <inheritdoc/>
public bool TraceSysAcc => Interlocked.CompareExchange(ref _traceSysAcc, 0, 0) != 0;
/// <inheritdoc/>
public ILogger Logger => _logger;
/// <inheritdoc/>
public void DecActiveAccounts() => Interlocked.Decrement(ref _activeAccounts);
/// <inheritdoc/>
public void IncActiveAccounts() => Interlocked.Increment(ref _activeAccounts);
// =========================================================================
// Logging helpers (mirrors Go s.Debugf / s.Noticef / s.Warnf / s.Errorf)
// =========================================================================
internal void Debugf(string fmt, params object?[] args) => _logger.LogDebug(fmt, args);
internal void Noticef(string fmt, params object?[] args) => _logger.LogInformation(fmt, args);
internal void Warnf(string fmt, params object?[] args) => _logger.LogWarning(fmt, args);
internal void Errorf(string fmt, params object?[] args) => _logger.LogError(fmt, args);
internal void Fatalf(string fmt, params object?[] args) => _logger.LogCritical(fmt, args);
// =========================================================================
// Constructor
// =========================================================================
/// <summary>
/// Direct constructor — do not call directly; use <see cref="NewServer(ServerOptions)"/>.
/// </summary>
private NatsServer(ServerOptions opts)
{
_opts = opts;
_gwLeafSubs = SubscriptionIndex.NewSublistWithCache();
_rateLimitLoggingCh = Channel.CreateBounded<TimeSpan>(1);
_syncOutSem = new SemaphoreSlim(MaxConcurrentSyncRequests, MaxConcurrentSyncRequests);
}
}
// Placeholder struct for stub channel types
internal readonly struct struct_ { }

View File

@@ -0,0 +1,373 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/server.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
using ZB.MOM.NatsNet.Server.WebSocket;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Wire-protocol Info payload
// ============================================================================
/// <summary>
/// The INFO payload sent to clients, routes, gateways and leaf nodes.
/// Mirrors Go <c>Info</c> struct in server.go.
/// </summary>
public sealed class ServerInfo
{
[JsonPropertyName("server_id")] public string Id { get; set; } = string.Empty;
[JsonPropertyName("server_name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("version")] public string Version { get; set; } = string.Empty;
[JsonPropertyName("proto")] public int Proto { get; set; }
[JsonPropertyName("git_commit")] public string? GitCommit { get; set; }
[JsonPropertyName("go")] public string GoVersion { get; set; } = string.Empty;
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("headers")] public bool Headers { get; set; }
[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_available")] public bool TlsAvailable { get; set; }
[JsonPropertyName("max_payload")] public int MaxPayload { get; set; }
[JsonPropertyName("jetstream")] public bool JetStream { get; set; }
[JsonPropertyName("ip")] public string? Ip { get; set; }
[JsonPropertyName("client_id")] public ulong Cid { get; set; }
[JsonPropertyName("client_ip")] public string? ClientIp { get; set; }
[JsonPropertyName("nonce")] public string? Nonce { get; set; }
[JsonPropertyName("cluster")] public string? Cluster { get; set; }
[JsonPropertyName("cluster_dynamic")] public bool Dynamic { get; set; }
[JsonPropertyName("domain")] public string? Domain { get; set; }
[JsonPropertyName("connect_urls")] public string[]? ClientConnectUrls { get; set; }
[JsonPropertyName("ws_connect_urls")] public string[]? WsConnectUrls { get; set; }
[JsonPropertyName("ldm")] public bool LameDuckMode { get; set; }
[JsonPropertyName("compression")] public string? Compression { get; set; }
[JsonPropertyName("connect_info")] public bool ConnectInfo { get; set; }
[JsonPropertyName("remote_account")] public string? RemoteAccount { get; set; }
[JsonPropertyName("acc_is_sys")] public bool IsSystemAccount { get; set; }
[JsonPropertyName("api_lvl")] public int JsApiLevel { get; set; }
[JsonPropertyName("xkey")] public string? XKey { get; set; }
// Route-specific
[JsonPropertyName("import")] public SubjectPermission? Import { get; set; }
[JsonPropertyName("export")] public SubjectPermission? Export { get; set; }
[JsonPropertyName("lnoc")] public bool Lnoc { get; set; }
[JsonPropertyName("lnocu")] public bool Lnocu { get; set; }
[JsonPropertyName("info_on_connect")] public bool InfoOnConnect { get; set; }
[JsonPropertyName("route_pool_size")] public int RoutePoolSize { get; set; }
[JsonPropertyName("route_pool_idx")] public int RoutePoolIdx { get; set; }
[JsonPropertyName("route_account")] public string? RouteAccount { get; set; }
[JsonPropertyName("route_acc_add_reqid")] public string? RouteAccReqId { get; set; }
[JsonPropertyName("gossip_mode")] public byte GossipMode { get; set; }
// Gateway-specific
[JsonPropertyName("gateway")] public string? Gateway { get; set; }
[JsonPropertyName("gateway_urls")] public string[]? GatewayUrls { get; set; }
[JsonPropertyName("gateway_url")] public string? GatewayUrl { get; set; }
[JsonPropertyName("gateway_cmd")] public byte GatewayCmd { get; set; }
[JsonPropertyName("gateway_cmd_payload")] public byte[]? GatewayCmdPayload { get; set; }
[JsonPropertyName("gateway_nrp")] public bool GatewayNrp { get; set; }
[JsonPropertyName("gateway_iom")] public bool GatewayIom { get; set; }
// LeafNode-specific
[JsonPropertyName("leafnode_urls")] public string[]? LeafNodeUrls { get; set; }
/// <summary>Returns a shallow clone of this <see cref="ServerInfo"/>.</summary>
internal ServerInfo ShallowClone() => (ServerInfo)MemberwiseClone();
}
// ============================================================================
// Server stats structures
// ============================================================================
/// <summary>
/// Aggregate message/byte counters for the server.
/// Mirrors Go embedded <c>stats</c> struct in server.go.
/// </summary>
internal sealed class ServerStats
{
public long InMsgs;
public long OutMsgs;
public long InBytes;
public long OutBytes;
public long SlowConsumers;
public long StaleConnections;
public long Stalls;
}
/// <summary>
/// Per-kind slow-consumer counters (atomic).
/// Mirrors Go embedded <c>scStats</c> in server.go.
/// </summary>
internal sealed class SlowConsumerStats
{
public long Clients;
public long Routes;
public long Leafs;
public long Gateways;
}
/// <summary>
/// Per-kind stale-connection counters (atomic, internal use only).
/// Mirrors Go embedded <c>staleStats</c> in server.go.
/// NOTE: The public JSON-serialisable monitoring equivalent is <c>StaleConnectionStats</c>
/// in Events/EventTypes.cs.
/// </summary>
internal sealed class InternalStaleStats
{
public long Clients;
public long Routes;
public long Leafs;
public long Gateways;
}
// ============================================================================
// nodeInfo — JetStream node metadata
// ============================================================================
/// <summary>
/// Per-node JetStream metadata stored in the server's node-info map.
/// Mirrors Go <c>nodeInfo</c> struct in server.go.
/// </summary>
internal sealed class NodeInfo
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string Cluster { get; set; } = string.Empty;
public string Domain { get; set; } = string.Empty;
public string Id { get; set; } = string.Empty;
public string[] Tags { get; set; } = [];
public object? Cfg { get; set; } // JetStreamConfig — session 19
public object? Stats { get; set; } // JetStreamStats — session 19
public bool Offline { get; set; }
public bool Js { get; set; }
public bool BinarySnapshots { get; set; }
public bool AccountNrg { get; set; }
}
// ============================================================================
// Server protocol version constants
// ============================================================================
/// <summary>
/// Server-to-server (route/leaf/gateway) protocol versions.
/// Mirrors the iota block at the top of server.go.
/// </summary>
public static class ServerProtocol
{
/// <summary>Original route protocol (2009).</summary>
public const int RouteProtoZero = 0;
/// <summary>Route protocol that supports INFO updates.</summary>
public const int RouteProtoInfo = 1;
/// <summary>Route/cluster protocol with account support.</summary>
public const int RouteProtoV2 = 2;
/// <summary>Protocol with distributed message tracing.</summary>
public const int MsgTraceProto = 3;
}
// ============================================================================
// Compression mode constants
// ============================================================================
/// <summary>
/// Compression mode string constants.
/// Mirrors the <c>const</c> block in server.go.
/// </summary>
public static class CompressionMode
{
public const string NotSupported = "not supported";
public const string Off = "off";
public const string Accept = "accept";
public const string S2Auto = "s2_auto";
public const string S2Uncompressed = "s2_uncompressed";
public const string S2Fast = "s2_fast";
public const string S2Better = "s2_better";
public const string S2Best = "s2_best";
}
// ============================================================================
// Stub types for cross-session dependencies
// ============================================================================
// These stubs will be replaced with full implementations in later sessions.
// They are declared here to allow the NatsServer class to compile.
// InternalState is now fully defined in Events/EventTypes.cs (session 12).
// JetStreamState — replaced by JsAccount in JetStream/JetStreamTypes.cs (session 19).
// JetStreamConfig — replaced by full implementation in JetStream/JetStreamTypes.cs (session 19).
// SrvGateway — full class is in Gateway/GatewayTypes.cs (session 16).
// SrvWebsocket — now fully defined in WebSocket/WebSocketTypes.cs (session 23).
// OcspMonitor — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23).
// IOcspResponseCache — now fully defined in Auth/Ocsp/OcspTypes.cs (session 23).
/// <summary>Stub for server MQTT state (session 22).</summary>
internal sealed class SrvMqtt { }
/// <summary>Stub for IP queue (session 02 — already ported as IpQueue).</summary>
// IpQueue is already in session 02 internals — used here via object.
// LeafNodeCfg — full class is in LeafNode/LeafNodeTypes.cs (session 15).
/// <summary>
/// Network resolver used by <see cref="NatsServer.GetRandomIP"/>.
/// Mirrors Go <c>netResolver</c> interface in server.go.
/// </summary>
internal interface INetResolver
{
Task<string[]> LookupHostAsync(string host, CancellationToken ct = default);
}
/// <summary>Factory for rate counters.</summary>
internal static class RateCounterFactory
{
public static ZB.MOM.NatsNet.Server.Internal.RateCounter Create(long rateLimit)
=> new(rateLimit);
}
// IRaftNode is now fully defined in JetStream/RaftTypes.cs (session 20).
// ============================================================================
// Session 10: Ports, TlsMixConn, CaptureHttpServerLog
// ============================================================================
/// <summary>
/// Describes the URLs at which this server can be contacted.
/// Mirrors Go <c>Ports</c> struct in server.go.
/// </summary>
public sealed class Ports
{
public string[]? Nats { get; set; }
public string[]? Monitoring { get; set; }
public string[]? Cluster { get; set; }
public string[]? Profile { get; set; }
public string[]? WebSocket { get; set; }
}
/// <summary>
/// Wraps a <see cref="Stream"/> with an optional "pre-read" buffer that is
/// drained first, then falls through to the underlying stream.
/// Used when we peek at the first bytes of a connection to detect TLS.
/// Mirrors Go <c>tlsMixConn</c>.
/// </summary>
internal sealed class TlsMixConn : Stream
{
private readonly Stream _inner;
private System.IO.MemoryStream? _pre;
public TlsMixConn(Stream inner, byte[] preRead)
{
_inner = inner;
if (preRead.Length > 0)
_pre = new System.IO.MemoryStream(preRead, writable: false);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => _inner.CanWrite;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
if (_pre is { } pre)
{
var n = pre.Read(buffer, offset, count);
if (pre.Position >= pre.Length)
_pre = null;
return n;
}
return _inner.Read(buffer, offset, count);
}
public override void Write(byte[] buffer, int offset, int count) =>
_inner.Write(buffer, offset, count);
public override void Flush() => _inner.Flush();
public override long Seek(long offset, SeekOrigin origin) =>
throw new NotSupportedException();
public override void SetLength(long value) =>
throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing) { _pre?.Dispose(); _inner.Dispose(); }
base.Dispose(disposing);
}
}
/// <summary>
/// Captures HTTP server log lines and routes them through the server's
/// error logger.
/// Mirrors Go <c>captureHTTPServerLog</c> in server.go.
/// </summary>
internal sealed class CaptureHttpServerLog : System.IO.TextWriter
{
private readonly NatsServer _server;
private readonly string _prefix;
public CaptureHttpServerLog(NatsServer server, string prefix)
{
_server = server;
_prefix = prefix;
}
public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8;
public override void Write(string? value)
{
if (value is null) return;
// Strip leading "http:" prefix that .NET's HttpListener sometimes emits.
var msg = value.StartsWith("http:", StringComparison.Ordinal) ? value[6..] : value;
_server.Errorf("{0}{1}", _prefix, msg.TrimEnd('\r', '\n'));
}
}
/// <summary>
/// Stub for JWT account claims (session 06/11).
/// Mirrors Go <c>jwt.AccountClaims</c> from nats.io/jwt/v2.
/// Full implementation will decode a signed JWT and expose limits/imports/exports.
/// </summary>
public sealed class AccountClaims
{
/// <summary>Account public NKey (subject of the JWT).</summary>
public string Subject { get; set; } = string.Empty;
/// <summary>Operator or signing-key that issued this JWT.</summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// Minimal stub decoder — returns null until session 11 provides full JWT parsing.
/// In Go: <c>jwt.DecodeAccountClaims(claimJWT)</c>.
/// </summary>
public static AccountClaims? TryDecode(string claimJwt)
{
if (string.IsNullOrEmpty(claimJwt)) return null;
// TODO: implement proper JWT decoding in session 11.
return null;
}
}

View File

@@ -2,3 +2,4 @@ using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.Tests")]
[assembly: InternalsVisibleTo("ZB.MOM.NatsNet.Server.IntegrationTests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // required for NSubstitute to proxy internal interfaces

View File

@@ -0,0 +1,185 @@
// Copyright 2013-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/route.go in the NATS server Go source.
using System.Text.Json.Serialization;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
// ============================================================================
// Session 14: Routes
// ============================================================================
/// <summary>
/// Designates whether a route was explicitly configured or discovered via gossip.
/// Mirrors Go <c>RouteType</c> iota in route.go.
/// Note: Go defines Implicit=0, Explicit=1 — we keep TombStone=2 for future use.
/// </summary>
public enum RouteType : int
{
/// <summary>This route was learned from speaking to other routes.</summary>
Implicit = 0,
/// <summary>This route was explicitly configured.</summary>
Explicit = 1,
/// <summary>Reserved tombstone marker for removed routes.</summary>
TombStone = 2,
}
/// <summary>
/// Gossip mode constants exchanged between route servers.
/// Mirrors the const block immediately after <c>routeInfo</c> in route.go.
/// Do not change values — they are part of the wire protocol.
/// </summary>
internal static class GossipMode
{
public const byte Default = 0;
public const byte Disabled = 1;
public const byte Override = 2;
}
/// <summary>
/// Per-connection route state embedded in <see cref="ClientConnection"/> when the
/// connection kind is <c>Router</c>.
/// Mirrors Go <c>route</c> struct in route.go.
/// </summary>
internal sealed class Route
{
/// <summary>Remote server ID string.</summary>
public string RemoteId { get; set; } = string.Empty;
/// <summary>Remote server name.</summary>
public string RemoteName { get; set; } = string.Empty;
/// <summary>True if this server solicited the outbound connection.</summary>
public bool DidSolicit { get; set; }
/// <summary>True if the connection should be retried on failure.</summary>
public bool Retry { get; set; }
/// <summary>Leaf-node origin cluster flag (lnoc).</summary>
public bool Lnoc { get; set; }
/// <summary>Leaf-node origin cluster with unsub support (lnocu).</summary>
public bool Lnocu { get; set; }
/// <summary>Whether this is an explicit or implicit route.</summary>
public RouteType RouteType { get; set; }
/// <summary>Remote URL used to establish the connection.</summary>
public Uri? Url { get; set; }
/// <summary>True if the remote requires authentication.</summary>
public bool AuthRequired { get; set; }
/// <summary>True if the remote requires TLS.</summary>
public bool TlsRequired { get; set; }
/// <summary>True if JetStream is enabled on the remote.</summary>
public bool JetStream { get; set; }
/// <summary>List of client connect URLs advertised by the remote.</summary>
public List<string> ConnectUrls { get; set; } = [];
/// <summary>List of WebSocket connect URLs advertised by the remote.</summary>
public List<string> WsConnUrls { get; set; } = [];
/// <summary>Gateway URL advertised by the remote.</summary>
public string GatewayUrl { get; set; } = string.Empty;
/// <summary>Leaf-node URL advertised by the remote.</summary>
public string LeafnodeUrl { get; set; } = string.Empty;
/// <summary>Cluster hash used for routing.</summary>
public string Hash { get; set; } = string.Empty;
/// <summary>Server ID hash (6 bytes encoded).</summary>
public string IdHash { get; set; } = string.Empty;
/// <summary>
/// Index of this route in the <c>s.routes[remoteID]</c> slice.
/// Initialized to -1 to indicate the route has not yet been registered.
/// </summary>
public int PoolIdx { get; set; } = -1;
/// <summary>
/// When set, this route is pinned to a specific account and the account
/// name will not be included in routed protocols.
/// </summary>
public byte[]? AccName { get; set; }
/// <summary>True if this is a connection to an old server or one with pooling disabled.</summary>
public bool NoPool { get; set; }
/// <summary>
/// Selected compression mode, which may differ from the server-configured mode.
/// </summary>
public string Compression { get; set; } = string.Empty;
/// <summary>
/// Transient gossip mode byte sent when initiating an implicit route.
/// </summary>
public byte GossipMode { get; set; }
/// <summary>
/// When set in a pooling scenario, signals that the route should trigger
/// creation of the next pooled connection after receiving the first PONG.
/// </summary>
public RouteInfo? StartNewRoute { get; set; }
}
/// <summary>
/// Minimal descriptor used to create a new route connection, including
/// the target URL, its type, and gossip mode.
/// Mirrors Go <c>routeInfo</c> struct (the small inner type) in route.go.
/// </summary>
internal sealed class RouteInfo
{
public Uri? Url { get; set; }
public RouteType RouteType { get; set; }
public byte GossipMode { get; set; }
}
/// <summary>
/// CONNECT protocol payload exchanged between cluster servers.
/// Fields map 1-to-1 with the JSON tags in Go's <c>connectInfo</c>.
/// Mirrors Go <c>connectInfo</c> struct in route.go.
/// </summary>
internal sealed class ConnectInfo
{
[JsonPropertyName("echo")] public bool Echo { get; set; }
[JsonPropertyName("verbose")] public bool Verbose { get; set; }
[JsonPropertyName("pedantic")] public bool Pedantic { get; set; }
[JsonPropertyName("user")] public string User { get; set; } = string.Empty;
[JsonPropertyName("pass")] public string Pass { get; set; } = string.Empty;
[JsonPropertyName("tls_required")] public bool Tls { get; set; }
[JsonPropertyName("headers")] public bool Headers { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
[JsonPropertyName("cluster")] public string Cluster { get; set; } = string.Empty;
[JsonPropertyName("cluster_dynamic")] public bool Dynamic { get; set; }
[JsonPropertyName("lnoc")] public bool Lnoc { get; set; }
[JsonPropertyName("lnocu")] public bool Lnocu { get; set; }
[JsonPropertyName("gateway")] public string Gateway { get; set; } = string.Empty;
}
/// <summary>
/// Holds a set of subscriptions for a single account, used when fanning out
/// route subscription interest.
/// Mirrors Go <c>asubs</c> struct in route.go.
/// </summary>
internal sealed class ASubs
{
public Account? Account { get; set; }
public List<Internal.Subscription> Subs { get; set; } = [];
}

View File

@@ -63,6 +63,12 @@ public static class ServerConstants
// Auth timeout — mirrors AUTH_TIMEOUT.
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
// Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout.
public const double DefaultAuthTimeout = 2.0;
// Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE.
public const int MaxPayload = MaxPayloadSize;
// How often pings are sent — mirrors DEFAULT_PING_INTERVAL.
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);

View File

@@ -461,17 +461,4 @@ public interface IClientAuthentication
string RemoteAddress();
}
/// <summary>
/// Account resolver interface for dynamic account loading.
/// Mirrors <c>AccountResolver</c> in accounts.go.
/// </summary>
public interface IAccountResolver
{
(string jwt, Exception? err) Fetch(string name);
Exception? Store(string name, string jwt);
bool IsReadOnly();
Exception? Start(object server);
bool IsTrackingUpdate();
Exception? Reload();
void Close();
}
// IAccountResolver is defined in Accounts/AccountResolver.cs.

View File

@@ -15,6 +15,7 @@
using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json.Serialization;
using System.Threading;
using ZB.MOM.NatsNet.Server.Auth;
@@ -31,20 +32,28 @@ public sealed partial class ServerOptions
// -------------------------------------------------------------------------
public string ConfigFile { get; set; } = string.Empty;
[JsonPropertyName("server_name")]
public string ServerName { get; set; } = string.Empty;
[JsonPropertyName("host")]
public string Host { get; set; } = string.Empty;
[JsonPropertyName("port")]
public int Port { get; set; }
public bool DontListen { get; set; }
[JsonPropertyName("client_advertise")]
public string ClientAdvertise { get; set; } = string.Empty;
public bool CheckConfig { get; set; }
[JsonPropertyName("pid_file")]
public string PidFile { get; set; } = string.Empty;
[JsonPropertyName("ports_file_dir")]
public string PortsFileDir { get; set; } = string.Empty;
// -------------------------------------------------------------------------
// Logging & Debugging
// -------------------------------------------------------------------------
[JsonPropertyName("trace")]
public bool Trace { get; set; }
[JsonPropertyName("debug")]
public bool Debug { get; set; }
public bool TraceVerbose { get; set; }
public bool TraceHeaders { get; set; }
@@ -52,7 +61,9 @@ public sealed partial class ServerOptions
public bool NoSigs { get; set; }
public bool Logtime { get; set; }
public bool LogtimeUtc { get; set; }
[JsonPropertyName("logfile")]
public string LogFile { get; set; } = string.Empty;
[JsonPropertyName("log_size_limit")]
public long LogSizeLimit { get; set; }
public long LogMaxFiles { get; set; }
public bool Syslog { get; set; }
@@ -65,11 +76,14 @@ public sealed partial class ServerOptions
// Networking & Limits
// -------------------------------------------------------------------------
[JsonPropertyName("max_connections")]
public int MaxConn { get; set; }
public int MaxSubs { get; set; }
public byte MaxSubTokens { get; set; }
public int MaxControlLine { get; set; }
[JsonPropertyName("max_payload")]
public int MaxPayload { get; set; }
[JsonPropertyName("max_pending")]
public long MaxPending { get; set; }
public bool NoFastProducerStall { get; set; }
public bool ProxyRequired { get; set; }
@@ -80,11 +94,16 @@ public sealed partial class ServerOptions
// Connectivity
// -------------------------------------------------------------------------
[JsonPropertyName("ping_interval")]
public TimeSpan PingInterval { get; set; }
[JsonPropertyName("ping_max")]
public int MaxPingsOut { get; set; }
[JsonPropertyName("write_deadline")]
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
[JsonPropertyName("lame_duck_duration")]
public TimeSpan LameDuckDuration { get; set; }
[JsonPropertyName("lame_duck_grace_period")]
public TimeSpan LameDuckGracePeriod { get; set; }
// -------------------------------------------------------------------------
@@ -92,22 +111,34 @@ public sealed partial class ServerOptions
// -------------------------------------------------------------------------
public string HttpHost { get; set; } = string.Empty;
[JsonPropertyName("http_port")]
public int HttpPort { get; set; }
[JsonPropertyName("http_base_path")]
public string HttpBasePath { get; set; } = string.Empty;
[JsonPropertyName("https_port")]
public int HttpsPort { get; set; }
// -------------------------------------------------------------------------
// Authentication & Authorization
// -------------------------------------------------------------------------
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
[JsonPropertyName("authorization")]
public string Authorization { get; set; } = string.Empty;
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("no_auth_user")]
public string NoAuthUser { get; set; } = string.Empty;
public string DefaultSentinel { get; set; } = string.Empty;
[JsonPropertyName("system_account")]
public string SystemAccount { get; set; } = string.Empty;
public bool NoSystemAccount { get; set; }
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
[JsonPropertyName("accounts")]
public List<Account> Accounts { get; set; } = [];
public AuthCalloutOpts? AuthCallout { get; set; }
public bool AlwaysEnableNonce { get; set; }
public List<User>? Users { get; set; }
@@ -146,8 +177,11 @@ public sealed partial class ServerOptions
// Cluster / Gateway / Leaf / WebSocket / MQTT
// -------------------------------------------------------------------------
[JsonPropertyName("cluster")]
public ClusterOpts Cluster { get; set; } = new();
[JsonPropertyName("gateway")]
public GatewayOpts Gateway { get; set; } = new();
[JsonPropertyName("leafnodes")]
public LeafNodeOpts LeafNode { get; set; } = new();
public WebsocketOpts Websocket { get; set; } = new();
public MqttOpts Mqtt { get; set; } = new();
@@ -163,6 +197,7 @@ public sealed partial class ServerOptions
// JetStream
// -------------------------------------------------------------------------
[JsonPropertyName("jetstream")]
public bool JetStream { get; set; }
public bool NoJetStreamStrict { get; set; }
public long JetStreamMaxMemory { get; set; }
@@ -182,6 +217,7 @@ public sealed partial class ServerOptions
public bool JetStreamMetaCompactSync { get; set; }
public int StreamMaxBufferedMsgs { get; set; }
public long StreamMaxBufferedSize { get; set; }
[JsonPropertyName("store_dir")]
public string StoreDir { get; set; } = string.Empty;
public TimeSpan SyncInterval { get; set; }
public bool SyncAlways { get; set; }

View File

@@ -0,0 +1,75 @@
// Copyright 2020-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/websocket.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.WebSocket;
/// <summary>
/// WebSocket opcode values as defined in RFC 6455 §5.2.
/// Mirrors Go <c>wsOpCode</c> type in server/websocket.go.
/// </summary>
internal enum WsOpCode : int
{
Continuation = 0,
Text = 1,
Binary = 2,
Close = 8,
Ping = 9,
Pong = 10,
}
/// <summary>
/// WebSocket protocol constants.
/// Mirrors the constant block at the top of server/websocket.go.
/// </summary>
internal static class WsConstants
{
// Frame header bits
public const int FinalBit = 1 << 7;
public const int Rsv1Bit = 1 << 6; // Used for per-message compression (RFC 7692)
public const int Rsv2Bit = 1 << 5;
public const int Rsv3Bit = 1 << 4;
public const int MaskBit = 1 << 7;
// Frame size limits
public const int MaxFrameHeaderSize = 14; // LeafNode may behave as a client
public const int MaxControlPayloadSize = 125;
public const int FrameSizeForBrowsers = 4096; // From experiment, browsers behave better with limited frame size
public const int CompressThreshold = 64; // Don't compress for small buffer(s)
public const int CloseStatusSize = 2;
// Close status codes (RFC 6455 §11.7)
public const int CloseNormalClosure = 1000;
public const int CloseGoingAway = 1001;
public const int CloseProtocolError = 1002;
public const int CloseUnsupportedData = 1003;
public const int CloseNoStatusReceived = 1005;
public const int CloseInvalidPayloadData = 1007;
public const int ClosePolicyViolation = 1008;
public const int CloseMessageTooBig = 1009;
public const int CloseInternalError = 1011;
public const int CloseTlsHandshake = 1015;
// Header strings
public const string NoMaskingHeader = "Nats-No-Masking";
public const string NoMaskingValue = "true";
public const string XForwardedForHeader = "X-Forwarded-For";
public const string PMCExtension = "permessage-deflate"; // per-message compression
public const string PMCSrvNoCtx = "server_no_context_takeover";
public const string PMCCliNoCtx = "client_no_context_takeover";
public const string SecProtoHeader = "Sec-Websocket-Protocol";
public const string MQTTSecProtoVal = "mqtt";
public const string SchemePrefix = "ws";
public const string SchemePrefixTls = "wss";
}

View File

@@ -0,0 +1,110 @@
// Copyright 2020-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/websocket.go in the NATS server Go source.
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server.WebSocket;
/// <summary>
/// Per-connection WebSocket read state.
/// Mirrors Go <c>wsReadInfo</c> struct in server/websocket.go.
/// </summary>
internal sealed class WsReadInfo
{
/// <summary>Whether masking is disabled for this connection (e.g. leaf node).</summary>
public bool NoMasking { get; set; }
/// <summary>Whether per-message deflate compression is active.</summary>
public bool Compressed { get; set; }
/// <summary>The current frame opcode.</summary>
public WsOpCode FrameType { get; set; }
/// <summary>Number of payload bytes remaining in the current frame.</summary>
public int PayloadLeft { get; set; }
/// <summary>The 4-byte masking key (only valid when masking is active).</summary>
public int[] Mask { get; set; } = new int[4];
/// <summary>Current offset into <see cref="Mask"/>.</summary>
public int MaskOffset { get; set; }
/// <summary>Accumulated compressed payload buffers awaiting decompression.</summary>
public byte[]? Compress { get; set; }
public WsReadInfo() { }
}
/// <summary>
/// Server-level WebSocket state, shared across all WebSocket connections.
/// Mirrors Go <c>srvWebsocket</c> struct in server/websocket.go.
/// Replaces the stub in NatsServerTypes.cs.
/// </summary>
internal sealed class SrvWebsocket
{
/// <summary>
/// Tracks WebSocket connect URLs per server (ref-counted).
/// Mirrors Go <c>connectURLsMap refCountedUrlSet</c>.
/// </summary>
public RefCountedUrlSet ConnectUrlsMap { get; set; } = new();
/// <summary>
/// TLS configuration for the WebSocket listener.
/// Mirrors Go <c>tls bool</c> field (true if TLS is required).
/// </summary>
public System.Net.Security.SslServerAuthenticationOptions? TlsConfig { get; set; }
/// <summary>Whether per-message deflate compression is enabled globally.</summary>
public bool Compression { get; set; }
/// <summary>Host the WebSocket server is listening on.</summary>
public string Host { get; set; } = string.Empty;
/// <summary>Port the WebSocket server is listening on (may be ephemeral).</summary>
public int Port { get; set; }
}
/// <summary>
/// Handles WebSocket upgrade and framing for a single connection.
/// Mirrors the WebSocket-related methods on Go <c>client</c> in server/websocket.go.
/// Full implementation is deferred to session 23.
/// </summary>
internal sealed class WebSocketHandler
{
private readonly NatsServer _server;
public WebSocketHandler(NatsServer server)
{
_server = server;
}
/// <summary>Upgrades an HTTP connection to WebSocket protocol.</summary>
public void UpgradeToWebSocket(
System.IO.Stream stream,
System.Net.Http.Headers.HttpRequestHeaders headers)
=> throw new NotImplementedException("TODO: session 23 — websocket");
/// <summary>Parses a WebSocket frame from the given buffer slice.</summary>
public void ParseFrame(byte[] data, int offset, int count)
=> throw new NotImplementedException("TODO: session 23 — websocket");
/// <summary>Writes a WebSocket frame with the given payload.</summary>
public void WriteFrame(WsOpCode opCode, byte[] payload, bool final, bool compress)
=> throw new NotImplementedException("TODO: session 23 — websocket");
/// <summary>Writes a WebSocket close frame with the given status code and reason.</summary>
public void WriteCloseFrame(int statusCode, string reason)
=> throw new NotImplementedException("TODO: session 23 — websocket");
}

View File

@@ -9,10 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
<PackageReference Include="BCrypt.Net-Next" Version="*" />
<PackageReference Include="IronSnappy" Version="*" />
<PackageReference Include="NATS.NKeys" Version="1.0.0-preview.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,478 @@
// Copyright 2018-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/accounts_test.go and server/dirstore_test.go in the NATS server Go source.
using Shouldly;
using Xunit;
namespace ZB.MOM.NatsNet.Server.Tests;
[Collection("AccountTests")]
public sealed class AccountTests
{
// =========================================================================
// Account Basic Tests
// =========================================================================
// Test 1
[Fact]
public void NewAccount_SetsNameAndUnlimitedLimits()
{
var acc = Account.NewAccount("foo");
acc.Name.ShouldBe("foo");
acc.MaxConnections.ShouldBe(-1);
acc.MaxLeafNodes.ShouldBe(-1);
}
// Test 2
[Fact]
public void ToString_ReturnsName()
{
var acc = Account.NewAccount("myaccount");
acc.ToString().ShouldBe(acc.Name);
}
// Test 3
[Fact]
public void IsExpired_InitiallyFalse()
{
var acc = Account.NewAccount("foo");
acc.IsExpired().ShouldBeFalse();
}
// Test 4
[Fact]
public void IsClaimAccount_NoJwt_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
// ClaimJwt defaults to empty string
acc.IsClaimAccount().ShouldBeFalse();
}
// Test 5
[Fact]
public void NumConnections_Initial_IsZero()
{
var acc = Account.NewAccount("foo");
acc.NumConnections().ShouldBe(0);
}
// Test 6
[Fact]
public void GetName_ReturnsName()
{
var acc = Account.NewAccount("thread-safe-name");
acc.GetName().ShouldBe("thread-safe-name");
}
// =========================================================================
// Subject Mapping Tests
// =========================================================================
// Test 7
[Fact]
public void AddMapping_ValidSubject_Succeeds()
{
var acc = Account.NewAccount("foo");
var err = acc.AddMapping("foo", "bar");
err.ShouldBeNull();
}
// Test 8
[Fact]
public void AddMapping_InvalidSubject_ReturnsError()
{
var acc = Account.NewAccount("foo");
var err = acc.AddMapping("foo..bar", "x");
err.ShouldNotBeNull();
}
// Test 9
[Fact]
public void RemoveMapping_ExistingMapping_ReturnsTrue()
{
var acc = Account.NewAccount("foo");
acc.AddMapping("foo", "bar").ShouldBeNull();
var removed = acc.RemoveMapping("foo");
removed.ShouldBeTrue();
}
// Test 10
[Fact]
public void RemoveMapping_NonExistentMapping_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
var removed = acc.RemoveMapping("nonexistent");
removed.ShouldBeFalse();
}
// Test 11
[Fact]
public void HasMappings_AfterAdd_ReturnsTrue()
{
var acc = Account.NewAccount("foo");
acc.AddMapping("foo", "bar").ShouldBeNull();
acc.HasMappings().ShouldBeTrue();
}
// Test 12
[Fact]
public void HasMappings_AfterRemove_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
acc.AddMapping("foo", "bar").ShouldBeNull();
acc.RemoveMapping("foo");
acc.HasMappings().ShouldBeFalse();
}
// Test 13
[Fact]
public void SelectMappedSubject_NoMapping_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
var (dest, mapped) = acc.SelectMappedSubject("foo");
mapped.ShouldBeFalse();
dest.ShouldBe("foo");
}
// Test 14
[Fact]
public void SelectMappedSubject_SimpleMapping_ReturnsMappedDest()
{
var acc = Account.NewAccount("foo");
acc.AddMapping("foo", "bar").ShouldBeNull();
var (dest, mapped) = acc.SelectMappedSubject("foo");
mapped.ShouldBeTrue();
dest.ShouldBe("bar");
}
// Test 15
[Fact]
public void AddWeightedMappings_DuplicateDest_ReturnsError()
{
var acc = Account.NewAccount("foo");
var err = acc.AddWeightedMappings("src",
MapDest.New("dest1", 50),
MapDest.New("dest1", 50)); // duplicate subject
err.ShouldNotBeNull();
}
// Test 16
[Fact]
public void AddWeightedMappings_WeightOver100_ReturnsError()
{
var acc = Account.NewAccount("foo");
var err = acc.AddWeightedMappings("src",
MapDest.New("dest1", 101)); // weight exceeds 100
err.ShouldNotBeNull();
}
// Test 17
[Fact]
public void AddWeightedMappings_TotalWeightOver100_ReturnsError()
{
var acc = Account.NewAccount("foo");
var err = acc.AddWeightedMappings("src",
MapDest.New("dest1", 80),
MapDest.New("dest2", 80)); // total = 160
err.ShouldNotBeNull();
}
// =========================================================================
// Connection Counting Tests
// =========================================================================
// Test 18
[Fact]
public void NumLeafNodes_Initial_IsZero()
{
var acc = Account.NewAccount("foo");
acc.NumLeafNodes().ShouldBe(0);
}
// Test 19
[Fact]
public void MaxTotalConnectionsReached_UnlimitedAccount_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
// MaxConnections is -1 (unlimited) by default
acc.MaxTotalConnectionsReached().ShouldBeFalse();
}
// Test 20
[Fact]
public void MaxTotalLeafNodesReached_UnlimitedAccount_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
// MaxLeafNodes is -1 (unlimited) by default
acc.MaxTotalLeafNodesReached().ShouldBeFalse();
}
// =========================================================================
// Export Service Tests
// =========================================================================
// Test 21
[Fact]
public void IsExportService_NoExports_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
acc.IsExportService("my.service").ShouldBeFalse();
}
// Test 22
[Fact]
public void IsExportServiceTracking_NoExports_ReturnsFalse()
{
var acc = Account.NewAccount("foo");
acc.IsExportServiceTracking("my.service").ShouldBeFalse();
}
}
// =========================================================================
// DirJwtStore Tests
// =========================================================================
[Collection("AccountTests")]
public sealed class DirJwtStoreTests : IDisposable
{
private readonly List<string> _tempDirs = [];
private string MakeTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(dir);
_tempDirs.Add(dir);
return dir;
}
public void Dispose()
{
foreach (var dir in _tempDirs)
{
try { Directory.Delete(dir, true); } catch { /* best effort */ }
}
}
// Test 23
[Fact]
public void DirJwtStore_WriteAndRead_Succeeds()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
const string key = "AAAAAAAAAA"; // minimum 2-char key
const string jwt = "header.payload.signature";
store.SaveAcc(key, jwt);
var loaded = store.LoadAcc(key);
loaded.ShouldBe(jwt);
}
// Test 24
[Fact]
public void DirJwtStore_ShardedWriteAndRead_Succeeds()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: true, create: false);
var keys = new[] { "ACCTKEY001", "ACCTKEY002", "ACCTKEY003" };
foreach (var k in keys)
{
store.SaveAcc(k, $"jwt.for.{k}");
}
foreach (var k in keys)
{
store.LoadAcc(k).ShouldBe($"jwt.for.{k}");
}
}
// Test 25
[Fact]
public void DirJwtStore_EmptyKey_ReturnsError()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
// LoadAcc with key shorter than 2 chars should throw
Should.Throw<Exception>(() => store.LoadAcc(""));
// SaveAcc with key shorter than 2 chars should throw
Should.Throw<Exception>(() => store.SaveAcc("", "some.jwt"));
}
// Test 26
[Fact]
public void DirJwtStore_MissingKey_ReturnsError()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
Should.Throw<FileNotFoundException>(() => store.LoadAcc("NONEXISTENT_KEY"));
}
// Test 27
[Fact]
public void DirJwtStore_Pack_ContainsSavedJwts()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
store.SaveAcc("ACCTKEYAAA", "jwt1.data.sig");
store.SaveAcc("ACCTKEYBBB", "jwt2.data.sig");
var packed = store.Pack(-1);
packed.ShouldContain("ACCTKEYAAA|jwt1.data.sig");
packed.ShouldContain("ACCTKEYBBB|jwt2.data.sig");
}
// Test 28
[Fact]
public void DirJwtStore_Merge_AddsNewEntries()
{
var dir = MakeTempDir();
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
// Pack format: key|jwt lines separated by newline
var packData = "ACCTKEYMERGE|merged.jwt.value";
store.Merge(packData);
var loaded = store.LoadAcc("ACCTKEYMERGE");
loaded.ShouldBe("merged.jwt.value");
}
// Test 29
[Fact]
public void DirJwtStore_ReadOnly_Prevents_Write()
{
var dir = MakeTempDir();
// Write a file first so the dir is valid
var writeable = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
writeable.SaveAcc("ACCTKEYRO", "original.jwt");
writeable.Dispose();
// Open as immutable
using var readOnly = DirJwtStore.NewImmutableDirJwtStore(dir, shard: false);
readOnly.IsReadOnly().ShouldBeTrue();
Should.Throw<InvalidOperationException>(() => readOnly.SaveAcc("ACCTKEYRO", "new.jwt"));
}
}
// =========================================================================
// MemoryAccountResolver Tests
// =========================================================================
[Collection("AccountTests")]
public sealed class MemoryAccountResolverTests
{
// Test 30
[Fact]
public async Task MemoryAccountResolver_StoreAndFetch_Roundtrip()
{
var resolver = new MemoryAccountResolver();
const string key = "MYACCOUNTKEY";
const string jwt = "header.payload.sig";
await resolver.StoreAsync(key, jwt);
var fetched = await resolver.FetchAsync(key);
fetched.ShouldBe(jwt);
}
// Test 31
[Fact]
public async Task MemoryAccountResolver_Fetch_MissingKey_Throws()
{
var resolver = new MemoryAccountResolver();
await Should.ThrowAsync<InvalidOperationException>(
() => resolver.FetchAsync("DOESNOTEXIST"));
}
// Test 32
[Fact]
public void MemoryAccountResolver_IsReadOnly_ReturnsFalse()
{
var resolver = new MemoryAccountResolver();
resolver.IsReadOnly().ShouldBeFalse();
}
}
// =========================================================================
// UrlAccountResolver Tests
// =========================================================================
[Collection("AccountTests")]
public sealed class UrlAccountResolverTests
{
// Test 33
[Fact]
public void UrlAccountResolver_NormalizesTrailingSlash()
{
// Two constructors: one with slash, one without.
// We verify construction doesn't throw and the resolver is usable.
// (We cannot inspect _url directly since it's private, but we can
// infer correctness via IsReadOnly and lack of constructor exception.)
var resolverNoSlash = new UrlAccountResolver("http://localhost:9090");
var resolverWithSlash = new UrlAccountResolver("http://localhost:9090/");
// Both should construct without error and have the same observable behaviour.
resolverNoSlash.IsReadOnly().ShouldBeTrue();
resolverWithSlash.IsReadOnly().ShouldBeTrue();
}
// Test 34
[Fact]
public void UrlAccountResolver_IsReadOnly_ReturnsTrue()
{
var resolver = new UrlAccountResolver("http://localhost:9090");
resolver.IsReadOnly().ShouldBeTrue();
}
}

View File

@@ -0,0 +1,120 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Auth;
using Shouldly;
using Xunit;
public class AuthHandlerExtendedTests
{
[Fact]
public void ValidateProxies_ProxyRequiredWithoutProtocol_ReturnsError()
{
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
var err = AuthHandler.ValidateProxies(opts);
err.ShouldNotBeNull();
err!.Message.ShouldContain("proxy_required");
}
[Fact]
public void ValidateProxies_ProxyRequiredWithProtocol_ReturnsNull()
{
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
var err = AuthHandler.ValidateProxies(opts);
err.ShouldBeNull();
}
[Fact]
public void GetAuthErrClosedState_ProxyNotTrusted_ReturnsProxyNotTrusted()
{
var err = new AuthProxyNotTrustedException();
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.ProxyNotTrusted);
}
[Fact]
public void GetAuthErrClosedState_ProxyRequired_ReturnsProxyRequired()
{
var err = new AuthProxyRequiredException();
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.ProxyRequired);
}
[Fact]
public void GetAuthErrClosedState_OtherError_ReturnsAuthenticationViolation()
{
var err = new InvalidOperationException("bad credentials");
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthenticationViolation);
}
[Fact]
public void GetAuthErrClosedState_NullError_ReturnsAuthenticationViolation()
{
AuthHandler.GetAuthErrClosedState(null).ShouldBe(ClosedState.AuthenticationViolation);
}
[Fact]
public void CheckClientTlsCertSubject_NullCert_ReturnsFalse()
{
AuthHandler.CheckClientTlsCertSubject(null, _ => true).ShouldBeFalse();
}
[Fact]
public void ProcessUserPermissionsTemplate_ExpandsAccountVariable()
{
var lim = new Permissions
{
Publish = new SubjectPermission { Allow = new List<string> { "{{account}}.events" } },
};
var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "myaccount", null);
err.ShouldBeNull();
result.Publish!.Allow![0].ShouldBe("myaccount.events");
}
[Fact]
public void ProcessUserPermissionsTemplate_ExpandsTagVariable()
{
var lim = new Permissions
{
Subscribe = new SubjectPermission { Allow = new List<string> { "{{tag.region}}.alerts" } },
};
var tags = new Dictionary<string, string> { ["region"] = "us-east" };
var (result, err) = AuthHandler.ProcessUserPermissionsTemplate(lim, "acc", tags);
err.ShouldBeNull();
result.Subscribe!.Allow![0].ShouldBe("us-east.alerts");
}
}
public class JwtProcessorOperatorTests
{
[Fact]
public void ReadOperatorJwtInternal_EmptyString_ReturnsError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal(string.Empty);
claims.ShouldBeNull();
err.ShouldNotBeNull();
}
[Fact]
public void ReadOperatorJwtInternal_InvalidPrefix_ReturnsFormatError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal("NOTAJWT.payload.sig");
claims.ShouldBeNull();
err.ShouldBeOfType<FormatException>();
}
[Fact]
public void ReadOperatorJwt_FileNotFound_ReturnsError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwt("/nonexistent/operator.jwt");
claims.ShouldBeNull();
err.ShouldBeOfType<IOException>();
}
[Fact]
public void ValidateTrustedOperators_EmptyList_ReturnsNull()
{
var opts = new ServerOptions();
JwtProcessor.ValidateTrustedOperators(opts).ShouldBeNull();
}
}

View File

@@ -40,7 +40,7 @@ public class JwtProcessorTests
public void WipeSlice_FillsWithX()
{
var buf = new byte[] { 0x01, 0x02, 0x03 };
JwtProcessor.WipeSlice(buf);
AuthHandler.WipeSlice(buf);
buf.ShouldAllBe(b => b == (byte)'x');
}
@@ -48,7 +48,7 @@ public class JwtProcessorTests
public void WipeSlice_EmptyBuffer_NoOp()
{
var buf = Array.Empty<byte>();
JwtProcessor.WipeSlice(buf);
AuthHandler.WipeSlice(buf);
}
// =========================================================================

View File

@@ -0,0 +1,149 @@
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
namespace ZB.MOM.NatsNet.Server.Tests.Config;
using System.Security.Authentication;
using ZB.MOM.NatsNet.Server.Config;
using Shouldly;
using Xunit;
public class ServerOptionsConfigurationTests
{
[Fact]
public void ProcessConfigString_MinimalJson_SetsPort()
{
var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}""");
opts.Port.ShouldBe(4222);
}
[Fact]
public void ProcessConfigString_EmptyJson_AppliesDefaults()
{
var opts = ServerOptionsConfiguration.ProcessConfigString("{}");
opts.Port.ShouldBe(ServerConstants.DefaultPort);
opts.Host.ShouldBe(ServerConstants.DefaultHost);
opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload);
}
[Fact]
public void ProcessConfigString_AllBasicFields_Roundtrip()
{
var json = """
{
"port": 5222,
"host": "127.0.0.1",
"server_name": "test-server",
"debug": true,
"trace": true,
"max_connections": 100,
"auth_timeout": 2.0
}
""";
var opts = ServerOptionsConfiguration.ProcessConfigString(json);
opts.Port.ShouldBe(5222);
opts.Host.ShouldBe("127.0.0.1");
opts.ServerName.ShouldBe("test-server");
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeTrue();
opts.MaxConn.ShouldBe(100);
opts.AuthTimeout.ShouldBe(2.0);
}
[Fact]
public void ProcessConfigFile_FileNotFound_Throws()
{
Should.Throw<FileNotFoundException>(() =>
ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json"));
}
[Fact]
public void ProcessConfigFile_ValidFile_ReturnsOptions()
{
var tmpFile = Path.GetTempFileName();
File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}""");
try
{
var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile);
opts.Port.ShouldBe(9090);
opts.ServerName.ShouldBe("from-file");
}
finally { File.Delete(tmpFile); }
}
}
public class NatsDurationJsonConverterTests
{
[Theory]
[InlineData("2s", 0, 0, 2, 0)]
[InlineData("100ms", 0, 0, 0, 100)]
[InlineData("1h30m", 1, 30, 0, 0)]
public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan(
string input, int hours, int minutes, int seconds, int ms)
{
var expected = new TimeSpan(0, hours, minutes, seconds, ms);
NatsDurationJsonConverter.Parse(input).ShouldBe(expected);
}
[Fact]
public void Parse_FiveMinutesTenSeconds_ReturnsCorrectSpan()
{
var result = NatsDurationJsonConverter.Parse("5m10s");
result.ShouldBe(TimeSpan.FromSeconds(310));
}
[Fact]
public void Parse_InvalidString_ThrowsFormatException()
{
Should.Throw<FormatException>(() => NatsDurationJsonConverter.Parse("notaduration"));
}
}
public class StorageSizeJsonConverterTests
{
[Theory]
[InlineData("1GB", 1L * 1024 * 1024 * 1024)]
[InlineData("512MB", 512L * 1024 * 1024)]
[InlineData("1KB", 1024L)]
[InlineData("1024", 1024L)]
public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes)
{
StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes);
}
}
public class NatsUrlJsonConverterTests
{
[Theory]
[InlineData("nats://localhost:4222", "nats://localhost:4222")]
[InlineData("localhost:4222", "nats://localhost:4222")]
[InlineData("localhost", "nats://localhost")]
public void Normalise_ValidUrls_NormalisesCorrectly(string input, string expected)
{
NatsUrlJsonConverter.Normalise(input).ShouldBe(expected);
}
[Fact]
public void Normalise_EmptyString_ReturnsEmpty()
{
NatsUrlJsonConverter.Normalise(string.Empty).ShouldBe(string.Empty);
}
}
public class TlsVersionJsonConverterTests
{
[Theory]
[InlineData("1.2", SslProtocols.Tls12)]
[InlineData("TLS12", SslProtocols.Tls12)]
[InlineData("1.3", SslProtocols.Tls13)]
[InlineData("TLS13", SslProtocols.Tls13)]
public void Parse_ValidVersionStrings_ReturnsCorrectProtocol(string input, SslProtocols expected)
{
TlsVersionJsonConverter.Parse(input).ShouldBe(expected);
}
[Fact]
public void Parse_InvalidVersion_ThrowsFormatException()
{
Should.Throw<FormatException>(() => TlsVersionJsonConverter.Parse("2.0"));
}
}

View File

@@ -0,0 +1,468 @@
// Copyright 2012-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/server_test.go in the NATS server Go source.
// Session 0910: standalone unit tests for NatsServer helpers.
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Shouldly;
using Xunit;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server.Tests;
/// <summary>
/// Standalone unit tests for <see cref="NatsServer"/> helpers.
/// Tests that require a running server (listener, TLS, cluster) are marked n/a
/// and will be ported in sessions 1023.
/// </summary>
public sealed class ServerTests
{
// =========================================================================
// TestSemanticVersion — Test ID 2866
// Validates that ServerConstants.Version matches semver format.
// Mirrors Go TestSemanticVersion in server/server_test.go.
// =========================================================================
[Fact]
public void Version_IsValidSemVer()
{
// SemVer regex: major.minor.patch with optional pre-release / build meta.
var semVerRe = new Regex(@"^\d+\.\d+\.\d+(-\S+)?(\+\S+)?$", RegexOptions.Compiled);
semVerRe.IsMatch(ServerConstants.Version).ShouldBeTrue(
$"Version ({ServerConstants.Version}) is not a valid SemVer string");
}
// =========================================================================
// TestProcessCommandLineArgs — Test ID 2882
// Tests the ProcessCommandLineArgs helper.
// The Go version uses flag.FlagSet; our C# port takes string[].
// Mirrors Go TestProcessCommandLineArgs in server/server_test.go.
// =========================================================================
[Fact]
public void ProcessCommandLineArgs_NoArgs_ReturnsFalse()
{
var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([]);
err.ShouldBeNull();
showVersion.ShouldBeFalse();
showHelp.ShouldBeFalse();
}
[Theory]
[InlineData("version", true, false)]
[InlineData("VERSION", true, false)]
[InlineData("help", false, true)]
[InlineData("HELP", false, true)]
public void ProcessCommandLineArgs_KnownSubcommand_ReturnsCorrectFlags(
string arg, bool wantVersion, bool wantHelp)
{
var (showVersion, showHelp, err) = NatsServer.ProcessCommandLineArgs([arg]);
err.ShouldBeNull();
showVersion.ShouldBe(wantVersion);
showHelp.ShouldBe(wantHelp);
}
[Fact]
public void ProcessCommandLineArgs_UnknownSubcommand_ReturnsError()
{
var (_, _, err) = NatsServer.ProcessCommandLineArgs(["foo"]);
err.ShouldNotBeNull();
}
// =========================================================================
// CompressionMode helpers — standalone tests for features 29762982
// =========================================================================
[Theory]
[InlineData("off", CompressionMode.Off)]
[InlineData("false", CompressionMode.Off)]
[InlineData("accept", CompressionMode.Accept)]
[InlineData("s2_fast", CompressionMode.S2Fast)]
[InlineData("fast", CompressionMode.S2Fast)]
[InlineData("better", CompressionMode.S2Better)]
[InlineData("best", CompressionMode.S2Best)]
public void ValidateAndNormalizeCompressionOption_KnownModes_NormalizesCorrectly(
string input, string expected)
{
var co = new CompressionOpts { Mode = input };
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast);
co.Mode.ShouldBe(expected);
}
[Fact]
public void ValidateAndNormalizeCompressionOption_OnAlias_MapsToChosenMode()
{
var co = new CompressionOpts { Mode = "on" };
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Better);
co.Mode.ShouldBe(CompressionMode.S2Better);
}
[Fact]
public void ValidateAndNormalizeCompressionOption_S2Auto_UsesDefaults_WhenNoThresholds()
{
var co = new CompressionOpts { Mode = "s2_auto" };
NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast);
co.Mode.ShouldBe(CompressionMode.S2Auto);
co.RttThresholds.ShouldBe(NatsServer.DefaultCompressionS2AutoRttThresholds.ToList());
}
[Fact]
public void ValidateAndNormalizeCompressionOption_UnsupportedMode_Throws()
{
var co = new CompressionOpts { Mode = "bogus" };
Should.Throw<InvalidOperationException>(
() => NatsServer.ValidateAndNormalizeCompressionOption(co, CompressionMode.S2Fast));
}
[Theory]
[InlineData(5, CompressionMode.S2Uncompressed)] // <= 10 ms threshold
[InlineData(25, CompressionMode.S2Fast)] // 10 < rtt <= 50 ms
[InlineData(75, CompressionMode.S2Better)] // 50 < rtt <= 100 ms
[InlineData(150, CompressionMode.S2Best)] // > 100 ms
public void SelectS2AutoModeBasedOnRtt_DefaultThresholds_CorrectMode(int rttMs, string expected)
{
var result = NatsServer.SelectS2AutoModeBasedOnRtt(
TimeSpan.FromMilliseconds(rttMs),
NatsServer.DefaultCompressionS2AutoRttThresholds);
result.ShouldBe(expected);
}
[Theory]
[InlineData(CompressionMode.Off, CompressionMode.S2Fast, CompressionMode.Off)]
[InlineData(CompressionMode.Accept, CompressionMode.Accept, CompressionMode.Off)]
[InlineData(CompressionMode.S2Fast, CompressionMode.Accept, CompressionMode.S2Fast)]
[InlineData(CompressionMode.Accept, CompressionMode.S2Fast, CompressionMode.S2Fast)]
[InlineData(CompressionMode.Accept, CompressionMode.S2Auto, CompressionMode.S2Fast)]
public void SelectCompressionMode_TableDriven(string local, string remote, string expected)
{
NatsServer.SelectCompressionMode(local, remote).ShouldBe(expected);
}
[Fact]
public void SelectCompressionMode_RemoteNotSupported_ReturnsNotSupported()
{
NatsServer.SelectCompressionMode(CompressionMode.S2Fast, CompressionMode.NotSupported)
.ShouldBe(CompressionMode.NotSupported);
}
[Fact]
public void CompressOptsEqual_SameMode_ReturnsTrue()
{
var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast };
var c2 = new CompressionOpts { Mode = CompressionMode.S2Fast };
NatsServer.CompressOptsEqual(c1, c2).ShouldBeTrue();
}
[Fact]
public void CompressOptsEqual_DifferentModes_ReturnsFalse()
{
var c1 = new CompressionOpts { Mode = CompressionMode.S2Fast };
var c2 = new CompressionOpts { Mode = CompressionMode.S2Best };
NatsServer.CompressOptsEqual(c1, c2).ShouldBeFalse();
}
// =========================================================================
// Validation helpers
// =========================================================================
[Fact]
public void ValidateCluster_ClusterNameWithSpaces_ReturnsError()
{
var opts = new ServerOptions();
opts.Cluster.Name = "bad name";
var err = NatsServer.ValidateCluster(opts);
err.ShouldNotBeNull();
err.ShouldBeSameAs(ServerErrors.ErrClusterNameHasSpaces);
}
[Fact]
public void ValidatePinnedCerts_ValidSha256_ReturnsNull()
{
var pinned = new PinnedCertSet(
[new string('a', 64)]); // 64 hex chars
var err = NatsServer.ValidatePinnedCerts(pinned);
err.ShouldBeNull();
}
[Fact]
public void ValidatePinnedCerts_InvalidSha256_ReturnsError()
{
var pinned = new PinnedCertSet(["not_a_sha256"]);
var err = NatsServer.ValidatePinnedCerts(pinned);
err.ShouldNotBeNull();
}
// =========================================================================
// GetServerProto
// =========================================================================
[Fact]
public void GetServerProto_DefaultOpts_ReturnsMsgTraceProto()
{
var opts = new ServerOptions();
// SetBaselineOptions so OverrideProto gets default 0.
opts.SetBaselineOptions();
var (s, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
s.ShouldNotBeNull();
s!.GetServerProto().ShouldBe(ServerProtocol.MsgTraceProto);
}
// =========================================================================
// Account helpers
// =========================================================================
[Fact]
public void ComputeRoutePoolIdx_PoolSizeOne_AlwaysReturnsZero()
{
NatsServer.ComputeRoutePoolIdx(1, "any-account").ShouldBe(0);
NatsServer.ComputeRoutePoolIdx(0, "any-account").ShouldBe(0);
}
[Fact]
public void ComputeRoutePoolIdx_PoolSizeN_ReturnsIndexInRange()
{
const int poolSize = 5;
var idx = NatsServer.ComputeRoutePoolIdx(poolSize, "my-account");
idx.ShouldBeInRange(0, poolSize - 1);
}
[Fact]
public void NeedsCompression_Empty_ReturnsFalse()
=> NatsServer.NeedsCompression(string.Empty).ShouldBeFalse();
[Fact]
public void NeedsCompression_Off_ReturnsFalse()
=> NatsServer.NeedsCompression(CompressionMode.Off).ShouldBeFalse();
[Fact]
public void NeedsCompression_S2Fast_ReturnsTrue()
=> NatsServer.NeedsCompression(CompressionMode.S2Fast).ShouldBeTrue();
}
// =============================================================================
// Session 10: Listeners, INFO JSON, TLS helpers, GetRandomIP
// =============================================================================
/// <summary>
/// Tests for session 10 features: GenerateInfoJson, TlsVersion helpers,
/// CopyInfo, and GetRandomIP.
/// </summary>
public sealed class ServerListenersTests
{
// =========================================================================
// GenerateInfoJson (feature 3069) — Test ID 2906
// Mirrors Go TestServerJsonMarshalNestedStructsPanic (guards against
// marshaller panics with nested/nullable structs).
// =========================================================================
[Fact]
public void GenerateInfoJson_MinimalInfo_ProducesInfoFrame()
{
var info = new ServerInfo { Id = "TEST", Version = "1.0.0", Host = "0.0.0.0", Port = 4222 };
var bytes = NatsServer.GenerateInfoJson(info);
var text = Encoding.UTF8.GetString(bytes);
text.ShouldStartWith("INFO {");
text.ShouldEndWith("}\r\n");
}
[Fact]
public void GenerateInfoJson_WithConnectUrls_IncludesUrls()
{
var info = new ServerInfo
{
Id = "TEST",
Version = "1.0.0",
ClientConnectUrls = ["nats://127.0.0.1:4222", "nats://127.0.0.1:4223"],
};
var text = Encoding.UTF8.GetString(NatsServer.GenerateInfoJson(info));
text.ShouldContain("connect_urls");
text.ShouldContain("4222");
}
[Fact]
public void GenerateInfoJson_WithSubjectPermissions_DoesNotThrow()
{
// Mirrors Go TestServerJsonMarshalNestedStructsPanic — guards against
// JSON marshaller failures with nested nullable structs.
var info = new ServerInfo
{
Id = "TEST",
Version = "1.0.0",
Import = new SubjectPermission { Allow = ["foo.>"], Deny = ["bar"] },
Export = new SubjectPermission { Allow = ["pub.>"] },
};
var bytes = NatsServer.GenerateInfoJson(info);
bytes.ShouldNotBeEmpty();
// Strip the "INFO " prefix (5 bytes) and the trailing "\r\n" (2 bytes) to get pure JSON.
var json = Encoding.UTF8.GetString(bytes, 5, bytes.Length - 7);
var doc = JsonDocument.Parse(json);
doc.RootElement.GetProperty("import").ValueKind.ShouldBe(JsonValueKind.Object);
}
// =========================================================================
// TlsVersion helpers (features 30793080)
// =========================================================================
[Theory]
[InlineData(0x0301u, "1.0")]
[InlineData(0x0302u, "1.1")]
[InlineData(0x0303u, "1.2")]
[InlineData(0x0304u, "1.3")]
public void TlsVersion_KnownCodes_ReturnsVersionString(uint ver, string expected)
=> NatsServer.TlsVersion((ushort)ver).ShouldBe(expected);
[Fact]
public void TlsVersion_UnknownCode_ReturnsUnknownLabel()
=> NatsServer.TlsVersion(0xFFFF).ShouldStartWith("Unknown");
[Theory]
[InlineData("1.0", (ushort)0x0301)]
[InlineData("1.1", (ushort)0x0302)]
[InlineData("1.2", (ushort)0x0303)]
[InlineData("1.3", (ushort)0x0304)]
public void TlsVersionFromString_KnownStrings_ReturnsCode(string input, ushort expected)
{
var (ver, err) = NatsServer.TlsVersionFromString(input);
err.ShouldBeNull();
ver.ShouldBe(expected);
}
[Fact]
public void TlsVersionFromString_UnknownString_ReturnsError()
{
var (_, err) = NatsServer.TlsVersionFromString("9.9");
err.ShouldNotBeNull();
}
// =========================================================================
// CopyInfo (feature 3069)
// =========================================================================
[Fact]
public void CopyInfo_DeepCopiesSlices()
{
var (s, err) = NatsServer.NewServer(new ServerOptions());
err.ShouldBeNull();
s.ShouldNotBeNull();
s!._info.ClientConnectUrls = ["nats://127.0.0.1:4222"];
var copy = s.CopyInfo();
copy.ClientConnectUrls.ShouldNotBeNull();
copy.ClientConnectUrls!.ShouldBe(["nats://127.0.0.1:4222"]);
// Mutating original slice shouldn't affect the copy.
s._info.ClientConnectUrls = ["nats://10.0.0.1:4222"];
copy.ClientConnectUrls[0].ShouldBe("nats://127.0.0.1:4222");
}
// =========================================================================
// GetRandomIP (feature 3141) — Test ID 2895
// Mirrors Go TestGetRandomIP.
// =========================================================================
private static NatsServer MakeServer()
{
var (s, err) = NatsServer.NewServer(new ServerOptions());
err.ShouldBeNull();
return s!;
}
[Fact]
public async Task GetRandomIP_NoPort_ReturnsFormatError()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
var (_, err) = await s.GetRandomIP(resolver, "noport");
err.ShouldNotBeNull();
err!.Message.ShouldContain("port");
}
[Fact]
public async Task GetRandomIP_ResolverThrows_PropagatesError()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("on purpose"));
var (_, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldNotBeNull();
err!.Message.ShouldContain("on purpose");
}
[Fact]
public async Task GetRandomIP_EmptyIps_ReturnsFallbackUrl()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(Array.Empty<string>()));
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
addr.ShouldBe("localhost:4222");
}
[Fact]
public async Task GetRandomIP_SingleIp_ReturnsMappedAddress()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4" }));
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
addr.ShouldBe("1.2.3.4:4222");
}
[Fact]
public async Task GetRandomIP_MultipleIps_AllSelectedWithinRange()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
var dist = new int[3];
for (int i = 0; i < 100; i++)
{
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222");
err.ShouldBeNull();
dist[int.Parse(addr[..1]) - 1]++;
}
// Each IP should appear at least once and no single IP should dominate.
foreach (var d in dist)
d.ShouldBeGreaterThan(0);
}
[Fact]
public async Task GetRandomIP_ExcludedIp_NeverReturned()
{
var s = MakeServer();
var resolver = Substitute.For<INetResolver>();
resolver.LookupHostAsync("localhost", Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new[] { "1.2.3.4", "2.2.3.4", "3.2.3.4" }));
var excluded = new HashSet<string> { "1.2.3.4:4222" };
for (int i = 0; i < 100; i++)
{
var (addr, err) = await s.GetRandomIP(resolver, "localhost:4222", excluded);
err.ShouldBeNull();
addr.ShouldNotBe("1.2.3.4:4222");
}
}
}

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 18:50:39 UTC
Generated: 2026-02-26 22:49:14 UTC
## Modules (12 total)
@@ -13,18 +13,16 @@ Generated: 2026-02-26 18:50:39 UTC
| Status | Count |
|--------|-------|
| complete | 667 |
| n_a | 82 |
| not_started | 2831 |
| stub | 93 |
| complete | 3596 |
| n_a | 77 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 274 |
| n_a | 163 |
| not_started | 2596 |
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
@@ -36,4 +34,4 @@ Generated: 2026-02-26 18:50:39 UTC
## Overall Progress
**1197/6942 items complete (17.2%)**
**4184/6942 items complete (60.3%)**

39
reports/report_06779a1.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 20:37:09 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 1075 |
| n_a | 82 |
| not_started | 2423 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**1668/6942 items complete (24.0%)**

39
reports/report_0df93c2.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 20:08:24 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 841 |
| n_a | 82 |
| not_started | 2657 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 278 |
| n_a | 181 |
| not_started | 2574 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**1393/6942 items complete (20.1%)**

39
reports/report_11b387e.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 19:18:19 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 744 |
| n_a | 82 |
| not_started | 2754 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 276 |
| n_a | 181 |
| not_started | 2576 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**1294/6942 items complete (18.6%)**

39
reports/report_12a14ec.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 20:46:14 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 1382 |
| n_a | 82 |
| not_started | 2116 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**1975/6942 items complete (28.5%)**

39
reports/report_3cffa5b.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:14:41 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 2422 |
| n_a | 77 |
| not_started | 1081 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**3010/6942 items complete (43.4%)**

39
reports/report_5a2c8a3.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:06:51 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 2048 |
| n_a | 77 |
| not_started | 1455 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**2636/6942 items complete (38.0%)**

38
reports/report_63715f2.md Normal file
View File

@@ -0,0 +1,38 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:59:34 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3503 |
| n_a | 77 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4091/6942 items complete (58.9%)**

39
reports/report_77403e3.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:02:04 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 1736 |
| n_a | 77 |
| not_started | 1767 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**2324/6942 items complete (33.5%)**

38
reports/report_8253f97.md Normal file
View File

@@ -0,0 +1,38 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 22:18:29 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3570 |
| n_a | 77 |
| stub | 26 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4158/6942 items complete (59.9%)**

39
reports/report_84d450b.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:23:40 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 2851 |
| n_a | 77 |
| not_started | 652 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**3439/6942 items complete (49.5%)**

37
reports/report_8c380e7.md Normal file
View File

@@ -0,0 +1,37 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 22:49:14 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3596 |
| n_a | 77 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4184/6942 items complete (60.3%)**

38
reports/report_9c1ffc0.md Normal file
View File

@@ -0,0 +1,38 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 22:29:06 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3570 |
| n_a | 77 |
| stub | 26 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4158/6942 items complete (59.9%)**

38
reports/report_a58e8e2.md Normal file
View File

@@ -0,0 +1,38 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:54:06 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3503 |
| n_a | 77 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4091/6942 items complete (58.9%)**

37
reports/report_aa1fb5a.md Normal file
View File

@@ -0,0 +1,37 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 22:38:47 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3596 |
| n_a | 77 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4184/6942 items complete (60.3%)**

39
reports/report_ce45dff.md Normal file
View File

@@ -0,0 +1,39 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 20:50:51 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 1601 |
| n_a | 82 |
| not_started | 1897 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**2194/6942 items complete (31.6%)**

38
reports/report_e6bc76b.md Normal file
View File

@@ -0,0 +1,38 @@
# NATS .NET Porting Status Report
Generated: 2026-02-26 21:31:42 UTC
## Modules (12 total)
| Status | Count |
|--------|-------|
| complete | 11 |
| not_started | 1 |
## Features (3673 total)
| Status | Count |
|--------|-------|
| complete | 3503 |
| n_a | 77 |
| stub | 93 |
## Unit Tests (3257 total)
| Status | Count |
|--------|-------|
| complete | 319 |
| n_a | 181 |
| not_started | 2533 |
| stub | 224 |
## Library Mappings (36 total)
| Status | Count |
|--------|-------|
| mapped | 36 |
## Overall Progress
**4091/6942 items complete (58.9%)**