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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user