feat: add monitoring HTTP endpoints and TLS support

Monitoring HTTP:
- /varz, /connz, /healthz via Kestrel Minimal API
- Pagination, sorting, subscription details on /connz
- ServerStats atomic counters, CPU/memory sampling
- CLI flags: -m, --http_port, --http_base_path, --https_port

TLS Support:
- 4-mode negotiation: no TLS, required, TLS-first, mixed
- Certificate loading, pinning (SHA-256), client cert verification
- PeekableStream for non-destructive TLS detection
- Token-bucket rate limiter for TLS handshakes
- CLI flags: --tls, --tlscert, --tlskey, --tlscacert, --tlsverify

29 new tests (78 → 107 total), all passing.

# Conflicts:
#	src/NATS.Server.Host/Program.cs
#	src/NATS.Server/NATS.Server.csproj
#	src/NATS.Server/NatsClient.cs
#	src/NATS.Server/NatsOptions.cs
#	src/NATS.Server/NatsServer.cs
#	src/NATS.Server/Protocol/NatsProtocol.cs
#	tests/NATS.Server.Tests/ClientTests.cs
This commit is contained in:
Joseph Doherty
2026-02-22 23:13:22 -05:00
24 changed files with 2596 additions and 43 deletions

View File

@@ -41,7 +41,7 @@ public class ClientTests : IAsyncDisposable
};
var authService = AuthService.Build(new NatsOptions());
_natsClient = new NatsClient(1, _serverSocket, new NatsOptions(), serverInfo, authService, null, NullLogger.Instance);
_natsClient = new NatsClient(1, new NetworkStream(_serverSocket, ownsSocket: false), _serverSocket, new NatsOptions(), serverInfo, authService, null, NullLogger.Instance, new ServerStats());
}
public async ValueTask DisposeAsync()
@@ -56,7 +56,7 @@ public class ClientTests : IAsyncDisposable
{
var runTask = _natsClient.RunAsync(_cts.Token);
// Read from client socket should get INFO
// Read from client socket -- should get INFO
var buf = new byte[4096];
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
@@ -80,7 +80,7 @@ public class ClientTests : IAsyncDisposable
// Send CONNECT then PING
await _clientSocket.SendAsync(Encoding.ASCII.GetBytes("CONNECT {}\r\nPING\r\n"));
// Read response should get PONG
// Read response -- should get PONG
var n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None);
var response = Encoding.ASCII.GetString(buf, 0, n);
@@ -128,7 +128,7 @@ public class ClientTests : IAsyncDisposable
response.ShouldBe("-ERR 'maximum connections exceeded'\r\n");
// Connection should be closed next read returns 0
// Connection should be closed -- next read returns 0
n = await _clientSocket.ReceiveAsync(buf, SocketFlags.None, readCts.Token);
n.ShouldBe(0);
}

View File

@@ -0,0 +1,51 @@
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\":");
}
}

View File

@@ -0,0 +1,274 @@
using System.Net;
using System.Net.Http.Json;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
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();
// Wait for monitoring HTTP server to be ready
for (int i = 0; i < 50; i++)
{
try
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (response.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
}
[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));
var buf = new byte[4096];
_ = await sock.ReceiveAsync(buf, SocketFlags.None); // Read INFO
var cmd = "CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray();
await sock.SendAsync(cmd, SocketFlags.None);
await Task.Delay(200);
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/varz");
var varz = await response.Content.ReadFromJsonAsync<Varz>();
varz.ShouldNotBeNull();
varz.Connections.ShouldBeGreaterThanOrEqualTo(1);
varz.TotalConnections.ShouldBeGreaterThanOrEqualTo(1UL);
varz.InMsgs.ShouldBeGreaterThanOrEqualTo(1L);
varz.InBytes.ShouldBeGreaterThanOrEqualTo(5L);
}
[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()
{
var sockets = new List<Socket>();
try
{
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);
}
finally
{
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");
}
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;
}
}
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();
// Wait for monitoring HTTP server to be ready
for (int i = 0; i < 50; i++)
{
try
{
var response = await _http.GetAsync($"http://127.0.0.1:{_monitorPort}/healthz");
if (response.IsSuccessStatusCode) break;
}
catch (HttpRequestException) { }
await Task.Delay(50);
}
}
public async Task DisposeAsync()
{
_http.Dispose();
await _cts.CancelAsync();
_server.Dispose();
File.Delete(_certPath);
File.Delete(_keyPath);
}
[Fact]
public async Task Connz_shows_tls_info_for_tls_client()
{
// Connect and upgrade to TLS
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); // Read INFO
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.TlsCipherSuite.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;
}
}

View File

@@ -0,0 +1,84 @@
using System.Net;
using System.Net.Sockets;
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 Task DisposeAsync()
{
_cts.Cancel();
_server.Dispose();
return 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));
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));
var buf = new byte[4096];
await pub.ReceiveAsync(buf, SocketFlags.None); // INFO
await pub.SendAsync("CONNECT {}\r\nSUB test 1\r\nPUB test 5\r\nhello\r\n"u8.ToArray());
await Task.Delay(200);
_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;
}
}

View File

@@ -0,0 +1,254 @@
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, _) = TlsHelperTests.GenerateTestCert();
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 = cert,
};
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();
}
[Fact]
public async Task MixedMode_allows_plaintext_when_AllowNonTls()
{
var (cert, _) = TlsHelperTests.GenerateTestCert();
var (serverSocket, clientSocket) = await CreateSocketPairAsync();
using var clientNetStream = new NetworkStream(clientSocket, ownsSocket: true);
var opts = new NatsOptions
{
TlsCert = "dummy",
TlsKey = "dummy",
AllowNonTls = true,
TlsTimeout = TimeSpan.FromSeconds(2),
};
var sslOpts = new SslServerAuthenticationOptions
{
ServerCertificate = cert,
};
var serverInfo = CreateServerInfo();
// Client side: read INFO then send plaintext (not TLS)
var clientTask = Task.Run(async () =>
{
var buf = new byte[4096];
var read = await clientNetStream.ReadAsync(buf);
var info = System.Text.Encoding.ASCII.GetString(buf, 0, read);
info.ShouldStartWith("INFO ");
// Send plaintext CONNECT (not a TLS handshake)
var connectLine = System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n");
await clientNetStream.WriteAsync(connectLine);
await clientNetStream.FlushAsync();
});
var serverNetStream = new NetworkStream(serverSocket, ownsSocket: true);
var (stream, infoSent) = await TlsConnectionWrapper.NegotiateAsync(
serverSocket, serverNetStream, opts, sslOpts, serverInfo, NullLogger.Instance, CancellationToken.None);
await clientTask;
// In mixed mode with plaintext client, we get a PeekableStream, not SslStream
stream.ShouldBeOfType<PeekableStream>();
infoSent.ShouldBeTrue();
stream.Dispose();
}
[Fact]
public async Task TlsRequired_rejects_plaintext()
{
var (cert, _) = TlsHelperTests.GenerateTestCert();
var (serverSocket, clientSocket) = await CreateSocketPairAsync();
using var clientNetStream = new NetworkStream(clientSocket, ownsSocket: true);
var opts = new NatsOptions
{
TlsCert = "dummy",
TlsKey = "dummy",
AllowNonTls = false,
TlsTimeout = TimeSpan.FromSeconds(2),
};
var sslOpts = new SslServerAuthenticationOptions
{
ServerCertificate = cert,
};
var serverInfo = CreateServerInfo();
// Client side: read INFO then send plaintext
var clientTask = Task.Run(async () =>
{
var buf = new byte[4096];
var read = await clientNetStream.ReadAsync(buf);
var info = System.Text.Encoding.ASCII.GetString(buf, 0, read);
info.ShouldStartWith("INFO ");
// Send plaintext data (first byte is 'C', not 0x16 TLS marker)
var connectLine = System.Text.Encoding.ASCII.GetBytes("CONNECT {}\r\n");
await clientNetStream.WriteAsync(connectLine);
await clientNetStream.FlushAsync();
});
var serverNetStream = new NetworkStream(serverSocket, ownsSocket: true);
await Should.ThrowAsync<InvalidOperationException>(async () =>
{
await TlsConnectionWrapper.NegotiateAsync(
serverSocket, serverNetStream, opts, sslOpts, serverInfo, NullLogger.Instance, CancellationToken.None);
});
await clientTask;
serverNetStream.Dispose();
}
[Fact]
public async Task TlsFirst_handshakes_before_sending_info()
{
var (cert, _) = TlsHelperTests.GenerateTestCert();
var (serverSocket, clientSocket) = await CreateSocketPairAsync();
using var clientNetStream = new NetworkStream(clientSocket, ownsSocket: true);
var opts = new NatsOptions { TlsCert = "dummy", TlsKey = "dummy", TlsHandshakeFirst = true };
var sslOpts = new SslServerAuthenticationOptions
{
ServerCertificate = cert,
};
var serverInfo = CreateServerInfo();
// Client side: immediately start TLS (no INFO first)
var clientTask = Task.Run(async () =>
{
var sslClient = new SslStream(clientNetStream, true, (_, _, _, _) => true);
await sslClient.AuthenticateAsClientAsync("localhost");
// After TLS, read INFO over encrypted stream
var buf = new byte[4096];
var read = await sslClient.ReadAsync(buf);
var info = System.Text.Encoding.ASCII.GetString(buf, 0, read);
info.ShouldStartWith("INFO ");
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);
}
}

View File

@@ -0,0 +1,110 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using NATS.Server;
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');
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);
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
for (int i = 0; i < 5; i++)
await limiter.WaitAsync(cts.Token);
}
// Public helper methods used by other test classes
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);
}
}

View File

@@ -0,0 +1,225 @@
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
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()
{
await _cts.CancelAsync();
_server.Dispose();
File.Delete(_certPath);
File.Delete(_keyPath);
}
[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[256];
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\nPING\r\n"u8.ToArray());
await ssl1.FlushAsync();
// Wait for PONG to confirm subscription is registered
var pongBuf = new byte[256];
var pongRead = await ssl1.ReadAsync(pongBuf);
var pongStr = Encoding.ASCII.GetString(pongBuf, 0, pongRead);
pongStr.ShouldContain("PONG");
// Pub on client 2
await ssl2.WriteAsync("CONNECT {}\r\nPUB test 5\r\nhello\r\nPING\r\n"u8.ToArray());
await ssl2.FlushAsync();
// Client 1 should receive MSG (may arrive across multiple TLS records)
var msg = await ReadUntilAsync(ssl1, "hello");
msg.ShouldContain("MSG test 1 5");
msg.ShouldContain("hello");
}
private static async Task<string> ReadUntilAsync(Stream stream, string expected, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource(timeoutMs);
var sb = new StringBuilder();
var buf = new byte[4096];
while (!sb.ToString().Contains(expected))
{
var n = await stream.ReadAsync(buf, cts.Token);
if (n == 0) break;
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
}
return sb.ToString();
}
private static async Task<SslStream> UpgradeToTlsAsync(TcpClient tcp)
{
var netStream = tcp.GetStream();
var buf = new byte[4096];
_ = await netStream.ReadAsync(buf); // Read INFO (discard)
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;
}
}
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()
{
await _cts.CancelAsync();
_server.Dispose();
File.Delete(_certPath);
File.Delete(_keyPath);
}
[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");
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;
}
}