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