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