feat(config): add system account, SIGHUP reload, and auth change propagation (E6+E7+E8)
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'.
This commit is contained in:
256
tests/NATS.Server.Tests/Auth/SystemAccountTests.cs
Normal file
256
tests/NATS.Server.Tests/Auth/SystemAccountTests.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
// Port of Go server/accounts_test.go — TestSystemAccountDefaultCreation,
|
||||
// TestSystemAccountSysSubjectRouting, TestNonSystemAccountCannotSubscribeToSys.
|
||||
// Reference: golang/nats-server/server/accounts_test.go, server.go — initSystemAccount.
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Server.Auth;
|
||||
|
||||
namespace NATS.Server.Tests.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the $SYS system account functionality including:
|
||||
/// - Default system account creation with IsSystemAccount flag
|
||||
/// - $SYS.> subject routing to the system account's SubList
|
||||
/// - Non-system accounts blocked from subscribing to $SYS.> subjects
|
||||
/// - System account event publishing
|
||||
/// Reference: Go server/accounts.go — isSystemAccount, isReservedSubject.
|
||||
/// </summary>
|
||||
public class SystemAccountTests
|
||||
{
|
||||
// ─── 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<(NatsServer server, int port, CancellationTokenSource cts)> StartServerAsync(NatsOptions options)
|
||||
{
|
||||
var port = GetFreePort();
|
||||
options.Port = port;
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
return (server, port, cts);
|
||||
}
|
||||
|
||||
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<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();
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the server creates a $SYS system account by default with
|
||||
/// IsSystemAccount set to true.
|
||||
/// Reference: Go server/server.go — initSystemAccount.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Default_system_account_is_created()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.SystemAccount.ShouldNotBeNull();
|
||||
server.SystemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
server.SystemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account constant matches "$SYS".
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_name_constant_is_correct()
|
||||
{
|
||||
Account.SystemAccountName.ShouldBe("$SYS");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a non-system account does not have IsSystemAccount set.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Regular_account_is_not_system_account()
|
||||
{
|
||||
var account = new Account("test-account");
|
||||
account.IsSystemAccount.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemAccount can be explicitly set on an account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IsSystemAccount_can_be_set()
|
||||
{
|
||||
var account = new Account("custom-sys") { IsSystemAccount = true };
|
||||
account.IsSystemAccount.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSystemSubject correctly identifies $SYS subjects.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("$SYS", true)]
|
||||
[InlineData("$SYS.ACCOUNT.test.CONNECT", true)]
|
||||
[InlineData("$SYS.SERVER.abc.STATSZ", true)]
|
||||
[InlineData("$SYS.REQ.SERVER.PING.VARZ", true)]
|
||||
[InlineData("foo.bar", false)]
|
||||
[InlineData("$G", false)]
|
||||
[InlineData("SYS.test", false)]
|
||||
[InlineData("$JS.API.STREAM.LIST", false)]
|
||||
[InlineData("$SYS.", true)]
|
||||
public void IsSystemSubject_identifies_sys_subjects(string subject, bool expected)
|
||||
{
|
||||
NatsServer.IsSystemSubject(subject).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account is listed among server accounts.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_is_in_server_accounts()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var accounts = server.GetAccounts().ToList();
|
||||
accounts.ShouldContain(a => a.Name == Account.SystemAccountName && a.IsSystemAccount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that IsSubscriptionAllowed blocks non-system accounts from $SYS.> subjects.
|
||||
/// Reference: Go server/accounts.go — isReservedForSys.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Non_system_account_cannot_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeFalse();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$SYS.REQ.SERVER.PING.VARZ").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the system account IS allowed to subscribe to $SYS.> subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void System_account_can_subscribe_to_sys_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.SERVER.abc.STATSZ").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "$SYS.ACCOUNT.test.CONNECT").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that any account can subscribe to non-$SYS subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Any_account_can_subscribe_to_regular_subjects()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var regularAccount = new Account("regular");
|
||||
|
||||
server.IsSubscriptionAllowed(regularAccount, "foo.bar").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(regularAccount, "$JS.API.STREAM.LIST").ShouldBeTrue();
|
||||
server.IsSubscriptionAllowed(server.SystemAccount, "foo.bar").ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that GetSubListForSubject routes $SYS subjects to the system account's SubList.
|
||||
/// Reference: Go server/server.go — sublist routing for internal subjects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSubListForSubject_routes_sys_to_system_account()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
|
||||
// $SYS subjects should route to the system account's SubList
|
||||
var sysList = server.GetSubListForSubject(globalAccount, "$SYS.SERVER.abc.STATSZ");
|
||||
sysList.ShouldBeSameAs(server.SystemAccount.SubList);
|
||||
|
||||
// Regular subjects should route to the specified account's SubList
|
||||
var regularList = server.GetSubListForSubject(globalAccount, "foo.bar");
|
||||
regularList.ShouldBeSameAs(globalAccount.SubList);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the EventSystem publishes to the system account's SubList
|
||||
/// and that internal subscriptions for monitoring are registered there.
|
||||
/// The subscriptions are wired up during StartAsync via InitEventTracking.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Event_system_subscribes_in_system_account()
|
||||
{
|
||||
var (server, _, cts) = await StartServerAsync(new NatsOptions());
|
||||
try
|
||||
{
|
||||
// The system account's SubList should have subscriptions registered
|
||||
// by the internal event system (VARZ, HEALTHZ, etc.)
|
||||
server.EventSystem.ShouldNotBeNull();
|
||||
server.SystemAccount.SubList.Count.ShouldBeGreaterThan(0u);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the global account is separate from the system account.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Global_and_system_accounts_are_separate()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
var globalAccount = server.GetOrCreateAccount(Account.GlobalAccountName);
|
||||
var systemAccount = server.SystemAccount;
|
||||
|
||||
globalAccount.ShouldNotBeSameAs(systemAccount);
|
||||
globalAccount.Name.ShouldBe(Account.GlobalAccountName);
|
||||
systemAccount.Name.ShouldBe(Account.SystemAccountName);
|
||||
globalAccount.IsSystemAccount.ShouldBeFalse();
|
||||
systemAccount.IsSystemAccount.ShouldBeTrue();
|
||||
globalAccount.SubList.ShouldNotBeSameAs(systemAccount.SubList);
|
||||
}
|
||||
}
|
||||
413
tests/NATS.Server.Tests/Configuration/AuthReloadTests.cs
Normal file
413
tests/NATS.Server.Tests/Configuration/AuthReloadTests.cs
Normal file
@@ -0,0 +1,413 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
394
tests/NATS.Server.Tests/Configuration/SignalReloadTests.cs
Normal file
394
tests/NATS.Server.Tests/Configuration/SignalReloadTests.cs
Normal file
@@ -0,0 +1,394 @@
|
||||
// Port of Go server/reload_test.go — TestConfigReloadSIGHUP, TestReloadAsync,
|
||||
// TestApplyDiff, TestReloadConfigOrThrow.
|
||||
// Reference: golang/nats-server/server/reload_test.go, reload.go.
|
||||
|
||||
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 SIGHUP-triggered config reload and the ConfigReloader async API.
|
||||
/// Covers:
|
||||
/// - PosixSignalRegistration for SIGHUP wired to ReloadConfig
|
||||
/// - ConfigReloader.ReloadAsync parses, diffs, and validates
|
||||
/// - ConfigReloader.ApplyDiff returns correct category flags
|
||||
/// - End-to-end reload via config file rewrite and ReloadConfigOrThrow
|
||||
/// Reference: Go server/reload.go — Reload, applyOptions.
|
||||
/// </summary>
|
||||
public class SignalReloadTests
|
||||
{
|
||||
// ─── 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<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>
|
||||
/// Verifies that HandleSignals registers a SIGHUP handler that calls ReloadConfig.
|
||||
/// We cannot actually send SIGHUP in a test, but we verify the handler is registered
|
||||
/// by confirming ReloadConfig works when called directly, and that the server survives
|
||||
/// signal registration without error.
|
||||
/// Reference: Go server/signals_unix.go — handleSignals.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HandleSignals_registers_sighup_handler()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-sighup-{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
|
||||
{
|
||||
// Register signal handlers — should not throw
|
||||
server.HandleSignals();
|
||||
|
||||
// Verify the reload mechanism works by calling it directly
|
||||
// (simulating what SIGHUP would trigger)
|
||||
File.WriteAllText(configPath, $"port: {port}\ndebug: true");
|
||||
server.ReloadConfig();
|
||||
|
||||
// The server should still be operational
|
||||
await using var client = new NatsConnection(new NatsOpts
|
||||
{
|
||||
Url = $"nats://127.0.0.1:{port}",
|
||||
});
|
||||
await client.ConnectAsync();
|
||||
await client.PingAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync correctly detects an unchanged config file.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_detects_unchanged_config()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-noop-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222 };
|
||||
|
||||
// Compute initial digest
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync correctly detects config changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_detects_changes()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-change-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = false };
|
||||
|
||||
// Compute initial digest
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change the config file
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: true");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.NewOptions.ShouldNotBeNull();
|
||||
result.NewOptions!.Debug.ShouldBeTrue();
|
||||
result.Changes.ShouldNotBeNull();
|
||||
result.Changes!.Count.ShouldBeGreaterThan(0);
|
||||
result.HasErrors.ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ReloadAsync reports errors for non-reloadable changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_reports_non_reloadable_errors()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-nonreload-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\nserver_name: original");
|
||||
|
||||
var currentOpts = new NatsOptions
|
||||
{
|
||||
ConfigFile = configPath,
|
||||
Port = 4222,
|
||||
ServerName = "original",
|
||||
};
|
||||
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change a non-reloadable option
|
||||
File.WriteAllText(configPath, "port: 4222\nserver_name: changed");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, null, [], CancellationToken.None);
|
||||
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.HasErrors.ShouldBeTrue();
|
||||
result.Errors!.ShouldContain(e => e.Contains("ServerName"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ConfigReloader.ApplyDiff returns correct category flags.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyDiff_returns_correct_category_flags()
|
||||
{
|
||||
var oldOpts = new NatsOptions { Debug = false, Username = "old" };
|
||||
var newOpts = new NatsOptions { Debug = true, Username = "new" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasLoggingChanges.ShouldBeTrue();
|
||||
result.HasAuthChanges.ShouldBeTrue();
|
||||
result.ChangeCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ApplyDiff detects TLS changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ApplyDiff_detects_tls_changes()
|
||||
{
|
||||
var oldOpts = new NatsOptions { TlsCert = null };
|
||||
var newOpts = new NatsOptions { TlsCert = "/path/to/cert.pem" };
|
||||
|
||||
var changes = ConfigReloader.Diff(oldOpts, newOpts);
|
||||
var result = ConfigReloader.ApplyDiff(changes, oldOpts, newOpts);
|
||||
|
||||
result.HasTlsChanges.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadAsync preserves CLI overrides during reload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadAsync_preserves_cli_overrides()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-cli-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: false");
|
||||
|
||||
var currentOpts = new NatsOptions { ConfigFile = configPath, Port = 4222, Debug = true };
|
||||
var cliSnapshot = new NatsOptions { Debug = true };
|
||||
var cliFlags = new HashSet<string> { "Debug" };
|
||||
|
||||
var (_, initialDigest) = NatsConfParser.ParseFileWithDigest(configPath);
|
||||
|
||||
// Change config — debug goes to true in file, but CLI override also says true
|
||||
File.WriteAllText(configPath, "port: 4222\ndebug: true");
|
||||
|
||||
var result = await ConfigReloader.ReloadAsync(
|
||||
configPath, currentOpts, initialDigest, cliSnapshot, cliFlags, CancellationToken.None);
|
||||
|
||||
// Config changed, so it should not be "unchanged"
|
||||
result.Unchanged.ShouldBeFalse();
|
||||
result.NewOptions.ShouldNotBeNull();
|
||||
result.NewOptions!.Debug.ShouldBeTrue("CLI override should preserve debug=true");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies end-to-end: rewrite config file and call ReloadConfigOrThrow
|
||||
/// to apply max_connections changes, then verify new connections are rejected.
|
||||
/// Reference: Go server/reload_test.go — TestConfigReloadMaxConnections.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reload_via_config_file_rewrite_applies_changes()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-e2e-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\nmax_connections: 65536");
|
||||
|
||||
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
|
||||
{
|
||||
// Establish one connection
|
||||
using var c1 = await RawConnectAsync(port);
|
||||
server.ClientCount.ShouldBe(1);
|
||||
|
||||
// Reduce max_connections to 1 via reload
|
||||
WriteConfigAndReload(server, configPath, $"port: {port}\nmax_connections: 1");
|
||||
|
||||
// New connection should be rejected
|
||||
using var c2 = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await c2.ConnectAsync(IPAddress.Loopback, port);
|
||||
var response = await ReadUntilAsync(c2, "-ERR", timeoutMs: 5000);
|
||||
response.ShouldContain("maximum connections exceeded");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfigOrThrow throws for non-reloadable changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReloadConfigOrThrow_throws_on_non_reloadable_change()
|
||||
{
|
||||
var configPath = Path.Combine(Path.GetTempPath(), $"natsdotnet-throw-{Guid.NewGuid():N}.conf");
|
||||
try
|
||||
{
|
||||
var port = GetFreePort();
|
||||
File.WriteAllText(configPath, $"port: {port}\nserver_name: original");
|
||||
|
||||
var options = new NatsOptions { ConfigFile = configPath, Port = port, ServerName = "original" };
|
||||
var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
var cts = new CancellationTokenSource();
|
||||
_ = server.StartAsync(cts.Token);
|
||||
await server.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Try to change a non-reloadable option
|
||||
File.WriteAllText(configPath, $"port: {port}\nserver_name: changed");
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("ServerName");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await cts.CancelAsync();
|
||||
server.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(configPath)) File.Delete(configPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfig does not throw when no config file is specified
|
||||
/// (it logs a warning and returns).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReloadConfig_no_config_file_does_not_throw()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
// Should not throw; just logs a warning
|
||||
Should.NotThrow(() => server.ReloadConfig());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReloadConfigOrThrow throws when no config file is specified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReloadConfigOrThrow_throws_when_no_config_file()
|
||||
{
|
||||
var options = new NatsOptions { Port = 0 };
|
||||
using var server = new NatsServer(options, NullLoggerFactory.Instance);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => server.ReloadConfigOrThrow())
|
||||
.Message.ShouldContain("No config file");
|
||||
}
|
||||
}
|
||||
497
tests/NATS.Server.Tests/LeafNodes/LeafSubjectFilterTests.cs
Normal file
497
tests/NATS.Server.Tests/LeafNodes/LeafSubjectFilterTests.cs
Normal file
@@ -0,0 +1,497 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NATS.Client.Core;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.LeafNodes;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Tests.LeafNodes;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for leaf node subject filtering via DenyExports and DenyImports.
|
||||
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231
|
||||
/// (DenyImports/DenyExports fields in RemoteLeafOpts).
|
||||
/// </summary>
|
||||
public class LeafSubjectFilterTests
|
||||
{
|
||||
// ── LeafHubSpokeMapper.IsSubjectAllowed Unit Tests ────────────────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Literal_deny_export_blocks_outbound_subject()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["secret.data"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("secret.data", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Literal_deny_import_blocks_inbound_subject()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: ["internal.status"]);
|
||||
|
||||
mapper.IsSubjectAllowed("internal.status", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("external.status", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Wildcard_deny_export_blocks_matching_subjects()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["admin.*"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("admin.config", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("admin.deep.nested", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Fwc_deny_import_blocks_all_matching_subjects()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: ["_SYS.>"]);
|
||||
|
||||
mapper.IsSubjectAllowed("_SYS.heartbeat", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("_SYS.a.b.c", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("user.data", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Bidirectional_filtering_applies_independently()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["export.denied"],
|
||||
denyImports: ["import.denied"]);
|
||||
|
||||
// Export deny does not affect inbound direction
|
||||
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("export.denied", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
|
||||
// Import deny does not affect outbound direction
|
||||
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("import.denied", LeafMapDirection.Inbound).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Multiple_deny_patterns_all_evaluated()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: ["admin.*", "secret.>", "internal.config"],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("admin.users", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("secret.key.value", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("internal.config", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
mapper.IsSubjectAllowed("public.data", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Empty_deny_lists_allow_everything()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
denyExports: [],
|
||||
denyImports: []);
|
||||
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Account_mapping_still_works_with_subject_filter()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string> { ["HUB_ACCT"] = "SPOKE_ACCT" },
|
||||
denyExports: ["denied.>"],
|
||||
denyImports: []);
|
||||
|
||||
var outbound = mapper.Map("HUB_ACCT", "foo.bar", LeafMapDirection.Outbound);
|
||||
outbound.Account.ShouldBe("SPOKE_ACCT");
|
||||
outbound.Subject.ShouldBe("foo.bar");
|
||||
|
||||
var inbound = mapper.Map("SPOKE_ACCT", "foo.bar", LeafMapDirection.Inbound);
|
||||
inbound.Account.ShouldBe("HUB_ACCT");
|
||||
inbound.Subject.ShouldBe("foo.bar");
|
||||
|
||||
mapper.IsSubjectAllowed("denied.test", LeafMapDirection.Outbound).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public void Default_constructor_allows_everything()
|
||||
{
|
||||
var mapper = new LeafHubSpokeMapper(new Dictionary<string, string>());
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Outbound).ShouldBeTrue();
|
||||
mapper.IsSubjectAllowed("any.subject", LeafMapDirection.Inbound).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ── Integration: DenyExports blocks hub→leaf message forwarding ────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_blocks_message_forwarding_hub_to_leaf()
|
||||
{
|
||||
// Start a hub with DenyExports configured
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["secret.>"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for leaf connection
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
|
||||
// Subscribe on spoke for allowed and denied subjects
|
||||
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("public.data");
|
||||
await using var deniedSub = await leafConn.SubscribeCoreAsync<string>("secret.data");
|
||||
await leafConn.PingAsync();
|
||||
|
||||
// Wait for interest propagation
|
||||
await Task.Delay(500);
|
||||
|
||||
// Publish from hub
|
||||
await hubConn.PublishAsync("public.data", "allowed-msg");
|
||||
await hubConn.PublishAsync("secret.data", "denied-msg");
|
||||
|
||||
// The allowed message should arrive
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
|
||||
|
||||
// The denied message should NOT arrive
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await deniedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyImports_blocks_message_forwarding_leaf_to_hub()
|
||||
{
|
||||
// Start hub with DenyImports — leaf→hub messages for denied subjects are dropped
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyImports = ["private.>"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for leaf connection
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
|
||||
// Subscribe on hub for both allowed and denied subjects
|
||||
await using var allowedSub = await hubConn.SubscribeCoreAsync<string>("public.data");
|
||||
await using var deniedSub = await hubConn.SubscribeCoreAsync<string>("private.data");
|
||||
await hubConn.PingAsync();
|
||||
|
||||
// Wait for interest propagation
|
||||
await Task.Delay(500);
|
||||
|
||||
// Publish from spoke (leaf)
|
||||
await leafConn.PublishAsync("public.data", "allowed-msg");
|
||||
await leafConn.PublishAsync("private.data", "denied-msg");
|
||||
|
||||
// The allowed message should arrive on hub
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed-msg");
|
||||
|
||||
// The denied message should NOT arrive
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await deniedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_with_wildcard_blocks_pattern_matching_subjects()
|
||||
{
|
||||
var hubOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["admin.*"],
|
||||
},
|
||||
};
|
||||
|
||||
var hub = new NatsServer(hubOptions, NullLoggerFactory.Instance);
|
||||
var hubCts = new CancellationTokenSource();
|
||||
_ = hub.StartAsync(hubCts.Token);
|
||||
await hub.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var spokeOptions = new NatsOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
LeafNode = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Remotes = [hub.LeafListen!],
|
||||
},
|
||||
};
|
||||
|
||||
var spoke = new NatsServer(spokeOptions, NullLoggerFactory.Instance);
|
||||
var spokeCts = new CancellationTokenSource();
|
||||
_ = spoke.StartAsync(spokeCts.Token);
|
||||
await spoke.WaitForReadyAsync();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
while (!timeout.IsCancellationRequested && (hub.Stats.Leafs == 0 || spoke.Stats.Leafs == 0))
|
||||
await Task.Delay(50, timeout.Token).ContinueWith(_ => { }, TaskScheduler.Default);
|
||||
|
||||
await using var leafConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{spoke.Port}" });
|
||||
await leafConn.ConnectAsync();
|
||||
await using var hubConn = new NatsConnection(new NatsOpts { Url = $"nats://127.0.0.1:{hub.Port}" });
|
||||
await hubConn.ConnectAsync();
|
||||
|
||||
// admin.users should be blocked; admin.deep.nested should pass (* doesn't match multi-token)
|
||||
await using var blockedSub = await leafConn.SubscribeCoreAsync<string>("admin.users");
|
||||
await using var allowedSub = await leafConn.SubscribeCoreAsync<string>("admin.deep.nested");
|
||||
await leafConn.PingAsync();
|
||||
await Task.Delay(500);
|
||||
|
||||
await hubConn.PublishAsync("admin.users", "blocked");
|
||||
await hubConn.PublishAsync("admin.deep.nested", "allowed");
|
||||
|
||||
// The multi-token subject passes because * matches only single token
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
|
||||
(await allowedSub.Msgs.ReadAsync(cts.Token)).Data.ShouldBe("allowed");
|
||||
|
||||
// The single-token subject is blocked
|
||||
using var leakCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await blockedSub.Msgs.ReadAsync(leakCts.Token));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await spokeCts.CancelAsync();
|
||||
spoke.Dispose();
|
||||
spokeCts.Dispose();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await hubCts.CancelAsync();
|
||||
hub.Dispose();
|
||||
hubCts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire-level: DenyExports blocks LS+ propagation ──────────────
|
||||
|
||||
// Go: TestLeafNodePermissions server/leafnode_test.go:1267
|
||||
[Fact]
|
||||
public async Task DenyExports_blocks_subscription_propagation()
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
var options = new LeafNodeOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
DenyExports = ["secret.>"],
|
||||
};
|
||||
|
||||
var manager = new LeafNodeManager(
|
||||
options,
|
||||
new ServerStats(),
|
||||
"HUB1",
|
||||
_ => { },
|
||||
_ => { },
|
||||
NullLogger<LeafNodeManager>.Instance);
|
||||
|
||||
await manager.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
using var remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await remoteSocket.ConnectAsync(IPAddress.Loopback, options.Port);
|
||||
|
||||
// Exchange handshakes — inbound connections send LEAF first, then read response
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await WriteLineAsync(remoteSocket, "LEAF SPOKE1", cts.Token);
|
||||
var line = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
line.ShouldStartWith("LEAF ");
|
||||
|
||||
await Task.Delay(200);
|
||||
|
||||
// Propagate allowed subscription
|
||||
manager.PropagateLocalSubscription("$G", "public.data", null);
|
||||
await Task.Delay(100);
|
||||
var lsLine = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
lsLine.ShouldBe("LS+ $G public.data");
|
||||
|
||||
// Propagate denied subscription — should NOT appear on wire
|
||||
manager.PropagateLocalSubscription("$G", "secret.data", null);
|
||||
|
||||
// Send a PING to verify nothing else was sent
|
||||
manager.PropagateLocalSubscription("$G", "allowed.check", null);
|
||||
await Task.Delay(100);
|
||||
var nextLine = await ReadLineAsync(remoteSocket, cts.Token);
|
||||
nextLine.ShouldBe("LS+ $G allowed.check");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await manager.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private static async Task<string> ReadLineAsync(Socket socket, CancellationToken ct)
|
||||
{
|
||||
var bytes = new List<byte>(64);
|
||||
var single = new byte[1];
|
||||
while (true)
|
||||
{
|
||||
var read = await socket.ReceiveAsync(single, SocketFlags.None, ct);
|
||||
if (read == 0)
|
||||
break;
|
||||
if (single[0] == (byte)'\n')
|
||||
break;
|
||||
if (single[0] != (byte)'\r')
|
||||
bytes.Add(single[0]);
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static Task WriteLineAsync(Socket socket, string line, CancellationToken ct)
|
||||
=> socket.SendAsync(Encoding.ASCII.GetBytes($"{line}\r\n"), SocketFlags.None, ct).AsTask();
|
||||
}
|
||||
1250
tests/NATS.Server.Tests/Networking/NetworkingGoParityTests.cs
Normal file
1250
tests/NATS.Server.Tests/Networking/NetworkingGoParityTests.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user