diff --git a/docs/plans/2026-02-22-monitoring-tls-plan.md b/docs/plans/2026-02-22-monitoring-tls-plan.md
new file mode 100644
index 0000000..460dbf9
--- /dev/null
+++ b/docs/plans/2026-02-22-monitoring-tls-plan.md
@@ -0,0 +1,2661 @@
+# Monitoring HTTP & TLS Support Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
+
+**Goal:** Port monitoring HTTP endpoints (/varz, /connz) and full TLS support (4 modes, cert pinning, rate limiting) from Go NATS server.
+
+**Architecture:** Kestrel Minimal APIs embedded in NatsServer for monitoring. SslStream wrapping with PeekableStream for TLS negotiation. ServerStats atomic counters for efficient /varz. TlsConnectionWrapper handles 4 TLS modes before NatsClient construction.
+
+**Tech Stack:** ASP.NET Core Minimal APIs (FrameworkReference), System.Net.Security (SslStream), System.Security.Cryptography.X509Certificates, xUnit + Shouldly
+
+**Design Doc:** `docs/plans/2026-02-22-monitoring-tls-design.md`
+
+---
+
+### Task 0: Project setup — csproj and configuration options
+
+**Files:**
+- Modify: `src/NATS.Server/NATS.Server.csproj`
+- Modify: `src/NATS.Server/NatsOptions.cs`
+- Modify: `src/NATS.Server/Protocol/NatsProtocol.cs:31-64` (ServerInfo)
+
+**Step 1: Add FrameworkReference to NATS.Server.csproj**
+
+```xml
+
+
+
+
+
+
+```
+
+**Step 2: Add monitoring and TLS config to NatsOptions**
+
+Add these properties to `NatsOptions.cs`:
+
+```csharp
+using System.Security.Authentication;
+
+namespace NATS.Server;
+
+public sealed class NatsOptions
+{
+ // Existing
+ public string Host { get; set; } = "0.0.0.0";
+ public int Port { get; set; } = 4222;
+ public string? ServerName { get; set; }
+ public int MaxPayload { get; set; } = 1024 * 1024;
+ public int MaxControlLine { get; set; } = 4096;
+ public int MaxConnections { get; set; } = 65536;
+ public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
+ public int MaxPingsOut { get; set; } = 2;
+
+ // Monitoring
+ public int MonitorPort { get; set; }
+ public string MonitorHost { get; set; } = "0.0.0.0";
+ public string? MonitorBasePath { get; set; }
+ public int MonitorHttpsPort { get; set; }
+
+ // TLS
+ public string? TlsCert { get; set; }
+ public string? TlsKey { get; set; }
+ public string? TlsCaCert { get; set; }
+ public bool TlsVerify { get; set; }
+ public bool TlsMap { get; set; }
+ public double TlsTimeout { get; set; } = 2.0;
+ public bool TlsHandshakeFirst { get; set; }
+ public TimeSpan TlsHandshakeFirstFallback { get; set; } = TimeSpan.FromMilliseconds(50);
+ public bool AllowNonTls { get; set; }
+ public long TlsRateLimit { get; set; }
+ public HashSet? TlsPinnedCerts { get; set; }
+ public SslProtocols TlsMinVersion { get; set; } = SslProtocols.Tls12;
+
+ public bool HasTls => TlsCert != null && TlsKey != null;
+}
+```
+
+**Step 3: Add TLS fields to ServerInfo**
+
+Add to `ServerInfo` class in `Protocol/NatsProtocol.cs`:
+
+```csharp
+[JsonPropertyName("tls_required")]
+[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+public bool TlsRequired { get; set; }
+
+[JsonPropertyName("tls_verify")]
+[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+public bool TlsVerify { get; set; }
+
+[JsonPropertyName("tls_available")]
+[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+public bool TlsAvailable { get; set; }
+```
+
+**Step 4: Build and verify no regressions**
+
+Run: `dotnet build`
+Expected: Success
+
+Run: `dotnet test`
+Expected: All existing tests pass
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/NATS.Server.csproj src/NATS.Server/NatsOptions.cs src/NATS.Server/Protocol/NatsProtocol.cs
+git commit -m "feat: add project setup for monitoring and TLS — csproj, config options, ServerInfo TLS fields"
+```
+
+---
+
+### Task 1: ServerStats and NatsClient metadata
+
+**Files:**
+- Create: `src/NATS.Server/ServerStats.cs`
+- Modify: `src/NATS.Server/NatsServer.cs:10-40` (add stats field, StartTime)
+- Modify: `src/NATS.Server/NatsClient.cs:24-58` (add metadata, accept ServerStats)
+- Create: `tests/NATS.Server.Tests/ServerStatsTests.cs`
+
+**Step 1: Write the failing test**
+
+Create `tests/NATS.Server.Tests/ServerStatsTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Server;
+
+namespace NATS.Server.Tests;
+
+public class ServerStatsTests : IAsyncLifetime
+{
+ private readonly NatsServer _server;
+ private readonly int _port;
+ private readonly CancellationTokenSource _cts = new();
+
+ public ServerStatsTests()
+ {
+ _port = GetFreePort();
+ _server = new NatsServer(new NatsOptions { Port = _port }, NullLoggerFactory.Instance);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _ = _server.StartAsync(_cts.Token);
+ await _server.WaitForReadyAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ _cts.Cancel();
+ _server.Dispose();
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public void Server_has_start_time()
+ {
+ _server.StartTime.ShouldNotBe(default);
+ _server.StartTime.ShouldBeLessThanOrEqualTo(DateTime.UtcNow);
+ }
+
+ [Fact]
+ public async Task Server_tracks_total_connections()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
+
+ // Wait for server to process the connection
+ await Task.Delay(100);
+
+ _server.Stats.TotalConnections.ShouldBeGreaterThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public async Task Server_stats_track_messages()
+ {
+ using var pub = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await pub.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
+ using var pubStream = new NetworkStream(pub);
+
+ // Read INFO
+ var buf = new byte[4096];
+ await pubStream.ReadAsync(buf);
+
+ // Send CONNECT and SUB
+ await pubStream.WriteAsync("CONNECT {}\r\nSUB test 1\r\n"u8.ToArray());
+ await Task.Delay(100);
+
+ // PUB a message
+ await pubStream.WriteAsync("PUB test 5\r\nhello\r\n"u8.ToArray());
+ await Task.Delay(100);
+
+ _server.Stats.InMsgs.ShouldBeGreaterThanOrEqualTo(1);
+ _server.Stats.InBytes.ShouldBeGreaterThanOrEqualTo(5);
+ }
+
+ [Fact]
+ public async Task Client_has_metadata()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
+ await Task.Delay(100);
+
+ var client = _server.GetClients().First();
+ client.RemoteIp.ShouldNotBeNullOrEmpty();
+ client.RemotePort.ShouldBeGreaterThan(0);
+ client.StartTime.ShouldNotBe(default);
+ }
+
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~ServerStatsTests" -v normal`
+Expected: FAIL — `ServerStats`, `StartTime`, `GetClients`, `RemoteIp`, `RemotePort` don't exist
+
+**Step 3: Implement ServerStats and wire into NatsServer/NatsClient**
+
+Create `src/NATS.Server/ServerStats.cs`:
+
+```csharp
+using System.Collections.Concurrent;
+
+namespace NATS.Server;
+
+public sealed class ServerStats
+{
+ public long InMsgs;
+ public long OutMsgs;
+ public long InBytes;
+ public long OutBytes;
+ public long TotalConnections;
+ public long SlowConsumers;
+ public long StaleConnections;
+ public long Stalls;
+ public long SlowConsumerClients;
+ public long SlowConsumerRoutes;
+ public long SlowConsumerLeafs;
+ public long SlowConsumerGateways;
+ public readonly ConcurrentDictionary HttpReqStats = new();
+}
+```
+
+Modify `NatsServer.cs` — add fields and expose them:
+
+```csharp
+// New fields (after existing fields):
+private readonly ServerStats _stats = new();
+private DateTime _startTime;
+
+// New public properties:
+public ServerStats Stats => _stats;
+public DateTime StartTime => _startTime;
+
+// New method to expose clients for monitoring:
+public IEnumerable GetClients() => _clients.Values;
+```
+
+In `StartAsync`, set `_startTime = DateTime.UtcNow;` right before the listen call.
+
+In the accept loop, add: `Interlocked.Increment(ref _stats.TotalConnections);`
+
+Pass `_stats` to the NatsClient constructor.
+
+Modify `NatsClient.cs` — add metadata fields and ServerStats:
+
+```csharp
+// New fields:
+private readonly ServerStats _serverStats;
+public DateTime StartTime { get; }
+public DateTime LastActivity;
+public string? RemoteIp { get; }
+public int RemotePort { get; }
+```
+
+Updated constructor:
+
+```csharp
+public NatsClient(ulong id, Socket socket, NatsOptions options, ServerInfo serverInfo,
+ ILogger logger, ServerStats serverStats)
+{
+ Id = id;
+ _socket = socket;
+ _stream = new NetworkStream(socket, ownsSocket: false);
+ _options = options;
+ _serverInfo = serverInfo;
+ _logger = logger;
+ _serverStats = serverStats;
+ _parser = new NatsParser(options.MaxPayload);
+ StartTime = DateTime.UtcNow;
+ LastActivity = StartTime;
+
+ if (socket.RemoteEndPoint is IPEndPoint ep)
+ {
+ RemoteIp = ep.Address.ToString();
+ RemotePort = ep.Port;
+ }
+}
+```
+
+In `ProcessPub`, add server stats increments:
+
+```csharp
+Interlocked.Increment(ref _serverStats.InMsgs);
+Interlocked.Add(ref _serverStats.InBytes, cmd.Payload.Length);
+```
+
+In `SendMessageAsync`, add server stats increments:
+
+```csharp
+Interlocked.Increment(ref _serverStats.OutMsgs);
+Interlocked.Add(ref _serverStats.OutBytes, payload.Length + headers.Length);
+```
+
+In `DispatchCommandAsync`, update `LastActivity = DateTime.UtcNow;` at the top.
+
+Update `NatsServer.StartAsync` to pass `_stats` when constructing NatsClient:
+
+```csharp
+var client = new NatsClient(clientId, socket, _options, _serverInfo, clientLogger, _stats);
+```
+
+**Step 4: Run all tests**
+
+Run: `dotnet test -v normal`
+Expected: All tests pass (update any existing test call sites that construct NatsClient to pass a `new ServerStats()`)
+
+Note: `ClientTests.cs` constructs NatsClient directly — update that constructor call to pass `new ServerStats()`.
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/ServerStats.cs src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ServerStatsTests.cs tests/NATS.Server.Tests/ClientTests.cs
+git commit -m "feat: add ServerStats counters and NatsClient metadata for monitoring"
+```
+
+---
+
+### Task 2: Refactor NatsClient to accept Stream
+
+This is needed for TLS — the constructor must accept a `Stream` (either `NetworkStream` or `SslStream`) instead of creating its own `NetworkStream` internally.
+
+**Files:**
+- Modify: `src/NATS.Server/NatsClient.cs:24-58`
+- Modify: `src/NATS.Server/NatsServer.cs:58-68` (accept loop)
+- Modify: `tests/NATS.Server.Tests/ClientTests.cs` (constructor calls)
+
+**Step 1: Write the failing test**
+
+No new test file — this is a refactor. We verify existing tests pass after the change.
+
+**Step 2: Refactor NatsClient constructor**
+
+Change constructor to accept `Stream` + `Socket`:
+
+```csharp
+public NatsClient(ulong id, Stream stream, Socket socket, NatsOptions options,
+ ServerInfo serverInfo, ILogger logger, ServerStats serverStats)
+{
+ Id = id;
+ _socket = socket;
+ _stream = stream;
+ _options = options;
+ _serverInfo = serverInfo;
+ _logger = logger;
+ _serverStats = serverStats;
+ _parser = new NatsParser(options.MaxPayload);
+ StartTime = DateTime.UtcNow;
+ LastActivity = StartTime;
+
+ if (socket.RemoteEndPoint is IPEndPoint ep)
+ {
+ RemoteIp = ep.Address.ToString();
+ RemotePort = ep.Port;
+ }
+}
+```
+
+Change `_stream` field type from `NetworkStream` to `Stream`:
+
+```csharp
+private readonly Stream _stream;
+```
+
+Update `NatsServer.StartAsync` accept loop:
+
+```csharp
+var networkStream = new NetworkStream(socket, ownsSocket: false);
+var client = new NatsClient(clientId, networkStream, socket, _options, _serverInfo, clientLogger, _stats);
+```
+
+Update `ClientTests.cs` constructor call to pass `NetworkStream` + `Socket`.
+
+**Step 3: Run all tests**
+
+Run: `dotnet test -v normal`
+Expected: All tests pass — behavior is identical, just constructor signature changed.
+
+**Step 4: Commit**
+
+```bash
+git add src/NATS.Server/NatsClient.cs src/NATS.Server/NatsServer.cs tests/NATS.Server.Tests/ClientTests.cs
+git commit -m "refactor: NatsClient accepts Stream parameter for TLS support"
+```
+
+---
+
+### Task 3: Monitoring JSON models (Varz, Connz, nested stubs)
+
+**Files:**
+- Create: `src/NATS.Server/Monitoring/Varz.cs`
+- Create: `src/NATS.Server/Monitoring/Connz.cs`
+- Create: `tests/NATS.Server.Tests/MonitorModelTests.cs`
+
+**Step 1: Write the failing test**
+
+Create `tests/NATS.Server.Tests/MonitorModelTests.cs`:
+
+```csharp
+using System.Text.Json;
+using NATS.Server.Monitoring;
+
+namespace NATS.Server.Tests;
+
+public class MonitorModelTests
+{
+ [Fact]
+ public void Varz_serializes_with_go_field_names()
+ {
+ var varz = new Varz
+ {
+ Id = "TESTID",
+ Name = "test-server",
+ Version = "0.1.0",
+ Host = "0.0.0.0",
+ Port = 4222,
+ InMsgs = 100,
+ OutMsgs = 200,
+ };
+
+ var json = JsonSerializer.Serialize(varz);
+ json.ShouldContain("\"server_id\":");
+ json.ShouldContain("\"server_name\":");
+ json.ShouldContain("\"in_msgs\":");
+ json.ShouldContain("\"out_msgs\":");
+ json.ShouldNotContain("\"InMsgs\"");
+ }
+
+ [Fact]
+ public void Connz_serializes_with_go_field_names()
+ {
+ var connz = new Connz
+ {
+ Id = "TESTID",
+ Now = DateTime.UtcNow,
+ NumConns = 1,
+ Total = 1,
+ Limit = 1024,
+ Conns =
+ [
+ new ConnInfo
+ {
+ Cid = 1,
+ Ip = "127.0.0.1",
+ Port = 5555,
+ InMsgs = 10,
+ Uptime = "1s",
+ Idle = "0s",
+ Start = DateTime.UtcNow,
+ LastActivity = DateTime.UtcNow,
+ },
+ ],
+ };
+
+ var json = JsonSerializer.Serialize(connz);
+ json.ShouldContain("\"server_id\":");
+ json.ShouldContain("\"num_connections\":");
+ json.ShouldContain("\"in_msgs\":");
+ json.ShouldContain("\"pending_bytes\":");
+ }
+
+ [Fact]
+ public void Varz_includes_nested_config_stubs()
+ {
+ var varz = new Varz
+ {
+ Id = "X", Name = "X", Version = "X", Host = "X",
+ };
+ var json = JsonSerializer.Serialize(varz);
+ json.ShouldContain("\"cluster\":");
+ json.ShouldContain("\"gateway\":");
+ json.ShouldContain("\"leaf\":");
+ json.ShouldContain("\"jetstream\":");
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorModelTests" -v normal`
+Expected: FAIL — namespaces/types don't exist
+
+**Step 3: Implement Varz and Connz models**
+
+Create `src/NATS.Server/Monitoring/Varz.cs`:
+
+```csharp
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Monitoring;
+
+public sealed class Varz
+{
+ [JsonPropertyName("server_id")] public string Id { get; set; } = "";
+ [JsonPropertyName("server_name")] public string Name { get; set; } = "";
+ [JsonPropertyName("version")] public string Version { get; set; } = "";
+ [JsonPropertyName("proto")] public int Proto { get; set; }
+ [JsonPropertyName("go")] public string Go { get; set; } = "";
+ [JsonPropertyName("host")] public string Host { get; set; } = "";
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("ip")] public string? Ip { get; set; }
+ [JsonPropertyName("connect_urls")] public string[]? ClientConnectUrls { get; set; }
+ [JsonPropertyName("ws_connect_urls")] public string[]? WsConnectUrls { get; set; }
+ [JsonPropertyName("http_host")] public string? HttpHost { get; set; }
+ [JsonPropertyName("http_port")] public int HttpPort { get; set; }
+ [JsonPropertyName("http_base_path")] public string? HttpBasePath { get; set; }
+ [JsonPropertyName("https_port")] public int HttpsPort { get; set; }
+ [JsonPropertyName("auth_required")] public bool AuthRequired { get; set; }
+ [JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
+ [JsonPropertyName("tls_verify")] public bool TlsVerify { get; set; }
+ [JsonPropertyName("tls_ocsp_peer_verify")] public bool TlsOcspPeerVerify { get; set; }
+ [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+ [JsonPropertyName("max_connections")] public int MaxConn { get; set; }
+ [JsonPropertyName("max_subscriptions")] public int MaxSubs { get; set; }
+ [JsonPropertyName("max_payload")] public int MaxPayload { get; set; }
+ [JsonPropertyName("max_pending")] public long MaxPending { get; set; }
+ [JsonPropertyName("max_control_line")] public int MaxControlLine { get; set; }
+ [JsonPropertyName("max_pings")] public int MaxPingsOut { get; set; }
+ [JsonPropertyName("ping_interval")] public long PingInterval { get; set; }
+ [JsonPropertyName("write_deadline")] public long WriteDeadline { get; set; }
+ [JsonPropertyName("start")] public DateTime Start { get; set; }
+ [JsonPropertyName("now")] public DateTime Now { get; set; }
+ [JsonPropertyName("uptime")] public string Uptime { get; set; } = "";
+ [JsonPropertyName("mem")] public long Mem { get; set; }
+ [JsonPropertyName("cpu")] public double Cpu { get; set; }
+ [JsonPropertyName("cores")] public int Cores { get; set; }
+ [JsonPropertyName("max_procs")] public int MaxProcs { get; set; }
+ [JsonPropertyName("connections")] public int Connections { get; set; }
+ [JsonPropertyName("total_connections")] public long TotalConnections { get; set; }
+ [JsonPropertyName("routes")] public int Routes { get; set; }
+ [JsonPropertyName("remotes")] public int Remotes { get; set; }
+ [JsonPropertyName("leafnodes")] public int Leafs { get; set; }
+ [JsonPropertyName("in_msgs")] public long InMsgs { get; set; }
+ [JsonPropertyName("out_msgs")] public long OutMsgs { get; set; }
+ [JsonPropertyName("in_bytes")] public long InBytes { get; set; }
+ [JsonPropertyName("out_bytes")] public long OutBytes { get; set; }
+ [JsonPropertyName("slow_consumers")] public long SlowConsumers { get; set; }
+ [JsonPropertyName("slow_consumers_stats")] public SlowConsumersStats? SlowConsumersStats { get; set; }
+ [JsonPropertyName("subscriptions")] public int Subscriptions { get; set; }
+ [JsonPropertyName("config_load_time")] public DateTime ConfigLoadTime { get; set; }
+ [JsonPropertyName("tags")] public string[]? Tags { get; set; }
+ [JsonPropertyName("system_account")] public string? SystemAccount { get; set; }
+ [JsonPropertyName("pinned_account_fail")] public long PinnedAccountFail { get; set; }
+ [JsonPropertyName("tls_cert_not_after")] public DateTime? TlsCertNotAfter { get; set; }
+ [JsonPropertyName("http_req_stats")] public Dictionary? HttpReqStats { get; set; }
+ [JsonPropertyName("cluster")] public ClusterOptsVarz Cluster { get; set; } = new();
+ [JsonPropertyName("gateway")] public GatewayOptsVarz Gateway { get; set; } = new();
+ [JsonPropertyName("leaf")] public LeafNodeOptsVarz LeafNode { get; set; } = new();
+ [JsonPropertyName("mqtt")] public MqttOptsVarz Mqtt { get; set; } = new();
+ [JsonPropertyName("websocket")] public WebsocketOptsVarz Websocket { get; set; } = new();
+ [JsonPropertyName("jetstream")] public JetStreamVarz JetStream { get; set; } = new();
+}
+
+public sealed class SlowConsumersStats
+{
+ [JsonPropertyName("clients")] public long Clients { get; set; }
+ [JsonPropertyName("routes")] public long Routes { get; set; }
+ [JsonPropertyName("gateways")] public long Gateways { get; set; }
+ [JsonPropertyName("leafs")] public long Leafs { get; set; }
+}
+
+public sealed class ClusterOptsVarz
+{
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("addr")] public string? Host { get; set; }
+ [JsonPropertyName("cluster_port")] public int Port { get; set; }
+ [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+ [JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
+ [JsonPropertyName("tls_verify")] public bool TlsVerify { get; set; }
+ [JsonPropertyName("pool_size")] public int PoolSize { get; set; }
+ [JsonPropertyName("urls")] public string[]? Urls { get; set; }
+}
+
+public sealed class GatewayOptsVarz
+{
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("host")] public string? Host { get; set; }
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+ [JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
+ [JsonPropertyName("tls_verify")] public bool TlsVerify { get; set; }
+ [JsonPropertyName("advertise")] public string? Advertise { get; set; }
+ [JsonPropertyName("connect_retries")] public int ConnectRetries { get; set; }
+ [JsonPropertyName("reject_unknown")] public bool RejectUnknown { get; set; }
+}
+
+public sealed class LeafNodeOptsVarz
+{
+ [JsonPropertyName("host")] public string? Host { get; set; }
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("auth_timeout")] public double AuthTimeout { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+ [JsonPropertyName("tls_required")] public bool TlsRequired { get; set; }
+ [JsonPropertyName("tls_verify")] public bool TlsVerify { get; set; }
+ [JsonPropertyName("tls_ocsp_peer_verify")] public bool TlsOcspPeerVerify { get; set; }
+}
+
+public sealed class MqttOptsVarz
+{
+ [JsonPropertyName("host")] public string? Host { get; set; }
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+}
+
+public sealed class WebsocketOptsVarz
+{
+ [JsonPropertyName("host")] public string? Host { get; set; }
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("tls_timeout")] public double TlsTimeout { get; set; }
+}
+
+public sealed class JetStreamVarz
+{
+ [JsonPropertyName("config")] public JetStreamConfig? Config { get; set; }
+ [JsonPropertyName("stats")] public JetStreamStats? Stats { get; set; }
+}
+
+public sealed class JetStreamConfig
+{
+ [JsonPropertyName("max_memory")] public long MaxMemory { get; set; }
+ [JsonPropertyName("max_storage")] public long MaxStorage { get; set; }
+ [JsonPropertyName("store_dir")] public string? StoreDir { get; set; }
+}
+
+public sealed class JetStreamStats
+{
+ [JsonPropertyName("memory")] public long Memory { get; set; }
+ [JsonPropertyName("storage")] public long Storage { get; set; }
+ [JsonPropertyName("accounts")] public int Accounts { get; set; }
+ [JsonPropertyName("ha_assets")] public int HaAssets { get; set; }
+ [JsonPropertyName("api")] public JetStreamApiStats Api { get; set; } = new();
+}
+
+public sealed class JetStreamApiStats
+{
+ [JsonPropertyName("total")] public long Total { get; set; }
+ [JsonPropertyName("errors")] public long Errors { get; set; }
+}
+```
+
+Create `src/NATS.Server/Monitoring/Connz.cs`:
+
+```csharp
+using System.Text.Json.Serialization;
+
+namespace NATS.Server.Monitoring;
+
+public sealed class Connz
+{
+ [JsonPropertyName("server_id")] public string Id { get; set; } = "";
+ [JsonPropertyName("now")] public DateTime Now { get; set; }
+ [JsonPropertyName("num_connections")] public int NumConns { get; set; }
+ [JsonPropertyName("total")] public int Total { get; set; }
+ [JsonPropertyName("offset")] public int Offset { get; set; }
+ [JsonPropertyName("limit")] public int Limit { get; set; }
+ [JsonPropertyName("connections")] public ConnInfo[] Conns { get; set; } = [];
+}
+
+public sealed class ConnInfo
+{
+ [JsonPropertyName("cid")] public ulong Cid { get; set; }
+ [JsonPropertyName("kind")] public string? Kind { get; set; }
+ [JsonPropertyName("type")] public string? Type { get; set; }
+ [JsonPropertyName("ip")] public string Ip { get; set; } = "";
+ [JsonPropertyName("port")] public int Port { get; set; }
+ [JsonPropertyName("start")] public DateTime Start { get; set; }
+ [JsonPropertyName("last_activity")] public DateTime LastActivity { get; set; }
+ [JsonPropertyName("stop")] public DateTime? Stop { get; set; }
+ [JsonPropertyName("reason")] public string? Reason { get; set; }
+ [JsonPropertyName("rtt")] public string? Rtt { get; set; }
+ [JsonPropertyName("uptime")] public string Uptime { get; set; } = "";
+ [JsonPropertyName("idle")] public string Idle { get; set; } = "";
+ [JsonPropertyName("pending_bytes")] public int Pending { get; set; }
+ [JsonPropertyName("in_msgs")] public long InMsgs { get; set; }
+ [JsonPropertyName("out_msgs")] public long OutMsgs { get; set; }
+ [JsonPropertyName("in_bytes")] public long InBytes { get; set; }
+ [JsonPropertyName("out_bytes")] public long OutBytes { get; set; }
+ [JsonPropertyName("subscriptions")] public int NumSubs { get; set; }
+ [JsonPropertyName("subscriptions_list")] public string[]? Subs { get; set; }
+ [JsonPropertyName("subscriptions_list_detail")] public SubDetail[]? SubsDetail { get; set; }
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("lang")] public string? Lang { get; set; }
+ [JsonPropertyName("version")] public string? Version { get; set; }
+ [JsonPropertyName("authorized_user")] public string? AuthorizedUser { get; set; }
+ [JsonPropertyName("account")] public string? Account { get; set; }
+ [JsonPropertyName("tls_version")] public string? TlsVersion { get; set; }
+ [JsonPropertyName("tls_cipher_suite")] public string? TlsCipher { get; set; }
+ [JsonPropertyName("tls_first")] public bool TlsFirst { get; set; }
+ [JsonPropertyName("mqtt_client")] public string? MqttClient { get; set; }
+}
+
+public sealed class SubDetail
+{
+ [JsonPropertyName("account")] public string? Account { get; set; }
+ [JsonPropertyName("subject")] public string Subject { get; set; } = "";
+ [JsonPropertyName("qgroup")] public string? Queue { get; set; }
+ [JsonPropertyName("sid")] public string Sid { get; set; } = "";
+ [JsonPropertyName("msgs")] public long Msgs { get; set; }
+ [JsonPropertyName("max")] public long Max { get; set; }
+ [JsonPropertyName("cid")] public ulong Cid { get; set; }
+}
+
+public enum SortOpt
+{
+ ByCid,
+ ByStart,
+ BySubs,
+ ByPending,
+ ByMsgsTo,
+ ByMsgsFrom,
+ ByBytesTo,
+ ByBytesFrom,
+ ByLast,
+ ByIdle,
+ ByUptime,
+}
+
+public enum ConnState
+{
+ Open,
+ Closed,
+ All,
+}
+
+public sealed class ConnzOptions
+{
+ public SortOpt Sort { get; set; } = SortOpt.ByCid;
+ public bool Subscriptions { get; set; }
+ public bool SubscriptionsDetail { get; set; }
+ public ConnState State { get; set; } = ConnState.Open;
+ public string? User { get; set; }
+ public string? Account { get; set; }
+ public string? FilterSubject { get; set; }
+ public int Offset { get; set; }
+ public int Limit { get; set; } = 1024;
+}
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorModelTests" -v normal`
+Expected: All 3 tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Monitoring/ tests/NATS.Server.Tests/MonitorModelTests.cs
+git commit -m "feat: add Varz and Connz monitoring JSON models with Go field name parity"
+```
+
+---
+
+### Task 4: MonitorServer with /healthz and /varz endpoints
+
+**Files:**
+- Create: `src/NATS.Server/Monitoring/MonitorServer.cs`
+- Create: `src/NATS.Server/Monitoring/VarzHandler.cs`
+- Create: `tests/NATS.Server.Tests/MonitorTests.cs`
+
+**Step 1: Write the failing test**
+
+Create `tests/NATS.Server.Tests/MonitorTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Http.Json;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Server;
+using NATS.Server.Monitoring;
+
+namespace NATS.Server.Tests;
+
+public class MonitorTests : IAsyncLifetime
+{
+ private readonly NatsServer _server;
+ private readonly int _natsPort;
+ private readonly int _monitorPort;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly HttpClient _http = new();
+
+ public MonitorTests()
+ {
+ _natsPort = GetFreePort();
+ _monitorPort = GetFreePort();
+ _server = new NatsServer(
+ new NatsOptions { Port = _natsPort, MonitorPort = _monitorPort },
+ NullLoggerFactory.Instance);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _ = _server.StartAsync(_cts.Token);
+ await _server.WaitForReadyAsync();
+ // Give monitoring server time to start
+ await Task.Delay(200);
+ }
+
+ public async Task DisposeAsync()
+ {
+ _http.Dispose();
+ _cts.Cancel();
+ _server.Dispose();
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task Healthz_returns_ok()
+ {
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task Varz_returns_server_identity()
+ {
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var varz = await response.Content.ReadFromJsonAsync();
+ varz.ShouldNotBeNull();
+ varz.Id.ShouldNotBeNullOrEmpty();
+ varz.Name.ShouldNotBeNullOrEmpty();
+ varz.Version.ShouldBe("0.1.0");
+ varz.Host.ShouldBe("0.0.0.0");
+ varz.Port.ShouldBe(_natsPort);
+ varz.MaxPayload.ShouldBe(1024 * 1024);
+ varz.Uptime.ShouldNotBeNullOrEmpty();
+ varz.Now.ShouldBeGreaterThan(DateTime.MinValue);
+ varz.Mem.ShouldBeGreaterThan(0);
+ varz.Cores.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task Varz_tracks_connections_and_messages()
+ {
+ // Connect a client and send a message
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
+ using var stream = new NetworkStream(sock);
+
+ var buf = new byte[4096];
+ await stream.ReadAsync(buf); // INFO
+
+ await stream.WriteAsync("CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray());
+ await Task.Delay(200);
+
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
+ var varz = await response.Content.ReadFromJsonAsync();
+
+ varz!.Connections.ShouldBeGreaterThanOrEqualTo(1);
+ varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(1);
+ varz.InMsgs.ShouldBeGreaterThanOrEqualTo(1);
+ varz.InBytes.ShouldBeGreaterThanOrEqualTo(5);
+ }
+
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorTests" -v normal`
+Expected: FAIL — MonitorServer doesn't exist, NatsServer doesn't start HTTP
+
+**Step 3: Implement MonitorServer and VarzHandler**
+
+Create `src/NATS.Server/Monitoring/VarzHandler.cs`:
+
+```csharp
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace NATS.Server.Monitoring;
+
+public sealed class VarzHandler
+{
+ private readonly NatsServer _server;
+ private readonly NatsOptions _options;
+ private readonly SemaphoreSlim _varzMu = new(1, 1);
+ private DateTime _lastCpuSampleTime;
+ private TimeSpan _lastCpuUsage;
+ private double _cachedCpuPercent;
+
+ public VarzHandler(NatsServer server, NatsOptions options)
+ {
+ _server = server;
+ _options = options;
+ var proc = Process.GetCurrentProcess();
+ _lastCpuSampleTime = DateTime.UtcNow;
+ _lastCpuUsage = proc.TotalProcessorTime;
+ }
+
+ public async Task HandleVarzAsync()
+ {
+ await _varzMu.WaitAsync();
+ try
+ {
+ var proc = Process.GetCurrentProcess();
+ var now = DateTime.UtcNow;
+ var uptime = now - _server.StartTime;
+ var stats = _server.Stats;
+
+ // CPU sampling with 1-second cache
+ if ((now - _lastCpuSampleTime).TotalSeconds >= 1.0)
+ {
+ var currentCpu = proc.TotalProcessorTime;
+ var elapsed = now - _lastCpuSampleTime;
+ _cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds
+ / elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0;
+ _lastCpuSampleTime = now;
+ _lastCpuUsage = currentCpu;
+ }
+
+ // Track HTTP request
+ stats.HttpReqStats.AddOrUpdate("/varz", 1, (_, v) => v + 1);
+
+ return new Varz
+ {
+ Id = _server.ServerId,
+ Name = _server.ServerName,
+ Version = Protocol.NatsProtocol.Version,
+ Proto = Protocol.NatsProtocol.ProtoVersion,
+ Go = $"dotnet {RuntimeInformation.FrameworkDescription}",
+ Host = _options.Host,
+ Port = _options.Port,
+ HttpHost = _options.MonitorHost,
+ HttpPort = _options.MonitorPort,
+ HttpBasePath = _options.MonitorBasePath,
+ HttpsPort = _options.MonitorHttpsPort,
+ TlsRequired = _options.HasTls && !_options.AllowNonTls,
+ TlsVerify = _options.HasTls && _options.TlsVerify,
+ TlsTimeout = _options.HasTls ? _options.TlsTimeout : 0,
+ MaxConn = _options.MaxConnections,
+ MaxPayload = _options.MaxPayload,
+ MaxControlLine = _options.MaxControlLine,
+ MaxPingsOut = _options.MaxPingsOut,
+ PingInterval = (long)_options.PingInterval.TotalNanoseconds,
+ Start = _server.StartTime,
+ Now = now,
+ Uptime = FormatUptime(uptime),
+ Mem = proc.WorkingSet64,
+ Cpu = Math.Round(_cachedCpuPercent, 2),
+ Cores = Environment.ProcessorCount,
+ MaxProcs = ThreadPool.ThreadCount,
+ Connections = _server.ClientCount,
+ TotalConnections = Interlocked.Read(ref stats.TotalConnections),
+ InMsgs = Interlocked.Read(ref stats.InMsgs),
+ OutMsgs = Interlocked.Read(ref stats.OutMsgs),
+ InBytes = Interlocked.Read(ref stats.InBytes),
+ OutBytes = Interlocked.Read(ref stats.OutBytes),
+ SlowConsumers = Interlocked.Read(ref stats.SlowConsumers),
+ SlowConsumersStats = new SlowConsumersStats
+ {
+ Clients = Interlocked.Read(ref stats.SlowConsumerClients),
+ Routes = Interlocked.Read(ref stats.SlowConsumerRoutes),
+ Gateways = Interlocked.Read(ref stats.SlowConsumerGateways),
+ Leafs = Interlocked.Read(ref stats.SlowConsumerLeafs),
+ },
+ Subscriptions = _server.SubList.Count,
+ ConfigLoadTime = _server.StartTime,
+ HttpReqStats = new Dictionary(stats.HttpReqStats),
+ };
+ }
+ finally
+ {
+ _varzMu.Release();
+ }
+ }
+
+ private static string FormatUptime(TimeSpan ts)
+ {
+ if (ts.TotalDays >= 1)
+ return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
+ if (ts.TotalHours >= 1)
+ return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
+ if (ts.TotalMinutes >= 1)
+ return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
+ return $"{(int)ts.TotalSeconds}s";
+ }
+}
+```
+
+Create `src/NATS.Server/Monitoring/MonitorServer.cs`:
+
+```csharp
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+namespace NATS.Server.Monitoring;
+
+public sealed class MonitorServer : IAsyncDisposable
+{
+ private readonly WebApplication _app;
+ private readonly ILogger _logger;
+
+ public MonitorServer(NatsServer server, NatsOptions options, ILoggerFactory loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger();
+
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.WebHost.UseUrls($"http://{options.MonitorHost}:{options.MonitorPort}");
+ builder.Logging.ClearProviders();
+ builder.Services.AddSingleton(loggerFactory);
+
+ _app = builder.Build();
+ var basePath = options.MonitorBasePath ?? "";
+
+ var varzHandler = new VarzHandler(server, options);
+
+ _app.MapGet(basePath + "/", () => Results.Ok(new
+ {
+ endpoints = new[]
+ {
+ "/varz", "/connz", "/healthz", "/routez",
+ "/gatewayz", "/leafz", "/subz", "/accountz", "/jsz",
+ }
+ }));
+ _app.MapGet(basePath + "/healthz", () => Results.Ok("ok"));
+ _app.MapGet(basePath + "/varz", async () => Results.Ok(await varzHandler.HandleVarzAsync()));
+
+ // Stubs for unimplemented endpoints
+ _app.MapGet(basePath + "/routez", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/gatewayz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/leafz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/subz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/subscriptionsz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/accountz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/accstatz", () => Results.Ok(new { }));
+ _app.MapGet(basePath + "/jsz", () => Results.Ok(new { }));
+ }
+
+ public async Task StartAsync(CancellationToken ct)
+ {
+ await _app.StartAsync(ct);
+ _logger.LogInformation("Monitoring listening on {Urls}", string.Join(", ", _app.Urls));
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _app.DisposeAsync();
+ }
+}
+```
+
+Modify `NatsServer.cs` — add public properties for monitoring and start MonitorServer:
+
+```csharp
+// New public properties:
+public string ServerId => _serverInfo.ServerId;
+public string ServerName => _serverInfo.ServerName;
+public int ClientCount => _clients.Count;
+
+// New field:
+private MonitorServer? _monitorServer;
+
+// In StartAsync, after _listeningStarted.TrySetResult():
+if (_options.MonitorPort > 0)
+{
+ _monitorServer = new MonitorServer(this, _options, _loggerFactory);
+ await _monitorServer.StartAsync(ct);
+}
+
+// In Dispose, add:
+if (_monitorServer != null)
+ _monitorServer.DisposeAsync().AsTask().GetAwaiter().GetResult();
+```
+
+Also need `SubList.Count` property — add to `SubList`:
+
+```csharp
+public int Count { get; private set; }
+// Increment in Insert, decrement in Remove
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorTests" -v normal`
+Expected: All 3 tests PASS
+
+Run: `dotnet test -v normal`
+Expected: All tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Monitoring/MonitorServer.cs src/NATS.Server/Monitoring/VarzHandler.cs src/NATS.Server/NatsServer.cs src/NATS.Server/Subscriptions/SubList.cs tests/NATS.Server.Tests/MonitorTests.cs
+git commit -m "feat: add MonitorServer with /healthz and /varz endpoints"
+```
+
+---
+
+### Task 5: ConnzHandler and /connz endpoint
+
+**Files:**
+- Create: `src/NATS.Server/Monitoring/ConnzHandler.cs`
+- Modify: `src/NATS.Server/Monitoring/MonitorServer.cs` (add /connz route)
+- Modify: `tests/NATS.Server.Tests/MonitorTests.cs` (add connz tests)
+
+**Step 1: Write the failing tests**
+
+Add to `MonitorTests.cs`:
+
+```csharp
+[Fact]
+public async Task Connz_returns_connections()
+{
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
+ using var stream = new NetworkStream(sock);
+ var buf = new byte[4096];
+ await stream.ReadAsync(buf);
+ await stream.WriteAsync("CONNECT {\"name\":\"test-client\",\"lang\":\"csharp\",\"version\":\"1.0\"}\r\n"u8.ToArray());
+ await Task.Delay(200);
+
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+
+ var connz = await response.Content.ReadFromJsonAsync();
+ connz.ShouldNotBeNull();
+ connz.NumConns.ShouldBeGreaterThanOrEqualTo(1);
+ connz.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
+
+ var conn = connz.Conns.First(c => c.Name == "test-client");
+ conn.Ip.ShouldNotBeNullOrEmpty();
+ conn.Port.ShouldBeGreaterThan(0);
+ conn.Lang.ShouldBe("csharp");
+ conn.Version.ShouldBe("1.0");
+ conn.Uptime.ShouldNotBeNullOrEmpty();
+}
+
+[Fact]
+public async Task Connz_pagination()
+{
+ // Connect 3 clients
+ var sockets = new List();
+ for (int i = 0; i < 3; i++)
+ {
+ var s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await s.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
+ var ns = new NetworkStream(s);
+ var buf = new byte[4096];
+ await ns.ReadAsync(buf);
+ await ns.WriteAsync("CONNECT {}\r\n"u8.ToArray());
+ sockets.Add(s);
+ }
+ await Task.Delay(200);
+
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?limit=2&offset=0");
+ var connz = await response.Content.ReadFromJsonAsync();
+
+ connz!.Conns.Length.ShouldBe(2);
+ connz.Total.ShouldBeGreaterThanOrEqualTo(3);
+ connz.Limit.ShouldBe(2);
+ connz.Offset.ShouldBe(0);
+
+ foreach (var s in sockets) s.Dispose();
+}
+
+[Fact]
+public async Task Connz_with_subscriptions()
+{
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
+ using var stream = new NetworkStream(sock);
+ var buf = new byte[4096];
+ await stream.ReadAsync(buf);
+ await stream.WriteAsync("CONNECT {}\r\nSUB foo 1\r\nSUB bar 2\r\n"u8.ToArray());
+ await Task.Delay(200);
+
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?subs=true");
+ var connz = await response.Content.ReadFromJsonAsync();
+
+ var conn = connz!.Conns.First(c => c.NumSubs >= 2);
+ conn.Subs.ShouldNotBeNull();
+ conn.Subs.ShouldContain("foo");
+ conn.Subs.ShouldContain("bar");
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorTests.Connz" -v normal`
+Expected: FAIL — /connz endpoint doesn't exist
+
+**Step 3: Implement ConnzHandler**
+
+Create `src/NATS.Server/Monitoring/ConnzHandler.cs`:
+
+```csharp
+using Microsoft.AspNetCore.Http;
+
+namespace NATS.Server.Monitoring;
+
+public sealed class ConnzHandler
+{
+ private readonly NatsServer _server;
+ private readonly NatsOptions _options;
+
+ public ConnzHandler(NatsServer server, NatsOptions options)
+ {
+ _server = server;
+ _options = options;
+ }
+
+ public Connz HandleConnz(HttpContext ctx)
+ {
+ var opts = ParseQueryParams(ctx);
+ var now = DateTime.UtcNow;
+ var clients = _server.GetClients().ToArray();
+
+ _server.Stats.HttpReqStats.AddOrUpdate("/connz", 1, (_, v) => v + 1);
+
+ var connInfos = clients.Select(c => BuildConnInfo(c, now, opts)).ToList();
+
+ // Sort
+ connInfos = opts.Sort switch
+ {
+ SortOpt.ByCid => connInfos.OrderBy(c => c.Cid).ToList(),
+ SortOpt.ByStart => connInfos.OrderBy(c => c.Start).ToList(),
+ SortOpt.BySubs => connInfos.OrderByDescending(c => c.NumSubs).ToList(),
+ SortOpt.ByPending => connInfos.OrderByDescending(c => c.Pending).ToList(),
+ SortOpt.ByMsgsTo => connInfos.OrderByDescending(c => c.OutMsgs).ToList(),
+ SortOpt.ByMsgsFrom => connInfos.OrderByDescending(c => c.InMsgs).ToList(),
+ SortOpt.ByBytesTo => connInfos.OrderByDescending(c => c.OutBytes).ToList(),
+ SortOpt.ByBytesFrom => connInfos.OrderByDescending(c => c.InBytes).ToList(),
+ SortOpt.ByLast => connInfos.OrderByDescending(c => c.LastActivity).ToList(),
+ SortOpt.ByIdle => connInfos.OrderByDescending(c => now - c.LastActivity).ToList(),
+ _ => connInfos.OrderBy(c => c.Cid).ToList(),
+ };
+
+ var total = connInfos.Count;
+ var paged = connInfos.Skip(opts.Offset).Take(opts.Limit).ToArray();
+
+ return new Connz
+ {
+ Id = _server.ServerId,
+ Now = now,
+ NumConns = paged.Length,
+ Total = total,
+ Offset = opts.Offset,
+ Limit = opts.Limit,
+ Conns = paged,
+ };
+ }
+
+ private ConnInfo BuildConnInfo(NatsClient client, DateTime now, ConnzOptions opts)
+ {
+ var info = new ConnInfo
+ {
+ Cid = client.Id,
+ Kind = "Client",
+ Ip = client.RemoteIp ?? "",
+ Port = client.RemotePort,
+ Start = client.StartTime,
+ LastActivity = client.LastActivity,
+ Uptime = FormatDuration(now - client.StartTime),
+ Idle = FormatDuration(now - client.LastActivity),
+ InMsgs = Interlocked.Read(ref client.InMsgs),
+ OutMsgs = Interlocked.Read(ref client.OutMsgs),
+ InBytes = Interlocked.Read(ref client.InBytes),
+ OutBytes = Interlocked.Read(ref client.OutBytes),
+ NumSubs = client.Subscriptions.Count,
+ Name = client.ClientOpts?.Name,
+ Lang = client.ClientOpts?.Lang,
+ Version = client.ClientOpts?.Version,
+ TlsVersion = client.TlsState?.TlsVersion,
+ TlsCipher = client.TlsState?.CipherSuite,
+ };
+
+ if (opts.Subscriptions)
+ {
+ info.Subs = client.Subscriptions.Values.Select(s => s.Subject).ToArray();
+ }
+
+ if (opts.SubscriptionsDetail)
+ {
+ info.SubsDetail = client.Subscriptions.Values.Select(s => new SubDetail
+ {
+ Subject = s.Subject,
+ Queue = s.Queue,
+ Sid = s.Sid,
+ Msgs = Interlocked.Read(ref s.MessageCount),
+ Max = s.MaxMessages,
+ Cid = client.Id,
+ }).ToArray();
+ }
+
+ return info;
+ }
+
+ private static ConnzOptions ParseQueryParams(HttpContext ctx)
+ {
+ var q = ctx.Request.Query;
+ var opts = new ConnzOptions();
+
+ if (q.TryGetValue("sort", out var sort))
+ {
+ opts.Sort = sort.ToString().ToLowerInvariant() switch
+ {
+ "cid" => SortOpt.ByCid,
+ "start" => SortOpt.ByStart,
+ "subs" => SortOpt.BySubs,
+ "pending" => SortOpt.ByPending,
+ "msgs_to" => SortOpt.ByMsgsTo,
+ "msgs_from" => SortOpt.ByMsgsFrom,
+ "bytes_to" => SortOpt.ByBytesTo,
+ "bytes_from" => SortOpt.ByBytesFrom,
+ "last" => SortOpt.ByLast,
+ "idle" => SortOpt.ByIdle,
+ "uptime" => SortOpt.ByUptime,
+ _ => SortOpt.ByCid,
+ };
+ }
+
+ if (q.TryGetValue("subs", out var subs))
+ {
+ if (subs == "detail")
+ opts.SubscriptionsDetail = true;
+ else
+ opts.Subscriptions = true;
+ }
+
+ if (q.TryGetValue("offset", out var offset) && int.TryParse(offset, out var o))
+ opts.Offset = o;
+
+ if (q.TryGetValue("limit", out var limit) && int.TryParse(limit, out var l))
+ opts.Limit = l;
+
+ return opts;
+ }
+
+ private static string FormatDuration(TimeSpan ts)
+ {
+ if (ts.TotalDays >= 1)
+ return $"{(int)ts.TotalDays}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s";
+ if (ts.TotalHours >= 1)
+ return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
+ if (ts.TotalMinutes >= 1)
+ return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
+ return $"{(int)ts.TotalSeconds}s";
+ }
+}
+```
+
+Add `/connz` route to `MonitorServer.cs`:
+
+```csharp
+var connzHandler = new ConnzHandler(server, options);
+_app.MapGet(basePath + "/connz", (HttpContext ctx) => Results.Ok(connzHandler.HandleConnz(ctx)));
+```
+
+Note: `NatsClient.TlsState` doesn't exist yet — add a placeholder property:
+
+```csharp
+// In NatsClient.cs
+public TlsConnectionState? TlsState { get; set; }
+```
+
+Create `src/NATS.Server/Tls/TlsConnectionState.cs`:
+
+```csharp
+using System.Security.Cryptography.X509Certificates;
+
+namespace NATS.Server.Tls;
+
+public sealed record TlsConnectionState(
+ string? TlsVersion,
+ string? CipherSuite,
+ X509Certificate2? PeerCert
+);
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~MonitorTests" -v normal`
+Expected: All 6 tests PASS
+
+Run: `dotnet test -v normal`
+Expected: All tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Monitoring/ConnzHandler.cs src/NATS.Server/Monitoring/MonitorServer.cs src/NATS.Server/NatsClient.cs src/NATS.Server/Tls/TlsConnectionState.cs tests/NATS.Server.Tests/MonitorTests.cs
+git commit -m "feat: add /connz endpoint with pagination, sorting, and subscription details"
+```
+
+---
+
+### Task 6: Wire monitoring CLI args into Host
+
+**Files:**
+- Modify: `src/NATS.Server.Host/Program.cs`
+
+**Step 1: Add CLI args for monitoring**
+
+Add to the switch in `Program.cs`:
+
+```csharp
+case "-m" or "--http_port" when i + 1 < args.Length:
+ options.MonitorPort = int.Parse(args[++i]);
+ break;
+case "--http_base_path" when i + 1 < args.Length:
+ options.MonitorBasePath = args[++i];
+ break;
+case "--https_port" when i + 1 < args.Length:
+ options.MonitorHttpsPort = int.Parse(args[++i]);
+ break;
+```
+
+**Step 2: Build and verify**
+
+Run: `dotnet build`
+Expected: Success
+
+**Step 3: Commit**
+
+```bash
+git add src/NATS.Server.Host/Program.cs
+git commit -m "feat: add -m/--http_port CLI flag for monitoring"
+```
+
+---
+
+### Task 7: TLS helpers — TlsHelper, PeekableStream, TlsRateLimiter
+
+**Files:**
+- Create: `src/NATS.Server/Tls/TlsHelper.cs`
+- Create: `src/NATS.Server/Tls/PeekableStream.cs`
+- Create: `src/NATS.Server/Tls/TlsRateLimiter.cs`
+- Create: `tests/NATS.Server.Tests/TlsHelperTests.cs`
+
+**Step 1: Write the failing tests**
+
+Create `tests/NATS.Server.Tests/TlsHelperTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using NATS.Server.Tls;
+
+namespace NATS.Server.Tests;
+
+public class TlsHelperTests
+{
+ [Fact]
+ public void LoadCertificate_loads_pem_cert_and_key()
+ {
+ var (certPath, keyPath) = GenerateTestCertFiles();
+ try
+ {
+ var cert = TlsHelper.LoadCertificate(certPath, keyPath);
+ cert.ShouldNotBeNull();
+ cert.HasPrivateKey.ShouldBeTrue();
+ }
+ finally
+ {
+ File.Delete(certPath);
+ File.Delete(keyPath);
+ }
+ }
+
+ [Fact]
+ public void BuildServerAuthOptions_creates_valid_options()
+ {
+ var (certPath, keyPath) = GenerateTestCertFiles();
+ try
+ {
+ var opts = new NatsOptions
+ {
+ TlsCert = certPath,
+ TlsKey = keyPath,
+ };
+ var authOpts = TlsHelper.BuildServerAuthOptions(opts);
+ authOpts.ShouldNotBeNull();
+ authOpts.ServerCertificate.ShouldNotBeNull();
+ }
+ finally
+ {
+ File.Delete(certPath);
+ File.Delete(keyPath);
+ }
+ }
+
+ [Fact]
+ public void MatchesPinnedCert_matches_correct_hash()
+ {
+ var (cert, _) = GenerateTestCert();
+ var hash = TlsHelper.GetCertificateHash(cert);
+ var pinned = new HashSet { hash };
+ TlsHelper.MatchesPinnedCert(cert, pinned).ShouldBeTrue();
+ }
+
+ [Fact]
+ public void MatchesPinnedCert_rejects_wrong_hash()
+ {
+ var (cert, _) = GenerateTestCert();
+ var pinned = new HashSet { "0000000000000000000000000000000000000000000000000000000000000000" };
+ TlsHelper.MatchesPinnedCert(cert, pinned).ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task PeekableStream_peeks_and_replays()
+ {
+ var data = "Hello, World!"u8.ToArray();
+ using var ms = new MemoryStream(data);
+ using var peekable = new PeekableStream(ms);
+
+ var peeked = await peekable.PeekAsync(1);
+ peeked.Length.ShouldBe(1);
+ peeked[0].ShouldBe((byte)'H');
+
+ // Now full read should return ALL bytes including the peeked one
+ var buf = new byte[data.Length];
+ int total = 0;
+ while (total < data.Length)
+ {
+ var read = await peekable.ReadAsync(buf.AsMemory(total));
+ if (read == 0) break;
+ total += read;
+ }
+ total.ShouldBe(data.Length);
+ buf.ShouldBe(data);
+ }
+
+ [Fact]
+ public async Task TlsRateLimiter_allows_within_limit()
+ {
+ using var limiter = new TlsRateLimiter(10); // 10 per second
+ // Should complete quickly
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ for (int i = 0; i < 5; i++)
+ await limiter.WaitAsync(cts.Token);
+ }
+
+ public static (string certPath, string keyPath) GenerateTestCertFiles()
+ {
+ var (cert, key) = GenerateTestCert();
+ var certPath = Path.GetTempFileName();
+ var keyPath = Path.GetTempFileName();
+ File.WriteAllText(certPath, cert.ExportCertificatePem());
+ File.WriteAllText(keyPath, key.ExportPkcs8PrivateKeyPem());
+ return (certPath, keyPath);
+ }
+
+ public static (X509Certificate2 cert, RSA key) GenerateTestCert()
+ {
+ var key = RSA.Create(2048);
+ var req = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
+ var sanBuilder = new SubjectAlternativeNameBuilder();
+ sanBuilder.AddIpAddress(IPAddress.Loopback);
+ sanBuilder.AddDnsName("localhost");
+ req.CertificateExtensions.Add(sanBuilder.Build());
+ var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
+ return (cert, key);
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsHelperTests" -v normal`
+Expected: FAIL — types don't exist
+
+**Step 3: Implement TlsHelper, PeekableStream, TlsRateLimiter**
+
+Create `src/NATS.Server/Tls/TlsHelper.cs`:
+
+```csharp
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+
+namespace NATS.Server.Tls;
+
+public static class TlsHelper
+{
+ public static X509Certificate2 LoadCertificate(string certPath, string? keyPath)
+ {
+ if (keyPath != null)
+ return X509Certificate2.CreateFromPemFile(certPath, keyPath);
+
+ return new X509Certificate2(certPath);
+ }
+
+ public static X509Certificate2Collection LoadCaCertificates(string caPath)
+ {
+ var collection = new X509Certificate2Collection();
+ collection.ImportFromPemFile(caPath);
+ return collection;
+ }
+
+ public static SslServerAuthenticationOptions BuildServerAuthOptions(NatsOptions opts)
+ {
+ var cert = LoadCertificate(opts.TlsCert!, opts.TlsKey);
+ var authOpts = new SslServerAuthenticationOptions
+ {
+ ServerCertificate = cert,
+ EnabledSslProtocols = opts.TlsMinVersion,
+ ClientCertificateRequired = opts.TlsVerify,
+ };
+
+ if (opts.TlsVerify && opts.TlsCaCert != null)
+ {
+ var caCerts = LoadCaCertificates(opts.TlsCaCert);
+ authOpts.RemoteCertificateValidationCallback = (_, cert, chain, errors) =>
+ {
+ if (cert == null) return false;
+ using var chain2 = new X509Chain();
+ chain2.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
+ foreach (var ca in caCerts)
+ chain2.ChainPolicy.CustomTrustStore.Add(ca);
+ chain2.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
+ return chain2.Build(new X509Certificate2(cert));
+ };
+ }
+
+ return authOpts;
+ }
+
+ public static string GetCertificateHash(X509Certificate2 cert)
+ {
+ var spki = cert.PublicKey.ExportSubjectPublicKeyInfo();
+ var hash = SHA256.HashData(spki);
+ return Convert.ToHexStringLower(hash);
+ }
+
+ public static bool MatchesPinnedCert(X509Certificate2 cert, HashSet pinned)
+ {
+ var hash = GetCertificateHash(cert);
+ return pinned.Contains(hash);
+ }
+}
+```
+
+Create `src/NATS.Server/Tls/PeekableStream.cs`:
+
+```csharp
+namespace NATS.Server.Tls;
+
+public sealed class PeekableStream : Stream
+{
+ private readonly Stream _inner;
+ private byte[]? _peekedBytes;
+ private int _peekedOffset;
+ private int _peekedCount;
+
+ public PeekableStream(Stream inner) => _inner = inner;
+
+ public async Task PeekAsync(int count, CancellationToken ct = default)
+ {
+ var buf = new byte[count];
+ int read = await _inner.ReadAsync(buf.AsMemory(0, count), ct);
+ if (read < count)
+ Array.Resize(ref buf, read);
+ _peekedBytes = buf;
+ _peekedOffset = 0;
+ _peekedCount = read;
+ return buf;
+ }
+
+ public override async ValueTask ReadAsync(Memory buffer, CancellationToken ct = default)
+ {
+ if (_peekedBytes != null && _peekedOffset < _peekedCount)
+ {
+ int available = _peekedCount - _peekedOffset;
+ int toCopy = Math.Min(available, buffer.Length);
+ _peekedBytes.AsMemory(_peekedOffset, toCopy).CopyTo(buffer);
+ _peekedOffset += toCopy;
+ if (_peekedOffset >= _peekedCount)
+ _peekedBytes = null;
+ return toCopy;
+ }
+ return await _inner.ReadAsync(buffer, ct);
+ }
+
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ if (_peekedBytes != null && _peekedOffset < _peekedCount)
+ {
+ int available = _peekedCount - _peekedOffset;
+ int toCopy = Math.Min(available, count);
+ Array.Copy(_peekedBytes, _peekedOffset, buffer, offset, toCopy);
+ _peekedOffset += toCopy;
+ if (_peekedOffset >= _peekedCount)
+ _peekedBytes = null;
+ return toCopy;
+ }
+ return _inner.Read(buffer, offset, count);
+ }
+
+ public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct)
+ => await ReadAsync(buffer.AsMemory(offset, count), ct);
+
+ public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
+ public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct)
+ => _inner.WriteAsync(buffer, offset, count, ct);
+ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default)
+ => _inner.WriteAsync(buffer, ct);
+ public override void Flush() => _inner.Flush();
+ public override Task FlushAsync(CancellationToken ct) => _inner.FlushAsync(ct);
+ public override bool CanRead => _inner.CanRead;
+ public override bool CanSeek => false;
+ public override bool CanWrite => _inner.CanWrite;
+ public override long Length => throw new NotSupportedException();
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) _inner.Dispose();
+ base.Dispose(disposing);
+ }
+}
+```
+
+Create `src/NATS.Server/Tls/TlsRateLimiter.cs`:
+
+```csharp
+namespace NATS.Server.Tls;
+
+public sealed class TlsRateLimiter : IDisposable
+{
+ private readonly SemaphoreSlim _semaphore;
+ private readonly Timer _refillTimer;
+ private readonly int _tokensPerSecond;
+
+ public TlsRateLimiter(long tokensPerSecond)
+ {
+ _tokensPerSecond = (int)Math.Max(1, tokensPerSecond);
+ _semaphore = new SemaphoreSlim(_tokensPerSecond, _tokensPerSecond);
+ _refillTimer = new Timer(Refill, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
+ }
+
+ private void Refill(object? state)
+ {
+ int toRelease = _tokensPerSecond - _semaphore.CurrentCount;
+ if (toRelease > 0)
+ _semaphore.Release(toRelease);
+ }
+
+ public Task WaitAsync(CancellationToken ct) => _semaphore.WaitAsync(ct);
+
+ public void Dispose()
+ {
+ _refillTimer.Dispose();
+ _semaphore.Dispose();
+ }
+}
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsHelperTests" -v normal`
+Expected: All 6 tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Tls/ tests/NATS.Server.Tests/TlsHelperTests.cs
+git commit -m "feat: add TlsHelper, PeekableStream, and TlsRateLimiter"
+```
+
+---
+
+### Task 8: TlsConnectionWrapper — 4-mode negotiation
+
+**Files:**
+- Create: `src/NATS.Server/Tls/TlsConnectionWrapper.cs`
+- Create: `tests/NATS.Server.Tests/TlsConnectionWrapperTests.cs`
+
+**Step 1: Write the failing tests**
+
+Create `tests/NATS.Server.Tests/TlsConnectionWrapperTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Server;
+using NATS.Server.Protocol;
+using NATS.Server.Tls;
+
+namespace NATS.Server.Tests;
+
+public class TlsConnectionWrapperTests
+{
+ [Fact]
+ public async Task NoTls_returns_plain_stream()
+ {
+ var (serverSocket, clientSocket) = await CreateSocketPairAsync();
+ using var serverStream = new NetworkStream(serverSocket, ownsSocket: true);
+ using var clientStream = new NetworkStream(clientSocket, ownsSocket: true);
+
+ var opts = new NatsOptions(); // No TLS configured
+ var serverInfo = CreateServerInfo();
+
+ var (stream, infoSent) = await TlsConnectionWrapper.NegotiateAsync(
+ serverSocket, serverStream, opts, null, serverInfo, NullLogger.Instance, CancellationToken.None);
+
+ stream.ShouldBe(serverStream); // Same stream, no wrapping
+ infoSent.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task TlsRequired_upgrades_to_ssl()
+ {
+ var (cert, key) = TlsHelperTests.GenerateTestCert();
+ var certWithKey = cert.CopyWithPrivateKey(key);
+
+ var (serverSocket, clientSocket) = await CreateSocketPairAsync();
+ using var clientNetStream = new NetworkStream(clientSocket, ownsSocket: true);
+
+ var opts = new NatsOptions { TlsCert = "dummy", TlsKey = "dummy" };
+ var sslOpts = new SslServerAuthenticationOptions
+ {
+ ServerCertificate = certWithKey,
+ };
+ var serverInfo = CreateServerInfo();
+
+ // Client side: read INFO then start TLS
+ var clientTask = Task.Run(async () =>
+ {
+ // Read INFO line
+ var buf = new byte[4096];
+ var read = await clientNetStream.ReadAsync(buf);
+ var info = System.Text.Encoding.ASCII.GetString(buf, 0, read);
+ info.ShouldStartWith("INFO ");
+
+ // Upgrade to TLS
+ var sslClient = new SslStream(clientNetStream, true,
+ (_, _, _, _) => true); // Trust all for testing
+ await sslClient.AuthenticateAsClientAsync("localhost");
+ return sslClient;
+ });
+
+ var serverNetStream = new NetworkStream(serverSocket, ownsSocket: true);
+ var (stream, infoSent) = await TlsConnectionWrapper.NegotiateAsync(
+ serverSocket, serverNetStream, opts, sslOpts, serverInfo, NullLogger.Instance, CancellationToken.None);
+
+ stream.ShouldBeOfType();
+ infoSent.ShouldBeTrue();
+
+ var clientSsl = await clientTask;
+
+ // Verify encrypted communication works
+ await stream.WriteAsync("PING\r\n"u8.ToArray());
+ await stream.FlushAsync();
+
+ var readBuf = new byte[64];
+ var bytesRead = await clientSsl.ReadAsync(readBuf);
+ var msg = System.Text.Encoding.ASCII.GetString(readBuf, 0, bytesRead);
+ msg.ShouldBe("PING\r\n");
+
+ stream.Dispose();
+ clientSsl.Dispose();
+ }
+
+ private static ServerInfo CreateServerInfo() => new()
+ {
+ ServerId = "TEST",
+ ServerName = "test",
+ Version = NatsProtocol.Version,
+ Host = "127.0.0.1",
+ Port = 4222,
+ };
+
+ private static async Task<(Socket server, Socket client)> CreateSocketPairAsync()
+ {
+ using var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ listener.Listen(1);
+ var port = ((IPEndPoint)listener.LocalEndPoint!).Port;
+
+ var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await client.ConnectAsync(new IPEndPoint(IPAddress.Loopback, port));
+ var server = await listener.AcceptAsync();
+
+ return (server, client);
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsConnectionWrapperTests" -v normal`
+Expected: FAIL — `TlsConnectionWrapper` doesn't exist
+
+**Step 3: Implement TlsConnectionWrapper**
+
+Create `src/NATS.Server/Tls/TlsConnectionWrapper.cs`:
+
+```csharp
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using NATS.Server.Protocol;
+
+namespace NATS.Server.Tls;
+
+public static class TlsConnectionWrapper
+{
+ private const byte TlsRecordMarker = 0x16;
+
+ public static async Task<(Stream stream, bool infoAlreadySent)> NegotiateAsync(
+ Socket socket,
+ Stream networkStream,
+ NatsOptions options,
+ SslServerAuthenticationOptions? sslOptions,
+ ServerInfo serverInfo,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ // Mode 1: No TLS
+ if (sslOptions == null || !options.HasTls)
+ return (networkStream, false);
+
+ // Mode 3: TLS First
+ if (options.TlsHandshakeFirst)
+ return await NegotiateTlsFirstAsync(socket, networkStream, options, sslOptions, serverInfo, logger, ct);
+
+ // Mode 2 & 4: Send INFO first, then decide
+ serverInfo.TlsRequired = !options.AllowNonTls;
+ serverInfo.TlsAvailable = options.AllowNonTls;
+ serverInfo.TlsVerify = options.TlsVerify;
+ await SendInfoAsync(networkStream, serverInfo, ct);
+
+ // Peek first byte to detect TLS
+ var peekable = new PeekableStream(networkStream);
+ var peeked = await PeekWithTimeoutAsync(peekable, 1, TimeSpan.FromSeconds(options.TlsTimeout), ct);
+
+ if (peeked.Length == 0)
+ {
+ // Client disconnected
+ return (peekable, true);
+ }
+
+ if (peeked[0] == TlsRecordMarker)
+ {
+ // Client is starting TLS
+ var sslStream = new SslStream(peekable, leaveInnerStreamOpen: false);
+ using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ handshakeCts.CancelAfter(TimeSpan.FromSeconds(options.TlsTimeout));
+
+ await sslStream.AuthenticateAsServerAsync(sslOptions, handshakeCts.Token);
+ logger.LogDebug("TLS handshake complete: {Protocol} {CipherSuite}",
+ sslStream.SslProtocol, sslStream.NegotiatedCipherSuite);
+
+ // Validate pinned certs
+ if (options.TlsPinnedCerts != null && sslStream.RemoteCertificate is System.Security.Cryptography.X509Certificates.X509Certificate2 remoteCert)
+ {
+ if (!TlsHelper.MatchesPinnedCert(remoteCert, options.TlsPinnedCerts))
+ {
+ logger.LogWarning("Certificate pinning check failed");
+ sslStream.Dispose();
+ throw new InvalidOperationException("Certificate pinning check failed");
+ }
+ }
+
+ return (sslStream, true);
+ }
+
+ // Mode 4: Mixed — client chose plaintext
+ if (options.AllowNonTls)
+ {
+ logger.LogDebug("Client connected without TLS (mixed mode)");
+ return (peekable, true);
+ }
+
+ // TLS required but client sent plaintext
+ logger.LogWarning("TLS required but client sent plaintext data");
+ throw new InvalidOperationException("TLS required");
+ }
+
+ private static async Task<(Stream stream, bool infoAlreadySent)> NegotiateTlsFirstAsync(
+ Socket socket,
+ Stream networkStream,
+ NatsOptions options,
+ SslServerAuthenticationOptions sslOptions,
+ ServerInfo serverInfo,
+ ILogger logger,
+ CancellationToken ct)
+ {
+ // Wait for data with fallback timeout
+ var peekable = new PeekableStream(networkStream);
+ var peeked = await PeekWithTimeoutAsync(peekable, 1, options.TlsHandshakeFirstFallback, ct);
+
+ if (peeked.Length > 0 && peeked[0] == TlsRecordMarker)
+ {
+ // Client started TLS immediately — handshake first, then send INFO
+ var sslStream = new SslStream(peekable, leaveInnerStreamOpen: false);
+ using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ handshakeCts.CancelAfter(TimeSpan.FromSeconds(options.TlsTimeout));
+
+ await sslStream.AuthenticateAsServerAsync(sslOptions, handshakeCts.Token);
+ logger.LogDebug("TLS-first handshake complete: {Protocol} {CipherSuite}",
+ sslStream.SslProtocol, sslStream.NegotiatedCipherSuite);
+
+ // Validate pinned certs
+ if (options.TlsPinnedCerts != null && sslStream.RemoteCertificate is System.Security.Cryptography.X509Certificates.X509Certificate2 remoteCert)
+ {
+ if (!TlsHelper.MatchesPinnedCert(remoteCert, options.TlsPinnedCerts))
+ {
+ sslStream.Dispose();
+ throw new InvalidOperationException("Certificate pinning check failed");
+ }
+ }
+
+ // Now send INFO over encrypted stream
+ serverInfo.TlsRequired = true;
+ serverInfo.TlsVerify = options.TlsVerify;
+ await SendInfoAsync(sslStream, serverInfo, ct);
+ return (sslStream, true);
+ }
+
+ // Fallback: timeout expired or non-TLS data — send INFO and negotiate normally
+ logger.LogDebug("TLS-first fallback: sending INFO");
+ serverInfo.TlsRequired = !options.AllowNonTls;
+ serverInfo.TlsAvailable = options.AllowNonTls;
+ serverInfo.TlsVerify = options.TlsVerify;
+
+ if (peeked.Length == 0)
+ {
+ // Timeout — send INFO on plain stream, then wait for TLS or plaintext
+ await SendInfoAsync(peekable, serverInfo, ct);
+
+ var peeked2 = await PeekWithTimeoutAsync(
+ peekable, 1, TimeSpan.FromSeconds(options.TlsTimeout), ct);
+
+ // This re-peek won't work well since PeekableStream only handles one peek.
+ // For the fallback, wrap in a fresh PeekableStream over the existing one.
+ // Actually, we need a different approach: after sending INFO, delegate to Mode 2/4 logic.
+ // For simplicity, just return the peekable stream and let the caller handle.
+ return (peekable, true);
+ }
+
+ // Non-TLS data received during fallback window
+ if (options.AllowNonTls)
+ {
+ await SendInfoAsync(peekable, serverInfo, ct);
+ return (peekable, true);
+ }
+
+ // TLS required but got plaintext
+ throw new InvalidOperationException("TLS required but client sent plaintext");
+ }
+
+ private static async Task PeekWithTimeoutAsync(
+ PeekableStream stream, int count, TimeSpan timeout, CancellationToken ct)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeout);
+ try
+ {
+ return await stream.PeekAsync(count, cts.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // Timeout — not a cancellation of the outer token
+ return [];
+ }
+ }
+
+ private static async Task SendInfoAsync(Stream stream, ServerInfo serverInfo, CancellationToken ct)
+ {
+ var infoJson = JsonSerializer.Serialize(serverInfo);
+ var infoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
+ await stream.WriteAsync(infoLine, ct);
+ await stream.FlushAsync(ct);
+ }
+}
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsConnectionWrapperTests" -v normal`
+Expected: Both tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/Tls/TlsConnectionWrapper.cs tests/NATS.Server.Tests/TlsConnectionWrapperTests.cs
+git commit -m "feat: add TlsConnectionWrapper with 4-mode TLS negotiation"
+```
+
+---
+
+### Task 9: Wire TLS into NatsServer accept loop
+
+**Files:**
+- Modify: `src/NATS.Server/NatsServer.cs` (accept loop)
+- Modify: `src/NATS.Server/NatsClient.cs` (InfoAlreadySent flag, skip SendInfo if set)
+
+**Step 1: Write the failing test**
+
+Add to a new file `tests/NATS.Server.Tests/TlsServerTests.cs`:
+
+```csharp
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using Microsoft.Extensions.Logging.Abstractions;
+using NATS.Server;
+
+namespace NATS.Server.Tests;
+
+public class TlsServerTests : IAsyncLifetime
+{
+ private readonly NatsServer _server;
+ private readonly int _port;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly string _certPath;
+ private readonly string _keyPath;
+
+ public TlsServerTests()
+ {
+ _port = GetFreePort();
+ (_certPath, _keyPath) = TlsHelperTests.GenerateTestCertFiles();
+ _server = new NatsServer(
+ new NatsOptions
+ {
+ Port = _port,
+ TlsCert = _certPath,
+ TlsKey = _keyPath,
+ },
+ NullLoggerFactory.Instance);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _ = _server.StartAsync(_cts.Token);
+ await _server.WaitForReadyAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ _cts.Cancel();
+ _server.Dispose();
+ File.Delete(_certPath);
+ File.Delete(_keyPath);
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task Tls_client_connects_and_receives_info()
+ {
+ using var tcp = new TcpClient();
+ await tcp.ConnectAsync(IPAddress.Loopback, _port);
+ using var netStream = tcp.GetStream();
+
+ // Read INFO (sent before TLS upgrade in Mode 2)
+ var buf = new byte[4096];
+ var read = await netStream.ReadAsync(buf);
+ var info = Encoding.ASCII.GetString(buf, 0, read);
+ info.ShouldStartWith("INFO ");
+ info.ShouldContain("\"tls_required\":true");
+
+ // Upgrade to TLS
+ using var sslStream = new SslStream(netStream, false,
+ (_, _, _, _) => true);
+ await sslStream.AuthenticateAsClientAsync("localhost");
+
+ // Send CONNECT + PING over TLS
+ await sslStream.WriteAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
+ await sslStream.FlushAsync();
+
+ // Read PONG
+ var pongBuf = new byte[64];
+ read = await sslStream.ReadAsync(pongBuf);
+ var pong = Encoding.ASCII.GetString(pongBuf, 0, read);
+ pong.ShouldContain("PONG");
+ }
+
+ [Fact]
+ public async Task Tls_pubsub_works_over_encrypted_connection()
+ {
+ using var tcp1 = new TcpClient();
+ await tcp1.ConnectAsync(IPAddress.Loopback, _port);
+ using var ssl1 = await UpgradeToTlsAsync(tcp1);
+
+ using var tcp2 = new TcpClient();
+ await tcp2.ConnectAsync(IPAddress.Loopback, _port);
+ using var ssl2 = await UpgradeToTlsAsync(tcp2);
+
+ // Sub on client 1
+ await ssl1.WriteAsync("CONNECT {}\r\nSUB test 1\r\n"u8.ToArray());
+ await ssl1.FlushAsync();
+ await Task.Delay(100);
+
+ // Pub on client 2
+ await ssl2.WriteAsync("CONNECT {}\r\nPUB test 5\r\nhello\r\n"u8.ToArray());
+ await ssl2.FlushAsync();
+ await Task.Delay(200);
+
+ // Client 1 should receive MSG
+ var buf = new byte[4096];
+ var read = await ssl1.ReadAsync(buf);
+ var msg = Encoding.ASCII.GetString(buf, 0, read);
+ msg.ShouldContain("MSG test 1 5");
+ msg.ShouldContain("hello");
+ }
+
+ private static async Task UpgradeToTlsAsync(TcpClient tcp)
+ {
+ var netStream = tcp.GetStream();
+ // Read INFO
+ var buf = new byte[4096];
+ await netStream.ReadAsync(buf);
+
+ var ssl = new SslStream(netStream, false, (_, _, _, _) => true);
+ await ssl.AuthenticateAsClientAsync("localhost");
+ return ssl;
+ }
+
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+}
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsServerTests" -v normal`
+Expected: FAIL — NatsServer doesn't do TLS negotiation yet
+
+**Step 3: Wire TLS into NatsServer and NatsClient**
+
+Modify `NatsServer.cs` — add TLS setup in constructor and accept loop:
+
+```csharp
+// New fields:
+private SslServerAuthenticationOptions? _sslOptions;
+private TlsRateLimiter? _tlsRateLimiter;
+
+// In constructor, after _serverInfo initialization:
+if (options.HasTls)
+{
+ _sslOptions = TlsHelper.BuildServerAuthOptions(options);
+ _serverInfo.TlsRequired = !options.AllowNonTls;
+ _serverInfo.TlsAvailable = options.AllowNonTls;
+ _serverInfo.TlsVerify = options.TlsVerify;
+
+ if (options.TlsRateLimit > 0)
+ _tlsRateLimiter = new TlsRateLimiter(options.TlsRateLimit);
+}
+
+// Replace the accept loop body in StartAsync:
+var socket = await _listener.AcceptAsync(ct);
+var clientId = Interlocked.Increment(ref _nextClientId);
+Interlocked.Increment(ref _stats.TotalConnections);
+
+_ = AcceptClientAsync(socket, clientId, ct);
+
+// New method:
+private async Task AcceptClientAsync(Socket socket, ulong clientId, CancellationToken ct)
+{
+ try
+ {
+ if (_sslOptions != null && _options.TlsRateLimit > 0)
+ await _tlsRateLimiter!.WaitAsync(ct);
+
+ var networkStream = new NetworkStream(socket, ownsSocket: false);
+ var serverInfoCopy = CloneServerInfo();
+
+ var (stream, infoAlreadySent) = await TlsConnectionWrapper.NegotiateAsync(
+ socket, networkStream, _options, _sslOptions, serverInfoCopy,
+ _loggerFactory.CreateLogger("NATS.Server.Tls"), ct);
+
+ TlsConnectionState? tlsState = null;
+ if (stream is SslStream ssl)
+ {
+ tlsState = new TlsConnectionState(
+ ssl.SslProtocol.ToString(),
+ ssl.NegotiatedCipherSuite.ToString(),
+ ssl.RemoteCertificate as X509Certificate2);
+ }
+
+ var clientLogger = _loggerFactory.CreateLogger($"NATS.Server.NatsClient[{clientId}]");
+ var client = new NatsClient(clientId, stream, socket, _options, _serverInfo,
+ clientLogger, _stats);
+ client.Router = this;
+ client.TlsState = tlsState;
+ client.InfoAlreadySent = infoAlreadySent;
+ _clients[clientId] = client;
+
+ await RunClientAsync(client, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Failed to accept client {ClientId}", clientId);
+ socket.Dispose();
+ }
+}
+
+private ServerInfo CloneServerInfo() => new()
+{
+ ServerId = _serverInfo.ServerId,
+ ServerName = _serverInfo.ServerName,
+ Version = _serverInfo.Version,
+ Host = _serverInfo.Host,
+ Port = _serverInfo.Port,
+ MaxPayload = _serverInfo.MaxPayload,
+ TlsRequired = _serverInfo.TlsRequired,
+ TlsVerify = _serverInfo.TlsVerify,
+ TlsAvailable = _serverInfo.TlsAvailable,
+};
+```
+
+Modify `NatsClient.cs` — add `InfoAlreadySent` flag and use it:
+
+```csharp
+public bool InfoAlreadySent { get; set; }
+
+// In RunAsync, change:
+if (!InfoAlreadySent)
+ await SendInfoAsync(_clientCts.Token);
+```
+
+Add necessary `using` statements:
+
+```csharp
+using System.Net.Security;
+using System.Security.Cryptography.X509Certificates;
+using NATS.Server.Tls;
+```
+
+**Step 4: Run tests**
+
+Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsServerTests" -v normal`
+Expected: Both tests PASS
+
+Run: `dotnet test -v normal`
+Expected: All tests PASS
+
+**Step 5: Commit**
+
+```bash
+git add src/NATS.Server/NatsServer.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/TlsServerTests.cs
+git commit -m "feat: wire TLS negotiation into NatsServer accept loop"
+```
+
+---
+
+### Task 10: TLS CLI args in Host
+
+**Files:**
+- Modify: `src/NATS.Server.Host/Program.cs`
+
+**Step 1: Add TLS CLI args**
+
+Add to the switch in `Program.cs`:
+
+```csharp
+case "--tls":
+ // Just a flag — requires --tlscert and --tlskey
+ break;
+case "--tlscert" when i + 1 < args.Length:
+ options.TlsCert = args[++i];
+ break;
+case "--tlskey" when i + 1 < args.Length:
+ options.TlsKey = args[++i];
+ break;
+case "--tlscacert" when i + 1 < args.Length:
+ options.TlsCaCert = args[++i];
+ break;
+case "--tlsverify":
+ options.TlsVerify = true;
+ break;
+```
+
+**Step 2: Build and verify**
+
+Run: `dotnet build`
+Expected: Success
+
+**Step 3: Commit**
+
+```bash
+git add src/NATS.Server.Host/Program.cs
+git commit -m "feat: add --tls, --tlscert, --tlskey, --tlsverify CLI flags"
+```
+
+---
+
+### Task 11: Full integration tests — TLS modes, mixed mode, monitoring + TLS
+
+**Files:**
+- Modify: `tests/NATS.Server.Tests/TlsServerTests.cs` (add mixed mode, TLS-first, timeout tests)
+- Modify: `tests/NATS.Server.Tests/MonitorTests.cs` (add /connz TLS field test)
+
+**Step 1: Write additional TLS tests**
+
+Add to `TlsServerTests.cs` or create a new class `TlsMixedModeTests`:
+
+```csharp
+public class TlsMixedModeTests : IAsyncLifetime
+{
+ private readonly NatsServer _server;
+ private readonly int _port;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly string _certPath;
+ private readonly string _keyPath;
+
+ public TlsMixedModeTests()
+ {
+ _port = GetFreePort();
+ (_certPath, _keyPath) = TlsHelperTests.GenerateTestCertFiles();
+ _server = new NatsServer(
+ new NatsOptions
+ {
+ Port = _port,
+ TlsCert = _certPath,
+ TlsKey = _keyPath,
+ AllowNonTls = true,
+ },
+ NullLoggerFactory.Instance);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _ = _server.StartAsync(_cts.Token);
+ await _server.WaitForReadyAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ _cts.Cancel();
+ _server.Dispose();
+ File.Delete(_certPath);
+ File.Delete(_keyPath);
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task Mixed_mode_accepts_plain_client()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _port));
+ using var stream = new NetworkStream(sock);
+
+ var buf = new byte[4096];
+ var read = await stream.ReadAsync(buf);
+ var info = Encoding.ASCII.GetString(buf, 0, read);
+ info.ShouldContain("\"tls_available\":true");
+
+ // Send plaintext CONNECT + PING (no TLS upgrade)
+ await stream.WriteAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
+ await stream.FlushAsync();
+
+ var pongBuf = new byte[64];
+ read = await stream.ReadAsync(pongBuf);
+ var pong = Encoding.ASCII.GetString(pongBuf, 0, read);
+ pong.ShouldContain("PONG");
+ }
+
+ [Fact]
+ public async Task Mixed_mode_accepts_tls_client()
+ {
+ using var tcp = new TcpClient();
+ await tcp.ConnectAsync(IPAddress.Loopback, _port);
+ using var netStream = tcp.GetStream();
+
+ var buf = new byte[4096];
+ await netStream.ReadAsync(buf); // Read INFO
+
+ using var ssl = new SslStream(netStream, false, (_, _, _, _) => true);
+ await ssl.AuthenticateAsClientAsync("localhost");
+
+ await ssl.WriteAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
+ await ssl.FlushAsync();
+
+ var pongBuf = new byte[64];
+ var read = await ssl.ReadAsync(pongBuf);
+ var pong = Encoding.ASCII.GetString(pongBuf, 0, read);
+ pong.ShouldContain("PONG");
+ }
+
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+}
+```
+
+Add `/connz` TLS field test to `MonitorTests.cs`:
+
+```csharp
+// This test needs its own fixture with TLS enabled and monitoring:
+public class MonitorTlsTests : IAsyncLifetime
+{
+ private readonly NatsServer _server;
+ private readonly int _natsPort;
+ private readonly int _monitorPort;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly HttpClient _http = new();
+ private readonly string _certPath;
+ private readonly string _keyPath;
+
+ public MonitorTlsTests()
+ {
+ _natsPort = GetFreePort();
+ _monitorPort = GetFreePort();
+ (_certPath, _keyPath) = TlsHelperTests.GenerateTestCertFiles();
+ _server = new NatsServer(
+ new NatsOptions
+ {
+ Port = _natsPort,
+ MonitorPort = _monitorPort,
+ TlsCert = _certPath,
+ TlsKey = _keyPath,
+ },
+ NullLoggerFactory.Instance);
+ }
+
+ public async Task InitializeAsync()
+ {
+ _ = _server.StartAsync(_cts.Token);
+ await _server.WaitForReadyAsync();
+ await Task.Delay(200);
+ }
+
+ public async Task DisposeAsync()
+ {
+ _http.Dispose();
+ _cts.Cancel();
+ _server.Dispose();
+ File.Delete(_certPath);
+ File.Delete(_keyPath);
+ await Task.CompletedTask;
+ }
+
+ [Fact]
+ public async Task Connz_shows_tls_info_for_tls_client()
+ {
+ using var tcp = new TcpClient();
+ await tcp.ConnectAsync(IPAddress.Loopback, _natsPort);
+ using var netStream = tcp.GetStream();
+ var buf = new byte[4096];
+ await netStream.ReadAsync(buf);
+
+ using var ssl = new SslStream(netStream, false, (_, _, _, _) => true);
+ await ssl.AuthenticateAsClientAsync("localhost");
+ await ssl.WriteAsync("CONNECT {}\r\n"u8.ToArray());
+ await ssl.FlushAsync();
+ await Task.Delay(200);
+
+ var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
+ var connz = await response.Content.ReadFromJsonAsync();
+
+ connz!.Conns.Length.ShouldBeGreaterThanOrEqualTo(1);
+ var conn = connz.Conns[0];
+ conn.TlsVersion.ShouldNotBeNullOrEmpty();
+ conn.TlsCipher.ShouldNotBeNullOrEmpty();
+ }
+
+ private static int GetFreePort()
+ {
+ using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ sock.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return ((IPEndPoint)sock.LocalEndPoint!).Port;
+ }
+}
+```
+
+**Step 2: Run all tests**
+
+Run: `dotnet test -v normal`
+Expected: All tests PASS
+
+**Step 3: Commit**
+
+```bash
+git add tests/NATS.Server.Tests/
+git commit -m "feat: add TLS mixed mode tests and monitoring TLS field verification"
+```
+
+---
+
+## Dependency Graph
+
+```
+Task 0 (project setup)
+ ├─> Task 1 (ServerStats + metadata)
+ │ └─> Task 2 (NatsClient Stream refactor)
+ │ ├─> Task 3 (monitoring models)
+ │ │ └─> Task 4 (MonitorServer + /healthz + /varz)
+ │ │ └─> Task 5 (/connz)
+ │ │ └─> Task 6 (monitoring CLI)
+ │ └─> Task 7 (TLS helpers)
+ │ └─> Task 8 (TlsConnectionWrapper)
+ │ └─> Task 9 (wire TLS into accept loop)
+ │ └─> Task 10 (TLS CLI)
+ │ └─> Task 11 (full integration tests)
+ └─> Task 6 (monitoring CLI, also depends on Task 0 for config)
+```
diff --git a/docs/plans/2026-02-22-monitoring-tls-plan.md.tasks.json b/docs/plans/2026-02-22-monitoring-tls-plan.md.tasks.json
new file mode 100644
index 0000000..34c5247
--- /dev/null
+++ b/docs/plans/2026-02-22-monitoring-tls-plan.md.tasks.json
@@ -0,0 +1,18 @@
+{
+ "planPath": "docs/plans/2026-02-22-monitoring-tls-plan.md",
+ "tasks": [
+ {"id": 0, "nativeId": 6, "subject": "Task 0: Project setup — csproj and configuration options", "status": "pending"},
+ {"id": 1, "nativeId": 7, "subject": "Task 1: ServerStats and NatsClient metadata", "status": "pending", "blockedBy": [0]},
+ {"id": 2, "nativeId": 8, "subject": "Task 2: Refactor NatsClient to accept Stream", "status": "pending", "blockedBy": [1]},
+ {"id": 3, "nativeId": 9, "subject": "Task 3: Monitoring JSON models (Varz, Connz, nested stubs)", "status": "pending", "blockedBy": [2]},
+ {"id": 4, "nativeId": 10, "subject": "Task 4: MonitorServer with /healthz and /varz endpoints", "status": "pending", "blockedBy": [3]},
+ {"id": 5, "nativeId": 11, "subject": "Task 5: ConnzHandler and /connz endpoint", "status": "pending", "blockedBy": [4]},
+ {"id": 6, "nativeId": 12, "subject": "Task 6: Wire monitoring CLI args into Host", "status": "pending", "blockedBy": [0]},
+ {"id": 7, "nativeId": 13, "subject": "Task 7: TLS helpers — TlsHelper, PeekableStream, TlsRateLimiter", "status": "pending", "blockedBy": [2]},
+ {"id": 8, "nativeId": 14, "subject": "Task 8: TlsConnectionWrapper — 4-mode negotiation", "status": "pending", "blockedBy": [7]},
+ {"id": 9, "nativeId": 15, "subject": "Task 9: Wire TLS into NatsServer accept loop", "status": "pending", "blockedBy": [8]},
+ {"id": 10, "nativeId": 16, "subject": "Task 10: TLS CLI args in Host", "status": "pending", "blockedBy": [9]},
+ {"id": 11, "nativeId": 17, "subject": "Task 11: Full integration tests — TLS modes, mixed mode, monitoring + TLS", "status": "pending", "blockedBy": [9, 5]}
+ ],
+ "lastUpdated": "2026-02-22T00:00:00Z"
+}