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:
Binary file not shown.
@@ -7,6 +7,7 @@ namespace NATS.Server.Auth;
|
||||
public sealed class Account : IDisposable
|
||||
{
|
||||
public const string GlobalAccountName = "$G";
|
||||
public const string SystemAccountName = "$SYS";
|
||||
|
||||
public string Name { get; }
|
||||
public SubList SubList { get; } = new();
|
||||
@@ -18,6 +19,13 @@ public sealed class Account : IDisposable
|
||||
public int MaxJetStreamStreams { get; set; } // 0 = unlimited
|
||||
public string? JetStreamTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether this account is the designated system account.
|
||||
/// The system account owns $SYS.> subjects for internal server-to-server communication.
|
||||
/// Reference: Go server/accounts.go — isSystemAccount().
|
||||
/// </summary>
|
||||
public bool IsSystemAccount { get; set; }
|
||||
|
||||
/// <summary>Per-account JetStream resource limits (storage, consumers, ack pending).</summary>
|
||||
public AccountLimits JetStreamLimits { get; set; } = AccountLimits.Unlimited;
|
||||
|
||||
|
||||
@@ -328,6 +328,73 @@ public static class ConfigReloader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a validated set of config changes by copying reloadable property values
|
||||
/// from <paramref name="newOpts"/> to <paramref name="currentOpts"/>. Returns category
|
||||
/// flags indicating which subsystems need to be notified.
|
||||
/// Reference: Go server/reload.go — applyOptions.
|
||||
/// </summary>
|
||||
public static ConfigApplyResult ApplyDiff(
|
||||
List<IConfigChange> changes,
|
||||
NatsOptions currentOpts,
|
||||
NatsOptions newOpts)
|
||||
{
|
||||
bool hasLoggingChanges = false;
|
||||
bool hasAuthChanges = false;
|
||||
bool hasTlsChanges = false;
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (change.IsLoggingChange) hasLoggingChanges = true;
|
||||
if (change.IsAuthChange) hasAuthChanges = true;
|
||||
if (change.IsTlsChange) hasTlsChanges = true;
|
||||
}
|
||||
|
||||
return new ConfigApplyResult(
|
||||
HasLoggingChanges: hasLoggingChanges,
|
||||
HasAuthChanges: hasAuthChanges,
|
||||
HasTlsChanges: hasTlsChanges,
|
||||
ChangeCount: changes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronous reload entry point that parses the config file, diffs against
|
||||
/// current options, validates changes, and returns the result. The caller (typically
|
||||
/// the SIGHUP handler) is responsible for applying the result to the running server.
|
||||
/// Reference: Go server/reload.go — Reload.
|
||||
/// </summary>
|
||||
public static async Task<ConfigReloadResult> ReloadAsync(
|
||||
string configFile,
|
||||
NatsOptions currentOpts,
|
||||
string? currentDigest,
|
||||
NatsOptions? cliSnapshot,
|
||||
HashSet<string> cliFlags,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var (newConfig, digest) = NatsConfParser.ParseFileWithDigest(configFile);
|
||||
if (digest == currentDigest)
|
||||
return new ConfigReloadResult(Unchanged: true);
|
||||
|
||||
var newOpts = new NatsOptions { ConfigFile = configFile };
|
||||
ConfigProcessor.ApplyConfig(newConfig, newOpts);
|
||||
|
||||
if (cliSnapshot != null)
|
||||
MergeCliOverrides(newOpts, cliSnapshot, cliFlags);
|
||||
|
||||
var changes = Diff(currentOpts, newOpts);
|
||||
var errors = Validate(changes);
|
||||
|
||||
return new ConfigReloadResult(
|
||||
Unchanged: false,
|
||||
NewOptions: newOpts,
|
||||
NewDigest: digest,
|
||||
Changes: changes,
|
||||
Errors: errors);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
// ─── Comparison helpers ─────────────────────────────────────────
|
||||
|
||||
private static void CompareAndAdd<T>(List<IConfigChange> changes, string name, T oldVal, T newVal)
|
||||
@@ -393,3 +460,41 @@ public static class ConfigReloader
|
||||
return !string.Equals(oldJetStream.StoreDir, newJetStream.StoreDir, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of applying a config diff — flags indicating which subsystems need notification.
|
||||
/// </summary>
|
||||
public readonly record struct ConfigApplyResult(
|
||||
bool HasLoggingChanges,
|
||||
bool HasAuthChanges,
|
||||
bool HasTlsChanges,
|
||||
int ChangeCount);
|
||||
|
||||
/// <summary>
|
||||
/// Result of an async config reload operation. Contains the parsed options, diff, and
|
||||
/// validation errors (if any). If <see cref="Unchanged"/> is true, no reload is needed.
|
||||
/// </summary>
|
||||
public sealed class ConfigReloadResult
|
||||
{
|
||||
public bool Unchanged { get; }
|
||||
public NatsOptions? NewOptions { get; }
|
||||
public string? NewDigest { get; }
|
||||
public List<IConfigChange>? Changes { get; }
|
||||
public List<string>? Errors { get; }
|
||||
|
||||
public ConfigReloadResult(
|
||||
bool Unchanged,
|
||||
NatsOptions? NewOptions = null,
|
||||
string? NewDigest = null,
|
||||
List<IConfigChange>? Changes = null,
|
||||
List<string>? Errors = null)
|
||||
{
|
||||
this.Unchanged = Unchanged;
|
||||
this.NewOptions = NewOptions;
|
||||
this.NewDigest = NewDigest;
|
||||
this.Changes = Changes;
|
||||
this.Errors = Errors;
|
||||
}
|
||||
|
||||
public bool HasErrors => Errors is { Count: > 0 };
|
||||
}
|
||||
|
||||
@@ -12,4 +12,20 @@ public sealed class LeafNodeOptions
|
||||
/// Go reference: leafnode.go — JsDomain in leafNodeCfg.
|
||||
/// </summary>
|
||||
public string? JetStreamDomain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Subjects to deny exporting (hub→leaf direction). Messages matching any of
|
||||
/// these patterns will not be forwarded from the hub to the leaf.
|
||||
/// Supports wildcards (* and >).
|
||||
/// Go reference: leafnode.go — DenyExports in RemoteLeafOpts (opts.go:231).
|
||||
/// </summary>
|
||||
public List<string> DenyExports { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Subjects to deny importing (leaf→hub direction). Messages matching any of
|
||||
/// these patterns will not be forwarded from the leaf to the hub.
|
||||
/// Supports wildcards (* and >).
|
||||
/// Go reference: leafnode.go — DenyImports in RemoteLeafOpts (opts.go:230).
|
||||
/// </summary>
|
||||
public List<string> DenyImports { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.LeafNodes;
|
||||
|
||||
public enum LeafMapDirection
|
||||
@@ -8,17 +10,45 @@ public enum LeafMapDirection
|
||||
|
||||
public sealed record LeafMappingResult(string Account, string Subject);
|
||||
|
||||
/// <summary>
|
||||
/// Maps accounts between hub and spoke, and applies subject-level export/import
|
||||
/// filtering on leaf connections. In the Go server, DenyExports restricts what
|
||||
/// flows hub→leaf (Publish permission) and DenyImports restricts what flows
|
||||
/// leaf→hub (Subscribe permission).
|
||||
/// Go reference: leafnode.go:470-507 (newLeafNodeCfg), opts.go:230-231.
|
||||
/// </summary>
|
||||
public sealed class LeafHubSpokeMapper
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string> _hubToSpoke;
|
||||
private readonly IReadOnlyDictionary<string, string> _spokeToHub;
|
||||
private readonly IReadOnlyList<string> _denyExports;
|
||||
private readonly IReadOnlyList<string> _denyImports;
|
||||
|
||||
public LeafHubSpokeMapper(IReadOnlyDictionary<string, string> hubToSpoke)
|
||||
: this(hubToSpoke, [], [])
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mapper with account mapping and subject deny filters.
|
||||
/// </summary>
|
||||
/// <param name="hubToSpoke">Account mapping from hub account names to spoke account names.</param>
|
||||
/// <param name="denyExports">Subject patterns to deny in hub→leaf (outbound) direction.</param>
|
||||
/// <param name="denyImports">Subject patterns to deny in leaf→hub (inbound) direction.</param>
|
||||
public LeafHubSpokeMapper(
|
||||
IReadOnlyDictionary<string, string> hubToSpoke,
|
||||
IReadOnlyList<string> denyExports,
|
||||
IReadOnlyList<string> denyImports)
|
||||
{
|
||||
_hubToSpoke = hubToSpoke;
|
||||
_spokeToHub = hubToSpoke.ToDictionary(static p => p.Value, static p => p.Key, StringComparer.Ordinal);
|
||||
_denyExports = denyExports;
|
||||
_denyImports = denyImports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an account from hub→spoke or spoke→hub based on direction.
|
||||
/// </summary>
|
||||
public LeafMappingResult Map(string account, string subject, LeafMapDirection direction)
|
||||
{
|
||||
if (direction == LeafMapDirection.Outbound && _hubToSpoke.TryGetValue(account, out var spoke))
|
||||
@@ -27,4 +57,27 @@ public sealed class LeafHubSpokeMapper
|
||||
return new LeafMappingResult(hub, subject);
|
||||
return new LeafMappingResult(account, subject);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subject is allowed to flow in the given direction.
|
||||
/// A subject is denied if it matches any pattern in the corresponding deny list.
|
||||
/// Go reference: leafnode.go:475-484 (DenyExports → Publish deny, DenyImports → Subscribe deny).
|
||||
/// </summary>
|
||||
public bool IsSubjectAllowed(string subject, LeafMapDirection direction)
|
||||
{
|
||||
var denyList = direction switch
|
||||
{
|
||||
LeafMapDirection.Outbound => _denyExports,
|
||||
LeafMapDirection.Inbound => _denyImports,
|
||||
_ => [],
|
||||
};
|
||||
|
||||
for (var i = 0; i < denyList.Count; i++)
|
||||
{
|
||||
if (SubjectMatch.MatchLiteral(subject, denyList[i]))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace NATS.Server.LeafNodes;
|
||||
/// <summary>
|
||||
/// Manages leaf node connections — both inbound (accepted) and outbound (solicited).
|
||||
/// Outbound connections use exponential backoff retry: 1s, 2s, 4s, ..., capped at 60s.
|
||||
/// Subject filtering via DenyExports (hub→leaf) and DenyImports (leaf→hub) is applied
|
||||
/// to both message forwarding and subscription propagation.
|
||||
/// Go reference: leafnode.go.
|
||||
/// </summary>
|
||||
public sealed class LeafNodeManager : IAsyncDisposable
|
||||
@@ -21,6 +23,7 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
private readonly Action<LeafMessage> _messageSink;
|
||||
private readonly ILogger<LeafNodeManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, LeafConnection> _connections = new(StringComparer.Ordinal);
|
||||
private readonly LeafHubSpokeMapper _subjectFilter;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private Socket? _listener;
|
||||
@@ -53,6 +56,10 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
_remoteSubSink = remoteSubSink;
|
||||
_messageSink = messageSink;
|
||||
_logger = logger;
|
||||
_subjectFilter = new LeafHubSpokeMapper(
|
||||
new Dictionary<string, string>(),
|
||||
options.DenyExports,
|
||||
options.DenyImports);
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
@@ -105,12 +112,31 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
|
||||
public async Task ForwardMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
// Apply subject filtering: outbound direction is hub→leaf (DenyExports).
|
||||
// The subject may be loop-marked ($LDS.{serverId}.{realSubject}), so we
|
||||
// strip the marker before checking the filter against the logical subject.
|
||||
// Go reference: leafnode.go:475-478 (DenyExports → Publish deny list).
|
||||
var filterSubject = LeafLoopDetector.TryUnmark(subject, out var unmarked) ? unmarked : subject;
|
||||
if (!_subjectFilter.IsSubjectAllowed(filterSubject, LeafMapDirection.Outbound))
|
||||
{
|
||||
_logger.LogDebug("Leaf outbound message denied for subject {Subject} (DenyExports)", filterSubject);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var connection in _connections.Values)
|
||||
await connection.SendMessageAsync(account, subject, replyTo, payload, ct);
|
||||
}
|
||||
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||
{
|
||||
// Subscription propagation is also subject to export filtering:
|
||||
// we don't propagate subscriptions for subjects that are denied.
|
||||
if (!_subjectFilter.IsSubjectAllowed(subject, LeafMapDirection.Outbound))
|
||||
{
|
||||
_logger.LogDebug("Leaf subscription propagation denied for subject {Subject} (DenyExports)", subject);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var connection in _connections.Values)
|
||||
_ = connection.SendLsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
@@ -251,6 +277,19 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
};
|
||||
connection.MessageReceived = msg =>
|
||||
{
|
||||
// Apply inbound filtering: DenyImports restricts leaf→hub messages.
|
||||
// The subject may be loop-marked ($LDS.{serverId}.{realSubject}), so we
|
||||
// strip the marker before checking the filter against the logical subject.
|
||||
// Go reference: leafnode.go:480-481 (DenyImports → Subscribe deny list).
|
||||
var filterSubject = LeafLoopDetector.TryUnmark(msg.Subject, out var unmarked)
|
||||
? unmarked
|
||||
: msg.Subject;
|
||||
if (!_subjectFilter.IsSubjectAllowed(filterSubject, LeafMapDirection.Inbound))
|
||||
{
|
||||
_logger.LogDebug("Leaf inbound message denied for subject {Subject} (DenyImports)", filterSubject);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_messageSink(msg);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
@@ -365,9 +365,10 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_globalAccount = new Account(Account.GlobalAccountName);
|
||||
_accounts[Account.GlobalAccountName] = _globalAccount;
|
||||
|
||||
// Create $SYS system account (stub -- no internal subscriptions yet)
|
||||
_systemAccount = new Account("$SYS");
|
||||
_accounts["$SYS"] = _systemAccount;
|
||||
// Create $SYS system account and mark it as the system account.
|
||||
// Reference: Go server/server.go — initSystemAccount, accounts.go — isSystemAccount().
|
||||
_systemAccount = new Account(Account.SystemAccountName) { IsSystemAccount = true };
|
||||
_accounts[Account.SystemAccountName] = _systemAccount;
|
||||
|
||||
// Create system internal client and event system
|
||||
var sysClientId = Interlocked.Increment(ref _nextClientId);
|
||||
@@ -1265,6 +1266,43 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the subject belongs to the $SYS subject space.
|
||||
/// Reference: Go server/server.go — isReservedSubject.
|
||||
/// </summary>
|
||||
public static bool IsSystemSubject(string subject)
|
||||
=> subject.StartsWith("$SYS.", StringComparison.Ordinal) || subject == "$SYS";
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the given account is allowed to subscribe to the specified subject.
|
||||
/// Non-system accounts cannot subscribe to $SYS.> subjects.
|
||||
/// Reference: Go server/accounts.go — isReservedForSys.
|
||||
/// </summary>
|
||||
public bool IsSubscriptionAllowed(Account? account, string subject)
|
||||
{
|
||||
if (!IsSystemSubject(subject))
|
||||
return true;
|
||||
|
||||
// System account is always allowed
|
||||
if (account != null && account.IsSystemAccount)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SubList appropriate for a given subject: system account SubList
|
||||
/// for $SYS.> subjects, or the provided account's SubList for everything else.
|
||||
/// Reference: Go server/server.go — sublist routing for internal subjects.
|
||||
/// </summary>
|
||||
public SubList GetSubListForSubject(Account? account, string subject)
|
||||
{
|
||||
if (IsSystemSubject(subject))
|
||||
return _systemAccount.SubList;
|
||||
|
||||
return account?.SubList ?? _globalAccount.SubList;
|
||||
}
|
||||
|
||||
public void SendInternalMsg(string subject, string? reply, object? msg)
|
||||
{
|
||||
_eventSystem?.Enqueue(new PublishMessage { Subject = subject, Reply = reply, Body = msg });
|
||||
@@ -1653,9 +1691,79 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
|
||||
if (hasAuthChanges)
|
||||
{
|
||||
// Rebuild auth service with new options
|
||||
// Rebuild auth service with new options, then propagate changes to connected clients
|
||||
var oldAuthService = _authService;
|
||||
_authService = AuthService.Build(_options);
|
||||
_logger.LogInformation("Authorization configuration reloaded");
|
||||
|
||||
// Re-evaluate connected clients against the new auth config.
|
||||
// Clients that no longer pass authentication are disconnected with AUTH_EXPIRED.
|
||||
// Reference: Go server/reload.go — applyOptions / reloadAuthorization.
|
||||
PropagateAuthChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates all connected clients against the current auth configuration.
|
||||
/// Clients whose credentials no longer pass authentication are disconnected
|
||||
/// with an "Authorization Violation" error via SendErrAndCloseAsync, which
|
||||
/// properly drains the outbound channel before closing the socket.
|
||||
/// Reference: Go server/reload.go — reloadAuthorization, client.go — applyAccountLimits.
|
||||
/// </summary>
|
||||
internal void PropagateAuthChanges()
|
||||
{
|
||||
if (!_authService.IsAuthRequired)
|
||||
{
|
||||
// Auth was disabled — all existing clients are fine
|
||||
return;
|
||||
}
|
||||
|
||||
var clientsToDisconnect = new List<NatsClient>();
|
||||
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
if (client.ClientOpts == null)
|
||||
continue; // Client hasn't sent CONNECT yet
|
||||
|
||||
var context = new ClientAuthContext
|
||||
{
|
||||
Opts = client.ClientOpts,
|
||||
Nonce = [], // Nonce is only used at connect time; re-evaluation skips it
|
||||
ClientCertificate = client.TlsState?.PeerCert,
|
||||
};
|
||||
|
||||
var result = _authService.Authenticate(context);
|
||||
if (result == null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Client {ClientId} credentials no longer valid after auth reload, disconnecting",
|
||||
client.Id);
|
||||
clientsToDisconnect.Add(client);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect clients that failed re-authentication.
|
||||
// Use SendErrAndCloseAsync which queues the -ERR, completes the outbound channel,
|
||||
// waits for the write loop to drain, then cancels the client.
|
||||
var disconnectTasks = new List<Task>(clientsToDisconnect.Count);
|
||||
foreach (var client in clientsToDisconnect)
|
||||
{
|
||||
disconnectTasks.Add(client.SendErrAndCloseAsync(
|
||||
NatsProtocol.ErrAuthorizationViolation,
|
||||
ClientClosedReason.AuthenticationExpired));
|
||||
}
|
||||
|
||||
// Wait for all disconnects to complete (with timeout to avoid blocking reload)
|
||||
if (disconnectTasks.Count > 0)
|
||||
{
|
||||
Task.WhenAll(disconnectTasks)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5))
|
||||
.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing)
|
||||
.GetAwaiter().GetResult();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Disconnected {Count} client(s) after auth configuration reload",
|
||||
clientsToDisconnect.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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