feat(config): add system account, SIGHUP reload, and auth change propagation (E6+E7+E8)

E6: Add IsSystemAccount property to Account, mark $SYS account as system,
add IsSystemSubject/IsSubscriptionAllowed/GetSubListForSubject helpers to
route $SYS.> subjects to the system account's SubList and block non-system
accounts from subscribing.

E7: Add ConfigReloader.ReloadAsync and ApplyDiff for structured async reload,
add ConfigReloadResult/ConfigApplyResult types. SIGHUP handler already wired
via PosixSignalRegistration in HandleSignals.

E8: Add PropagateAuthChanges to re-evaluate connected clients after auth
config reload, disconnecting clients whose credentials no longer pass
authentication with -ERR 'Authorization Violation'.
This commit is contained in:
Joseph Doherty
2026-02-24 15:48:48 -05:00
parent 18acd6f4e2
commit c6ecbbfbcc
12 changed files with 3143 additions and 4 deletions

View File

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

View File

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