Files
natsdotnet/docs/plans/2026-02-22-monitoring-tls-plan.md
Joseph Doherty 9d0d5064ac docs: add implementation plan for monitoring HTTP and TLS support
12 tasks covering ServerStats, monitoring models, Kestrel endpoints,
TLS helpers, 4-mode connection wrapper, and full integration tests.
2026-02-22 21:47:23 -05:00

2662 lines
88 KiB
Markdown

# 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
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
```
**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<string>? 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<string, long> 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<NatsClient> 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<string, long>? 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>();
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>();
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<Varz> 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<string, long>(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<MonitorServer> _logger;
public MonitorServer(NatsServer server, NatsOptions options, ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<MonitorServer>();
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>();
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<Socket>();
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>();
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<Connz>();
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<string> { hash };
TlsHelper.MatchesPinnedCert(cert, pinned).ShouldBeTrue();
}
[Fact]
public void MatchesPinnedCert_rejects_wrong_hash()
{
var (cert, _) = GenerateTestCert();
var pinned = new HashSet<string> { "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<string> 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<byte[]> 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<int> ReadAsync(Memory<byte> 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<int> 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<byte> 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<SslStream>();
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<byte[]> 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<SslStream> 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>();
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)
```