E6: Add IsSystemAccount property to Account, mark $SYS account as system, add IsSystemSubject/IsSubscriptionAllowed/GetSubListForSubject helpers to route $SYS.> subjects to the system account's SubList and block non-system accounts from subscribing. E7: Add ConfigReloader.ReloadAsync and ApplyDiff for structured async reload, add ConfigReloadResult/ConfigApplyResult types. SIGHUP handler already wired via PosixSignalRegistration in HandleSignals. E8: Add PropagateAuthChanges to re-evaluate connected clients after auth config reload, disconnecting clients whose credentials no longer pass authentication with -ERR 'Authorization Violation'.
414 lines
16 KiB
C#
414 lines
16 KiB
C#
// Port of Go server/reload_test.go — TestConfigReloadAuthChangeDisconnects,
|
|
// TestConfigReloadAuthEnabled, TestConfigReloadAuthDisabled,
|
|
// TestConfigReloadUserCredentialChange.
|
|
// Reference: golang/nats-server/server/reload_test.go lines 720-900.
|
|
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NATS.Client.Core;
|
|
using NATS.Server.Configuration;
|
|
|
|
namespace NATS.Server.Tests.Configuration;
|
|
|
|
/// <summary>
|
|
/// Tests for auth change propagation on config reload.
|
|
/// Covers:
|
|
/// - Enabling auth disconnects unauthenticated clients
|
|
/// - Changing credentials disconnects clients with old credentials
|
|
/// - Disabling auth allows previously rejected connections
|
|
/// - Clients with correct credentials survive reload
|
|
/// Reference: Go server/reload.go — reloadAuthorization.
|
|
/// </summary>
|
|
public class AuthReloadTests
|
|
{
|
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
}
|
|
|
|
private static async Task<Socket> RawConnectAsync(int port)
|
|
{
|
|
var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
|
await sock.ConnectAsync(IPAddress.Loopback, port);
|
|
var buf = new byte[4096];
|
|
await sock.ReceiveAsync(buf, SocketFlags.None);
|
|
return sock;
|
|
}
|
|
|
|
private static async Task SendConnectAsync(Socket sock, string? user = null, string? pass = null)
|
|
{
|
|
string connectJson;
|
|
if (user != null && pass != null)
|
|
connectJson = $"CONNECT {{\"verbose\":false,\"pedantic\":false,\"user\":\"{user}\",\"pass\":\"{pass}\"}}\r\n";
|
|
else
|
|
connectJson = "CONNECT {\"verbose\":false,\"pedantic\":false}\r\n";
|
|
await sock.SendAsync(Encoding.ASCII.GetBytes(connectJson), SocketFlags.None);
|
|
}
|
|
|
|
private static async Task<string> ReadUntilAsync(Socket sock, 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, StringComparison.Ordinal))
|
|
{
|
|
int n;
|
|
try { n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token); }
|
|
catch (OperationCanceledException) { break; }
|
|
if (n == 0) break;
|
|
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static void WriteConfigAndReload(NatsServer server, string configPath, string configText)
|
|
{
|
|
File.WriteAllText(configPath, configText);
|
|
server.ReloadConfigOrThrow();
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Port of Go TestConfigReloadAuthChangeDisconnects (reload_test.go).
|
|
///
|
|
/// Verifies that enabling authentication via hot reload disconnects clients
|
|
/// that connected without credentials. The server should send -ERR
|
|
/// 'Authorization Violation' and close the connection.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Enabling_auth_disconnects_unauthenticated_clients()
|
|
{
|
|
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authdc-{Guid.NewGuid():N}.conf");
|
|
try
|
|
{
|
|
var port = GetFreePort();
|
|
|
|
// Start with no auth
|
|
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
|
|
|
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Connect a client without credentials
|
|
using var sock = await RawConnectAsync(port);
|
|
await SendConnectAsync(sock);
|
|
|
|
// Send a PING to confirm the connection is established
|
|
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
|
var pong = await ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
|
pong.ShouldContain("PONG");
|
|
|
|
server.ClientCount.ShouldBeGreaterThanOrEqualTo(1);
|
|
|
|
// Enable auth via reload
|
|
WriteConfigAndReload(server, configPath,
|
|
$"port: {port}\nauthorization {{\n user: admin\n password: secret123\n}}");
|
|
|
|
// The unauthenticated client should receive an -ERR and/or be disconnected.
|
|
// Read whatever the server sends before closing the socket.
|
|
var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000);
|
|
// The server should have sent -ERR 'Authorization Violation' before closing
|
|
errResponse.ShouldContain("Authorization Violation",
|
|
Case.Insensitive,
|
|
$"Expected 'Authorization Violation' in response but got: '{errResponse}'");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(configPath)) File.Delete(configPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that changing user credentials disconnects clients using old credentials.
|
|
/// Reference: Go server/reload_test.go — TestConfigReloadUserCredentialChange.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Changing_credentials_disconnects_old_credential_clients()
|
|
{
|
|
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-credchg-{Guid.NewGuid():N}.conf");
|
|
try
|
|
{
|
|
var port = GetFreePort();
|
|
|
|
// Start with user/password auth
|
|
File.WriteAllText(configPath,
|
|
$"port: {port}\nauthorization {{\n user: alice\n password: pass1\n}}");
|
|
|
|
var options = ConfigProcessor.ProcessConfigFile(configPath);
|
|
options.Port = port;
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Connect with the original credentials
|
|
using var sock = await RawConnectAsync(port);
|
|
await SendConnectAsync(sock, "alice", "pass1");
|
|
|
|
// Verify connection works
|
|
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
|
var pong = await ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
|
pong.ShouldContain("PONG");
|
|
|
|
// Change the password via reload
|
|
WriteConfigAndReload(server, configPath,
|
|
$"port: {port}\nauthorization {{\n user: alice\n password: pass2\n}}");
|
|
|
|
// The client with the old password should be disconnected
|
|
var errResponse = await ReadAllBeforeCloseAsync(sock, timeoutMs: 5000);
|
|
errResponse.ShouldContain("Authorization Violation",
|
|
Case.Insensitive,
|
|
$"Expected 'Authorization Violation' in response but got: '{errResponse}'");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(configPath)) File.Delete(configPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that disabling auth on reload allows new unauthenticated connections.
|
|
/// Reference: Go server/reload_test.go — TestConfigReloadDisableUserAuthentication.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Disabling_auth_allows_new_connections()
|
|
{
|
|
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-authoff-{Guid.NewGuid():N}.conf");
|
|
try
|
|
{
|
|
var port = GetFreePort();
|
|
|
|
// Start with auth enabled
|
|
File.WriteAllText(configPath,
|
|
$"port: {port}\nauthorization {{\n user: bob\n password: secret\n}}");
|
|
|
|
var options = ConfigProcessor.ProcessConfigFile(configPath);
|
|
options.Port = port;
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Verify unauthenticated connections are rejected
|
|
await using var noAuthClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await noAuthClient.ConnectAsync();
|
|
await noAuthClient.PingAsync();
|
|
});
|
|
ContainsInChain(ex, "Authorization Violation").ShouldBeTrue();
|
|
|
|
// Disable auth via reload
|
|
WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: false");
|
|
|
|
// New connections without credentials should now succeed
|
|
await using var newClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
});
|
|
await newClient.ConnectAsync();
|
|
await newClient.PingAsync();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(configPath)) File.Delete(configPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that clients with the new correct credentials survive an auth reload.
|
|
/// This connects a new client after the reload with the new credentials and
|
|
/// verifies it works.
|
|
/// Reference: Go server/reload_test.go — TestConfigReloadEnableUserAuthentication.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task New_clients_with_correct_credentials_work_after_auth_reload()
|
|
{
|
|
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-newauth-{Guid.NewGuid():N}.conf");
|
|
try
|
|
{
|
|
var port = GetFreePort();
|
|
|
|
// Start with no auth
|
|
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
|
|
|
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Enable auth via reload
|
|
WriteConfigAndReload(server, configPath,
|
|
$"port: {port}\nauthorization {{\n user: carol\n password: newpass\n}}");
|
|
|
|
// New connection with correct credentials should succeed
|
|
await using var authClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://carol:newpass@127.0.0.1:{port}",
|
|
});
|
|
await authClient.ConnectAsync();
|
|
await authClient.PingAsync();
|
|
|
|
// New connection without credentials should be rejected
|
|
await using var noAuthClient = new NatsConnection(new NatsOpts
|
|
{
|
|
Url = $"nats://127.0.0.1:{port}",
|
|
MaxReconnectRetry = 0,
|
|
});
|
|
|
|
var ex = await Should.ThrowAsync<NatsException>(async () =>
|
|
{
|
|
await noAuthClient.ConnectAsync();
|
|
await noAuthClient.PingAsync();
|
|
});
|
|
ContainsInChain(ex, "Authorization Violation").ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(configPath)) File.Delete(configPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that PropagateAuthChanges is a no-op when auth is disabled.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task PropagateAuthChanges_noop_when_auth_disabled()
|
|
{
|
|
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noauth-{Guid.NewGuid():N}.conf");
|
|
try
|
|
{
|
|
var port = GetFreePort();
|
|
File.WriteAllText(configPath, $"port: {port}\ndebug: false");
|
|
|
|
var options = new NatsOptions { ConfigFile = configPath, Port = port };
|
|
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
|
var cts = new CancellationTokenSource();
|
|
_ = server.StartAsync(cts.Token);
|
|
await server.WaitForReadyAsync();
|
|
|
|
try
|
|
{
|
|
// Connect a client
|
|
using var sock = await RawConnectAsync(port);
|
|
await SendConnectAsync(sock);
|
|
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
|
var pong = await ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
|
pong.ShouldContain("PONG");
|
|
|
|
var countBefore = server.ClientCount;
|
|
|
|
// Reload with a logging change only (no auth change)
|
|
WriteConfigAndReload(server, configPath, $"port: {port}\ndebug: true");
|
|
|
|
// Wait a moment for any async operations
|
|
await Task.Delay(200);
|
|
|
|
// Client count should remain the same (no disconnections)
|
|
server.ClientCount.ShouldBe(countBefore);
|
|
|
|
// Client should still be responsive
|
|
await sock.SendAsync("PING\r\n"u8.ToArray(), SocketFlags.None);
|
|
var pong2 = await ReadUntilAsync(sock, "PONG", timeoutMs: 3000);
|
|
pong2.ShouldContain("PONG");
|
|
}
|
|
finally
|
|
{
|
|
await cts.CancelAsync();
|
|
server.Dispose();
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(configPath)) File.Delete(configPath);
|
|
}
|
|
}
|
|
|
|
// ─── Private helpers ────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Reads all data from the socket until the connection is closed or timeout elapses.
|
|
/// This is more robust than ReadUntilAsync for cases where the server sends an error
|
|
/// and immediately closes the connection — we want to capture everything sent.
|
|
/// </summary>
|
|
private static async Task<string> ReadAllBeforeCloseAsync(Socket sock, int timeoutMs = 5000)
|
|
{
|
|
using var cts = new CancellationTokenSource(timeoutMs);
|
|
var sb = new StringBuilder();
|
|
var buf = new byte[4096];
|
|
while (true)
|
|
{
|
|
int n;
|
|
try
|
|
{
|
|
n = await sock.ReceiveAsync(buf, SocketFlags.None, cts.Token);
|
|
}
|
|
catch (OperationCanceledException) { break; }
|
|
catch (SocketException) { break; }
|
|
if (n == 0) break; // Connection closed
|
|
sb.Append(Encoding.ASCII.GetString(buf, 0, n));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static bool ContainsInChain(Exception ex, string substring)
|
|
{
|
|
Exception? current = ex;
|
|
while (current != null)
|
|
{
|
|
if (current.Message.Contains(substring, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
current = current.InnerException;
|
|
}
|
|
return false;
|
|
}
|
|
}
|