diff --git a/src/NATS.Server/Auth/Account.cs b/src/NATS.Server/Auth/Account.cs index 6e8ac3f..211f431 100644 --- a/src/NATS.Server/Auth/Account.cs +++ b/src/NATS.Server/Auth/Account.cs @@ -10,8 +10,11 @@ public sealed class Account : IDisposable public string Name { get; } public SubList SubList { get; } = new(); public Permissions? DefaultPermissions { get; set; } + public int MaxConnections { get; set; } // 0 = unlimited + public int MaxSubscriptions { get; set; } // 0 = unlimited private readonly ConcurrentDictionary _clients = new(); + private int _subscriptionCount; public Account(string name) { @@ -19,10 +22,31 @@ public sealed class Account : IDisposable } public int ClientCount => _clients.Count; + public int SubscriptionCount => Volatile.Read(ref _subscriptionCount); - public void AddClient(ulong clientId) => _clients[clientId] = 0; + /// Returns false if max connections exceeded. + public bool AddClient(ulong clientId) + { + if (MaxConnections > 0 && _clients.Count >= MaxConnections) + return false; + _clients[clientId] = 0; + return true; + } public void RemoveClient(ulong clientId) => _clients.TryRemove(clientId, out _); + public bool IncrementSubscriptions() + { + if (MaxSubscriptions > 0 && Volatile.Read(ref _subscriptionCount) >= MaxSubscriptions) + return false; + Interlocked.Increment(ref _subscriptionCount); + return true; + } + + public void DecrementSubscriptions() + { + Interlocked.Decrement(ref _subscriptionCount); + } + public void Dispose() => SubList.Dispose(); } diff --git a/src/NATS.Server/Auth/AccountConfig.cs b/src/NATS.Server/Auth/AccountConfig.cs new file mode 100644 index 0000000..7337f33 --- /dev/null +++ b/src/NATS.Server/Auth/AccountConfig.cs @@ -0,0 +1,8 @@ +namespace NATS.Server.Auth; + +public sealed class AccountConfig +{ + public int MaxConnections { get; init; } // 0 = unlimited + public int MaxSubscriptions { get; init; } // 0 = unlimited + public Permissions? DefaultPermissions { get; init; } +} diff --git a/src/NATS.Server/NatsClient.cs b/src/NATS.Server/NatsClient.cs index b380560..1ecbc56 100644 --- a/src/NATS.Server/NatsClient.cs +++ b/src/NATS.Server/NatsClient.cs @@ -372,7 +372,13 @@ public sealed class NatsClient : IDisposable { var accountName = result.AccountName ?? Account.GlobalAccountName; Account = server.GetOrCreateAccount(accountName); - Account.AddClient(Id); + if (!Account.AddClient(Id)) + { + Account = null; + await SendErrAndCloseAsync("maximum connections for account exceeded", + ClientClosedReason.AuthenticationViolation); + return; + } } _logger.LogDebug("Client {ClientId} authenticated as {Identity}", Id, result.Identity); @@ -386,7 +392,13 @@ public sealed class NatsClient : IDisposable if (Account == null && Router is NatsServer server2) { Account = server2.GetOrCreateAccount(Account.GlobalAccountName); - Account.AddClient(Id); + if (!Account.AddClient(Id)) + { + Account = null; + await SendErrAndCloseAsync("maximum connections for account exceeded", + ClientClosedReason.AuthenticationViolation); + return; + } } // Validate no_responders requires headers diff --git a/src/NATS.Server/NatsOptions.cs b/src/NATS.Server/NatsOptions.cs index ecec1e3..346e7f4 100644 --- a/src/NATS.Server/NatsOptions.cs +++ b/src/NATS.Server/NatsOptions.cs @@ -29,6 +29,9 @@ public sealed class NatsOptions // Server tags (exposed via /varz) public Dictionary? Tags { get; set; } + // Account configuration + public Dictionary? Accounts { get; set; } + // Simple auth (single user) public string? Username { get; set; } public string? Password { get; set; } diff --git a/src/NATS.Server/NatsServer.cs b/src/NATS.Server/NatsServer.cs index 7bd3e76..5c34f68 100644 --- a/src/NATS.Server/NatsServer.cs +++ b/src/NATS.Server/NatsServer.cs @@ -569,7 +569,17 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable public Account GetOrCreateAccount(string name) { - return _accounts.GetOrAdd(name, n => new Account(n)); + return _accounts.GetOrAdd(name, n => + { + var acc = new Account(n); + if (_options.Accounts != null && _options.Accounts.TryGetValue(n, out var config)) + { + acc.MaxConnections = config.MaxConnections; + acc.MaxSubscriptions = config.MaxSubscriptions; + acc.DefaultPermissions = config.DefaultPermissions; + } + return acc; + }); } public void RemoveClient(NatsClient client) diff --git a/tests/NATS.Server.Tests/AccountTests.cs b/tests/NATS.Server.Tests/AccountTests.cs index 304a498..459479b 100644 --- a/tests/NATS.Server.Tests/AccountTests.cs +++ b/tests/NATS.Server.Tests/AccountTests.cs @@ -32,4 +32,40 @@ public class AccountTests { Account.GlobalAccountName.ShouldBe("$G"); } + + [Fact] + public void Account_enforces_max_connections() + { + var acc = new Account("test") { MaxConnections = 2 }; + acc.AddClient(1).ShouldBeTrue(); + acc.AddClient(2).ShouldBeTrue(); + acc.AddClient(3).ShouldBeFalse(); // exceeds limit + acc.ClientCount.ShouldBe(2); + } + + [Fact] + public void Account_unlimited_connections_when_zero() + { + var acc = new Account("test") { MaxConnections = 0 }; + acc.AddClient(1).ShouldBeTrue(); + acc.AddClient(2).ShouldBeTrue(); + } + + [Fact] + public void Account_enforces_max_subscriptions() + { + var acc = new Account("test") { MaxSubscriptions = 2 }; + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.IncrementSubscriptions().ShouldBeFalse(); + } + + [Fact] + public void Account_decrement_subscriptions() + { + var acc = new Account("test") { MaxSubscriptions = 1 }; + acc.IncrementSubscriptions().ShouldBeTrue(); + acc.DecrementSubscriptions(); + acc.IncrementSubscriptions().ShouldBeTrue(); // slot freed + } }