1912 lines
56 KiB
Markdown
1912 lines
56 KiB
Markdown
# Sections 7-10 Gaps Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Achieve Go parity for monitoring (subz, connz closed state, sorting), TLS cert mapping, logging infrastructure, and ping/pong RTT tracking.
|
|
|
|
**Architecture:** Four independent work streams touching separate files. Stream 1 (Monitoring) adds subz endpoint, closed-connection tracking, and missing connz sort/filter options. Stream 2 (TLS) adds cert-to-user mapping via X500DistinguishedName. Stream 3 (Logging) adds file rotation, debug/trace modes, color output, timestamp control, and log reopening. Stream 4 (Ping/Pong) adds RTT measurement, first-PING delay, stale connection stats, and ByRtt sorting.
|
|
|
|
**Tech Stack:** .NET 10, C# 14, xUnit 3, Shouldly, Serilog (Sinks.File, Sinks.SyslogMessages), System.Security.Cryptography.X509Certificates
|
|
|
|
---
|
|
|
|
### Task 0: Add NuGet dependencies for logging sinks
|
|
|
|
**Files:**
|
|
- Modify: `Directory.Packages.props:6-26`
|
|
- Modify: `src/NATS.Server.Host/NATS.Server.Host.csproj:11-14`
|
|
|
|
**Step 1: Add package versions to Directory.Packages.props**
|
|
|
|
Add under the `<!-- Logging -->` section after line 10:
|
|
|
|
```xml
|
|
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
|
<PackageVersion Include="Serilog.Sinks.SyslogMessages" Version="3.0.1" />
|
|
```
|
|
|
|
**Step 2: Add package references to Host csproj**
|
|
|
|
Add to the existing `<ItemGroup>` with Serilog packages in `src/NATS.Server.Host/NATS.Server.Host.csproj`:
|
|
|
|
```xml
|
|
<PackageReference Include="Serilog.Sinks.File" />
|
|
<PackageReference Include="Serilog.Sinks.SyslogMessages" />
|
|
```
|
|
|
|
**Step 3: Restore packages**
|
|
|
|
Run: `dotnet restore`
|
|
Expected: Success, no errors
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add Directory.Packages.props src/NATS.Server.Host/NATS.Server.Host.csproj
|
|
git commit -m "chore: add Serilog.Sinks.File and SyslogMessages packages"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Add logging and ping/pong options to NatsOptions
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsOptions.cs:6-68`
|
|
|
|
**Step 1: Add logging options after the `ConfigFile` property (line 48)**
|
|
|
|
```csharp
|
|
// Logging
|
|
public string? LogFile { get; set; }
|
|
public long LogSizeLimit { get; set; }
|
|
public int LogMaxFiles { get; set; }
|
|
public bool Debug { get; set; }
|
|
public bool Trace { get; set; }
|
|
public bool Logtime { get; set; } = true;
|
|
public bool LogtimeUTC { get; set; }
|
|
public bool Syslog { get; set; }
|
|
public string? RemoteSyslog { get; set; }
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
Run: `dotnet build src/NATS.Server/NATS.Server.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/NatsOptions.cs
|
|
git commit -m "feat: add logging and timestamp options to NatsOptions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Add CLI flag parsing for logging and debug/trace modes
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server.Host/Program.cs:12-59`
|
|
|
|
**Step 1: Add CLI flag cases inside the `switch` block**
|
|
|
|
Add after the existing `--tlsverify` case (line 57):
|
|
|
|
```csharp
|
|
case "-D":
|
|
options.Debug = true;
|
|
break;
|
|
case "-V" or "-T":
|
|
options.Trace = true;
|
|
break;
|
|
case "-DV":
|
|
options.Debug = true;
|
|
options.Trace = true;
|
|
break;
|
|
case "--log_file" when i + 1 < args.Length:
|
|
options.LogFile = args[++i];
|
|
break;
|
|
case "--log_size_limit" when i + 1 < args.Length:
|
|
options.LogSizeLimit = long.Parse(args[++i]);
|
|
break;
|
|
case "--log_max_files" when i + 1 < args.Length:
|
|
options.LogMaxFiles = int.Parse(args[++i]);
|
|
break;
|
|
case "--logtime" when i + 1 < args.Length:
|
|
options.Logtime = bool.Parse(args[++i]);
|
|
break;
|
|
case "--logtime_utc":
|
|
options.LogtimeUTC = true;
|
|
break;
|
|
case "--syslog":
|
|
options.Syslog = true;
|
|
break;
|
|
case "--remote_syslog" when i + 1 < args.Length:
|
|
options.RemoteSyslog = args[++i];
|
|
break;
|
|
```
|
|
|
|
**Step 2: Rebuild Serilog logger configuration based on options**
|
|
|
|
Replace the existing Serilog configuration block (lines 4-8) with:
|
|
|
|
```csharp
|
|
// Parse options first to configure logging from them
|
|
var options = new NatsOptions();
|
|
|
|
for (int i = 0; i < args.Length; i++)
|
|
{
|
|
switch (args[i])
|
|
{
|
|
// ... all existing cases plus the new ones above ...
|
|
}
|
|
}
|
|
|
|
// Build Serilog configuration from options
|
|
var logConfig = new LoggerConfiguration()
|
|
.Enrich.FromLogContext();
|
|
|
|
// Set minimum level based on flags
|
|
if (options.Trace)
|
|
logConfig.MinimumLevel.Verbose();
|
|
else if (options.Debug)
|
|
logConfig.MinimumLevel.Debug();
|
|
else
|
|
logConfig.MinimumLevel.Information();
|
|
|
|
// Build output template
|
|
var timestampFormat = options.LogtimeUTC
|
|
? "{Timestamp:yyyy/MM/dd HH:mm:ss.ffffff} "
|
|
: "{Timestamp:HH:mm:ss} ";
|
|
var template = options.Logtime
|
|
? $"[{timestampFormat}{{Level:u3}}] {{Message:lj}}{{NewLine}}{{Exception}}"
|
|
: "[{Level:u3}] {Message:lj}{NewLine}{Exception}";
|
|
|
|
// Console sink with color auto-detection
|
|
if (!Console.IsOutputRedirected)
|
|
logConfig.WriteTo.Console(outputTemplate: template, theme: Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme.Code);
|
|
else
|
|
logConfig.WriteTo.Console(outputTemplate: template);
|
|
|
|
// File sink with rotation
|
|
if (!string.IsNullOrEmpty(options.LogFile))
|
|
{
|
|
logConfig.WriteTo.File(
|
|
options.LogFile,
|
|
fileSizeLimitBytes: options.LogSizeLimit > 0 ? options.LogSizeLimit : null,
|
|
retainedFileCountLimit: options.LogMaxFiles > 0 ? options.LogMaxFiles : null,
|
|
rollOnFileSizeLimit: options.LogSizeLimit > 0,
|
|
outputTemplate: template);
|
|
}
|
|
|
|
// Syslog sink
|
|
if (!string.IsNullOrEmpty(options.RemoteSyslog))
|
|
{
|
|
logConfig.WriteTo.UdpSyslog(options.RemoteSyslog);
|
|
}
|
|
else if (options.Syslog)
|
|
{
|
|
logConfig.WriteTo.LocalSyslog("nats-server");
|
|
}
|
|
|
|
Log.Logger = logConfig.CreateLogger();
|
|
```
|
|
|
|
Also add the required `using` at the top of the file:
|
|
|
|
```csharp
|
|
using Serilog.Sinks.SystemConsole.Themes;
|
|
```
|
|
|
|
And remove the duplicate `var options = new NatsOptions();` line and CLI parsing block that now comes before logger setup (the options parsing and logger build are combined at the top).
|
|
|
|
**Step 3: Verify build**
|
|
|
|
Run: `dotnet build src/NATS.Server.Host/NATS.Server.Host.csproj`
|
|
Expected: Build succeeded
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server.Host/Program.cs
|
|
git commit -m "feat: add CLI flags for debug/trace modes, file logging, syslog, color, timestamps"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Implement log reopening on SIGUSR1
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsServer.cs:197-235` (HandleSignals)
|
|
- Modify: `src/NATS.Server.Host/Program.cs`
|
|
|
|
**Step 1: Add `ReOpenLogFile` callback property on NatsServer**
|
|
|
|
Add after the `IsLameDuckMode` property (around line 63 in NatsServer.cs):
|
|
|
|
```csharp
|
|
public Action? ReOpenLogFile { get; set; }
|
|
```
|
|
|
|
**Step 2: Update SIGUSR1 handler in HandleSignals**
|
|
|
|
Replace the existing SIGUSR1 handler (lines 222-226) with:
|
|
|
|
```csharp
|
|
_signalRegistrations.Add(PosixSignalRegistration.Create((PosixSignal)10, ctx =>
|
|
{
|
|
ctx.Cancel = true;
|
|
_logger.LogInformation("Trapped SIGUSR1 signal — reopening log file");
|
|
ReOpenLogFile?.Invoke();
|
|
}));
|
|
```
|
|
|
|
**Step 3: Wire up the callback in Program.cs**
|
|
|
|
After `server.HandleSignals();` add:
|
|
|
|
```csharp
|
|
server.ReOpenLogFile = () =>
|
|
{
|
|
Log.Information("Reopening log file");
|
|
Log.CloseAndFlush();
|
|
// Rebuild logger with same configuration
|
|
// (The logConfig variable must be captured in closure or rebuilt)
|
|
Log.Logger = logConfig.CreateLogger();
|
|
Log.Information("File log re-opened");
|
|
};
|
|
```
|
|
|
|
Note: The `logConfig` variable from Task 2 must be extracted to a scope visible here. Move the `LoggerConfiguration` build into a local function or store `logConfig` before `CreateLogger()`.
|
|
|
|
**Step 4: Verify build**
|
|
|
|
Run: `dotnet build`
|
|
Expected: Build succeeded
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/NatsServer.cs src/NATS.Server.Host/Program.cs
|
|
git commit -m "feat: implement log reopening on SIGUSR1 signal"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Add RTT tracking to NatsClient
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/NatsClient.cs:72-74` (ping state), `322-324` (pong handler), `607-639` (ping timer)
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/RttTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Server.Monitoring;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class RttTests : IAsyncLifetime
|
|
{
|
|
private readonly NatsServer _server;
|
|
private readonly int _natsPort;
|
|
private readonly int _monitorPort;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly HttpClient _http = new();
|
|
|
|
public RttTests()
|
|
{
|
|
_natsPort = GetFreePort();
|
|
_monitorPort = GetFreePort();
|
|
_server = new NatsServer(
|
|
new NatsOptions
|
|
{
|
|
Port = _natsPort,
|
|
MonitorPort = _monitorPort,
|
|
PingInterval = TimeSpan.FromMilliseconds(200),
|
|
MaxPingsOut = 4,
|
|
},
|
|
NullLoggerFactory.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_ = _server.StartAsync(_cts.Token);
|
|
await _server.WaitForReadyAsync();
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
try
|
|
{
|
|
var resp = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
|
|
if (resp.IsSuccessStatusCode) break;
|
|
}
|
|
catch (HttpRequestException) { }
|
|
await Task.Delay(50);
|
|
}
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
_http.Dispose();
|
|
await _cts.CancelAsync();
|
|
_server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rtt_populated_after_ping_pong_cycle()
|
|
{
|
|
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
|
|
|
|
// Send CONNECT + PING (triggers firstPongSent)
|
|
await stream.WriteAsync("CONNECT {}\r\nPING\r\n"u8.ToArray());
|
|
await stream.FlushAsync();
|
|
_ = await stream.ReadAsync(buf); // PONG
|
|
|
|
// Wait for server's PING cycle
|
|
await Task.Delay(500);
|
|
|
|
// Read server PING and respond with PONG
|
|
var received = new byte[4096];
|
|
int totalRead = 0;
|
|
bool gotPing = false;
|
|
using var readCts = new CancellationTokenSource(2000);
|
|
while (!gotPing && !readCts.IsCancellationRequested)
|
|
{
|
|
var n = await stream.ReadAsync(received.AsMemory(totalRead), readCts.Token);
|
|
totalRead += n;
|
|
var text = System.Text.Encoding.ASCII.GetString(received, 0, totalRead);
|
|
if (text.Contains("PING"))
|
|
{
|
|
gotPing = true;
|
|
await stream.WriteAsync("PONG\r\n"u8.ToArray());
|
|
await stream.FlushAsync();
|
|
}
|
|
}
|
|
|
|
gotPing.ShouldBeTrue("Server should have sent PING");
|
|
|
|
// Wait for RTT to be computed
|
|
await Task.Delay(200);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz");
|
|
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
|
connz.ShouldNotBeNull();
|
|
var conn = connz.Conns.FirstOrDefault(c => c.Rtt != "");
|
|
conn.ShouldNotBeNull("At least one connection should have RTT populated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connz_sort_by_rtt()
|
|
{
|
|
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\n"u8.ToArray());
|
|
await Task.Delay(200);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=rtt");
|
|
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
|
|
}
|
|
|
|
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~RttTests" -v normal`
|
|
Expected: Tests fail (RTT never populated, `sort=rtt` not recognized)
|
|
|
|
**Step 3: Add RTT fields to NatsClient**
|
|
|
|
In `src/NATS.Server/NatsClient.cs`, add after line 74 (`private long _lastIn;`):
|
|
|
|
```csharp
|
|
// RTT tracking
|
|
private long _rttStartTicks;
|
|
private long _rtt;
|
|
public TimeSpan Rtt => new(Interlocked.Read(ref _rtt));
|
|
```
|
|
|
|
**Step 4: Update PONG handler to compute RTT**
|
|
|
|
Replace the `CommandType.Pong` case at line 322-323:
|
|
|
|
```csharp
|
|
case CommandType.Pong:
|
|
Interlocked.Exchange(ref _pingsOut, 0);
|
|
var rttStart = Interlocked.Read(ref _rttStartTicks);
|
|
if (rttStart > 0)
|
|
{
|
|
var elapsed = DateTime.UtcNow.Ticks - rttStart;
|
|
if (elapsed <= 0) elapsed = 1; // min 1 tick for Windows granularity
|
|
Interlocked.Exchange(ref _rtt, elapsed);
|
|
}
|
|
_flags.SetFlag(ClientFlags.FirstPongSent);
|
|
break;
|
|
```
|
|
|
|
**Step 5: Record RTT start time when sending PING**
|
|
|
|
In `RunPingTimerAsync`, add before `WriteProtocol(NatsProtocol.PingBytes);` (line 632):
|
|
|
|
```csharp
|
|
Interlocked.Exchange(ref _rttStartTicks, DateTime.UtcNow.Ticks);
|
|
```
|
|
|
|
**Step 6: Add first-PING delay logic**
|
|
|
|
In `RunPingTimerAsync`, add at the top of the `while` loop body (after `while (await timer.WaitForNextTickAsync(ct))` at line 612), before the elapsed check:
|
|
|
|
```csharp
|
|
// Delay first PING until client has responded with PONG or 2 seconds elapsed
|
|
if (!_flags.HasFlag(ClientFlags.FirstPongSent)
|
|
&& (DateTime.UtcNow - StartTime).TotalSeconds < 2)
|
|
{
|
|
continue;
|
|
}
|
|
```
|
|
|
|
**Step 7: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~RttTests.Rtt_populated" -v normal`
|
|
Expected: Still fails — ByRtt sort not added yet (that's task 6)
|
|
|
|
**Step 8: Commit the RTT tracking (independent of sort)**
|
|
|
|
```bash
|
|
git add src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/RttTests.cs
|
|
git commit -m "feat: add RTT tracking and first-PING delay to NatsClient"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Add stale connection stats and expose in varz
|
|
|
|
**Files:**
|
|
- Modify: `src/NATS.Server/ServerStats.cs:1-20`
|
|
- Modify: `src/NATS.Server/Monitoring/Varz.cs:153-161`
|
|
- Modify: `src/NATS.Server/Monitoring/VarzHandler.cs:86-93`
|
|
- Modify: `src/NATS.Server/NatsClient.cs:646-665` (MarkClosed)
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Add to `tests/NATS.Server.Tests/ServerStatsTests.cs`:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public void StaleConnection_stats_incremented_on_mark_closed()
|
|
{
|
|
var stats = new ServerStats();
|
|
stats.StaleConnectionClients.ShouldBe(0);
|
|
|
|
Interlocked.Increment(ref stats.StaleConnectionClients);
|
|
stats.StaleConnectionClients.ShouldBe(1);
|
|
}
|
|
```
|
|
|
|
**Step 2: Add stale connection fields to ServerStats**
|
|
|
|
In `src/NATS.Server/ServerStats.cs`, add after `SlowConsumerGateways` (line 16):
|
|
|
|
```csharp
|
|
public long StaleConnectionClients;
|
|
public long StaleConnectionRoutes;
|
|
public long StaleConnectionLeafs;
|
|
public long StaleConnectionGateways;
|
|
```
|
|
|
|
**Step 3: Add StaleConnectionStats model to Varz.cs**
|
|
|
|
Add after the `SlowConsumersStats` class (after line 220):
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Statistics about stale connections by connection type.
|
|
/// Corresponds to Go server/monitor.go StaleConnectionStats struct.
|
|
/// </summary>
|
|
public sealed class StaleConnectionStats
|
|
{
|
|
[JsonPropertyName("clients")]
|
|
public ulong Clients { get; set; }
|
|
|
|
[JsonPropertyName("routes")]
|
|
public ulong Routes { get; set; }
|
|
|
|
[JsonPropertyName("gateways")]
|
|
public ulong Gateways { get; set; }
|
|
|
|
[JsonPropertyName("leafs")]
|
|
public ulong Leafs { get; set; }
|
|
}
|
|
```
|
|
|
|
Add to the `Varz` class after `SlowConsumerStats` (after line 158):
|
|
|
|
```csharp
|
|
[JsonPropertyName("stale_connections")]
|
|
public long StaleConnections { get; set; }
|
|
|
|
[JsonPropertyName("stale_connection_stats")]
|
|
public StaleConnectionStats StaleConnectionStatsDetail { get; set; } = new();
|
|
```
|
|
|
|
**Step 4: Populate in VarzHandler**
|
|
|
|
In `src/NATS.Server/Monitoring/VarzHandler.cs`, add after line 93 (after `SlowConsumerStats` block):
|
|
|
|
```csharp
|
|
StaleConnections = Interlocked.Read(ref stats.StaleConnections),
|
|
StaleConnectionStatsDetail = new StaleConnectionStats
|
|
{
|
|
Clients = (ulong)Interlocked.Read(ref stats.StaleConnectionClients),
|
|
Routes = (ulong)Interlocked.Read(ref stats.StaleConnectionRoutes),
|
|
Gateways = (ulong)Interlocked.Read(ref stats.StaleConnectionGateways),
|
|
Leafs = (ulong)Interlocked.Read(ref stats.StaleConnectionLeafs),
|
|
},
|
|
```
|
|
|
|
**Step 5: Increment stale stats in NatsClient.MarkClosed**
|
|
|
|
In `src/NATS.Server/NatsClient.cs`, inside the `MarkClosed` method, add after the existing switch (around line 662):
|
|
|
|
```csharp
|
|
if (reason == ClientClosedReason.StaleConnection)
|
|
{
|
|
Interlocked.Increment(ref _serverStats.StaleConnections);
|
|
Interlocked.Increment(ref _serverStats.StaleConnectionClients);
|
|
}
|
|
```
|
|
|
|
**Step 6: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~StaleConnection" -v normal`
|
|
Expected: PASS
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/ServerStats.cs src/NATS.Server/Monitoring/Varz.cs src/NATS.Server/Monitoring/VarzHandler.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/ServerStatsTests.cs
|
|
git commit -m "feat: add stale connection stats tracking and varz exposure"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add closed connection tracking and connz state filtering
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Monitoring/ClosedClient.cs`
|
|
- Modify: `src/NATS.Server/NatsServer.cs:20-21,64,575-582`
|
|
- Modify: `src/NATS.Server/Monitoring/ConnzHandler.cs:11-49,99-138`
|
|
- Modify: `src/NATS.Server/Monitoring/Connz.cs:158-171`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Add to `tests/NATS.Server.Tests/MonitorTests.cs` inside the `MonitorTests` class:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task Connz_state_closed_returns_disconnected_clients()
|
|
{
|
|
// Connect then disconnect a client
|
|
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\":\"closing-client\"}\r\n"u8.ToArray());
|
|
await Task.Delay(200);
|
|
sock.Shutdown(SocketShutdown.Both);
|
|
sock.Dispose();
|
|
await Task.Delay(500); // Wait for server to detect disconnect
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?state=closed");
|
|
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
|
connz.ShouldNotBeNull();
|
|
connz.Conns.ShouldContain(c => c.Name == "closing-client");
|
|
var closed = connz.Conns.First(c => c.Name == "closing-client");
|
|
closed.Stop.ShouldNotBeNull();
|
|
closed.Reason.ShouldNotBeNullOrEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connz_sort_by_stop_requires_closed_state()
|
|
{
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=stop&state=open");
|
|
// Should return 400 or ignore invalid sort for open state
|
|
var connz = await response.Content.ReadFromJsonAsync<Connz>();
|
|
// Fallback to ByCid when sort is invalid for state
|
|
connz.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connz_sort_by_reason()
|
|
{
|
|
// Connect then disconnect a client
|
|
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await sock.ConnectAsync(new IPEndPoint(IPAddress.Loopback, _natsPort));
|
|
var buf = new byte[4096];
|
|
_ = await sock.ReceiveAsync(buf);
|
|
sock.Shutdown(SocketShutdown.Both);
|
|
sock.Dispose();
|
|
await Task.Delay(500);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/connz?sort=reason&state=closed");
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Connz_state_closed" -v normal`
|
|
Expected: FAIL
|
|
|
|
**Step 3: Create ClosedClient record**
|
|
|
|
Create `src/NATS.Server/Monitoring/ClosedClient.cs`:
|
|
|
|
```csharp
|
|
namespace NATS.Server.Monitoring;
|
|
|
|
/// <summary>
|
|
/// Snapshot of a closed client connection for /connz reporting.
|
|
/// Corresponds to Go server/monitor.go closedClient struct.
|
|
/// </summary>
|
|
public sealed record ClosedClient
|
|
{
|
|
public required ulong Cid { get; init; }
|
|
public string Ip { get; init; } = "";
|
|
public int Port { get; init; }
|
|
public DateTime Start { get; init; }
|
|
public DateTime Stop { get; init; }
|
|
public string Reason { get; init; } = "";
|
|
public string Name { get; init; } = "";
|
|
public string Lang { get; init; } = "";
|
|
public string Version { get; init; } = "";
|
|
public long InMsgs { get; init; }
|
|
public long OutMsgs { get; init; }
|
|
public long InBytes { get; init; }
|
|
public long OutBytes { get; init; }
|
|
public uint NumSubs { get; init; }
|
|
public TimeSpan Rtt { get; init; }
|
|
public string TlsVersion { get; init; } = "";
|
|
public string TlsCipherSuite { get; init; } = "";
|
|
}
|
|
```
|
|
|
|
**Step 4: Add closed client tracking to NatsServer**
|
|
|
|
In `src/NATS.Server/NatsServer.cs`, add field after `_nextClientId` (line 35):
|
|
|
|
```csharp
|
|
private readonly ConcurrentQueue<ClosedClient> _closedClients = new();
|
|
private const int MaxClosedClients = 10_000;
|
|
```
|
|
|
|
Add public accessor after `GetClients()` (line 64):
|
|
|
|
```csharp
|
|
public IEnumerable<ClosedClient> GetClosedClients() => _closedClients;
|
|
public IEnumerable<Account> GetAccounts() => _accounts.Values;
|
|
```
|
|
|
|
Add import at top:
|
|
|
|
```csharp
|
|
using NATS.Server.Monitoring;
|
|
```
|
|
|
|
**Step 5: Populate closed client snapshot in RemoveClient**
|
|
|
|
Replace `RemoveClient` method (lines 575-582):
|
|
|
|
```csharp
|
|
public void RemoveClient(NatsClient client)
|
|
{
|
|
_clients.TryRemove(client.Id, out _);
|
|
_logger.LogDebug("Removed client {ClientId}", client.Id);
|
|
|
|
// Snapshot for closed-connections tracking
|
|
_closedClients.Enqueue(new ClosedClient
|
|
{
|
|
Cid = client.Id,
|
|
Ip = client.RemoteIp ?? "",
|
|
Port = client.RemotePort,
|
|
Start = client.StartTime,
|
|
Stop = DateTime.UtcNow,
|
|
Reason = client.CloseReason.ToReasonString(),
|
|
Name = client.ClientOpts?.Name ?? "",
|
|
Lang = client.ClientOpts?.Lang ?? "",
|
|
Version = client.ClientOpts?.Version ?? "",
|
|
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 = (uint)client.Subscriptions.Count,
|
|
Rtt = client.Rtt,
|
|
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
|
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
|
});
|
|
|
|
// Cap closed clients list
|
|
while (_closedClients.Count > MaxClosedClients)
|
|
_closedClients.TryDequeue(out _);
|
|
|
|
var subList = client.Account?.SubList ?? _globalAccount.SubList;
|
|
client.RemoveAllSubscriptions(subList);
|
|
client.Account?.RemoveClient(client.Id);
|
|
}
|
|
```
|
|
|
|
**Step 6: Add ByStop, ByReason, ByRtt to SortOpt enum**
|
|
|
|
In `src/NATS.Server/Monitoring/Connz.cs`, update the `SortOpt` enum:
|
|
|
|
```csharp
|
|
public enum SortOpt
|
|
{
|
|
ByCid,
|
|
ByStart,
|
|
BySubs,
|
|
ByPending,
|
|
ByMsgsTo,
|
|
ByMsgsFrom,
|
|
ByBytesTo,
|
|
ByBytesFrom,
|
|
ByLast,
|
|
ByIdle,
|
|
ByUptime,
|
|
ByStop,
|
|
ByReason,
|
|
ByRtt,
|
|
}
|
|
```
|
|
|
|
**Step 7: Update ConnzHandler to support state filtering and new sorts**
|
|
|
|
Replace `HandleConnz` method in `src/NATS.Server/Monitoring/ConnzHandler.cs`:
|
|
|
|
```csharp
|
|
public Connz HandleConnz(HttpContext ctx)
|
|
{
|
|
var opts = ParseQueryParams(ctx);
|
|
var now = DateTime.UtcNow;
|
|
|
|
var connInfos = new List<ConnInfo>();
|
|
|
|
// Collect open connections
|
|
if (opts.State is ConnState.Open or ConnState.All)
|
|
{
|
|
var clients = server.GetClients().ToArray();
|
|
connInfos.AddRange(clients.Select(c => BuildConnInfo(c, now, opts)));
|
|
}
|
|
|
|
// Collect closed connections
|
|
if (opts.State is ConnState.Closed or ConnState.All)
|
|
{
|
|
connInfos.AddRange(server.GetClosedClients().Select(c => BuildClosedConnInfo(c, now, opts)));
|
|
}
|
|
|
|
// Validate sort options that require closed state
|
|
if (opts.Sort is SortOpt.ByStop or SortOpt.ByReason && opts.State == ConnState.Open)
|
|
opts.Sort = SortOpt.ByCid; // Fallback
|
|
|
|
// 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(),
|
|
SortOpt.ByUptime => connInfos.OrderByDescending(c => now - c.Start).ToList(),
|
|
SortOpt.ByStop => connInfos.OrderByDescending(c => c.Stop ?? DateTime.MinValue).ToList(),
|
|
SortOpt.ByReason => connInfos.OrderBy(c => c.Reason).ToList(),
|
|
SortOpt.ByRtt => connInfos.OrderByDescending(c => c.Rtt).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,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 8: Add BuildClosedConnInfo helper**
|
|
|
|
Add after `BuildConnInfo` in `ConnzHandler.cs`:
|
|
|
|
```csharp
|
|
private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts)
|
|
{
|
|
return new ConnInfo
|
|
{
|
|
Cid = closed.Cid,
|
|
Kind = "Client",
|
|
Type = "Client",
|
|
Ip = closed.Ip,
|
|
Port = closed.Port,
|
|
Start = closed.Start,
|
|
Stop = closed.Stop,
|
|
LastActivity = closed.Stop,
|
|
Uptime = FormatDuration(closed.Stop - closed.Start),
|
|
Idle = FormatDuration(now - closed.Stop),
|
|
InMsgs = closed.InMsgs,
|
|
OutMsgs = closed.OutMsgs,
|
|
InBytes = closed.InBytes,
|
|
OutBytes = closed.OutBytes,
|
|
NumSubs = closed.NumSubs,
|
|
Name = closed.Name,
|
|
Lang = closed.Lang,
|
|
Version = closed.Version,
|
|
Reason = closed.Reason,
|
|
Rtt = FormatRtt(closed.Rtt),
|
|
TlsVersion = closed.TlsVersion,
|
|
TlsCipherSuite = closed.TlsCipherSuite,
|
|
};
|
|
}
|
|
|
|
private static string FormatRtt(TimeSpan rtt)
|
|
{
|
|
if (rtt == TimeSpan.Zero) return "";
|
|
if (rtt.TotalMilliseconds < 1)
|
|
return $"{rtt.TotalMicroseconds:F3}µs";
|
|
if (rtt.TotalSeconds < 1)
|
|
return $"{rtt.TotalMilliseconds:F3}ms";
|
|
return $"{rtt.TotalSeconds:F3}s";
|
|
}
|
|
```
|
|
|
|
**Step 9: Update BuildConnInfo to include RTT**
|
|
|
|
In the existing `BuildConnInfo` method, add after `TlsCipherSuite` (line 75):
|
|
|
|
```csharp
|
|
Rtt = FormatRtt(client.Rtt),
|
|
```
|
|
|
|
**Step 10: Update ParseQueryParams for new sort options and state filter**
|
|
|
|
In `ParseQueryParams`, add the new sort cases and state parsing:
|
|
|
|
```csharp
|
|
// Add to the sort switch:
|
|
"stop" => SortOpt.ByStop,
|
|
"reason" => SortOpt.ByReason,
|
|
"rtt" => SortOpt.ByRtt,
|
|
|
|
// Add state parsing after the existing limit parsing:
|
|
if (q.TryGetValue("state", out var state))
|
|
{
|
|
opts.State = state.ToString().ToLowerInvariant() switch
|
|
{
|
|
"open" => ConnState.Open,
|
|
"closed" => ConnState.Closed,
|
|
"all" => ConnState.All,
|
|
_ => ConnState.Open,
|
|
};
|
|
}
|
|
```
|
|
|
|
**Step 11: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~Connz_state_closed|FullyQualifiedName~Connz_sort_by_stop|FullyQualifiedName~Connz_sort_by_reason|FullyQualifiedName~RttTests" -v normal`
|
|
Expected: All PASS
|
|
|
|
**Step 12: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Monitoring/ClosedClient.cs src/NATS.Server/NatsServer.cs src/NATS.Server/Monitoring/ConnzHandler.cs src/NATS.Server/Monitoring/Connz.cs tests/NATS.Server.Tests/MonitorTests.cs tests/NATS.Server.Tests/RttTests.cs
|
|
git commit -m "feat: add closed connection tracking, state filtering, ByStop/ByReason/ByRtt sorting"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Implement /subz endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Monitoring/Subsz.cs`
|
|
- Create: `src/NATS.Server/Monitoring/SubszHandler.cs`
|
|
- Modify: `src/NATS.Server/Monitoring/MonitorServer.cs:78-87`
|
|
- Modify: `src/NATS.Server/Subscriptions/SubList.cs` (add Stats method)
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/SubszTests.cs`:
|
|
|
|
```csharp
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using System.Net.Sockets;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Server.Monitoring;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class SubszTests : IAsyncLifetime
|
|
{
|
|
private readonly NatsServer _server;
|
|
private readonly int _natsPort;
|
|
private readonly int _monitorPort;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly HttpClient _http = new();
|
|
|
|
public SubszTests()
|
|
{
|
|
_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();
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
try
|
|
{
|
|
var resp = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
|
|
if (resp.IsSuccessStatusCode) break;
|
|
}
|
|
catch (HttpRequestException) { }
|
|
await Task.Delay(50);
|
|
}
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
_http.Dispose();
|
|
await _cts.CancelAsync();
|
|
_server.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subz_returns_empty_when_no_subscriptions()
|
|
{
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
|
|
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
|
|
|
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
|
|
subz.ShouldNotBeNull();
|
|
subz.NumSubs.ShouldBe(0u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subz_returns_count_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\nSUB baz.* 3\r\n"u8.ToArray());
|
|
await Task.Delay(200);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz");
|
|
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
|
|
subz.ShouldNotBeNull();
|
|
subz.NumSubs.ShouldBeGreaterThanOrEqualTo(3u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subz_subs_true_returns_subscription_details()
|
|
{
|
|
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\n"u8.ToArray());
|
|
await Task.Delay(200);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz?subs=true");
|
|
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
|
|
subz.ShouldNotBeNull();
|
|
subz.Subs.ShouldNotBeEmpty();
|
|
subz.Subs.ShouldContain(s => s.Subject == "foo");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subz_test_subject_filters_matching_subs()
|
|
{
|
|
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}/subz?subs=true&test=foo.hello");
|
|
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
|
|
subz.ShouldNotBeNull();
|
|
subz.Subs.ShouldContain(s => s.Subject == "foo.*");
|
|
subz.Subs.ShouldNotContain(s => s.Subject == "bar");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subz_pagination()
|
|
{
|
|
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 a 1\r\nSUB b 2\r\nSUB c 3\r\n"u8.ToArray());
|
|
await Task.Delay(200);
|
|
|
|
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/subz?subs=true&offset=0&limit=2");
|
|
var subz = await response.Content.ReadFromJsonAsync<Subsz>();
|
|
subz.ShouldNotBeNull();
|
|
subz.Subs.Length.ShouldBe(2);
|
|
subz.Total.ShouldBeGreaterThanOrEqualTo(3);
|
|
}
|
|
|
|
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 tests to verify they fail**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubszTests" -v normal`
|
|
Expected: FAIL (subz returns empty `{}`)
|
|
|
|
**Step 3: Add SubList.GetAllSubscriptions() method**
|
|
|
|
In `src/NATS.Server/Subscriptions/SubList.cs`, add a public method to enumerate all subscriptions. Add after the `Count` property (line 34):
|
|
|
|
```csharp
|
|
/// <summary>
|
|
/// Returns all subscriptions in the trie. For monitoring only.
|
|
/// </summary>
|
|
public List<Subscription> GetAllSubscriptions()
|
|
{
|
|
_lock.EnterReadLock();
|
|
try
|
|
{
|
|
var result = new List<Subscription>();
|
|
CollectAll(_root, result);
|
|
return result;
|
|
}
|
|
finally
|
|
{
|
|
_lock.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
private static void CollectAll(TrieLevel level, List<Subscription> result)
|
|
{
|
|
foreach (var (_, node) in level.Nodes)
|
|
{
|
|
foreach (var sub in node.PlainSubs) result.Add(sub);
|
|
foreach (var (_, qset) in node.QueueSubs)
|
|
foreach (var sub in qset) result.Add(sub);
|
|
if (node.Next != null) CollectAll(node.Next, result);
|
|
}
|
|
if (level.Pwc != null)
|
|
{
|
|
foreach (var sub in level.Pwc.PlainSubs) result.Add(sub);
|
|
foreach (var (_, qset) in level.Pwc.QueueSubs)
|
|
foreach (var sub in qset) result.Add(sub);
|
|
if (level.Pwc.Next != null) CollectAll(level.Pwc.Next, result);
|
|
}
|
|
if (level.Fwc != null)
|
|
{
|
|
foreach (var sub in level.Fwc.PlainSubs) result.Add(sub);
|
|
foreach (var (_, qset) in level.Fwc.QueueSubs)
|
|
foreach (var sub in qset) result.Add(sub);
|
|
if (level.Fwc.Next != null) CollectAll(level.Fwc.Next, result);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the current number of entries in the cache.
|
|
/// </summary>
|
|
public int CacheCount
|
|
{
|
|
get
|
|
{
|
|
_lock.EnterReadLock();
|
|
try { return _cache?.Count ?? 0; }
|
|
finally { _lock.ExitReadLock(); }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Create Subsz model**
|
|
|
|
Create `src/NATS.Server/Monitoring/Subsz.cs`:
|
|
|
|
```csharp
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace NATS.Server.Monitoring;
|
|
|
|
/// <summary>
|
|
/// Subscription information response. Corresponds to Go server/monitor.go Subsz struct.
|
|
/// </summary>
|
|
public sealed class Subsz
|
|
{
|
|
[JsonPropertyName("server_id")]
|
|
public string Id { get; set; } = "";
|
|
|
|
[JsonPropertyName("now")]
|
|
public DateTime Now { get; set; }
|
|
|
|
[JsonPropertyName("num_subscriptions")]
|
|
public uint NumSubs { get; set; }
|
|
|
|
[JsonPropertyName("num_cache")]
|
|
public int NumCache { get; set; }
|
|
|
|
[JsonPropertyName("total")]
|
|
public int Total { get; set; }
|
|
|
|
[JsonPropertyName("offset")]
|
|
public int Offset { get; set; }
|
|
|
|
[JsonPropertyName("limit")]
|
|
public int Limit { get; set; }
|
|
|
|
[JsonPropertyName("subscriptions")]
|
|
public SubDetail[] Subs { get; set; } = [];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Options passed to Subsz() for filtering.
|
|
/// Corresponds to Go server/monitor.go SubszOptions struct.
|
|
/// </summary>
|
|
public sealed class SubszOptions
|
|
{
|
|
public int Offset { get; set; }
|
|
public int Limit { get; set; } = 1024;
|
|
public bool Subscriptions { get; set; }
|
|
public string Account { get; set; } = "";
|
|
public string Test { get; set; } = "";
|
|
}
|
|
```
|
|
|
|
**Step 5: Create SubszHandler**
|
|
|
|
Create `src/NATS.Server/Monitoring/SubszHandler.cs`:
|
|
|
|
```csharp
|
|
using Microsoft.AspNetCore.Http;
|
|
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Monitoring;
|
|
|
|
/// <summary>
|
|
/// Handles /subz endpoint requests, returning subscription information.
|
|
/// Corresponds to Go server/monitor.go handleSubsz function.
|
|
/// </summary>
|
|
public sealed class SubszHandler(NatsServer server)
|
|
{
|
|
public Subsz HandleSubsz(HttpContext ctx)
|
|
{
|
|
var opts = ParseQueryParams(ctx);
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Collect subscriptions from all accounts (or filtered)
|
|
var allSubs = new List<Subscription>();
|
|
foreach (var account in server.GetAccounts())
|
|
{
|
|
if (!string.IsNullOrEmpty(opts.Account) && account.Name != opts.Account)
|
|
continue;
|
|
allSubs.AddRange(account.SubList.GetAllSubscriptions());
|
|
}
|
|
|
|
// Filter by test subject if provided
|
|
if (!string.IsNullOrEmpty(opts.Test))
|
|
{
|
|
allSubs = allSubs.Where(s => SubjectMatch.MatchLiteral(opts.Test, s.Subject)).ToList();
|
|
}
|
|
|
|
var total = allSubs.Count;
|
|
var numSubs = server.GetAccounts()
|
|
.Where(a => string.IsNullOrEmpty(opts.Account) || a.Name == opts.Account)
|
|
.Aggregate(0u, (sum, a) => sum + a.SubList.Count);
|
|
var numCache = server.GetAccounts()
|
|
.Where(a => string.IsNullOrEmpty(opts.Account) || a.Name == opts.Account)
|
|
.Sum(a => a.SubList.CacheCount);
|
|
|
|
SubDetail[] details = [];
|
|
if (opts.Subscriptions)
|
|
{
|
|
details = allSubs
|
|
.Skip(opts.Offset)
|
|
.Take(opts.Limit)
|
|
.Select(s => new SubDetail
|
|
{
|
|
Subject = s.Subject,
|
|
Queue = s.Queue ?? "",
|
|
Sid = s.Sid,
|
|
Msgs = Interlocked.Read(ref s.MessageCount),
|
|
Max = s.MaxMessages,
|
|
Cid = s.Client?.Id ?? 0,
|
|
})
|
|
.ToArray();
|
|
}
|
|
|
|
return new Subsz
|
|
{
|
|
Id = server.ServerId,
|
|
Now = now,
|
|
NumSubs = numSubs,
|
|
NumCache = numCache,
|
|
Total = total,
|
|
Offset = opts.Offset,
|
|
Limit = opts.Limit,
|
|
Subs = details,
|
|
};
|
|
}
|
|
|
|
private static SubszOptions ParseQueryParams(HttpContext ctx)
|
|
{
|
|
var q = ctx.Request.Query;
|
|
var opts = new SubszOptions();
|
|
|
|
if (q.TryGetValue("subs", out var subs))
|
|
opts.Subscriptions = subs == "true" || subs == "1" || subs == "detail";
|
|
|
|
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;
|
|
|
|
if (q.TryGetValue("acc", out var acc))
|
|
opts.Account = acc.ToString();
|
|
|
|
if (q.TryGetValue("test", out var test))
|
|
opts.Test = test.ToString();
|
|
|
|
return opts;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 6: Wire into MonitorServer**
|
|
|
|
In `src/NATS.Server/Monitoring/MonitorServer.cs`:
|
|
|
|
Add field after `_connzHandler` (line 17):
|
|
|
|
```csharp
|
|
private readonly SubszHandler _subszHandler;
|
|
```
|
|
|
|
Initialize in constructor after `_connzHandler` (line 31):
|
|
|
|
```csharp
|
|
_subszHandler = new SubszHandler(server);
|
|
```
|
|
|
|
Replace the two subz stubs (lines 78-87) with:
|
|
|
|
```csharp
|
|
_app.MapGet(basePath + "/subz", (HttpContext ctx) =>
|
|
{
|
|
stats.HttpReqStats.AddOrUpdate("/subz", 1, (_, v) => v + 1);
|
|
return Results.Ok(_subszHandler.HandleSubsz(ctx));
|
|
});
|
|
_app.MapGet(basePath + "/subscriptionsz", (HttpContext ctx) =>
|
|
{
|
|
stats.HttpReqStats.AddOrUpdate("/subscriptionsz", 1, (_, v) => v + 1);
|
|
return Results.Ok(_subszHandler.HandleSubsz(ctx));
|
|
});
|
|
```
|
|
|
|
**Step 7: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~SubszTests" -v normal`
|
|
Expected: All PASS
|
|
|
|
**Step 8: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Monitoring/Subsz.cs src/NATS.Server/Monitoring/SubszHandler.cs src/NATS.Server/Monitoring/MonitorServer.cs src/NATS.Server/Subscriptions/SubList.cs tests/NATS.Server.Tests/SubszTests.cs
|
|
git commit -m "feat: implement /subz endpoint with account filter, test subject, and pagination"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Implement TLS cert-to-user mapping (TlsMap)
|
|
|
|
**Files:**
|
|
- Create: `src/NATS.Server/Auth/TlsMapAuthenticator.cs`
|
|
- Modify: `src/NATS.Server/Auth/IAuthenticator.cs:10-14`
|
|
- Modify: `src/NATS.Server/Auth/AuthService.cs:30-67`
|
|
- Modify: `src/NATS.Server/NatsClient.cs:345-403`
|
|
|
|
**Step 1: Write failing test**
|
|
|
|
Create `tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using NATS.Server.Auth;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class TlsMapAuthenticatorTests
|
|
{
|
|
private static X509Certificate2 CreateSelfSignedCert(string cn)
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
|
}
|
|
|
|
private static X509Certificate2 CreateCertWithDn(string dn)
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
var req = new CertificateRequest(dn, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
return req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void Matches_user_by_cn()
|
|
{
|
|
var users = new List<User>
|
|
{
|
|
new() { Username = "alice", Password = "" },
|
|
};
|
|
var auth = new TlsMapAuthenticator(users);
|
|
var cert = CreateSelfSignedCert("alice");
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new Protocol.ClientOptions(),
|
|
Nonce = [],
|
|
ClientCertificate = cert,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("alice");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_when_no_cert()
|
|
{
|
|
var users = new List<User>
|
|
{
|
|
new() { Username = "alice", Password = "" },
|
|
};
|
|
var auth = new TlsMapAuthenticator(users);
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new Protocol.ClientOptions(),
|
|
Nonce = [],
|
|
ClientCertificate = null,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_null_when_cn_doesnt_match()
|
|
{
|
|
var users = new List<User>
|
|
{
|
|
new() { Username = "alice", Password = "" },
|
|
};
|
|
var auth = new TlsMapAuthenticator(users);
|
|
var cert = CreateSelfSignedCert("bob");
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new Protocol.ClientOptions(),
|
|
Nonce = [],
|
|
ClientCertificate = cert,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Matches_by_full_dn_string()
|
|
{
|
|
var users = new List<User>
|
|
{
|
|
new() { Username = "CN=alice, O=TestOrg", Password = "" },
|
|
};
|
|
var auth = new TlsMapAuthenticator(users);
|
|
var cert = CreateCertWithDn("CN=alice, O=TestOrg");
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new Protocol.ClientOptions(),
|
|
Nonce = [],
|
|
ClientCertificate = cert,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Identity.ShouldBe("CN=alice, O=TestOrg");
|
|
}
|
|
|
|
[Fact]
|
|
public void Returns_permissions_from_matched_user()
|
|
{
|
|
var perms = new Permissions
|
|
{
|
|
Publish = new SubjectPermission { Allow = ["foo.>"] },
|
|
};
|
|
var users = new List<User>
|
|
{
|
|
new() { Username = "alice", Password = "", Permissions = perms },
|
|
};
|
|
var auth = new TlsMapAuthenticator(users);
|
|
var cert = CreateSelfSignedCert("alice");
|
|
|
|
var ctx = new ClientAuthContext
|
|
{
|
|
Opts = new Protocol.ClientOptions(),
|
|
Nonce = [],
|
|
ClientCertificate = cert,
|
|
};
|
|
|
|
var result = auth.Authenticate(ctx);
|
|
result.ShouldNotBeNull();
|
|
result.Permissions.ShouldNotBeNull();
|
|
result.Permissions.Publish!.Allow.ShouldContain("foo.>");
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify failure**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsMapAuthenticatorTests" -v normal`
|
|
Expected: Compilation error — `TlsMapAuthenticator` and `ClientCertificate` don't exist
|
|
|
|
**Step 3: Add ClientCertificate to ClientAuthContext**
|
|
|
|
In `src/NATS.Server/Auth/IAuthenticator.cs`, add to `ClientAuthContext`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using NATS.Server.Protocol;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
public interface IAuthenticator
|
|
{
|
|
AuthResult? Authenticate(ClientAuthContext context);
|
|
}
|
|
|
|
public sealed class ClientAuthContext
|
|
{
|
|
public required ClientOptions Opts { get; init; }
|
|
public required byte[] Nonce { get; init; }
|
|
public X509Certificate2? ClientCertificate { get; init; }
|
|
}
|
|
```
|
|
|
|
**Step 4: Create TlsMapAuthenticator**
|
|
|
|
Create `src/NATS.Server/Auth/TlsMapAuthenticator.cs`:
|
|
|
|
```csharp
|
|
using System.Security.Cryptography.X509Certificates;
|
|
|
|
namespace NATS.Server.Auth;
|
|
|
|
/// <summary>
|
|
/// Authenticates clients by mapping TLS certificate subject DN to configured users.
|
|
/// Corresponds to Go server/auth.go checkClientTLSCertSubject.
|
|
/// </summary>
|
|
public sealed class TlsMapAuthenticator : IAuthenticator
|
|
{
|
|
private readonly Dictionary<string, User> _usersByDn;
|
|
private readonly Dictionary<string, User> _usersByCn;
|
|
|
|
public TlsMapAuthenticator(IReadOnlyList<User> users)
|
|
{
|
|
_usersByDn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
|
_usersByCn = new Dictionary<string, User>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var user in users)
|
|
{
|
|
_usersByDn[user.Username] = user;
|
|
// Also index by just the username as a potential CN match
|
|
_usersByCn[user.Username] = user;
|
|
}
|
|
}
|
|
|
|
public AuthResult? Authenticate(ClientAuthContext context)
|
|
{
|
|
var cert = context.ClientCertificate;
|
|
if (cert == null)
|
|
return null;
|
|
|
|
var dn = cert.SubjectName;
|
|
var dnString = dn.Name; // RFC 2253 format: "CN=alice, O=TestOrg"
|
|
|
|
// Try exact DN match first
|
|
if (_usersByDn.TryGetValue(dnString, out var user))
|
|
{
|
|
return BuildResult(user);
|
|
}
|
|
|
|
// Try CN extraction
|
|
var cn = ExtractCn(dn);
|
|
if (cn != null && _usersByCn.TryGetValue(cn, out user))
|
|
{
|
|
return BuildResult(user);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? ExtractCn(X500DistinguishedName dn)
|
|
{
|
|
// Parse the DN to extract the CN component
|
|
var dnString = dn.Name;
|
|
foreach (var rdn in dnString.Split(',', StringSplitOptions.TrimEntries))
|
|
{
|
|
if (rdn.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return rdn[3..];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static AuthResult BuildResult(User user)
|
|
{
|
|
return new AuthResult
|
|
{
|
|
Identity = user.Username,
|
|
AccountName = user.Account,
|
|
Permissions = user.Permissions,
|
|
Expiry = user.ConnectionDeadline,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Wire TlsMap into AuthService**
|
|
|
|
In `src/NATS.Server/Auth/AuthService.cs`, add TlsMap authenticator in `Build()`. Add before the NKeys check (before line 39):
|
|
|
|
```csharp
|
|
// TLS certificate mapping (highest priority when enabled)
|
|
if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
|
|
{
|
|
authenticators.Add(new TlsMapAuthenticator(options.Users));
|
|
authRequired = true;
|
|
}
|
|
```
|
|
|
|
**Step 6: Pass client certificate to auth context**
|
|
|
|
In `src/NATS.Server/NatsClient.cs`, in `ProcessConnectAsync`, update the `ClientAuthContext` construction (around line 353):
|
|
|
|
```csharp
|
|
var context = new ClientAuthContext
|
|
{
|
|
Opts = ClientOpts,
|
|
Nonce = _nonce ?? [],
|
|
ClientCertificate = TlsState?.PeerCert,
|
|
};
|
|
```
|
|
|
|
**Step 7: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsMapAuthenticatorTests" -v normal`
|
|
Expected: All PASS
|
|
|
|
**Step 8: Run full test suite to verify no regressions**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests -v normal`
|
|
Expected: All existing tests pass
|
|
|
|
**Step 9: Commit**
|
|
|
|
```bash
|
|
git add src/NATS.Server/Auth/TlsMapAuthenticator.cs src/NATS.Server/Auth/IAuthenticator.cs src/NATS.Server/Auth/AuthService.cs src/NATS.Server/NatsClient.cs tests/NATS.Server.Tests/TlsMapAuthenticatorTests.cs
|
|
git commit -m "feat: implement TLS cert-to-user mapping via X500 DN matching"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Add TLS rate limiter test
|
|
|
|
**Files:**
|
|
- Create: `tests/NATS.Server.Tests/TlsRateLimiterTests.cs`
|
|
|
|
**Step 1: Write test**
|
|
|
|
```csharp
|
|
using NATS.Server.Tls;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class TlsRateLimiterTests
|
|
{
|
|
[Fact]
|
|
public async Task Rate_limiter_allows_configured_tokens_per_second()
|
|
{
|
|
using var limiter = new TlsRateLimiter(5);
|
|
|
|
// Should allow 5 tokens immediately
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
using var cts = new CancellationTokenSource(100);
|
|
await limiter.WaitAsync(cts.Token); // Should not throw
|
|
}
|
|
|
|
// 6th token should block (no refill yet)
|
|
using var blockCts = new CancellationTokenSource(200);
|
|
var blocked = false;
|
|
try
|
|
{
|
|
await limiter.WaitAsync(blockCts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
blocked = true;
|
|
}
|
|
blocked.ShouldBeTrue("6th token should be blocked before refill");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rate_limiter_refills_after_one_second()
|
|
{
|
|
using var limiter = new TlsRateLimiter(2);
|
|
|
|
// Consume all tokens
|
|
await limiter.WaitAsync(CancellationToken.None);
|
|
await limiter.WaitAsync(CancellationToken.None);
|
|
|
|
// Wait for refill
|
|
await Task.Delay(1200);
|
|
|
|
// Should have tokens again
|
|
using var cts = new CancellationTokenSource(200);
|
|
await limiter.WaitAsync(cts.Token); // Should not throw
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~TlsRateLimiterTests" -v normal`
|
|
Expected: All PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/NATS.Server.Tests/TlsRateLimiterTests.cs
|
|
git commit -m "test: add TLS rate limiter unit tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: File logging tests
|
|
|
|
**Files:**
|
|
- Create: `tests/NATS.Server.Tests/LoggingTests.cs`
|
|
|
|
**Step 1: Write test**
|
|
|
|
```csharp
|
|
using Serilog;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class LoggingTests : IDisposable
|
|
{
|
|
private readonly string _logDir;
|
|
|
|
public LoggingTests()
|
|
{
|
|
_logDir = Path.Combine(Path.GetTempPath(), $"nats-log-test-{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_logDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_logDir, true); } catch { }
|
|
}
|
|
|
|
[Fact]
|
|
public void File_sink_creates_log_file()
|
|
{
|
|
var logPath = Path.Combine(_logDir, "test.log");
|
|
|
|
using var logger = new LoggerConfiguration()
|
|
.WriteTo.File(logPath)
|
|
.CreateLogger();
|
|
|
|
logger.Information("Hello from test");
|
|
logger.Dispose();
|
|
|
|
File.Exists(logPath).ShouldBeTrue();
|
|
var content = File.ReadAllText(logPath);
|
|
content.ShouldContain("Hello from test");
|
|
}
|
|
|
|
[Fact]
|
|
public void File_sink_rotates_on_size_limit()
|
|
{
|
|
var logPath = Path.Combine(_logDir, "rotate.log");
|
|
|
|
using var logger = new LoggerConfiguration()
|
|
.WriteTo.File(
|
|
logPath,
|
|
fileSizeLimitBytes: 200,
|
|
rollOnFileSizeLimit: true,
|
|
retainedFileCountLimit: 3)
|
|
.CreateLogger();
|
|
|
|
// Write enough to trigger rotation
|
|
for (int i = 0; i < 50; i++)
|
|
logger.Information("Log message number {Number} with some padding text", i);
|
|
|
|
logger.Dispose();
|
|
|
|
// Should have created rotated files
|
|
var logFiles = Directory.GetFiles(_logDir, "rotate*.log");
|
|
logFiles.Length.ShouldBeGreaterThan(1);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Add Serilog.Sinks.File to test project**
|
|
|
|
In `tests/NATS.Server.Tests/NATS.Server.Tests.csproj`, add:
|
|
|
|
```xml
|
|
<PackageReference Include="Serilog.Sinks.File" />
|
|
```
|
|
|
|
**Step 3: Run tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests --filter "FullyQualifiedName~LoggingTests" -v normal`
|
|
Expected: All PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/NATS.Server.Tests/LoggingTests.cs tests/NATS.Server.Tests/NATS.Server.Tests.csproj
|
|
git commit -m "test: add file logging and rotation tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Run full test suite and verify
|
|
|
|
**Step 1: Run all tests**
|
|
|
|
Run: `dotnet test tests/NATS.Server.Tests -v normal`
|
|
Expected: All tests pass with no regressions
|
|
|
|
**Step 2: Build the solution**
|
|
|
|
Run: `dotnet build`
|
|
Expected: Build succeeded, no warnings
|
|
|
|
**Step 3: Commit any fixups**
|
|
|
|
If any tests need adjustments, fix and commit:
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: resolve test issues from sections 7-10 implementation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Update differences.md to reflect implementation
|
|
|
|
**Files:**
|
|
- Modify: `differences.md`
|
|
|
|
**Step 1: Update Section 7 (Monitoring)**
|
|
|
|
- `/subz` / `/subscriptionsz`: Change from `Stub` to `Y`
|
|
- Connz sorting: Add `ByStop`, `ByReason`, `ByRtt` as implemented — change "ByStop, ByReason" from missing to Y
|
|
- Connz state filtering: Note `state=open|closed|all` now supported
|
|
- Connz subscription detail: Note populated (was already in code but now confirmed)
|
|
|
|
**Step 2: Update Section 8 (TLS)**
|
|
|
|
- TLS rate limiting: Change "Property only" to `Y` with note about semaphore implementation
|
|
- Cert subject→user mapping: Change `N` to `Y` with note about X500DistinguishedName
|
|
|
|
**Step 3: Update Section 9 (Logging)**
|
|
|
|
- File logging with rotation: Change `N` to `Y`
|
|
- Trace mode: Change `N` to `Y` with note about `-V` flag
|
|
- Debug mode: Change `N` to `Y` with note about `-D` flag
|
|
- Color output on TTY: Change `N` to `Y`
|
|
- Timestamp format control: Change `N` to `Y`
|
|
- Log reopening (SIGUSR1): Change `N` to `Y`
|
|
- Syslog: Change `N` to `Y`
|
|
|
|
**Step 4: Update Section 10 (Ping/Pong)**
|
|
|
|
- RTT-based first PING delay: Change `N` to `Y`
|
|
- RTT tracking: Change `N` to `Y`
|
|
- Stale connection watcher: Change `N` to `Y` with note about PeriodicTimer approach + stale stats
|
|
|
|
**Step 5: Update Summary section**
|
|
|
|
Remove items that are now implemented from the critical gaps list.
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add differences.md
|
|
git commit -m "docs: update differences.md sections 7-10 to reflect implemented features"
|
|
```
|
|
|
|
---
|
|
|
|
## Parallelization Notes
|
|
|
|
These tasks can be parallelized in groups:
|
|
|
|
**Group A (can run in parallel):**
|
|
- Task 0 (NuGet deps) — must complete first
|
|
- Task 1 (NatsOptions) — must complete first
|
|
|
|
**Group B (after Group A, can run in parallel):**
|
|
- Tasks 2-3 (Logging CLI + SIGUSR1)
|
|
- Tasks 4-5 (RTT + Stale stats)
|
|
- Task 7 (Subz endpoint)
|
|
- Task 8 (TlsMap auth)
|
|
- Task 9 (TLS rate limiter test)
|
|
|
|
**Group C (after Group B):**
|
|
- Task 6 (Closed connections + connz) — depends on Task 4 (RTT) for `client.Rtt`
|
|
- Task 10 (Logging tests)
|
|
|
|
**Group D (after all):**
|
|
- Task 11 (Full test suite)
|
|
- Task 12 (Update differences.md)
|