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

88 KiB

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

<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:

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:

[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

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:

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:

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:

// 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:

// New fields:
private readonly ServerStats _serverStats;
public DateTime StartTime { get; }
public DateTime LastActivity;
public string? RemoteIp { get; }
public int RemotePort { get; }

Updated constructor:

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:

Interlocked.Increment(ref _serverStats.InMsgs);
Interlocked.Add(ref _serverStats.InBytes, cmd.Payload.Length);

In SendMessageAsync, add server stats increments:

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:

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

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:

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:

private readonly Stream _stream;

Update NatsServer.StartAsync accept loop:

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

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:

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:

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:

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

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:

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:

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:

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:

// 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:

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

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:

[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:

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:

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:

// In NatsClient.cs
public TlsConnectionState? TlsState { get; set; }

Create src/NATS.Server/Tls/TlsConnectionState.cs:

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

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:

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

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:

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:

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:

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:

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

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:

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:

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

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:

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:

// 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:

public bool InfoAlreadySent { get; set; }

// In RunAsync, change:
if (!InfoAlreadySent)
    await SendInfoAsync(_clientCts.Token);

Add necessary using statements:

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

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:

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

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:

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:

// 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

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)