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

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

View File

@@ -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; } = [];
}