Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
@@ -43,12 +45,13 @@ public static class ConfigProcessor
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
ProcessKey(key, value, opts, errors, warnings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -58,11 +61,16 @@ public static class ConfigProcessor
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
throw new ConfigProcessorException("Configuration errors", errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
private static void ProcessKey(
|
||||
string key,
|
||||
object? value,
|
||||
NatsOptions opts,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
|
||||
// but we normalize here for the switch statement.
|
||||
@@ -277,8 +285,15 @@ public static class ConfigProcessor
|
||||
ParseWebSocket(wsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (accounts, resolver, operator, etc.)
|
||||
// Accounts block — each key is an account name containing users/limits
|
||||
case "accounts":
|
||||
if (value is Dictionary<string, object?> accountsDict)
|
||||
ParseAccounts(accountsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (resolver, operator, etc.)
|
||||
default:
|
||||
warnings.Add(new UnknownConfigFieldWarning(key).Message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -745,6 +760,9 @@ public static class ConfigProcessor
|
||||
case "store_dir":
|
||||
options.StoreDir = ToString(value);
|
||||
break;
|
||||
case "domain":
|
||||
options.Domain = ToString(value);
|
||||
break;
|
||||
case "max_mem_store":
|
||||
try
|
||||
{
|
||||
@@ -766,6 +784,68 @@ public static class ConfigProcessor
|
||||
errors.Add($"Invalid jetstream.max_file_store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_interval":
|
||||
try
|
||||
{
|
||||
options.SyncInterval = ParseDuration(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.sync_interval: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_always":
|
||||
options.SyncAlways = ToBool(value);
|
||||
break;
|
||||
case "compress_ok":
|
||||
options.CompressOk = ToBool(value);
|
||||
break;
|
||||
case "unique_tag":
|
||||
options.UniqueTag = ToString(value);
|
||||
break;
|
||||
case "strict":
|
||||
options.Strict = ToBool(value);
|
||||
break;
|
||||
case "max_ack_pending":
|
||||
options.MaxAckPending = ToInt(value);
|
||||
break;
|
||||
case "memory_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.MemoryMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.StoreMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "max_bytes_required":
|
||||
options.MaxBytesRequired = ToBool(value);
|
||||
break;
|
||||
case "tiers":
|
||||
if (value is Dictionary<string, object?> tiers)
|
||||
{
|
||||
foreach (var (tierName, rawTier) in tiers)
|
||||
{
|
||||
if (rawTier is Dictionary<string, object?> tierDict)
|
||||
options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -773,6 +853,47 @@ public static class ConfigProcessor
|
||||
return options;
|
||||
}
|
||||
|
||||
private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary<string, object?> dict, List<string> errors)
|
||||
{
|
||||
var tier = new JetStreamTier { Name = tierName };
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "memory" or "max_memory":
|
||||
try
|
||||
{
|
||||
tier.Memory = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store" or "max_store":
|
||||
try
|
||||
{
|
||||
tier.Store = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "streams" or "max_streams":
|
||||
tier.Streams = ToInt(value);
|
||||
break;
|
||||
case "consumers" or "max_consumers":
|
||||
tier.Consumers = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
@@ -831,12 +952,80 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Accounts parsing ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the top-level "accounts" block. Each key is an account name, and each
|
||||
/// value is a dictionary that may contain "users" (array) and account-level limits.
|
||||
/// Users are stamped with the account name and appended to opts.Users / opts.NKeys.
|
||||
/// Go reference: opts.go — configureAccounts / parseAccounts.
|
||||
/// </summary>
|
||||
private static void ParseAccounts(Dictionary<string, object?> accountsDict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
opts.Accounts ??= new Dictionary<string, AccountConfig>();
|
||||
|
||||
foreach (var (accountName, accountValue) in accountsDict)
|
||||
{
|
||||
if (accountValue is not Dictionary<string, object?> acctDict)
|
||||
{
|
||||
errors.Add($"Expected account '{accountName}' value to be a map");
|
||||
continue;
|
||||
}
|
||||
|
||||
int maxConnections = 0;
|
||||
int maxSubscriptions = 0;
|
||||
List<object?>? userList = null;
|
||||
|
||||
foreach (var (key, value) in acctDict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "users":
|
||||
if (value is List<object?> ul)
|
||||
userList = ul;
|
||||
break;
|
||||
case "max_connections" or "max_conns":
|
||||
maxConnections = ToInt(value);
|
||||
break;
|
||||
case "max_subscriptions" or "max_subs":
|
||||
maxSubscriptions = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Accounts[accountName] = new AccountConfig
|
||||
{
|
||||
MaxConnections = maxConnections,
|
||||
MaxSubscriptions = maxSubscriptions,
|
||||
};
|
||||
|
||||
if (userList is not null)
|
||||
{
|
||||
var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName);
|
||||
|
||||
if (plainUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.Users?.ToList() ?? [];
|
||||
existing.AddRange(plainUsers);
|
||||
opts.Users = existing;
|
||||
}
|
||||
|
||||
if (nkeyUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.NKeys?.ToList() ?? [];
|
||||
existing.AddRange(nkeyUsers);
|
||||
opts.NKeys = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a users array into plain users and NKey users.
|
||||
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
|
||||
/// Go reference: opts.go — parseUsers (lines ~2500-2700).
|
||||
/// </summary>
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors)
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors, string? defaultAccount = null)
|
||||
{
|
||||
var plainUsers = new List<User>();
|
||||
var nkeyUsers = new List<Auth.NKeyUser>();
|
||||
@@ -888,7 +1077,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Nkey = nkey,
|
||||
Permissions = permissions,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -903,7 +1092,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
@@ -1087,6 +1276,9 @@ public static class ConfigProcessor
|
||||
case "handshake_first_fallback":
|
||||
opts.TlsHandshakeFirstFallback = ParseDuration(value);
|
||||
break;
|
||||
case "ocsp_peer":
|
||||
ParseOcspPeer(value, opts, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
@@ -1094,6 +1286,31 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseOcspPeer(object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool verify:
|
||||
opts.OcspPeerVerify = verify;
|
||||
return;
|
||||
case Dictionary<string, object?> dict:
|
||||
try
|
||||
{
|
||||
var cfg = OCSPPeerConfig.Parse(dict);
|
||||
opts.OcspPeerVerify = cfg.Verify;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags parsing ──────────────────────────────────────────────
|
||||
|
||||
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
|
||||
@@ -1431,8 +1648,28 @@ public static class ConfigProcessor
|
||||
/// Thrown when one or more configuration validation errors are detected.
|
||||
/// All errors are collected rather than failing on the first one.
|
||||
/// </summary>
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors)
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
public IReadOnlyList<string> Warnings => warnings ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a non-fatal configuration warning.
|
||||
/// Go reference: configWarningErr.
|
||||
/// </summary>
|
||||
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
||||
{
|
||||
public string? SourceLocation { get; } = source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warning used when an unknown config field is encountered.
|
||||
/// Go reference: unknownConfigFieldErr.
|
||||
/// </summary>
|
||||
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
||||
: ConfigWarningException($"unknown field {field}", source)
|
||||
{
|
||||
public string Field { get; } = field;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,115 @@ public sealed class GatewayOptions
|
||||
/// </summary>
|
||||
public sealed class RemoteGatewayOptions
|
||||
{
|
||||
private int _connAttempts;
|
||||
|
||||
public string? Name { get; set; }
|
||||
public List<string> Urls { get; set; } = [];
|
||||
public bool Implicit { get; set; }
|
||||
public byte[]? Hash { get; set; }
|
||||
public byte[]? OldHash { get; set; }
|
||||
public string? TlsName { get; private set; }
|
||||
public bool VarzUpdateUrls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep clone helper for remote gateway options.
|
||||
/// Go reference: RemoteGatewayOpts.clone.
|
||||
/// </summary>
|
||||
public RemoteGatewayOptions Clone()
|
||||
{
|
||||
return new RemoteGatewayOptions
|
||||
{
|
||||
Name = Name,
|
||||
Urls = [.. Urls],
|
||||
Implicit = Implicit,
|
||||
Hash = Hash == null ? null : [.. Hash],
|
||||
OldHash = OldHash == null ? null : [.. OldHash],
|
||||
TlsName = TlsName,
|
||||
VarzUpdateUrls = VarzUpdateUrls,
|
||||
};
|
||||
}
|
||||
|
||||
public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
|
||||
|
||||
public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
|
||||
|
||||
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
|
||||
|
||||
public bool IsImplicit() => Implicit;
|
||||
|
||||
public List<Uri> GetUrls(Random? random = null)
|
||||
{
|
||||
var urls = new List<Uri>();
|
||||
foreach (var url in Urls)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
urls.Add(uri);
|
||||
}
|
||||
|
||||
random ??= Random.Shared;
|
||||
for (var i = urls.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = random.Next(i + 1);
|
||||
(urls[i], urls[j]) = (urls[j], urls[i]);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public List<string> GetUrlsAsStrings()
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var uri in GetUrls())
|
||||
result.Add($"{uri.Scheme}://{uri.Authority}");
|
||||
return result;
|
||||
}
|
||||
|
||||
public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
var merged = new List<string>();
|
||||
AddUrlsInternal(merged, configuredUrls);
|
||||
AddUrlsInternal(merged, discoveredUrls);
|
||||
Urls = merged;
|
||||
}
|
||||
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
public void AddUrls(IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
AddUrlsInternal(Urls, discoveredUrls);
|
||||
}
|
||||
|
||||
private static void AddUrlsInternal(List<string> target, IEnumerable<string> urls)
|
||||
{
|
||||
var seen = new HashSet<string>(target, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var url in urls)
|
||||
{
|
||||
if (!TryNormalizeRemoteUrl(url, out var uri))
|
||||
continue;
|
||||
|
||||
var normalized = $"{uri.Scheme}://{uri.Authority}";
|
||||
if (seen.Add(normalized))
|
||||
target.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeRemoteUrl(string? raw, out Uri uri)
|
||||
{
|
||||
uri = default!;
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return false;
|
||||
|
||||
var normalized = raw.Contains("://", StringComparison.Ordinal) ? raw : $"nats://{raw}";
|
||||
if (Uri.TryCreate(normalized, UriKind.Absolute, out var parsed) && parsed is not null)
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ namespace NATS.Server.Configuration;
|
||||
// Controls the lifecycle parameters for the JetStream subsystem.
|
||||
public sealed class JetStreamOptions
|
||||
{
|
||||
// Go: server/jetstream.go constants (dynJetStreamConfig defaults)
|
||||
public const string JetStreamStoreDir = "jetstream";
|
||||
public const long JetStreamMaxStoreDefault = 1L << 40; // 1 TiB
|
||||
public const long JetStreamMaxMemDefault = 256L * 1024 * 1024; // 256 MiB
|
||||
|
||||
/// <summary>
|
||||
/// Directory where JetStream persists stream data.
|
||||
/// Maps to Go's JetStreamConfig.StoreDir (jetstream.go:enableJetStream:430).
|
||||
@@ -41,4 +46,64 @@ public sealed class JetStreamOptions
|
||||
/// Maps to Go's Options.JetStreamDomain (opts.go).
|
||||
/// </summary>
|
||||
public string? Domain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File-store sync interval.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.SyncInterval.
|
||||
/// </summary>
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forces sync on each write when true.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.SyncAlways.
|
||||
/// </summary>
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether compression is allowed for JetStream replication/storage paths.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.CompressOK.
|
||||
/// </summary>
|
||||
public bool CompressOk { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique placement tag used in clustered deployments.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.UniqueTag.
|
||||
/// </summary>
|
||||
public string? UniqueTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables strict validation mode for JetStream.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.Strict.
|
||||
/// </summary>
|
||||
public bool Strict { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account-level maximum pending acknowledgements.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxAckPending.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bytes allowed per memory-backed stream.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MemoryMaxStreamBytes.
|
||||
/// </summary>
|
||||
public long MemoryMaxStreamBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bytes allowed per file-backed stream.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.StoreMaxStreamBytes.
|
||||
/// </summary>
|
||||
public long StoreMaxStreamBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, stream configs must specify explicit MaxBytes.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxBytesRequired.
|
||||
/// </summary>
|
||||
public bool MaxBytesRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-tier JetStream limits keyed by tier name.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits tiers.
|
||||
/// </summary>
|
||||
public Dictionary<string, NATS.Server.JetStream.JetStreamTier> Tiers { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,109 @@ public sealed class RemoteLeafOptions
|
||||
|
||||
/// <summary>Whether to not randomize URL order.</summary>
|
||||
public bool DontRandomize { get; init; }
|
||||
|
||||
private int _urlIndex = -1;
|
||||
private TimeSpan _connectDelay;
|
||||
private Timer? _migrateTimer;
|
||||
|
||||
/// <summary>Last URL selected by <see cref="PickNextUrl"/>.</summary>
|
||||
public string? CurrentUrl { get; private set; }
|
||||
|
||||
/// <summary>Saved TLS hostname for SNI usage on solicited connections.</summary>
|
||||
public string? TlsName { get; private set; }
|
||||
|
||||
/// <summary>Username parsed from URL user-info fallback.</summary>
|
||||
public string? Username { get; private set; }
|
||||
|
||||
/// <summary>Password parsed from URL user-info fallback.</summary>
|
||||
public string? Password { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next URL using round-robin order and updates <see cref="CurrentUrl"/>.
|
||||
/// Go reference: leafnode.go leafNodeCfg.pickNextURL.
|
||||
/// </summary>
|
||||
public string PickNextUrl()
|
||||
{
|
||||
if (Urls.Count == 0)
|
||||
throw new InvalidOperationException("No remote leaf URLs configured.");
|
||||
|
||||
var idx = Interlocked.Increment(ref _urlIndex);
|
||||
var next = Urls[idx % Urls.Count];
|
||||
CurrentUrl = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current selected URL, or null if no URL has been selected yet.
|
||||
/// Go reference: leafnode.go leafNodeCfg.getCurrentURL.
|
||||
/// </summary>
|
||||
public string? GetCurrentUrl() => CurrentUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the currently configured reconnect/connect delay for this remote.
|
||||
/// Go reference: leafnode.go leafNodeCfg.getConnectDelay.
|
||||
/// </summary>
|
||||
public TimeSpan GetConnectDelay() => _connectDelay;
|
||||
|
||||
/// <summary>
|
||||
/// Sets reconnect/connect delay for this remote.
|
||||
/// Go reference: leafnode.go leafNodeCfg.setConnectDelay.
|
||||
/// </summary>
|
||||
public void SetConnectDelay(TimeSpan delay) => _connectDelay = delay;
|
||||
|
||||
/// <summary>
|
||||
/// Starts or replaces the JetStream migration timer callback for this remote leaf.
|
||||
/// Go reference: leafnode.go leafNodeCfg.migrateTimer.
|
||||
/// </summary>
|
||||
public void StartMigrateTimer(TimerCallback callback, TimeSpan delay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
var timer = new Timer(callback, null, delay, Timeout.InfiniteTimeSpan);
|
||||
var previous = Interlocked.Exchange(ref _migrateTimer, timer);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the JetStream migration timer if active.
|
||||
/// Go reference: leafnode.go leafNodeCfg.cancelMigrateTimer.
|
||||
/// </summary>
|
||||
public void CancelMigrateTimer()
|
||||
{
|
||||
var timer = Interlocked.Exchange(ref _migrateTimer, null);
|
||||
timer?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves TLS hostname from URL for future SNI usage.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveTLSHostname.
|
||||
/// </summary>
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryParseUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves username/password from URL user info for fallback auth.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveUserPassword.
|
||||
/// </summary>
|
||||
public void SaveUserPassword(string url)
|
||||
{
|
||||
if (!TryParseUrl(url, out var uri) || string.IsNullOrEmpty(uri.UserInfo))
|
||||
return;
|
||||
|
||||
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
|
||||
Username = Uri.UnescapeDataString(parts[0]);
|
||||
Password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryParseUrl(string url, out Uri uri)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out uri!))
|
||||
return true;
|
||||
|
||||
return Uri.TryCreate($"nats://{url}", UriKind.Absolute, out uri!);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LeafNodeOptions
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Port of Go conf/lex.go — state-machine tokenizer for NATS config files.
|
||||
// Reference: golang/nats-server/conf/lex.go
|
||||
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public sealed class NatsConfLexer
|
||||
@@ -145,16 +149,23 @@ public sealed class NatsConfLexer
|
||||
return Eof;
|
||||
}
|
||||
|
||||
if (_input[_pos] == '\n')
|
||||
var span = _input.AsSpan(_pos);
|
||||
var status = Rune.DecodeFromUtf16(span, out var rune, out var consumed);
|
||||
if (status != OperationStatus.Done || consumed <= 0)
|
||||
{
|
||||
consumed = 1;
|
||||
rune = new Rune(_input[_pos]);
|
||||
}
|
||||
|
||||
if (rune.Value == '\n')
|
||||
{
|
||||
_line++;
|
||||
_lstart = _pos;
|
||||
}
|
||||
|
||||
var c = _input[_pos];
|
||||
_width = 1;
|
||||
_pos += _width;
|
||||
return c;
|
||||
_width = consumed;
|
||||
_pos += consumed;
|
||||
return rune.IsBmp ? (char)rune.Value : '\uFFFD';
|
||||
}
|
||||
|
||||
private void Ignore()
|
||||
@@ -186,6 +197,20 @@ public sealed class NatsConfLexer
|
||||
return null;
|
||||
}
|
||||
|
||||
private LexState? Errorf(string format, params object?[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
return Errorf(format);
|
||||
|
||||
var escapedArgs = new object?[args.Length];
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
escapedArgs[i] = args[i] is char c ? EscapeSpecial(c) : args[i];
|
||||
}
|
||||
|
||||
return Errorf(string.Format(CultureInfo.InvariantCulture, format, escapedArgs));
|
||||
}
|
||||
|
||||
// --- Helper methods ---
|
||||
|
||||
private static bool IsWhitespace(char c) => c is '\t' or ' ';
|
||||
@@ -1476,9 +1501,8 @@ public sealed class NatsConfLexer
|
||||
var r = lx.Peek();
|
||||
if (IsNL(r) || r == Eof)
|
||||
{
|
||||
// Consume the comment text but don't emit it as a user-visible token.
|
||||
// Just ignore it and pop back.
|
||||
lx.Ignore();
|
||||
// Match Go behavior: emit comment body as a text token.
|
||||
lx.Emit(TokenType.Text);
|
||||
return lx.Pop();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
@@ -15,6 +16,7 @@ namespace NATS.Server.Configuration;
|
||||
/// </summary>
|
||||
public static class NatsConfParser
|
||||
{
|
||||
private const string BcryptPrefix = "2a$";
|
||||
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
|
||||
// and emits a Variable token whose value begins with "2a$" or "2b$".
|
||||
private const string BcryptPrefix2A = "2a$";
|
||||
@@ -34,12 +36,24 @@ public static class NatsConfParser
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||
ParseFile(filePath, includeDepth: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
|
||||
|
||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
@@ -68,6 +82,94 @@ public static class NatsConfParser
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(string filePath)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth: 0);
|
||||
state.Run();
|
||||
CleanupUsedEnvVars(state.Mapping);
|
||||
|
||||
var digest = ComputeConfigDigest(state.Mapping);
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
// Go pedantic mode removes env-var wrapper nodes from parsed maps before digesting.
|
||||
// The current parser does not persist env-wrapper nodes, so this remains a no-op hook.
|
||||
private static void CleanupUsedEnvVars(Dictionary<string, object?> _) { }
|
||||
|
||||
private static string ComputeConfigDigest(Dictionary<string, object?> config)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(ms))
|
||||
{
|
||||
WriteCanonicalJsonValue(writer, config);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hashBytes = SHA256.HashData(ms.ToArray());
|
||||
return "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private static void WriteCanonicalJsonValue(Utf8JsonWriter writer, object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
case Dictionary<string, object?> map:
|
||||
writer.WriteStartObject();
|
||||
foreach (var key in map.Keys.OrderBy(static k => k, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(key);
|
||||
WriteCanonicalJsonValue(writer, map[key]);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
return;
|
||||
case List<object?> list:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list)
|
||||
WriteCanonicalJsonValue(writer, item);
|
||||
writer.WriteEndArray();
|
||||
return;
|
||||
case IReadOnlyList<string> stringList:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in stringList)
|
||||
writer.WriteStringValue(item);
|
||||
writer.WriteEndArray();
|
||||
return;
|
||||
case bool b:
|
||||
writer.WriteBooleanValue(b);
|
||||
return;
|
||||
case int i:
|
||||
writer.WriteNumberValue(i);
|
||||
return;
|
||||
case long l:
|
||||
writer.WriteNumberValue(l);
|
||||
return;
|
||||
case double d:
|
||||
writer.WriteNumberValue(d);
|
||||
return;
|
||||
case float f:
|
||||
writer.WriteNumberValue(f);
|
||||
return;
|
||||
case decimal dec:
|
||||
writer.WriteNumberValue(dec);
|
||||
return;
|
||||
case string s:
|
||||
writer.WriteStringValue(s);
|
||||
return;
|
||||
default:
|
||||
JsonSerializer.Serialize(writer, value, value.GetType());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: parse an environment variable value by wrapping it in a synthetic
|
||||
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
|
||||
@@ -99,6 +201,8 @@ public static class NatsConfParser
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
// Pedantic-mode key token stack (Go parser field: ikeys).
|
||||
private readonly List<Token> _itemKeys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -182,6 +286,18 @@ public static class NatsConfParser
|
||||
return last;
|
||||
}
|
||||
|
||||
private void PushItemKey(Token token) => _itemKeys.Add(token);
|
||||
|
||||
private Token PopItemKey()
|
||||
{
|
||||
if (_itemKeys.Count == 0)
|
||||
return default;
|
||||
|
||||
var last = _itemKeys[^1];
|
||||
_itemKeys.RemoveAt(_itemKeys.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void SetValue(object? val)
|
||||
{
|
||||
// Array context: append the value.
|
||||
@@ -195,6 +311,7 @@ public static class NatsConfParser
|
||||
if (_ctx is Dictionary<string, object?> map)
|
||||
{
|
||||
var key = PopKey();
|
||||
_ = PopItemKey();
|
||||
map[key] = val;
|
||||
return;
|
||||
}
|
||||
@@ -211,6 +328,7 @@ public static class NatsConfParser
|
||||
|
||||
case TokenType.Key:
|
||||
PushKey(token.Value);
|
||||
PushItemKey(token);
|
||||
break;
|
||||
|
||||
case TokenType.String:
|
||||
@@ -262,6 +380,7 @@ public static class NatsConfParser
|
||||
break;
|
||||
|
||||
case TokenType.Comment:
|
||||
case TokenType.Text:
|
||||
// Skip comments entirely.
|
||||
break;
|
||||
|
||||
@@ -347,7 +466,8 @@ public static class NatsConfParser
|
||||
|
||||
// Special case: raw bcrypt strings ($2a$... or $2b$...).
|
||||
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
|
||||
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
if (varName.StartsWith(BcryptPrefix, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
|
||||
{
|
||||
SetValue("$" + varName);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Port of Go conf/lex.go token types.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public enum TokenType
|
||||
@@ -7,6 +9,7 @@ public enum TokenType
|
||||
Error,
|
||||
Eof,
|
||||
Key,
|
||||
Text,
|
||||
String,
|
||||
Bool,
|
||||
Integer,
|
||||
@@ -22,3 +25,34 @@ public enum TokenType
|
||||
}
|
||||
|
||||
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic token wrapper matching Go conf/parse.go token accessors.
|
||||
/// </summary>
|
||||
public sealed class PedanticToken
|
||||
{
|
||||
private readonly Token _item;
|
||||
private readonly object? _value;
|
||||
private readonly bool _usedVariable;
|
||||
private readonly string _sourceFile;
|
||||
|
||||
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
|
||||
{
|
||||
_item = item;
|
||||
_value = value;
|
||||
_usedVariable = usedVariable;
|
||||
_sourceFile = sourceFile ?? string.Empty;
|
||||
}
|
||||
|
||||
public string MarshalJson() => JsonSerializer.Serialize(Value());
|
||||
|
||||
public object? Value() => _value ?? _item.Value;
|
||||
|
||||
public int Line() => _item.Line;
|
||||
|
||||
public bool IsUsedVariable() => _usedVariable;
|
||||
|
||||
public string SourceFile() => _sourceFile;
|
||||
|
||||
public int Position() => _item.Position;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user