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:
Joseph Doherty
2026-02-24 15:48:48 -05:00
parent 18acd6f4e2
commit c6ecbbfbcc
12 changed files with 3143 additions and 4 deletions

View 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);
}
}

View 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;
}
}

View 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");
}
}

View 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();
}

File diff suppressed because it is too large Load Diff