feat(config+ws): add TLS cert reload, WS compression negotiation, WS JWT auth (E9+E10+E11)
E9: TLS Certificate Reload - Add TlsCertificateProvider with Interlocked-swappable cert field - New connections get current cert, existing connections keep theirs - ConfigReloader.ReloadTlsCertificate rebuilds SslServerAuthenticationOptions - NatsServer.ApplyConfigChanges triggers TLS reload on TLS config changes - 11 tests covering cert swap, versioning, thread safety, config diff E10: WebSocket Compression Negotiation (RFC 7692) - Add WsDeflateNegotiator to parse Sec-WebSocket-Extensions parameters - Parse server_no_context_takeover, client_no_context_takeover, server_max_window_bits, client_max_window_bits - WsDeflateParams record struct with ToResponseHeaderValue() - NATS always enforces no_context_takeover (matching Go server) - WsUpgrade returns negotiated WsDeflateParams in upgrade result - 22 tests covering parameter parsing, clamping, response headers E11: WebSocket JWT Authentication - Extract JWT from Authorization header (Bearer token), cookie, or ?jwt= query param - Priority: Authorization header > cookie > query parameter - WsUpgrade.TryUpgradeAsync now parses query string from request URI - Add FailUnauthorizedAsync for 401 responses - 24 tests covering all JWT extraction sources and priority ordering
This commit is contained in:
239
tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs
Normal file
239
tests/NATS.Server.Tests/Configuration/TlsReloadTests.cs
Normal file
@@ -0,0 +1,239 @@
|
||||
// Tests for TLS certificate hot reload (E9).
|
||||
// Verifies that TlsCertificateProvider supports atomic cert swapping
|
||||
// and that ConfigReloader.ReloadTlsCertificate integrates correctly.
|
||||
// Reference: golang/nats-server/server/reload_test.go — TestConfigReloadRotateTLS (line 392).
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Tests.Configuration;
|
||||
|
||||
public class TlsReloadTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a self-signed X509Certificate2 for testing.
|
||||
/// </summary>
|
||||
private static X509Certificate2 GenerateSelfSignedCert(string cn = "test")
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
|
||||
// Export and re-import to ensure the cert has the private key bound
|
||||
return X509CertificateLoader.LoadPkcs12(cert.Export(X509ContentType.Pkcs12), null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_GetCurrentCertificate_ReturnsInitialCert()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — initial cert is usable
|
||||
var cert = GenerateSelfSignedCert("initial");
|
||||
using var provider = new TlsCertificateProvider(cert);
|
||||
|
||||
var current = provider.GetCurrentCertificate();
|
||||
|
||||
current.ShouldNotBeNull();
|
||||
current.Subject.ShouldContain("initial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_SwapCertificate_ReturnsOldCert()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — cert rotation returns old cert
|
||||
var cert1 = GenerateSelfSignedCert("cert1");
|
||||
var cert2 = GenerateSelfSignedCert("cert2");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var old = provider.SwapCertificate(cert2);
|
||||
|
||||
old.ShouldNotBeNull();
|
||||
old.Subject.ShouldContain("cert1");
|
||||
old.Dispose();
|
||||
|
||||
var current = provider.GetCurrentCertificate();
|
||||
current.ShouldNotBeNull();
|
||||
current.Subject.ShouldContain("cert2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_SwapCertificate_IncrementsVersion()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — version tracking for reload detection
|
||||
var cert1 = GenerateSelfSignedCert("v1");
|
||||
var cert2 = GenerateSelfSignedCert("v2");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var v0 = provider.Version;
|
||||
v0.ShouldBe(0);
|
||||
|
||||
provider.SwapCertificate(cert2)?.Dispose();
|
||||
provider.Version.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CertificateProvider_MultipleSwa_NewConnectionsGetLatest()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — multiple rotations, each new
|
||||
// handshake gets the latest certificate
|
||||
var cert1 = GenerateSelfSignedCert("round1");
|
||||
var cert2 = GenerateSelfSignedCert("round2");
|
||||
var cert3 = GenerateSelfSignedCert("round3");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round1");
|
||||
|
||||
provider.SwapCertificate(cert2)?.Dispose();
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round2");
|
||||
|
||||
provider.SwapCertificate(cert3)?.Dispose();
|
||||
provider.GetCurrentCertificate()!.Subject.ShouldContain("round3");
|
||||
|
||||
provider.Version.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CertificateProvider_ConcurrentAccess_IsThreadSafe()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — cert swap must be safe under
|
||||
// concurrent connection accept
|
||||
var cert1 = GenerateSelfSignedCert("concurrent1");
|
||||
using var provider = new TlsCertificateProvider(cert1);
|
||||
|
||||
var tasks = new Task[50];
|
||||
for (int i = 0; i < tasks.Length; i++)
|
||||
{
|
||||
var idx = i;
|
||||
tasks[i] = Task.Run(() =>
|
||||
{
|
||||
if (idx % 2 == 0)
|
||||
{
|
||||
// Readers — simulate new connections getting current cert
|
||||
var c = provider.GetCurrentCertificate();
|
||||
c.ShouldNotBeNull();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Writers — simulate reload
|
||||
var newCert = GenerateSelfSignedCert($"swap-{idx}");
|
||||
provider.SwapCertificate(newCert)?.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// After all swaps, the provider should still return a valid cert
|
||||
provider.GetCurrentCertificate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_NullProvider_ReturnsFalse()
|
||||
{
|
||||
// Edge case: server running without TLS
|
||||
var opts = new NatsOptions();
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, null);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_NoTlsConfig_ReturnsFalse()
|
||||
{
|
||||
// Edge case: provider exists but options don't have TLS paths
|
||||
var cert = GenerateSelfSignedCert("no-tls");
|
||||
using var provider = new TlsCertificateProvider(cert);
|
||||
|
||||
var opts = new NatsOptions(); // HasTls is false (no TlsCert/TlsKey)
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReloadTlsCertificate_WithCertFiles_SwapsCertAndSslOptions()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — full reload with cert files.
|
||||
// Write a self-signed cert to temp files and verify the provider loads it.
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"nats-tls-test-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
try
|
||||
{
|
||||
var certPath = Path.Combine(tempDir, "cert.pem");
|
||||
var keyPath = Path.Combine(tempDir, "key.pem");
|
||||
WriteSelfSignedCertFiles(certPath, keyPath, "reload-test");
|
||||
|
||||
// Create provider with initial cert
|
||||
var initialCert = GenerateSelfSignedCert("initial");
|
||||
using var provider = new TlsCertificateProvider(initialCert);
|
||||
|
||||
var opts = new NatsOptions { TlsCert = certPath, TlsKey = keyPath };
|
||||
var result = ConfigReloader.ReloadTlsCertificate(opts, provider);
|
||||
|
||||
result.ShouldBeTrue();
|
||||
provider.Version.ShouldBeGreaterThan(0);
|
||||
provider.GetCurrentCertificate().ShouldNotBeNull();
|
||||
provider.GetCurrentSslOptions().ShouldNotBeNull();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigDiff_DetectsTlsChanges()
|
||||
{
|
||||
// Go parity: TestConfigReloadEnableTLS, TestConfigReloadDisableTLS
|
||||
// Verify that diff detects TLS option changes and flags them
|
||||
var oldOpts = new NatsOptions { TlsCert = "/old/cert.pem", TlsKey = "/old/key.pem" };
|
||||
var newOpts = new NatsOptions { TlsCert = "/new/cert.pem", TlsKey = "/new/key.pem" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
|
||||
changes.Count.ShouldBeGreaterThan(0);
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsCert");
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsKey");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigDiff_TlsVerifyChange_IsTlsChange()
|
||||
{
|
||||
// Go parity: TestConfigReloadRotateTLS — enabling client verification
|
||||
var oldOpts = new NatsOptions { TlsVerify = false };
|
||||
var newOpts = new NatsOptions { TlsVerify = true };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
|
||||
changes.ShouldContain(c => c.IsTlsChange && c.Name == "TlsVerify");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConfigApplyResult_ReportsTlsChanges()
|
||||
{
|
||||
// Verify ApplyDiff flags TLS changes correctly
|
||||
var changes = new List<IConfigChange>
|
||||
{
|
||||
new ConfigChange("TlsCert", isTlsChange: true),
|
||||
new ConfigChange("TlsKey", isTlsChange: true),
|
||||
};
|
||||
var oldOpts = new NatsOptions();
|
||||
var newOpts = new NatsOptions();
|
||||
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasTlsChanges.ShouldBeTrue();
|
||||
result.ChangeCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to write a self-signed certificate to PEM files.
|
||||
/// </summary>
|
||||
private static void WriteSelfSignedCertFiles(string certPath, string keyPath, string cn)
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var req = new CertificateRequest($"CN={cn}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1));
|
||||
|
||||
File.WriteAllText(certPath, cert.ExportCertificatePem());
|
||||
File.WriteAllText(keyPath, rsa.ExportRSAPrivateKeyPem());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user