diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs index 5f0ae03..b6a9681 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptionTypes.cs @@ -16,6 +16,7 @@ using System.Net.Security; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; +using ZB.MOM.NatsNet.Server.Auth; namespace ZB.MOM.NatsNet.Server; @@ -172,6 +173,7 @@ public class ClusterOpts public int PoolSize { get; set; } public List PinnedAccounts { get; set; } = []; public CompressionOpts Compression { get; set; } = new(); + public RoutePermissions? Permissions { get; set; } public TimeSpan PingInterval { get; set; } public int MaxPingsOut { get; set; } public TimeSpan WriteDeadline { get; set; } @@ -232,6 +234,7 @@ public class LeafNodeOpts public bool ProxyRequired { get; set; } public string Nkey { get; set; } = string.Empty; public string Account { get; set; } = string.Empty; + public List? Users { get; set; } public double AuthTimeout { get; set; } public SslServerAuthenticationOptions? TlsConfig { get; set; } public double TlsTimeout { get; set; } diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs index 4319145..7e93482 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs @@ -16,6 +16,10 @@ using System.Runtime.InteropServices; using System.Text.Json; using System.Threading; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Net.Security; +using ZB.MOM.NatsNet.Server.Auth; using ZB.MOM.NatsNet.Server.Config; namespace ZB.MOM.NatsNet.Server; @@ -953,10 +957,1189 @@ public sealed partial class ServerOptions return null; } + // ------------------------------------------------------------------------- + // Batch 6: opts.go package-level parse/config helpers (F2) + // ------------------------------------------------------------------------- + + /// + /// Parses JetStream account enablement/limits for an account-level block. + /// Mirrors parseJetStreamForAccount in opts.go. + /// + public static Exception? ParseJetStreamForAccount( + object? value, + Account account, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(account); + + var normalized = NormalizeConfigValue(value); + switch (normalized) + { + case bool enabled: + account.JetStreamLimits = enabled ? CreateDefaultJetStreamAccountTiers() : null; + return null; + case string mode: + switch (mode.Trim().ToLowerInvariant()) + { + case "enabled": + case "enable": + account.JetStreamLimits = CreateDefaultJetStreamAccountTiers(); + return null; + case "disabled": + case "disable": + account.JetStreamLimits = null; + return null; + default: + return new InvalidOperationException( + $"Expected 'enabled' or 'disabled' for string value, got '{mode}'"); + } + default: + if (!TryGetMap(normalized, out var map)) + { + return new InvalidOperationException( + $"Expected map, bool or string to define JetStream, got {normalized?.GetType().Name ?? "null"}"); + } + + var limits = CreateUnlimitedJetStreamAccountLimits(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "max_memory": + case "max_mem": + case "mem": + case "memory": + if (!TryConvertToLong(entry, out var maxMemory)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MaxMemory = maxMemory; + break; + case "max_store": + case "max_file": + case "max_disk": + case "store": + case "disk": + if (!TryConvertToLong(entry, out var maxStore)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MaxStore = maxStore; + break; + case "max_streams": + case "streams": + if (!TryConvertToLong(entry, out var maxStreams)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MaxStreams = checked((int)maxStreams); + break; + case "max_consumers": + case "consumers": + if (!TryConvertToLong(entry, out var maxConsumers)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MaxConsumers = checked((int)maxConsumers); + break; + case "max_bytes_required": + case "max_stream_bytes": + case "max_bytes": + if (!TryConvertToBool(entry, out var maxBytesRequired)) + return new InvalidOperationException($"Expected a parseable bool for \"{rawKey}\", got {entry}"); + limits.MaxBytesRequired = maxBytesRequired; + break; + case "mem_max_stream_bytes": + case "memory_max_stream_bytes": + if (!TryConvertToLong(entry, out var memoryMaxStreamBytes)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MemoryMaxStreamBytes = memoryMaxStreamBytes; + break; + case "disk_max_stream_bytes": + case "store_max_stream_bytes": + if (!TryConvertToLong(entry, out var storeMaxStreamBytes)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.StoreMaxStreamBytes = storeMaxStreamBytes; + break; + case "max_ack_pending": + if (!TryConvertToLong(entry, out var maxAckPending)) + return new InvalidOperationException($"Expected a parseable size for \"{rawKey}\", got {entry}"); + limits.MaxAckPending = checked((int)maxAckPending); + break; + case "cluster_traffic": + { + var traffic = entry as string ?? string.Empty; + switch (traffic) + { + case "system": + case "": + account.NrgAccount = string.Empty; + break; + case "owner": + account.NrgAccount = account.Name; + break; + default: + return new InvalidOperationException( + $"Expected 'system' or 'owner' string value for \"{rawKey}\", got {entry}"); + } + + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + account.JetStreamLimits = new Dictionary(StringComparer.Ordinal) + { + [string.Empty] = limits, + }; + + return null; + } + } + + /// + /// Parses storage sizes from integer or suffixed-string values. + /// Mirrors getStorageSize in opts.go. + /// + public static long GetStorageSize(object? value) + { + if (TryConvertToLong(value, out var asLong)) + return asLong; + + if (NormalizeConfigValue(value) is not string raw) + throw new InvalidOperationException("must be int64 or string"); + + var s = raw.Trim(); + if (s.Length == 0) + return 0; + + var suffix = char.ToUpperInvariant(s[^1]); + var prefix = s[..^1]; + if (!long.TryParse(prefix, out var parsed)) + throw new InvalidOperationException($"invalid size value: {raw}"); + + return suffix switch + { + 'K' => parsed << 10, + 'M' => parsed << 20, + 'G' => parsed << 30, + 'T' => parsed << 40, + _ => throw new InvalidOperationException("sizes defined as strings must end in K, M, G, T"), + }; + } + + /// + /// Parses server-level JetStream limits. + /// Mirrors parseJetStreamLimits in opts.go. + /// + public static Exception? ParseJetStreamLimits( + object? value, + ServerOptions options, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected a map to define JetStreamLimits, got {value?.GetType().Name ?? "null"}"); + + options.JetStreamLimits = new JsLimitOpts(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "max_ack_pending": + if (TryConvertToLong(entry, out var maxAckPending)) + options.JetStreamLimits.MaxAckPending = checked((int)maxAckPending); + break; + case "max_ha_assets": + if (TryConvertToLong(entry, out var maxHaAssets)) + options.JetStreamLimits.MaxHaAssets = checked((int)maxHaAssets); + break; + case "max_request_batch": + if (TryConvertToLong(entry, out var maxRequestBatch)) + options.JetStreamLimits.MaxRequestBatch = checked((int)maxRequestBatch); + break; + case "duplicate_window": + options.JetStreamLimits.Duplicates = ParseDuration("duplicate_window", entry, errors, warnings: null); + break; + case "batch": + { + var parseError = ParseJetStreamLimitsBatch(entry, options, errors); + if (parseError != null) + return parseError; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses nested batch limits inside JetStream limits block. + /// Mirrors parseJetStreamLimitsBatch in opts.go. + /// + public static Exception? ParseJetStreamLimitsBatch( + object? value, + ServerOptions options, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected a map to define batch limits, got {value?.GetType().Name ?? "null"}"); + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "max_inflight_per_stream": + if (TryConvertToLong(entry, out var maxInflightPerStream)) + options.JetStreamLimits.MaxBatchInflightPerStream = checked((int)maxInflightPerStream); + break; + case "max_inflight_total": + if (TryConvertToLong(entry, out var maxInflightTotal)) + options.JetStreamLimits.MaxBatchInflightTotal = checked((int)maxInflightTotal); + break; + case "max_msgs": + if (TryConvertToLong(entry, out var maxBatchSize)) + options.JetStreamLimits.MaxBatchSize = checked((int)maxBatchSize); + break; + case "timeout": + options.JetStreamLimits.MaxBatchTimeout = ParseDuration("timeout", entry, errors, warnings: null); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses JetStream TPM options. + /// Mirrors parseJetStreamTPM in opts.go. + /// + public static Exception? ParseJetStreamTPM( + object? value, + ServerOptions options, + ICollection? errors = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected a map to define JetStream TPM, got {value?.GetType().Name ?? "null"}"); + + options.JetStreamTpm = new JsTpmOpts(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "keys_file": + options.JetStreamTpm.KeysFile = entry as string ?? string.Empty; + break; + case "encryption_password": + options.JetStreamTpm.KeyPassword = entry as string ?? string.Empty; + break; + case "srk_password": + options.JetStreamTpm.SrkPassword = entry as string ?? string.Empty; + break; + case "pcr": + if (TryConvertToLong(entry, out var pcr)) + options.JetStreamTpm.Pcr = checked((int)pcr); + break; + case "cipher": + { + var parseError = SetJetStreamEkCipher(options, entry); + if (parseError != null) + return parseError; + break; + } + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses JetStream encryption cipher selection. + /// Mirrors setJetStreamEkCipher in opts.go. + /// + public static Exception? SetJetStreamEkCipher(ServerOptions options, object? value) + { + ArgumentNullException.ThrowIfNull(options); + + var cipher = (NormalizeConfigValue(value) as string ?? string.Empty).ToLowerInvariant(); + switch (cipher) + { + case "chacha": + case "chachapoly": + options.JetStreamCipher = StoreCipher.ChaCha; + return null; + case "aes": + options.JetStreamCipher = StoreCipher.Aes; + return null; + default: + return new InvalidOperationException($"Unknown cipher type: \"{value}\""); + } + } + + /// + /// Parses top-level JetStream enablement/configuration. + /// Mirrors parseJetStream in opts.go. + /// + public static Exception? ParseJetStream( + object? value, + ServerOptions options, + ICollection? errors = null, + ICollection? warnings = null) + { + ArgumentNullException.ThrowIfNull(options); + + var normalized = NormalizeConfigValue(value); + switch (normalized) + { + case bool enabled: + options.JetStream = enabled; + return null; + case string mode: + switch (mode.Trim().ToLowerInvariant()) + { + case "enabled": + case "enable": + options.JetStream = true; + return null; + case "disabled": + case "disable": + options.JetStream = false; + return null; + default: + return new InvalidOperationException( + $"Expected 'enabled' or 'disabled' for string value, got '{mode}'"); + } + default: + if (!TryGetMap(normalized, out var map)) + { + return new InvalidOperationException( + $"Expected map, bool or string to define JetStream, got {normalized?.GetType().Name ?? "null"}"); + } + + var enable = true; + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "strict": + if (!TryConvertToBool(entry, out var strictMode)) + return new InvalidOperationException($"Expected bool for \"{rawKey}\", got {entry}"); + options.NoJetStreamStrict = !strictMode; + break; + case "store": + case "store_dir": + case "storedir": + if (!string.IsNullOrEmpty(options.StoreDir)) + return new InvalidOperationException("Duplicate 'store_dir' configuration"); + options.StoreDir = entry as string ?? string.Empty; + break; + case "sync": + case "sync_interval": + if ((entry as string)?.Equals("always", StringComparison.OrdinalIgnoreCase) == true) + { + options.SyncInterval = TimeSpan.FromMinutes(2); + options.SyncAlways = true; + } + else + { + options.SyncInterval = ParseDuration(rawKey, entry, errors, warnings); + } + + options.SyncSet = true; + break; + case "max_memory_store": + case "max_mem_store": + case "max_mem": + options.JetStreamMaxMemory = GetStorageSize(entry); + options.MaxMemSet = true; + break; + case "max_file_store": + case "max_file": + options.JetStreamMaxStore = GetStorageSize(entry); + options.MaxStoreSet = true; + break; + case "domain": + options.JetStreamDomain = entry as string ?? string.Empty; + break; + case "enable": + case "enabled": + if (TryConvertToBool(entry, out var toggle)) + enable = toggle; + break; + case "key": + case "ek": + case "encryption_key": + options.JetStreamKey = entry as string ?? string.Empty; + break; + case "prev_key": + case "prev_ek": + case "prev_encryption_key": + options.JetStreamOldKey = entry as string ?? string.Empty; + break; + case "cipher": + { + var parseError = SetJetStreamEkCipher(options, entry); + if (parseError != null) + return parseError; + break; + } + case "extension_hint": + options.JetStreamExtHint = entry as string ?? string.Empty; + break; + case "limits": + { + var parseError = ParseJetStreamLimits(entry, options, errors); + if (parseError != null) + return parseError; + break; + } + case "tpm": + { + var parseError = ParseJetStreamTPM(entry, options, errors); + if (parseError != null) + return parseError; + break; + } + case "unique_tag": + options.JetStreamUniqueTag = (entry as string ?? string.Empty).Trim().ToLowerInvariant(); + break; + case "max_outstanding_catchup": + options.JetStreamMaxCatchup = GetStorageSize(entry); + break; + case "max_buffered_size": + options.StreamMaxBufferedSize = GetStorageSize(entry); + break; + case "max_buffered_msgs": + if (TryConvertToLong(entry, out var bufferedMsgs)) + options.StreamMaxBufferedMsgs = checked((int)bufferedMsgs); + break; + case "request_queue_limit": + if (TryConvertToLong(entry, out var requestQueueLimit)) + options.JetStreamRequestQueueLimit = requestQueueLimit; + break; + case "meta_compact": + if (TryConvertToLong(entry, out var compact)) + { + if (compact < 0) + return new InvalidOperationException($"Expected an absolute size for \"{rawKey}\", got {entry}"); + options.JetStreamMetaCompact = checked((ulong)compact); + } + + break; + case "meta_compact_size": + { + var compactSize = GetStorageSize(entry); + if (compactSize < 0) + return new InvalidOperationException($"Expected an absolute size for \"{rawKey}\", got {entry}"); + options.JetStreamMetaCompactSize = checked((ulong)compactSize); + break; + } + case "meta_compact_sync": + if (TryConvertToBool(entry, out var compactSync)) + options.JetStreamMetaCompactSync = compactSync; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + options.JetStream = enable; + return null; + } + } + + /// + /// Parses leaf-node configuration. + /// Mirrors parseLeafNodes in opts.go. + /// + public static Exception? ParseLeafNodes( + object? value, + ServerOptions options, + ICollection? errors = null, + ICollection? warnings = null) + { + ArgumentNullException.ThrowIfNull(options); + + if (!TryGetMap(value, out var map)) + return new InvalidOperationException($"Expected map to define a leafnode, got {value?.GetType().Name ?? "null"}"); + + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "listen": + { + try + { + var (host, port) = ParseListen(entry); + options.LeafNode.Host = host; + options.LeafNode.Port = port; + } + catch (Exception ex) + { + errors?.Add(ex); + } + + break; + } + case "port": + if (TryConvertToLong(entry, out var leafPort)) + options.LeafNode.Port = checked((int)leafPort); + break; + case "host": + case "net": + options.LeafNode.Host = entry as string ?? string.Empty; + break; + case "authorization": + { + var auth = ParseLeafAuthorization(entry, errors, warnings); + if (auth == null) + break; + options.LeafNode.Username = auth.Username; + options.LeafNode.Password = auth.Password; + options.LeafNode.ProxyRequired = auth.ProxyRequired; + options.LeafNode.AuthTimeout = auth.TimeoutSeconds; + options.LeafNode.Account = auth.AccountName; + options.LeafNode.Users = auth.Users; + options.LeafNode.Nkey = auth.Nkey; + break; + } + case "remotes": + options.LeafNode.Remotes = ParseRemoteLeafNodes(entry, errors, warnings); + break; + case "reconnect": + case "reconnect_delay": + case "reconnect_interval": + options.LeafNode.ReconnectInterval = ParseDuration("reconnect", entry, errors, warnings); + break; + case "tls": + { + var (config, tlsOpts, parseError) = GetTLSConfig(entry, requireClientCertificate: false); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + options.LeafNode.TlsConfig = config; + options.LeafNode.TlsTimeout = tlsOpts?.Timeout > 0 + ? tlsOpts.Timeout + : ServerConstants.DefaultLeafTlsTimeout.TotalSeconds; + options.LeafNode.TlsMap = tlsOpts?.Map ?? false; + options.LeafNode.TlsPinnedCerts = tlsOpts?.PinnedCerts; + options.LeafNode.TlsHandshakeFirst = tlsOpts?.HandshakeFirst ?? false; + options.LeafNode.TlsHandshakeFirstFallback = tlsOpts?.FallbackDelay ?? TimeSpan.Zero; + options.LeafNode.TlsConfigOpts = tlsOpts; + break; + } + case "leafnode_advertise": + case "advertise": + options.LeafNode.Advertise = entry as string ?? string.Empty; + break; + case "no_advertise": + if (TryConvertToBool(entry, out var noAdvertise)) + { + options.LeafNode.NoAdvertise = noAdvertise; + TrackExplicitVal(options.InConfig, "LeafNode.NoAdvertise", noAdvertise); + } + + break; + case "min_version": + case "minimum_version": + options.LeafNode.MinVersion = entry as string ?? string.Empty; + break; + case "compression": + { + var parseError = ParseCompression( + options.LeafNode.Compression, + CompressionModes.S2Auto, + "compression", + entry); + if (parseError != null) + errors?.Add(parseError); + break; + } + case "isolate_leafnode_interest": + case "isolate": + if (TryConvertToBool(entry, out var isolate)) + options.LeafNode.IsolateLeafnodeInterest = isolate; + break; + case "write_deadline": + options.LeafNode.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings); + break; + case "write_timeout": + options.LeafNode.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return null; + } + + /// + /// Parses leaf-node authorization block. + /// Mirrors parseLeafAuthorization in opts.go. + /// + public static LeafAuthorization? ParseLeafAuthorization( + object? value, + ICollection? errors = null, + ICollection? warnings = null) + { + if (!TryGetMap(value, out var map)) + { + errors?.Add(new InvalidOperationException("leafnode authorization should be a map")); + return null; + } + + var auth = new LeafAuthorization(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "user": + case "username": + auth.Username = entry as string ?? string.Empty; + break; + case "pass": + case "password": + auth.Password = entry as string ?? string.Empty; + break; + case "nkey": + auth.Nkey = entry as string ?? string.Empty; + break; + case "timeout": + switch (entry) + { + case long l: + auth.TimeoutSeconds = l; + break; + case double d: + auth.TimeoutSeconds = d; + break; + case string s: + auth.TimeoutSeconds = ParseDuration("timeout", s, errors, warnings).TotalSeconds; + break; + default: + errors?.Add(new InvalidOperationException("error parsing leafnode authorization config, 'timeout' wrong type")); + break; + } + + if (auth.TimeoutSeconds > TimeSpan.FromMinutes(1).TotalSeconds) + { + warnings?.Add(new InvalidOperationException( + $"timeout of {entry} ({auth.TimeoutSeconds} seconds) is high, consider keeping it under 60 seconds")); + } + + break; + case "users": + auth.Users = ParseLeafUsers(entry, errors); + break; + case "account": + auth.AccountName = entry as string ?? string.Empty; + break; + case "proxy_required": + if (TryConvertToBool(entry, out var proxyRequired)) + auth.ProxyRequired = proxyRequired; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + return auth; + } + + /// + /// Parses leaf-node authorization users list. + /// Mirrors parseLeafUsers in opts.go. + /// + public static List ParseLeafUsers(object? value, ICollection? errors = null) + { + if (!TryGetArray(value, out var usersArray)) + throw new InvalidOperationException($"Expected users field to be an array, got {value?.GetType().Name ?? "null"}"); + + var users = new List(usersArray.Count); + foreach (var userEntry in usersArray) + { + if (!TryGetMap(userEntry, out var userMap)) + { + errors?.Add(new InvalidOperationException($"Expected user entry to be a map/struct, got {userEntry}")); + continue; + } + + var user = new User(); + foreach (var (rawKey, rawValue) in userMap) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "user": + case "username": + user.Username = entry as string ?? string.Empty; + break; + case "pass": + case "password": + user.Password = entry as string ?? string.Empty; + break; + case "account": + user.Account = new Account { Name = entry as string ?? string.Empty }; + break; + case "proxy_required": + if (TryConvertToBool(entry, out var proxyRequired)) + user.ProxyRequired = proxyRequired; + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + users.Add(user); + } + + return users; + } + + /// + /// Parses remote leaf-node definitions. + /// Mirrors parseRemoteLeafNodes in opts.go. + /// + public static List ParseRemoteLeafNodes( + object? value, + ICollection? errors = null, + ICollection? warnings = null) + { + if (!TryGetArray(value, out var remoteArray)) + throw new InvalidOperationException($"Expected remotes field to be an array, got {value?.GetType().Name ?? "null"}"); + + var remotes = new List(remoteArray.Count); + foreach (var remoteEntry in remoteArray) + { + if (!TryGetMap(remoteEntry, out var remoteMap)) + { + errors?.Add(new InvalidOperationException( + $"Expected remote leafnode entry to be a map/struct, got {remoteEntry}")); + continue; + } + + var remote = new RemoteLeafOpts(); + foreach (var (rawKey, rawValue) in remoteMap) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "no_randomize": + case "dont_randomize": + if (TryConvertToBool(entry, out var noRandomize)) + remote.NoRandomize = noRandomize; + break; + case "url": + case "urls": + if (entry is string singleUrl) + { + remote.Urls.Add(ParseURL(singleUrl, "leafnode")); + } + else if (TryGetArray(entry, out var urlArray)) + { + remote.Urls = ParseURLs(urlArray, "leafnode", warnings, errors); + } + + break; + case "account": + case "local": + remote.LocalAccount = entry as string ?? string.Empty; + break; + case "creds": + case "credentials": + remote.Credentials = ExpandPath(entry as string ?? string.Empty); + break; + case "nkey": + case "seed": + remote.Nkey = entry as string ?? string.Empty; + break; + case "tls": + { + var (config, tlsOpts, parseError) = GetTLSConfig(entry, requireClientCertificate: false); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + remote.TlsConfig = config; + remote.TlsTimeout = tlsOpts?.Timeout > 0 + ? tlsOpts.Timeout + : ServerConstants.DefaultLeafTlsTimeout.TotalSeconds; + remote.TlsHandshakeFirst = tlsOpts?.HandshakeFirst ?? false; + remote.TlsConfigOpts = tlsOpts; + break; + } + case "hub": + if (TryConvertToBool(entry, out var hub)) + remote.Hub = hub; + break; + case "deny_imports": + case "deny_import": + remote.DenyImports = ParseStringList(entry); + break; + case "deny_exports": + case "deny_export": + remote.DenyExports = ParseStringList(entry); + break; + case "ws_compress": + case "ws_compression": + case "websocket_compress": + case "websocket_compression": + if (TryConvertToBool(entry, out var wsCompression)) + remote.Websocket.Compression = wsCompression; + break; + case "ws_no_masking": + case "websocket_no_masking": + if (TryConvertToBool(entry, out var wsNoMasking)) + remote.Websocket.NoMasking = wsNoMasking; + break; + case "jetstream_cluster_migrate": + case "js_cluster_migrate": + if (TryConvertToBool(entry, out var migrateToggle)) + { + remote.JetStreamClusterMigrate = migrateToggle; + } + else if (TryGetMap(entry, out var migrateMap)) + { + remote.JetStreamClusterMigrate = true; + if (migrateMap.TryGetValue("leader_migrate_delay", out var delayValue)) + { + remote.JetStreamClusterMigrateDelay = ParseDuration( + "leader_migrate_delay", + delayValue, + errors, + warnings); + } + } + else + { + errors?.Add(new InvalidOperationException( + $"Expected boolean or map for jetstream_cluster_migrate, got {entry?.GetType().Name ?? "null"}")); + } + + break; + case "isolate_leafnode_interest": + case "isolate": + if (TryConvertToBool(entry, out var localIsolation)) + remote.LocalIsolation = localIsolation; + break; + case "request_isolation": + if (TryConvertToBool(entry, out var requestIsolation)) + remote.RequestIsolation = requestIsolation; + break; + case "compression": + { + var parseError = ParseCompression( + remote.Compression, + CompressionModes.S2Auto, + "compression", + entry); + if (parseError != null) + errors?.Add(parseError); + break; + } + case "first_info_timeout": + remote.FirstInfoTimeout = ParseDuration("first_info_timeout", entry, errors, warnings); + break; + case "disabled": + if (TryConvertToBool(entry, out var disabled)) + remote.Disabled = disabled; + break; + case "proxy": + if (TryGetMap(entry, out var proxyMap)) + { + foreach (var (proxyKeyRaw, proxyValueRaw) in proxyMap) + { + var proxyKey = proxyKeyRaw.ToLowerInvariant(); + var proxyValue = NormalizeConfigValue(proxyValueRaw); + switch (proxyKey) + { + case "url": + remote.Proxy.Url = proxyValue as string ?? string.Empty; + break; + case "username": + remote.Proxy.Username = proxyValue as string ?? string.Empty; + break; + case "password": + remote.Proxy.Password = proxyValue as string ?? string.Empty; + break; + case "timeout": + remote.Proxy.Timeout = ParseDuration("proxy timeout", proxyValue, errors, warnings); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{proxyKeyRaw}\"")); + break; + } + } + } + + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + remotes.Add(remote); + } + + return remotes; + } + + /// + /// Parses TLS config block and builds runtime TLS options. + /// Mirrors getTLSConfig in opts.go. + /// + public static (SslServerAuthenticationOptions? Config, TlsConfigOpts? Options, Exception? Error) GetTLSConfig( + object? value, + bool requireClientCertificate = true) + { + if (!TryGetMap(value, out var map)) + return (null, null, new InvalidOperationException("TLS options should be a map")); + + var tlsOpts = new TlsConfigOpts(); + foreach (var (rawKey, rawValue) in map) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "cert_file": + case "cert": + tlsOpts.CertFile = entry as string ?? string.Empty; + break; + case "key_file": + case "key": + tlsOpts.KeyFile = entry as string ?? string.Empty; + break; + case "ca_file": + case "ca": + tlsOpts.CaFile = entry as string ?? string.Empty; + break; + case "verify": + if (TryConvertToBool(entry, out var verify)) + tlsOpts.Verify = verify; + break; + case "map": + if (TryConvertToBool(entry, out var mapCert)) + tlsOpts.Map = mapCert; + break; + case "timeout": + if (entry is string timeoutString) + { + tlsOpts.Timeout = ParseDuration("tls timeout", timeoutString).TotalSeconds; + } + else if (TryConvertToDouble(entry, out var timeoutSeconds)) + { + tlsOpts.Timeout = timeoutSeconds; + } + + break; + case "handshake_first": + if (TryConvertToBool(entry, out var handshakeFirst)) + tlsOpts.HandshakeFirst = handshakeFirst; + break; + case "handshake_first_fallback": + case "fallback_delay": + tlsOpts.FallbackDelay = ParseDuration("fallback_delay", entry); + break; + case "pinned_certs": + tlsOpts.PinnedCerts = new PinnedCertSet(ParseStringList(entry)); + break; + case "min_version": + if (entry is string minVersion) + { + try + { + tlsOpts.MinVersion = TlsVersionJsonConverter.Parse(minVersion); + } + catch (Exception ex) + { + return (null, null, ex); + } + } + + break; + } + } + + var enabledProtocols = tlsOpts.MinVersion switch + { + SslProtocols.Tls13 => SslProtocols.Tls13, + SslProtocols.Tls12 => SslProtocols.Tls12 | SslProtocols.Tls13, + _ => SslProtocols.Tls12 | SslProtocols.Tls13, + }; + + var config = new SslServerAuthenticationOptions + { + EnabledSslProtocols = enabledProtocols, + ClientCertificateRequired = requireClientCertificate || tlsOpts.Verify, + CertificateRevocationCheckMode = X509RevocationMode.NoCheck, + }; + + return (config, tlsOpts, null); + } + + /// + /// Parses list of remote gateways. + /// Mirrors parseGateways in opts.go. + /// + public static List ParseGateways( + object? value, + ICollection? errors = null, + ICollection? warnings = null) + { + if (!TryGetArray(value, out var gatewayArray)) + throw new InvalidOperationException($"Expected gateways field to be an array, got {value?.GetType().Name ?? "null"}"); + + var gateways = new List(gatewayArray.Count); + foreach (var gatewayEntry in gatewayArray) + { + if (!TryGetMap(gatewayEntry, out var gatewayMap)) + { + errors?.Add(new InvalidOperationException($"Expected gateway entry to be a map/struct, got {gatewayEntry}")); + continue; + } + + var gateway = new RemoteGatewayOpts(); + foreach (var (rawKey, rawValue) in gatewayMap) + { + var key = rawKey.ToLowerInvariant(); + var entry = NormalizeConfigValue(rawValue); + switch (key) + { + case "name": + gateway.Name = entry as string ?? string.Empty; + break; + case "tls": + { + var (config, tlsOpts, parseError) = GetTLSConfig(entry); + if (parseError != null) + { + errors?.Add(parseError); + break; + } + + gateway.TlsConfig = config; + gateway.TlsTimeout = tlsOpts?.Timeout ?? 0; + gateway.TlsConfigOpts = tlsOpts; + break; + } + case "url": + gateway.Urls.Add(ParseURL(entry as string ?? string.Empty, "gateway")); + break; + case "urls": + if (TryGetArray(entry, out var urlArray)) + gateway.Urls = ParseURLs(urlArray, "gateway", warnings, errors); + break; + default: + if (!ConfigFlags.AllowUnknownTopLevelField) + errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\"")); + break; + } + } + + gateways.Add(gateway); + } + + return gateways; + } + + /// + /// Maps parsed pub/sub permissions to route import/export permissions. + /// Mirrors setClusterPermissions in opts.go. + /// + public static void SetClusterPermissions(ClusterOpts options, Permissions permissions) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(permissions); + + options.Permissions = new RoutePermissions + { + Import = permissions.Publish?.Clone(), + Export = permissions.Subscribe?.Clone(), + }; + } + // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- + private static Dictionary CreateDefaultJetStreamAccountTiers() => + new(StringComparer.Ordinal) + { + [string.Empty] = CreateUnlimitedJetStreamAccountLimits(), + }; + + private static JetStreamAccountLimits CreateUnlimitedJetStreamAccountLimits() => new() + { + MaxMemory = -1, + MaxStore = -1, + MaxStreams = -1, + MaxConsumers = -1, + MaxAckPending = -1, + MemoryMaxStreamBytes = -1, + StoreMaxStreamBytes = -1, + MaxBytesRequired = false, + }; + + public sealed class LeafAuthorization + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Nkey { get; set; } = string.Empty; + public double TimeoutSeconds { get; set; } + public List? Users { get; set; } + public string AccountName { get; set; } = string.Empty; + public bool ProxyRequired { get; set; } + } + private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection? errors) { errors?.Add(new InvalidOperationException( @@ -1112,6 +2295,27 @@ public sealed partial class ServerOptions return false; } + private static List ParseStringList(object? value) + { + if (TryGetArray(value, out var items)) + { + var result = new List(items.Count); + foreach (var item in items) + { + var s = NormalizeConfigValue(item) as string; + if (!string.IsNullOrEmpty(s)) + result.Add(s); + } + + return result; + } + + if (NormalizeConfigValue(value) is string sValue) + return [sValue]; + + return []; + } + private static object? NormalizeConfigValue(object? value) { if (value is not JsonElement element) diff --git a/porting.db b/porting.db index f32776f..26d0c0e 100644 Binary files a/porting.db and b/porting.db differ diff --git a/reports/current.md b/reports/current.md index c951463..9b5c14c 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-28 14:29:51 UTC +Generated: 2026-02-28 14:37:42 UTC ## Modules (12 total) @@ -12,10 +12,10 @@ Generated: 2026-02-28 14:29:51 UTC | Status | Count | |--------|-------| -| deferred | 2108 | +| deferred | 2094 | | n_a | 24 | | stub | 1 | -| verified | 1540 | +| verified | 1554 | ## Unit Tests (3257 total) @@ -34,4 +34,4 @@ Generated: 2026-02-28 14:29:51 UTC ## Overall Progress -**2785/6942 items complete (40.1%)** +**2799/6942 items complete (40.3%)**