feat: add leaf node TLS certificate hot-reload (Gap 12.1)

Add UpdateTlsConfig to LeafNodeManager with CurrentCertPath, CurrentKeyPath,
IsTlsEnabled, and TlsReloadCount. Add LeafTlsReloadResult record. Add 10 unit
tests in LeafTlsReloadTests covering change detection, no-op idempotency, path
tracking, counter semantics, and result payload.
This commit is contained in:
Joseph Doherty
2026-02-25 12:08:15 -05:00
parent b9c83d6b3b
commit ef425db187
2 changed files with 209 additions and 0 deletions

View File

@@ -42,6 +42,60 @@ public sealed class LeafNodeManager : IAsyncDisposable
public string ListenEndpoint => $"{_options.Host}:{_options.Port}";
/// <summary>
/// Current TLS certificate path, or null if TLS is not configured.
/// Updated by <see cref="UpdateTlsConfig"/>.
/// Go reference: leafnode.go — TLSConfig / tlsCertFile on server options.
/// </summary>
public string? CurrentCertPath { get; private set; }
/// <summary>
/// Current TLS private key path, or null if TLS is not configured.
/// Updated by <see cref="UpdateTlsConfig"/>.
/// </summary>
public string? CurrentKeyPath { get; private set; }
/// <summary>
/// True when a TLS certificate is currently configured for leaf node connections.
/// Go reference: leafnode.go — tls_required / tlsTimeout on LeafNodeOpts.
/// </summary>
public bool IsTlsEnabled => CurrentCertPath is not null;
/// <summary>
/// Incremented each time <see cref="UpdateTlsConfig"/> detects a change and applies it.
/// Useful for testing and observability.
/// </summary>
public int TlsReloadCount { get; private set; }
/// <summary>
/// Compares <paramref name="newCertPath"/> and <paramref name="newKeyPath"/> against the
/// currently active values. When either differs the paths are updated, the reload counter
/// is incremented, and a <see cref="LeafTlsReloadResult"/> with <c>Changed = true</c> is
/// returned. When both are identical a result with <c>Changed = false</c> is returned and
/// no state is mutated.
/// Go reference: leafnode.go — reloadTLSConfig hot-reload path.
/// </summary>
public LeafTlsReloadResult UpdateTlsConfig(string? newCertPath, string? newKeyPath)
{
var previousCert = CurrentCertPath;
if (string.Equals(CurrentCertPath, newCertPath, StringComparison.Ordinal)
&& string.Equals(CurrentKeyPath, newKeyPath, StringComparison.Ordinal))
{
return new LeafTlsReloadResult(Changed: false, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null);
}
CurrentCertPath = newCertPath;
CurrentKeyPath = newKeyPath;
TlsReloadCount++;
_logger.LogInformation(
"Leaf node TLS config updated (cert={CertPath}, key={KeyPath}, reloads={Count})",
newCertPath, newKeyPath, TlsReloadCount);
return new LeafTlsReloadResult(Changed: true, PreviousCertPath: previousCert, NewCertPath: newCertPath, Error: null);
}
public LeafNodeManager(
LeafNodeOptions options,
ServerStats stats,
@@ -326,3 +380,16 @@ public sealed class LeafNodeManager : IAsyncDisposable
return new IPEndPoint(IPAddress.Parse(parts[0]), int.Parse(parts[1]));
}
}
/// <summary>
/// Describes the outcome of a <see cref="LeafNodeManager.UpdateTlsConfig"/> call.
/// </summary>
/// <param name="Changed">True when the cert or key path differed from the previously active values.</param>
/// <param name="PreviousCertPath">The cert path that was active before this call.</param>
/// <param name="NewCertPath">The cert path supplied to this call.</param>
/// <param name="Error">Non-null when an error prevented the reload (reserved for future use).</param>
public sealed record LeafTlsReloadResult(
bool Changed,
string? PreviousCertPath,
string? NewCertPath,
string? Error);