6450 lines
251 KiB
C#
6450 lines
251 KiB
C#
// Copyright 2012-2025 The NATS Authors
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
// Adapted from server/opts.go in the NATS server Go source.
|
|
|
|
using System.Runtime.InteropServices;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Security.Authentication;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using System.Net.Security;
|
|
using ZB.MOM.NatsNet.Server.Auth;
|
|
using ZB.MOM.NatsNet.Server.Config;
|
|
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
|
|
|
namespace ZB.MOM.NatsNet.Server;
|
|
|
|
// Global flag for unknown field handling.
|
|
internal static class ConfigFlags
|
|
{
|
|
private static int _allowUnknownTopLevelField;
|
|
|
|
/// <summary>
|
|
/// Sets whether unknown top-level config fields should be allowed.
|
|
/// Mirrors <c>NoErrOnUnknownFields</c> in opts.go.
|
|
/// </summary>
|
|
public static void NoErrOnUnknownFields(bool noError) =>
|
|
Interlocked.Exchange(ref _allowUnknownTopLevelField, noError ? 1 : 0);
|
|
|
|
public static bool AllowUnknownTopLevelField =>
|
|
Interlocked.CompareExchange(ref _allowUnknownTopLevelField, 0, 0) != 0;
|
|
}
|
|
|
|
public sealed partial class ServerOptions
|
|
{
|
|
/// <summary>
|
|
/// Toggles unknown top-level field handling for config parsing.
|
|
/// Mirrors <c>NoErrOnUnknownFields</c> in opts.go.
|
|
/// </summary>
|
|
public static void NoErrOnUnknownFields(bool noError) => ConfigFlags.NoErrOnUnknownFields(noError);
|
|
|
|
/// <summary>
|
|
/// Snapshot of command-line flags, populated during <see cref="ConfigureOptions"/>.
|
|
/// Mirrors <c>FlagSnapshot</c> in opts.go.
|
|
/// </summary>
|
|
public static ServerOptions? FlagSnapshot { get; internal set; }
|
|
|
|
/// <summary>
|
|
/// Deep-copies this <see cref="ServerOptions"/> instance.
|
|
/// Mirrors <c>Options.Clone()</c> in opts.go.
|
|
/// </summary>
|
|
public ServerOptions Clone()
|
|
{
|
|
// Start with a shallow memberwise clone.
|
|
var clone = (ServerOptions)MemberwiseClone();
|
|
|
|
// Deep-copy reference types that need isolation.
|
|
if (Routes.Count > 0)
|
|
clone.Routes = Routes.Select(u => new Uri(u.ToString())).ToList();
|
|
|
|
clone.Cluster = CloneClusterOpts(Cluster);
|
|
clone.Gateway = CloneGatewayOpts(Gateway);
|
|
clone.LeafNode = CloneLeafNodeOpts(LeafNode);
|
|
clone.Websocket = CloneWebsocketOpts(Websocket);
|
|
clone.Mqtt = CloneMqttOpts(Mqtt);
|
|
|
|
clone.Tags = [.. Tags];
|
|
clone.Metadata = new Dictionary<string, string>(Metadata);
|
|
clone.TrustedKeys = [.. TrustedKeys];
|
|
clone.JsAccDefaultDomain = new Dictionary<string, string>(JsAccDefaultDomain);
|
|
clone.InConfig = new Dictionary<string, bool>(InConfig);
|
|
clone.InCmdLine = new Dictionary<string, bool>(InCmdLine);
|
|
clone.OperatorJwt = [.. OperatorJwt];
|
|
clone.ResolverPreloads = new Dictionary<string, string>(ResolverPreloads);
|
|
clone.ResolverPinnedAccounts = [.. ResolverPinnedAccounts];
|
|
|
|
return clone;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the SHA-256 digest of the configuration.
|
|
/// Mirrors <c>Options.ConfigDigest()</c> in opts.go.
|
|
/// </summary>
|
|
public string ConfigDigest() => ConfigDigestValue;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Merge / Baseline
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Merges file-based options with command-line flag options.
|
|
/// Flag options override file options where set.
|
|
/// Mirrors <c>MergeOptions</c> in opts.go.
|
|
/// </summary>
|
|
public static ServerOptions MergeOptions(ServerOptions? fileOpts, ServerOptions? flagOpts)
|
|
{
|
|
if (fileOpts == null) return flagOpts ?? new ServerOptions();
|
|
if (flagOpts == null) return fileOpts;
|
|
|
|
var opts = fileOpts.Clone();
|
|
|
|
if (flagOpts.Port != 0) opts.Port = flagOpts.Port;
|
|
if (!string.IsNullOrEmpty(flagOpts.Host)) opts.Host = flagOpts.Host;
|
|
if (flagOpts.DontListen) opts.DontListen = true;
|
|
if (!string.IsNullOrEmpty(flagOpts.ClientAdvertise)) opts.ClientAdvertise = flagOpts.ClientAdvertise;
|
|
if (!string.IsNullOrEmpty(flagOpts.Username)) opts.Username = flagOpts.Username;
|
|
if (!string.IsNullOrEmpty(flagOpts.Password)) opts.Password = flagOpts.Password;
|
|
if (!string.IsNullOrEmpty(flagOpts.Authorization)) opts.Authorization = flagOpts.Authorization;
|
|
if (flagOpts.HttpPort != 0) opts.HttpPort = flagOpts.HttpPort;
|
|
if (!string.IsNullOrEmpty(flagOpts.HttpBasePath)) opts.HttpBasePath = flagOpts.HttpBasePath;
|
|
if (flagOpts.Debug) opts.Debug = true;
|
|
if (flagOpts.Trace) opts.Trace = true;
|
|
if (flagOpts.Logtime) opts.Logtime = true;
|
|
if (!string.IsNullOrEmpty(flagOpts.LogFile)) opts.LogFile = flagOpts.LogFile;
|
|
if (!string.IsNullOrEmpty(flagOpts.PidFile)) opts.PidFile = flagOpts.PidFile;
|
|
if (!string.IsNullOrEmpty(flagOpts.PortsFileDir)) opts.PortsFileDir = flagOpts.PortsFileDir;
|
|
if (flagOpts.ProfPort != 0) opts.ProfPort = flagOpts.ProfPort;
|
|
if (!string.IsNullOrEmpty(flagOpts.Cluster.ListenStr)) opts.Cluster.ListenStr = flagOpts.Cluster.ListenStr;
|
|
if (flagOpts.Cluster.NoAdvertise) opts.Cluster.NoAdvertise = true;
|
|
if (flagOpts.Cluster.ConnectRetries != 0) opts.Cluster.ConnectRetries = flagOpts.Cluster.ConnectRetries;
|
|
if (!string.IsNullOrEmpty(flagOpts.Cluster.Advertise)) opts.Cluster.Advertise = flagOpts.Cluster.Advertise;
|
|
if (!string.IsNullOrEmpty(flagOpts.RoutesStr)) MergeRoutes(opts, flagOpts);
|
|
if (flagOpts.JetStream) opts.JetStream = true;
|
|
if (!string.IsNullOrEmpty(flagOpts.StoreDir)) opts.StoreDir = flagOpts.StoreDir;
|
|
|
|
return opts;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses route URLs from a comma-separated string.
|
|
/// Mirrors <c>RoutesFromStr</c> in opts.go.
|
|
/// </summary>
|
|
public static List<Uri> RoutesFromStr(string routesStr)
|
|
{
|
|
var parts = routesStr.Split(',');
|
|
if (parts.Length == 0) return [];
|
|
|
|
var urls = new List<Uri>();
|
|
foreach (var r in parts)
|
|
{
|
|
var trimmed = r.Trim();
|
|
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var u))
|
|
urls.Add(u);
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies system-wide defaults to any unset options.
|
|
/// Mirrors <c>setBaselineOptions</c> in opts.go.
|
|
/// </summary>
|
|
public void SetBaselineOptions()
|
|
{
|
|
if (string.IsNullOrEmpty(Host))
|
|
Host = ServerConstants.DefaultHost;
|
|
if (string.IsNullOrEmpty(HttpHost))
|
|
HttpHost = Host;
|
|
if (Port == 0)
|
|
Port = ServerConstants.DefaultPort;
|
|
else if (Port == ServerConstants.RandomPort)
|
|
Port = 0;
|
|
if (MaxConn == 0)
|
|
MaxConn = ServerConstants.DefaultMaxConnections;
|
|
if (PingInterval == TimeSpan.Zero)
|
|
PingInterval = ServerConstants.DefaultPingInterval;
|
|
if (MaxPingsOut == 0)
|
|
MaxPingsOut = ServerConstants.DefaultPingMaxOut;
|
|
if (TlsTimeout == 0)
|
|
TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
|
|
if (AuthTimeout == 0)
|
|
AuthTimeout = GetDefaultAuthTimeout(TlsConfig, TlsTimeout);
|
|
|
|
// Cluster defaults
|
|
if (Cluster.Port != 0 || !string.IsNullOrEmpty(Cluster.ListenStr))
|
|
{
|
|
if (string.IsNullOrEmpty(Cluster.Host))
|
|
Cluster.Host = ServerConstants.DefaultHost;
|
|
if (Cluster.TlsTimeout == 0)
|
|
Cluster.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
|
|
if (Cluster.AuthTimeout == 0)
|
|
Cluster.AuthTimeout = GetDefaultAuthTimeout(Cluster.TlsConfig, Cluster.TlsTimeout);
|
|
if (Cluster.PoolSize == 0)
|
|
Cluster.PoolSize = ServerConstants.DefaultRoutePoolSize;
|
|
|
|
// Add system account to pinned accounts if pool is enabled.
|
|
if (Cluster.PoolSize > 0)
|
|
{
|
|
var sysAccName = SystemAccount;
|
|
if (string.IsNullOrEmpty(sysAccName) && !NoSystemAccount)
|
|
sysAccName = ServerConstants.DefaultSystemAccount;
|
|
if (!string.IsNullOrEmpty(sysAccName) && !Cluster.PinnedAccounts.Contains(sysAccName))
|
|
Cluster.PinnedAccounts.Add(sysAccName);
|
|
}
|
|
|
|
// Default compression to "accept".
|
|
if (string.IsNullOrEmpty(Cluster.Compression.Mode))
|
|
Cluster.Compression.Mode = CompressionModes.Accept;
|
|
}
|
|
|
|
// LeafNode defaults
|
|
if (LeafNode.Port != 0)
|
|
{
|
|
if (string.IsNullOrEmpty(LeafNode.Host))
|
|
LeafNode.Host = ServerConstants.DefaultHost;
|
|
if (LeafNode.TlsTimeout == 0)
|
|
LeafNode.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
|
|
if (LeafNode.AuthTimeout == 0)
|
|
LeafNode.AuthTimeout = GetDefaultAuthTimeout(LeafNode.TlsConfig, LeafNode.TlsTimeout);
|
|
if (string.IsNullOrEmpty(LeafNode.Compression.Mode))
|
|
LeafNode.Compression.Mode = CompressionModes.S2Auto;
|
|
}
|
|
|
|
// Remote leafnode defaults
|
|
foreach (var r in LeafNode.Remotes)
|
|
{
|
|
foreach (var u in r.Urls)
|
|
{
|
|
if (u.IsDefaultPort || string.IsNullOrEmpty(u.GetComponents(UriComponents.Port, UriFormat.Unescaped)))
|
|
{
|
|
var builder = new UriBuilder(u) { Port = ServerConstants.DefaultLeafNodePort };
|
|
r.Urls[r.Urls.IndexOf(u)] = builder.Uri;
|
|
}
|
|
}
|
|
if (string.IsNullOrEmpty(r.Compression.Mode))
|
|
r.Compression.Mode = CompressionModes.S2Auto;
|
|
if (r.FirstInfoTimeout <= TimeSpan.Zero)
|
|
r.FirstInfoTimeout = ServerConstants.DefaultLeafNodeInfoWait;
|
|
}
|
|
|
|
if (LeafNode.ReconnectInterval == TimeSpan.Zero)
|
|
LeafNode.ReconnectInterval = ServerConstants.DefaultLeafNodeReconnect;
|
|
|
|
// Protocol limits
|
|
if (MaxControlLine == 0)
|
|
MaxControlLine = ServerConstants.MaxControlLineSize;
|
|
if (MaxPayload == 0)
|
|
MaxPayload = ServerConstants.MaxPayloadSize;
|
|
if (MaxPending == 0)
|
|
MaxPending = ServerConstants.MaxPendingSize;
|
|
if (WriteDeadline == TimeSpan.Zero)
|
|
WriteDeadline = ServerConstants.DefaultFlushDeadline;
|
|
if (MaxClosedClients == 0)
|
|
MaxClosedClients = ServerConstants.DefaultMaxClosedClients;
|
|
if (LameDuckDuration == TimeSpan.Zero)
|
|
LameDuckDuration = ServerConstants.DefaultLameDuckDuration;
|
|
if (LameDuckGracePeriod == TimeSpan.Zero)
|
|
LameDuckGracePeriod = ServerConstants.DefaultLameDuckGracePeriod;
|
|
|
|
// Gateway defaults
|
|
if (Gateway.Port != 0)
|
|
{
|
|
if (string.IsNullOrEmpty(Gateway.Host))
|
|
Gateway.Host = ServerConstants.DefaultHost;
|
|
if (Gateway.TlsTimeout == 0)
|
|
Gateway.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
|
|
if (Gateway.AuthTimeout == 0)
|
|
Gateway.AuthTimeout = GetDefaultAuthTimeout(Gateway.TlsConfig, Gateway.TlsTimeout);
|
|
}
|
|
|
|
// Error reporting
|
|
if (ConnectErrorReports == 0)
|
|
ConnectErrorReports = ServerConstants.DefaultConnectErrorReports;
|
|
if (ReconnectErrorReports == 0)
|
|
ReconnectErrorReports = ServerConstants.DefaultReconnectErrorReports;
|
|
|
|
// WebSocket defaults
|
|
if (Websocket.Port != 0)
|
|
{
|
|
if (string.IsNullOrEmpty(Websocket.Host))
|
|
Websocket.Host = ServerConstants.DefaultHost;
|
|
}
|
|
|
|
// MQTT defaults
|
|
if (Mqtt.Port != 0)
|
|
{
|
|
if (string.IsNullOrEmpty(Mqtt.Host))
|
|
Mqtt.Host = ServerConstants.DefaultHost;
|
|
if (Mqtt.TlsTimeout == 0)
|
|
Mqtt.TlsTimeout = ServerConstants.TlsTimeout.TotalSeconds;
|
|
}
|
|
|
|
// JetStream defaults
|
|
if (JetStreamMaxMemory == 0 && !MaxMemSet)
|
|
JetStreamMaxMemory = -1;
|
|
if (JetStreamMaxStore == 0 && !MaxStoreSet)
|
|
JetStreamMaxStore = -1;
|
|
if (SyncInterval == TimeSpan.Zero && !SyncSet)
|
|
SyncInterval = TimeSpan.FromMinutes(2); // defaultSyncInterval
|
|
if (JetStreamRequestQueueLimit <= 0)
|
|
JetStreamRequestQueueLimit = 4096; // JSDefaultRequestQueueLimit
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes an HTTP base path (ensure leading slash, clean redundant separators).
|
|
/// Mirrors <c>normalizeBasePath</c> in opts.go.
|
|
/// </summary>
|
|
public static string NormalizeBasePath(string p)
|
|
{
|
|
if (string.IsNullOrEmpty(p)) return "/";
|
|
if (p[0] != '/') p = "/" + p;
|
|
// Simple path clean: collapse repeated slashes and remove trailing slash.
|
|
while (p.Contains("//")) p = p.Replace("//", "/");
|
|
return p.Length > 1 && p.EndsWith('/') ? p[..^1] : p;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Computes the default auth timeout based on TLS config presence.
|
|
/// Mirrors <c>getDefaultAuthTimeout</c> in opts.go.
|
|
/// </summary>
|
|
public static double GetDefaultAuthTimeout(object? tlsConfig, double tlsTimeout)
|
|
{
|
|
if (tlsConfig != null)
|
|
return tlsTimeout + 1.0;
|
|
return ServerConstants.AuthTimeout.TotalSeconds;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the user's home directory.
|
|
/// Mirrors <c>homeDir</c> in opts.go.
|
|
/// </summary>
|
|
public static string HomeDir() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
|
|
/// <summary>
|
|
/// Expands environment variables and ~/ prefix in a path.
|
|
/// Mirrors <c>expandPath</c> in opts.go.
|
|
/// </summary>
|
|
public static string ExpandPath(string p)
|
|
{
|
|
p = Environment.ExpandEnvironmentVariables(p);
|
|
if (!p.StartsWith('~')) return p;
|
|
return Path.Combine(HomeDir(), p[1..].TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads a PID from a file path if possible, otherwise returns the string as-is.
|
|
/// Mirrors <c>maybeReadPidFile</c> in opts.go.
|
|
/// </summary>
|
|
public static string MaybeReadPidFile(string pidStr)
|
|
{
|
|
try { return File.ReadAllText(pidStr).Trim(); }
|
|
catch { return pidStr; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies TLS overrides from command-line options.
|
|
/// Mirrors <c>overrideTLS</c> in opts.go.
|
|
/// </summary>
|
|
public Exception? OverrideTls()
|
|
{
|
|
if (string.IsNullOrEmpty(TlsCert))
|
|
return new InvalidOperationException("TLS Server certificate must be present and valid");
|
|
if (string.IsNullOrEmpty(TlsKey))
|
|
return new InvalidOperationException("TLS Server private key must be present and valid");
|
|
|
|
// TLS config generation is deferred to GenTlsConfig (session 06+).
|
|
// For now, mark that TLS is enabled.
|
|
Tls = true;
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Overrides cluster options from the --cluster flag.
|
|
/// Mirrors <c>overrideCluster</c> in opts.go.
|
|
/// </summary>
|
|
public Exception? OverrideCluster()
|
|
{
|
|
if (string.IsNullOrEmpty(Cluster.ListenStr))
|
|
{
|
|
Cluster.Port = 0;
|
|
return null;
|
|
}
|
|
|
|
var listenStr = Cluster.ListenStr;
|
|
var wantsRandom = false;
|
|
if (listenStr.EndsWith(":-1"))
|
|
{
|
|
wantsRandom = true;
|
|
listenStr = listenStr[..^3] + ":0";
|
|
}
|
|
|
|
if (!Uri.TryCreate(listenStr, UriKind.Absolute, out var clusterUri))
|
|
return new InvalidOperationException($"could not parse cluster URL: {Cluster.ListenStr}");
|
|
|
|
Cluster.Host = clusterUri.Host;
|
|
Cluster.Port = wantsRandom ? -1 : clusterUri.Port;
|
|
|
|
var userInfo = clusterUri.UserInfo;
|
|
if (!string.IsNullOrEmpty(userInfo))
|
|
{
|
|
var parts = userInfo.Split(':', 2);
|
|
if (parts.Length != 2)
|
|
return new InvalidOperationException("expected cluster password to be set");
|
|
Cluster.Username = parts[0];
|
|
Cluster.Password = parts[1];
|
|
}
|
|
else
|
|
{
|
|
Cluster.Username = string.Empty;
|
|
Cluster.Password = string.Empty;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Batch 6: opts.go package-level parse/config helpers (F1)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Deep copies route/gateway URL lists.
|
|
/// Mirrors <c>deepCopyURLs</c> in opts.go.
|
|
/// </summary>
|
|
public static List<Uri>? DeepCopyURLs(IReadOnlyList<Uri>? urls)
|
|
{
|
|
if (urls == null)
|
|
return null;
|
|
|
|
var copied = new List<Uri>(urls.Count);
|
|
foreach (var u in urls)
|
|
copied.Add(new Uri(u.ToString(), UriKind.Absolute));
|
|
return copied;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads server options from a config file.
|
|
/// Mirrors package-level <c>ProcessConfigFile</c> in opts.go.
|
|
/// </summary>
|
|
public static ServerOptions ProcessConfigFile(string configFile) =>
|
|
ServerOptionsConfiguration.ProcessConfigFile(configFile);
|
|
|
|
/// <summary>
|
|
/// Receiver-style config loader that updates this instance with values from
|
|
/// <paramref name="configFile"/>.
|
|
/// Mirrors Go <c>Options.ProcessConfigFile</c>.
|
|
/// </summary>
|
|
public Exception? ProcessConfigFileOverload2510(string configFile)
|
|
{
|
|
ConfigFile = configFile;
|
|
if (string.IsNullOrEmpty(configFile))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var data = File.ReadAllText(configFile);
|
|
ConfigDigestValue = ComputeConfigDigest(data);
|
|
return ProcessConfigString(data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ex;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receiver-style config loader from in-memory content.
|
|
/// Mirrors Go <c>Options.ProcessConfigString</c>.
|
|
/// </summary>
|
|
public Exception? ProcessConfigString(string data)
|
|
{
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(
|
|
data,
|
|
new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = true,
|
|
CommentHandling = JsonCommentHandling.Skip,
|
|
});
|
|
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
|
return new InvalidOperationException("configuration root must be an object");
|
|
|
|
var normalized = NormalizeConfigValue(doc.RootElement);
|
|
var configMap = normalized as IReadOnlyDictionary<string, object?>
|
|
?? normalized as Dictionary<string, object?>;
|
|
if (configMap == null)
|
|
return new InvalidOperationException("configuration root must be a key/value object");
|
|
|
|
return ProcessConfigFileInternal(string.Empty, configMap);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return ex;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Internal receiver config pipeline that processes each top-level config key.
|
|
/// Mirrors Go <c>Options.processConfigFile</c>.
|
|
/// </summary>
|
|
public Exception? ProcessConfigFileInternal(string configFile, IReadOnlyDictionary<string, object?> config)
|
|
{
|
|
var errors = new List<Exception>();
|
|
var warnings = new List<Exception>();
|
|
|
|
if (config.Count == 0)
|
|
warnings.Add(new InvalidOperationException($"{configFile}: config has no values or is empty"));
|
|
|
|
var sysErr = ConfigureSystemAccount(this, config);
|
|
if (sysErr != null)
|
|
errors.Add(sysErr);
|
|
|
|
foreach (var (key, value) in config)
|
|
ProcessConfigFileLine(key, value, errors, warnings);
|
|
|
|
if (AuthCallout?.AllowedAccounts is { Count: > 0 })
|
|
{
|
|
var configuredAccounts = new HashSet<string>(
|
|
Accounts.Select(a => a.Name),
|
|
StringComparer.Ordinal);
|
|
|
|
foreach (var account in AuthCallout.AllowedAccounts)
|
|
{
|
|
if (!configuredAccounts.Contains(account))
|
|
{
|
|
errors.Add(new InvalidOperationException(
|
|
$"auth_callout allowed account \"{account}\" not found in configured accounts"));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.Count == 0 && warnings.Count == 0)
|
|
return null;
|
|
|
|
return new ProcessConfigException(errors, warnings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a single top-level config key.
|
|
/// Mirrors Go <c>Options.processConfigFileLine</c>.
|
|
/// </summary>
|
|
public void ProcessConfigFileLine(
|
|
string key,
|
|
object? value,
|
|
ICollection<Exception> errors,
|
|
ICollection<Exception> warnings)
|
|
{
|
|
try
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
switch (key.ToLowerInvariant())
|
|
{
|
|
case "listen":
|
|
{
|
|
var (host, port) = ParseListen(normalized);
|
|
Host = host;
|
|
Port = port;
|
|
break;
|
|
}
|
|
case "client_advertise":
|
|
if (normalized is string ca)
|
|
ClientAdvertise = ca;
|
|
else
|
|
errors.Add(new InvalidOperationException("client_advertise must be a string"));
|
|
break;
|
|
case "port":
|
|
if (TryConvertToLong(normalized, out var p))
|
|
Port = checked((int)p);
|
|
else
|
|
errors.Add(new InvalidOperationException("port must be an integer"));
|
|
break;
|
|
case "server_name":
|
|
if (normalized is not string sn)
|
|
{
|
|
errors.Add(new InvalidOperationException("server_name must be a string"));
|
|
}
|
|
else if (sn.Contains(' '))
|
|
{
|
|
errors.Add(ServerErrors.ErrServerNameHasSpaces);
|
|
}
|
|
else
|
|
{
|
|
ServerName = sn;
|
|
}
|
|
break;
|
|
case "host":
|
|
case "net":
|
|
if (normalized is string configuredHost)
|
|
Host = configuredHost;
|
|
else
|
|
errors.Add(new InvalidOperationException($"{key} must be a string"));
|
|
break;
|
|
case "debug":
|
|
if (TryConvertToBool(normalized, out var debug))
|
|
{
|
|
Debug = debug;
|
|
TrackExplicitVal(InConfig, nameof(Debug), Debug);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("debug must be a boolean"));
|
|
}
|
|
break;
|
|
case "trace":
|
|
if (TryConvertToBool(normalized, out var trace))
|
|
{
|
|
Trace = trace;
|
|
TrackExplicitVal(InConfig, nameof(Trace), Trace);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("trace must be a boolean"));
|
|
}
|
|
break;
|
|
case "trace_verbose":
|
|
if (TryConvertToBool(normalized, out var traceVerbose))
|
|
{
|
|
TraceVerbose = traceVerbose;
|
|
Trace = traceVerbose;
|
|
TrackExplicitVal(InConfig, nameof(TraceVerbose), TraceVerbose);
|
|
TrackExplicitVal(InConfig, nameof(Trace), Trace);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("trace_verbose must be a boolean"));
|
|
}
|
|
break;
|
|
case "trace_headers":
|
|
if (TryConvertToBool(normalized, out var traceHeaders))
|
|
{
|
|
TraceHeaders = traceHeaders;
|
|
Trace = traceHeaders;
|
|
TrackExplicitVal(InConfig, nameof(TraceHeaders), TraceHeaders);
|
|
TrackExplicitVal(InConfig, nameof(Trace), Trace);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("trace_headers must be a boolean"));
|
|
}
|
|
break;
|
|
case "logtime":
|
|
if (TryConvertToBool(normalized, out var logtime))
|
|
{
|
|
Logtime = logtime;
|
|
TrackExplicitVal(InConfig, nameof(Logtime), Logtime);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("logtime must be a boolean"));
|
|
}
|
|
break;
|
|
case "logtime_utc":
|
|
if (TryConvertToBool(normalized, out var logtimeUtc))
|
|
{
|
|
LogtimeUtc = logtimeUtc;
|
|
TrackExplicitVal(InConfig, nameof(LogtimeUtc), LogtimeUtc);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("logtime_utc must be a boolean"));
|
|
}
|
|
break;
|
|
case "disable_sublist_cache":
|
|
case "no_sublist_cache":
|
|
if (TryConvertToBool(normalized, out var noSublistCache))
|
|
NoSublistCache = noSublistCache;
|
|
else
|
|
errors.Add(new InvalidOperationException($"{key} must be a boolean"));
|
|
break;
|
|
case "accounts":
|
|
{
|
|
var err = ParseAccounts(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "default_sentinel":
|
|
if (normalized is string sentinel)
|
|
DefaultSentinel = sentinel;
|
|
else
|
|
errors.Add(new InvalidOperationException("default_sentinel must be a string"));
|
|
break;
|
|
case "authorization":
|
|
{
|
|
var (auth, err) = ParseAuthorization(normalized, errors, warnings);
|
|
if (err != null)
|
|
{
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
|
|
if (auth == null)
|
|
break;
|
|
|
|
AuthBlockDefined = true;
|
|
Username = auth.User;
|
|
Password = auth.Pass;
|
|
ProxyRequired = auth.ProxyRequired;
|
|
Authorization = auth.Token;
|
|
AuthTimeout = auth.TimeoutSeconds;
|
|
AuthCallout = auth.Callout;
|
|
|
|
if ((!string.IsNullOrEmpty(auth.User) || !string.IsNullOrEmpty(auth.Pass)) &&
|
|
!string.IsNullOrEmpty(auth.Token))
|
|
{
|
|
errors.Add(new InvalidOperationException("Cannot have a user/pass and token"));
|
|
}
|
|
break;
|
|
}
|
|
case "cluster":
|
|
{
|
|
var err = ParseCluster(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "gateway":
|
|
{
|
|
var err = ParseGateway(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "leafnodes":
|
|
{
|
|
var err = ParseLeafNodes(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "routes":
|
|
if (normalized is string routesString)
|
|
{
|
|
RoutesStr = routesString;
|
|
Routes = RoutesFromStr(routesString);
|
|
break;
|
|
}
|
|
|
|
if (TryGetArray(normalized, out var routes))
|
|
{
|
|
Routes = ParseURLs(routes, "route", warnings, errors);
|
|
}
|
|
else
|
|
{
|
|
errors.Add(new InvalidOperationException("routes must be a string or array"));
|
|
}
|
|
break;
|
|
case "jetstream":
|
|
{
|
|
var err = ParseJetStream(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "websocket":
|
|
{
|
|
var err = ParseWebsocket(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "mqtt":
|
|
{
|
|
var err = ParseMQTT(normalized, this, errors, warnings);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
case "proxies":
|
|
{
|
|
var (proxies, err) = ParseProxies(normalized);
|
|
if (err != null)
|
|
errors.Add(err);
|
|
else
|
|
Proxies = proxies;
|
|
break;
|
|
}
|
|
case "system_account":
|
|
case "system":
|
|
{
|
|
var err = ConfigureSystemAccount(
|
|
this,
|
|
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[key] = normalized,
|
|
});
|
|
if (err != null)
|
|
errors.Add(err);
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors.Add(new InvalidOperationException($"unknown field \"{key}\""));
|
|
break;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors.Add(ex);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Normalizes token-like values to plain CLR values.
|
|
/// Mirrors <c>unwrapValue</c> intent from opts.go.
|
|
/// </summary>
|
|
public static object? UnwrapValue(object? value) => NormalizeConfigValue(value);
|
|
|
|
/// <summary>
|
|
/// Converts a recovered panic/exception to an error list entry.
|
|
/// Mirrors <c>convertPanicToErrorList</c> in opts.go.
|
|
/// </summary>
|
|
public static void ConvertPanicToErrorList(Exception? panic, ICollection<Exception>? errors, string? context = null)
|
|
{
|
|
if (panic == null || errors == null)
|
|
return;
|
|
|
|
var message = string.IsNullOrWhiteSpace(context)
|
|
? "encountered panic while processing config"
|
|
: $"encountered panic while processing {context}";
|
|
errors.Add(new InvalidOperationException(message, panic));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a recovered panic/exception to a single error output.
|
|
/// Mirrors <c>convertPanicToError</c> in opts.go.
|
|
/// </summary>
|
|
public static void ConvertPanicToError(Exception? panic, ref Exception? error, string? context = null)
|
|
{
|
|
if (panic == null || error != null)
|
|
return;
|
|
|
|
var message = string.IsNullOrWhiteSpace(context)
|
|
? "encountered panic while processing config"
|
|
: $"encountered panic while processing {context}";
|
|
error = new InvalidOperationException(message, panic);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies <c>system_account</c>/<c>system</c> config values.
|
|
/// Mirrors <c>configureSystemAccount</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ConfigureSystemAccount(ServerOptions options, IReadOnlyDictionary<string, object?> config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
if (!TryGetFirst(config, ["system_account", "system"], out var value))
|
|
return null;
|
|
|
|
if (value is not string systemAccount)
|
|
return new InvalidOperationException("system account name must be a string");
|
|
|
|
options.SystemAccount = systemAccount;
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a username/nkey identity map for duplicate detection.
|
|
/// Mirrors <c>setupUsersAndNKeysDuplicateCheckMap</c> in opts.go.
|
|
/// </summary>
|
|
public static HashSet<string> SetupUsersAndNKeysDuplicateCheckMap(ServerOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
var identities = new HashSet<string>(StringComparer.Ordinal);
|
|
if (options.Users != null)
|
|
{
|
|
foreach (var user in options.Users)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(user.Username))
|
|
identities.Add(user.Username);
|
|
}
|
|
}
|
|
|
|
if (options.Nkeys != null)
|
|
{
|
|
foreach (var user in options.Nkeys)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(user.Nkey))
|
|
identities.Add(user.Nkey);
|
|
}
|
|
}
|
|
|
|
return identities;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a duration from config value.
|
|
/// Mirrors <c>parseDuration</c> in opts.go.
|
|
/// </summary>
|
|
public static TimeSpan ParseDuration(
|
|
string field,
|
|
object? value,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
if (value is string s)
|
|
{
|
|
try
|
|
{
|
|
return NatsDurationJsonConverter.Parse(s);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(new InvalidOperationException($"error parsing {field}: {ex.Message}", ex));
|
|
return TimeSpan.Zero;
|
|
}
|
|
}
|
|
|
|
if (TryConvertToLong(value, out var legacySeconds))
|
|
{
|
|
warnings?.Add(new InvalidOperationException($"{field} should be converted to a duration"));
|
|
return TimeSpan.FromSeconds(legacySeconds);
|
|
}
|
|
|
|
errors?.Add(new InvalidOperationException($"{field} should be a duration string or number of seconds"));
|
|
return TimeSpan.Zero;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses write timeout policy value.
|
|
/// Mirrors <c>parseWriteDeadlinePolicy</c> in opts.go.
|
|
/// </summary>
|
|
public static WriteTimeoutPolicy ParseWriteDeadlinePolicy(string value, ICollection<Exception>? errors = null) =>
|
|
value.ToLowerInvariant() switch
|
|
{
|
|
"default" => WriteTimeoutPolicy.Default,
|
|
"close" => WriteTimeoutPolicy.Close,
|
|
"retry" => WriteTimeoutPolicy.Retry,
|
|
_ => ParseWriteDeadlinePolicyFallback(value, errors),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Parses <c>listen</c> values (<c>port</c> or <c>host:port</c>).
|
|
/// Mirrors <c>parseListen</c> in opts.go.
|
|
/// </summary>
|
|
public static (string Host, int Port) ParseListen(object? value)
|
|
{
|
|
if (TryConvertToLong(value, out var portOnly))
|
|
return (string.Empty, checked((int)portOnly));
|
|
|
|
if (value is not string address)
|
|
throw new InvalidOperationException($"expected port or host:port, got {value?.GetType().Name ?? "null"}");
|
|
|
|
if (!TrySplitHostPort(address, out var host, out var port))
|
|
throw new InvalidOperationException($"could not parse address string \"{address}\"");
|
|
|
|
return (host, port);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses cluster block config.
|
|
/// Mirrors <c>parseCluster</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseCluster(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
if (!TryGetMap(value, out var clusterMap))
|
|
return new InvalidOperationException($"Expected map to define cluster, got {value?.GetType().Name ?? "null"}");
|
|
|
|
foreach (var (rawKey, rawValue) in clusterMap)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
|
|
switch (key)
|
|
{
|
|
case "name":
|
|
{
|
|
var name = entry as string ?? string.Empty;
|
|
if (name.Contains(' '))
|
|
{
|
|
errors?.Add(new InvalidOperationException(ServerErrors.ErrClusterNameHasSpaces.Message));
|
|
break;
|
|
}
|
|
|
|
options.Cluster.Name = name;
|
|
break;
|
|
}
|
|
case "listen":
|
|
{
|
|
try
|
|
{
|
|
var (host, port) = ParseListen(entry);
|
|
options.Cluster.Host = host;
|
|
options.Cluster.Port = port;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(ex);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "port":
|
|
if (TryConvertToLong(entry, out var clusterPort))
|
|
options.Cluster.Port = checked((int)clusterPort);
|
|
break;
|
|
case "host":
|
|
case "net":
|
|
options.Cluster.Host = entry as string ?? string.Empty;
|
|
break;
|
|
case "authorization":
|
|
{
|
|
var auth = ParseSimpleAuthorization(entry, errors, warnings);
|
|
if (auth == null)
|
|
break;
|
|
if (auth.HasUsers)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Cluster authorization does not allow multiple users"));
|
|
break;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(auth.Token))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Cluster authorization does not support tokens"));
|
|
break;
|
|
}
|
|
|
|
if (auth.HasCallout)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Cluster authorization does not support callouts"));
|
|
break;
|
|
}
|
|
|
|
options.Cluster.Username = auth.Username;
|
|
options.Cluster.Password = auth.Password;
|
|
if (auth.TimeoutSeconds > 0)
|
|
options.Cluster.AuthTimeout = auth.TimeoutSeconds;
|
|
break;
|
|
}
|
|
case "routes":
|
|
if (TryGetArray(entry, out var routes))
|
|
options.Routes = ParseURLs(routes, "route", warnings, errors);
|
|
break;
|
|
case "cluster_advertise":
|
|
case "advertise":
|
|
options.Cluster.Advertise = entry as string ?? string.Empty;
|
|
break;
|
|
case "no_advertise":
|
|
if (TryConvertToBool(entry, out var noAdvertise))
|
|
{
|
|
options.Cluster.NoAdvertise = noAdvertise;
|
|
TrackExplicitVal(options.InConfig, "Cluster.NoAdvertise", noAdvertise);
|
|
}
|
|
|
|
break;
|
|
case "connect_retries":
|
|
if (TryConvertToLong(entry, out var retries))
|
|
options.Cluster.ConnectRetries = checked((int)retries);
|
|
break;
|
|
case "connect_backoff":
|
|
if (TryConvertToBool(entry, out var connectBackoff))
|
|
options.Cluster.ConnectBackoff = connectBackoff;
|
|
break;
|
|
case "compression":
|
|
{
|
|
var parseError = ParseCompression(
|
|
options.Cluster.Compression,
|
|
CompressionModes.S2Fast,
|
|
"compression",
|
|
entry);
|
|
if (parseError != null)
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
case "ping_interval":
|
|
options.Cluster.PingInterval = ParseDuration("ping_interval", entry, errors, warnings);
|
|
break;
|
|
case "ping_max":
|
|
if (TryConvertToLong(entry, out var pingMax))
|
|
options.Cluster.MaxPingsOut = checked((int)pingMax);
|
|
break;
|
|
case "write_deadline":
|
|
options.Cluster.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
|
|
break;
|
|
case "write_timeout":
|
|
options.Cluster.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses compression option values from bool/string/map forms.
|
|
/// Mirrors <c>parseCompression</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseCompression(
|
|
CompressionOpts compression,
|
|
string chosenModeForOn,
|
|
string fieldName,
|
|
object? value)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(compression);
|
|
|
|
switch (NormalizeConfigValue(value))
|
|
{
|
|
case string mode:
|
|
compression.Mode = mode;
|
|
return null;
|
|
case bool enabled:
|
|
compression.Mode = enabled ? chosenModeForOn : CompressionModes.Off;
|
|
return null;
|
|
default:
|
|
if (!TryGetMap(value, out var map))
|
|
return new InvalidOperationException(
|
|
$"field \"{fieldName}\" should be a boolean or a structure, got {value?.GetType().Name ?? "null"}");
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "mode":
|
|
compression.Mode = entry as string ?? string.Empty;
|
|
break;
|
|
case "rtt_thresholds":
|
|
case "thresholds":
|
|
case "rtts":
|
|
case "rtt":
|
|
if (!TryGetArray(entry, out var thresholds))
|
|
return new InvalidOperationException("rtt_thresholds should be an array");
|
|
|
|
foreach (var threshold in thresholds)
|
|
{
|
|
if (threshold is not string thresholdValue)
|
|
return new InvalidOperationException("rtt_thresholds entries should be duration strings");
|
|
compression.RttThresholds.Add(NatsDurationJsonConverter.Parse(thresholdValue));
|
|
}
|
|
|
|
break;
|
|
default:
|
|
return new InvalidOperationException($"unknown field \"{rawKey}\"");
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses URL arrays with duplicate detection.
|
|
/// Mirrors <c>parseURLs</c> in opts.go.
|
|
/// </summary>
|
|
public static List<Uri> ParseURLs(
|
|
IEnumerable<object?> values,
|
|
string type,
|
|
ICollection<Exception>? warnings = null,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
var urls = new List<Uri>();
|
|
var dedupe = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var rawValue in values)
|
|
{
|
|
if (NormalizeConfigValue(rawValue) is not string urlValue)
|
|
{
|
|
errors?.Add(new InvalidOperationException($"{type} url must be a string"));
|
|
continue;
|
|
}
|
|
|
|
if (!dedupe.Add(urlValue))
|
|
{
|
|
warnings?.Add(new InvalidOperationException($"Duplicate {type} entry detected: {urlValue}"));
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
urls.Add(ParseURL(urlValue, type));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(ex);
|
|
}
|
|
}
|
|
|
|
return urls;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a single URL entry.
|
|
/// Mirrors <c>parseURL</c> in opts.go.
|
|
/// </summary>
|
|
public static Uri ParseURL(string value, string type)
|
|
{
|
|
var trimmed = value.Trim();
|
|
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
|
|
throw new InvalidOperationException($"error parsing {type} url [\"{trimmed}\"]");
|
|
return parsed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses gateway block config.
|
|
/// Mirrors <c>parseGateway</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseGateway(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
if (!TryGetMap(value, out var gatewayMap))
|
|
return new InvalidOperationException($"Expected gateway to be a map, got {value?.GetType().Name ?? "null"}");
|
|
|
|
foreach (var (rawKey, rawValue) in gatewayMap)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
|
|
switch (key)
|
|
{
|
|
case "name":
|
|
{
|
|
var name = entry as string ?? string.Empty;
|
|
if (name.Contains(' '))
|
|
{
|
|
errors?.Add(new InvalidOperationException(ServerErrors.ErrGatewayNameHasSpaces.Message));
|
|
break;
|
|
}
|
|
|
|
options.Gateway.Name = name;
|
|
break;
|
|
}
|
|
case "listen":
|
|
{
|
|
try
|
|
{
|
|
var (host, port) = ParseListen(entry);
|
|
options.Gateway.Host = host;
|
|
options.Gateway.Port = port;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(ex);
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "port":
|
|
if (TryConvertToLong(entry, out var gatewayPort))
|
|
options.Gateway.Port = checked((int)gatewayPort);
|
|
break;
|
|
case "host":
|
|
case "net":
|
|
options.Gateway.Host = entry as string ?? string.Empty;
|
|
break;
|
|
case "authorization":
|
|
{
|
|
var auth = ParseSimpleAuthorization(entry, errors, warnings);
|
|
if (auth == null)
|
|
break;
|
|
if (auth.HasUsers)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Gateway authorization does not allow multiple users"));
|
|
break;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(auth.Token))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Gateway authorization does not support tokens"));
|
|
break;
|
|
}
|
|
|
|
if (auth.HasCallout)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Gateway authorization does not support callouts"));
|
|
break;
|
|
}
|
|
|
|
options.Gateway.Username = auth.Username;
|
|
options.Gateway.Password = auth.Password;
|
|
if (auth.TimeoutSeconds > 0)
|
|
options.Gateway.AuthTimeout = auth.TimeoutSeconds;
|
|
break;
|
|
}
|
|
case "advertise":
|
|
options.Gateway.Advertise = entry as string ?? string.Empty;
|
|
break;
|
|
case "connect_retries":
|
|
if (TryConvertToLong(entry, out var retries))
|
|
options.Gateway.ConnectRetries = checked((int)retries);
|
|
break;
|
|
case "connect_backoff":
|
|
if (TryConvertToBool(entry, out var connectBackoff))
|
|
options.Gateway.ConnectBackoff = connectBackoff;
|
|
break;
|
|
case "reject_unknown":
|
|
case "reject_unknown_cluster":
|
|
if (TryConvertToBool(entry, out var rejectUnknown))
|
|
options.Gateway.RejectUnknown = rejectUnknown;
|
|
break;
|
|
case "write_deadline":
|
|
options.Gateway.WriteDeadline = ParseDuration("write_deadline", entry, errors, warnings);
|
|
break;
|
|
case "write_timeout":
|
|
options.Gateway.WriteTimeout = ParseWriteDeadlinePolicy(entry as string ?? string.Empty, errors);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Batch 6: opts.go package-level parse/config helpers (F2)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Parses JetStream account enablement/limits for an account-level block.
|
|
/// Mirrors <c>parseJetStreamForAccount</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseJetStreamForAccount(
|
|
object? value,
|
|
Account account,
|
|
ICollection<Exception>? 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<string, object>(StringComparer.Ordinal)
|
|
{
|
|
[string.Empty] = limits,
|
|
};
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses storage sizes from integer or suffixed-string values.
|
|
/// Mirrors <c>getStorageSize</c> in opts.go.
|
|
/// </summary>
|
|
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"),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses server-level JetStream limits.
|
|
/// Mirrors <c>parseJetStreamLimits</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseJetStreamLimits(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses nested batch limits inside JetStream limits block.
|
|
/// Mirrors <c>parseJetStreamLimitsBatch</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseJetStreamLimitsBatch(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses JetStream TPM options.
|
|
/// Mirrors <c>parseJetStreamTPM</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseJetStreamTPM(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses JetStream encryption cipher selection.
|
|
/// Mirrors <c>setJetStreamEkCipher</c> in opts.go.
|
|
/// </summary>
|
|
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}\"");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses top-level JetStream enablement/configuration.
|
|
/// Mirrors <c>parseJetStream</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseJetStream(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses leaf-node configuration.
|
|
/// Mirrors <c>parseLeafNodes</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseLeafNodes(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses leaf-node authorization block.
|
|
/// Mirrors <c>parseLeafAuthorization</c> in opts.go.
|
|
/// </summary>
|
|
public static LeafAuthorization? ParseLeafAuthorization(
|
|
object? value,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses leaf-node authorization users list.
|
|
/// Mirrors <c>parseLeafUsers</c> in opts.go.
|
|
/// </summary>
|
|
public static List<User> ParseLeafUsers(object? value, ICollection<Exception>? 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<User>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses remote leaf-node definitions.
|
|
/// Mirrors <c>parseRemoteLeafNodes</c> in opts.go.
|
|
/// </summary>
|
|
public static List<RemoteLeafOpts> ParseRemoteLeafNodes(
|
|
object? value,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? 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<RemoteLeafOpts>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses TLS config block and builds runtime TLS options.
|
|
/// Mirrors <c>getTLSConfig</c> in opts.go.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses list of remote gateways.
|
|
/// Mirrors <c>parseGateways</c> in opts.go.
|
|
/// </summary>
|
|
public static List<RemoteGatewayOpts> ParseGateways(
|
|
object? value,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? 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<RemoteGatewayOpts>(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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps parsed pub/sub permissions to route import/export permissions.
|
|
/// Mirrors <c>setClusterPermissions</c> in opts.go.
|
|
/// </summary>
|
|
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(),
|
|
};
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Batch 6: opts.go package-level parse/config helpers (F3)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true for reserved account names.
|
|
/// Mirrors <c>isReservedAccount</c> in opts.go.
|
|
/// </summary>
|
|
public static bool IsReservedAccount(string name) =>
|
|
string.Equals(name, ServerConstants.DefaultGlobalAccount, StringComparison.Ordinal);
|
|
|
|
/// <summary>
|
|
/// Parsed account export entry used during account-config parsing.
|
|
/// </summary>
|
|
public sealed class AccountExportConfig
|
|
{
|
|
public Account? Account { get; set; }
|
|
public string Subject { get; set; } = string.Empty;
|
|
public List<string> AccountNames { get; set; } = [];
|
|
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
|
|
public object? Latency { get; set; }
|
|
public TimeSpan ResponseThreshold { get; set; }
|
|
public uint AccountTokenPosition { get; set; }
|
|
public bool AllowTrace { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parsed account stream-import entry used during account-config parsing.
|
|
/// </summary>
|
|
public sealed class AccountImportStreamConfig
|
|
{
|
|
public Account? Account { get; set; }
|
|
public string AccountName { get; set; } = string.Empty;
|
|
public string Subject { get; set; } = string.Empty;
|
|
public string Prefix { get; set; } = string.Empty;
|
|
public string To { get; set; } = string.Empty;
|
|
public bool AllowTrace { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parsed account service-import entry used during account-config parsing.
|
|
/// </summary>
|
|
public sealed class AccountImportServiceConfig
|
|
{
|
|
public Account? Account { get; set; }
|
|
public string AccountName { get; set; } = string.Empty;
|
|
public string Subject { get; set; } = string.Empty;
|
|
public string To { get; set; } = string.Empty;
|
|
public bool Share { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parsed authorization block used for top-level/config-subtree authorization parsing.
|
|
/// </summary>
|
|
public sealed class ParsedAuthorizationBlock
|
|
{
|
|
public string User { get; set; } = string.Empty;
|
|
public string Pass { get; set; } = string.Empty;
|
|
public string Token { get; set; } = string.Empty;
|
|
public double TimeoutSeconds { get; set; }
|
|
public bool ProxyRequired { get; set; }
|
|
public List<User> Users { get; set; } = [];
|
|
public List<NkeyUser> Nkeys { get; set; } = [];
|
|
public Permissions? DefaultPermissions { get; set; }
|
|
public AuthCalloutOpts? Callout { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a weighted account mapping destination entry.
|
|
/// Mirrors <c>parseAccountMapDest</c> in opts.go.
|
|
/// </summary>
|
|
public static MapDest? ParseAccountMapDest(object? value, ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Expected an entry for the mapping destination"));
|
|
return null;
|
|
}
|
|
|
|
var mdest = new MapDest();
|
|
var sawWeight = false;
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "dest":
|
|
case "destination":
|
|
if (entry is string subject)
|
|
{
|
|
mdest.Subject = subject;
|
|
}
|
|
else
|
|
{
|
|
errors?.Add(new InvalidOperationException("mapping destination must be a string"));
|
|
return null;
|
|
}
|
|
|
|
break;
|
|
case "weight":
|
|
{
|
|
long parsedWeight;
|
|
switch (entry)
|
|
{
|
|
case string weightString:
|
|
{
|
|
var normalizedWeight = weightString.Trim().TrimEnd('%');
|
|
if (!long.TryParse(normalizedWeight, out parsedWeight))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Invalid weight \"{weightString}\" for mapping destination"));
|
|
return null;
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
if (!TryConvertToLong(entry, out parsedWeight))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown entry type for weight \"{entry?.GetType().Name ?? "null"}\""));
|
|
return null;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (parsedWeight is > 100 or < 0)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Invalid weight {parsedWeight} for mapping destination"));
|
|
return null;
|
|
}
|
|
|
|
mdest.Weight = (byte)parsedWeight;
|
|
sawWeight = true;
|
|
break;
|
|
}
|
|
case "cluster":
|
|
mdest.Cluster = entry as string ?? string.Empty;
|
|
break;
|
|
default:
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" for mapping destination"));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (!sawWeight)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Missing weight for mapping destination \"{mdest.Subject}\""));
|
|
return null;
|
|
}
|
|
|
|
return mdest;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account subject mappings.
|
|
/// Mirrors <c>parseAccountMappings</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseAccountMappings(
|
|
object? value,
|
|
Account account,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
if (!TryGetMap(value, out var map))
|
|
return new InvalidOperationException($"Expected account mappings map, got {value?.GetType().Name ?? "null"}");
|
|
|
|
foreach (var (subject, rawDestination) in map)
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"Subject \"{subject}\" is not a valid subject"));
|
|
continue;
|
|
}
|
|
|
|
var destination = NormalizeConfigValue(rawDestination);
|
|
switch (destination)
|
|
{
|
|
case string mappedSubject:
|
|
{
|
|
var mapError = account.AddMapping(subject, mappedSubject);
|
|
if (mapError != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Error adding mapping for \"{subject}\" to \"{mappedSubject}\": {mapError.Message}",
|
|
mapError));
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
if (TryGetArray(destination, out var destinationArray))
|
|
{
|
|
var destinations = new List<MapDest>(destinationArray.Count);
|
|
foreach (var entry in destinationArray)
|
|
{
|
|
var parsedDestination = ParseAccountMapDest(entry, errors);
|
|
if (parsedDestination != null)
|
|
destinations.Add(parsedDestination);
|
|
}
|
|
|
|
if (destinations.Count == 0)
|
|
break;
|
|
|
|
var mapError = account.AddWeightedMappings(subject, [.. destinations]);
|
|
if (mapError != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Error adding mapping for \"{subject}\": {mapError.Message}",
|
|
mapError));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (TryGetMap(destination, out _))
|
|
{
|
|
var parsedDestination = ParseAccountMapDest(destination, errors);
|
|
if (parsedDestination == null)
|
|
break;
|
|
|
|
var mapError = account.AddWeightedMappings(subject, parsedDestination);
|
|
if (mapError != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Error adding mapping for \"{subject}\": {mapError.Message}",
|
|
mapError));
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown type {destination?.GetType().Name ?? "null"} for mapping destination"));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account-level connection/subscription/payload limits.
|
|
/// Mirrors <c>parseAccountLimits</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseAccountLimits(
|
|
object? value,
|
|
Account account,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
if (!TryGetMap(value, out var map))
|
|
return new InvalidOperationException(
|
|
$"Expected account limits to be a map/struct, got {value?.GetType().Name ?? "null"}");
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
if (!TryConvertToLong(entry, out var numericValue))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected numeric value parsing account limit \"{rawKey}\""));
|
|
continue;
|
|
}
|
|
|
|
switch (key)
|
|
{
|
|
case "max_connections":
|
|
case "max_conn":
|
|
account.MaxConnections = checked((int)numericValue);
|
|
break;
|
|
case "max_subscriptions":
|
|
case "max_subs":
|
|
account.MaxSubscriptions = checked((int)numericValue);
|
|
break;
|
|
case "max_payload":
|
|
case "max_pay":
|
|
account.MaxPayload = checked((int)numericValue);
|
|
break;
|
|
case "max_leafnodes":
|
|
case "max_leafs":
|
|
account.MaxLeafNodes = checked((int)numericValue);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" parsing account limits"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account message trace destination/sampling configuration.
|
|
/// Mirrors <c>parseAccountMsgTrace</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseAccountMsgTrace(object? value, string topKey, Account account)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
|
|
static Exception? ProcessDestination(Account targetAccount, string key, object? rawValue)
|
|
{
|
|
if (rawValue is not string destination)
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Field \"{key}\" should be a string, got {rawValue?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
if (!SubscriptionIndex.IsValidPublishSubject(destination))
|
|
return new InvalidOperationException($"Trace destination \"{destination}\" is not valid");
|
|
|
|
targetAccount.SetMessageTraceDestination(destination);
|
|
return null;
|
|
}
|
|
|
|
static Exception? ProcessSampling(Account targetAccount, int sampling)
|
|
{
|
|
if (sampling is <= 0 or > 100)
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Trace destination sampling value {sampling} is invalid, needs to be [1..100]");
|
|
}
|
|
|
|
targetAccount.SetMessageTraceSampling(sampling);
|
|
return null;
|
|
}
|
|
|
|
var normalized = NormalizeConfigValue(value);
|
|
switch (normalized)
|
|
{
|
|
case string:
|
|
return ProcessDestination(account, topKey, normalized);
|
|
default:
|
|
if (!TryGetMap(normalized, out var map))
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Expected account message trace \"{topKey}\" to be a string or map/struct, got {normalized?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "dest":
|
|
{
|
|
var destinationError = ProcessDestination(account, rawKey, entry);
|
|
if (destinationError != null)
|
|
return destinationError;
|
|
break;
|
|
}
|
|
case "sampling":
|
|
{
|
|
int parsedSampling;
|
|
switch (entry)
|
|
{
|
|
case string samplingString:
|
|
{
|
|
var normalizedSampling = samplingString.Trim().TrimEnd('%');
|
|
if (!int.TryParse(normalizedSampling, out parsedSampling))
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Invalid trace destination sampling value \"{samplingString}\"");
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
if (!TryConvertToLong(entry, out var longSampling))
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Trace destination sampling field \"{rawKey}\" should be an integer or a percentage, got {entry?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
parsedSampling = checked((int)longSampling);
|
|
break;
|
|
}
|
|
|
|
var samplingError = ProcessSampling(account, parsedSampling);
|
|
if (samplingError != null)
|
|
return samplingError;
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" parsing account message trace map/struct \"{topKey}\"");
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses the top-level <c>accounts</c> block.
|
|
/// Mirrors <c>parseAccounts</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseAccounts(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
var pendingImportStreams = new List<AccountImportStreamConfig>();
|
|
var pendingImportServices = new List<AccountImportServiceConfig>();
|
|
var pendingExportStreams = new List<AccountExportConfig>();
|
|
var pendingExportServices = new List<AccountExportConfig>();
|
|
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (TryGetArray(normalized, out var accountArray))
|
|
{
|
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var accountEntry in accountArray)
|
|
{
|
|
var accountName = NormalizeConfigValue(accountEntry) as string ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(accountName))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Expected account name to be a string"));
|
|
continue;
|
|
}
|
|
|
|
if (IsReservedAccount(accountName))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account"));
|
|
continue;
|
|
}
|
|
|
|
if (!seen.Add(accountName))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"Duplicate Account Entry: {accountName}"));
|
|
continue;
|
|
}
|
|
|
|
options.Accounts.Add(Account.NewAccount(accountName));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (!TryGetMap(normalized, out var accountsMap))
|
|
{
|
|
return new InvalidOperationException(
|
|
$"Expected accounts to be an array or map, got {normalized?.GetType().Name ?? "null"}");
|
|
}
|
|
|
|
options.Users ??= [];
|
|
options.Nkeys ??= [];
|
|
var identities = SetupUsersAndNKeysDuplicateCheckMap(options);
|
|
|
|
foreach (var (accountName, accountValueRaw) in accountsMap)
|
|
{
|
|
var accountValue = NormalizeConfigValue(accountValueRaw);
|
|
if (!TryGetMap(accountValue, out var accountMap))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Expected map entries for accounts"));
|
|
continue;
|
|
}
|
|
|
|
if (IsReservedAccount(accountName))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"\"{accountName}\" is a reserved account"));
|
|
continue;
|
|
}
|
|
|
|
var account = Account.NewAccount(accountName);
|
|
options.Accounts.Add(account);
|
|
|
|
var parsedUsers = new List<User>();
|
|
var parsedNkeys = new List<NkeyUser>();
|
|
|
|
foreach (var (rawKey, rawValue) in accountMap)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "nkey":
|
|
{
|
|
var accountKey = entry as string ?? string.Empty;
|
|
if (!IsLikelyPublicNkey(accountKey, 'A'))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Not a valid public nkey for an account: \"{accountKey}\""));
|
|
break;
|
|
}
|
|
|
|
account.Nkey = accountKey;
|
|
break;
|
|
}
|
|
case "imports":
|
|
{
|
|
var (importStreams, importServices, importError) = ParseAccountImports(entry, account, errors);
|
|
if (importError != null)
|
|
{
|
|
errors?.Add(importError);
|
|
break;
|
|
}
|
|
|
|
pendingImportStreams.AddRange(importStreams);
|
|
pendingImportServices.AddRange(importServices);
|
|
break;
|
|
}
|
|
case "exports":
|
|
{
|
|
var (exportStreams, exportServices, exportError) = ParseAccountExports(entry, account, errors);
|
|
if (exportError != null)
|
|
{
|
|
errors?.Add(exportError);
|
|
break;
|
|
}
|
|
|
|
pendingExportStreams.AddRange(exportStreams);
|
|
pendingExportServices.AddRange(exportServices);
|
|
break;
|
|
}
|
|
case "jetstream":
|
|
{
|
|
var jsError = ParseJetStreamForAccount(entry, account, errors);
|
|
if (jsError != null)
|
|
errors?.Add(jsError);
|
|
break;
|
|
}
|
|
case "users":
|
|
{
|
|
var (accountNkeys, accountUsers, usersError) = ParseUsers(entry, errors);
|
|
if (usersError != null)
|
|
{
|
|
errors?.Add(usersError);
|
|
break;
|
|
}
|
|
|
|
parsedUsers = accountUsers;
|
|
parsedNkeys = accountNkeys;
|
|
break;
|
|
}
|
|
case "default_permissions":
|
|
{
|
|
var permissions = ParsePermissionsValue(entry, errors);
|
|
if (permissions != null)
|
|
account.DefaultPerms = permissions;
|
|
break;
|
|
}
|
|
case "mappings":
|
|
case "maps":
|
|
{
|
|
var mappingsError = ParseAccountMappings(entry, account, errors);
|
|
if (mappingsError != null)
|
|
errors?.Add(mappingsError);
|
|
break;
|
|
}
|
|
case "limits":
|
|
{
|
|
var limitsError = ParseAccountLimits(entry, account, errors);
|
|
if (limitsError != null)
|
|
errors?.Add(limitsError);
|
|
break;
|
|
}
|
|
case "msg_trace":
|
|
case "trace_dest":
|
|
{
|
|
var traceError = ParseAccountMsgTrace(entry, key, account);
|
|
if (traceError != null)
|
|
{
|
|
errors?.Add(traceError);
|
|
break;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(account.GetMessageTraceDestination()) &&
|
|
account.GetMessageTraceSampling() == 0)
|
|
{
|
|
account.SetMessageTraceSampling(100);
|
|
}
|
|
else if (account.GetMessageTraceSampling() > 0 &&
|
|
string.IsNullOrEmpty(account.GetMessageTraceDestination()))
|
|
{
|
|
warnings?.Add(new InvalidOperationException(
|
|
"Trace destination sampling ignored since no destination was set"));
|
|
account.SetMessageTraceSampling(0);
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (parsedNkeys.Count > 0 || parsedUsers.Count > 0)
|
|
{
|
|
if (!string.IsNullOrEmpty(options.Username))
|
|
errors?.Add(new InvalidOperationException("Cannot have a single user/pass and accounts"));
|
|
if (!string.IsNullOrEmpty(options.Authorization))
|
|
errors?.Add(new InvalidOperationException("Cannot have a token and accounts"));
|
|
}
|
|
|
|
ApplyDefaultPermissions(parsedUsers, parsedNkeys, account.DefaultPerms);
|
|
|
|
foreach (var nkeyUser in parsedNkeys)
|
|
{
|
|
if (!identities.Add(nkeyUser.Nkey))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"Duplicate nkey \"{nkeyUser.Nkey}\" detected"));
|
|
continue;
|
|
}
|
|
|
|
nkeyUser.Account = account;
|
|
options.Nkeys.Add(nkeyUser);
|
|
}
|
|
|
|
foreach (var user in parsedUsers)
|
|
{
|
|
if (!identities.Add(user.Username))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"Duplicate user \"{user.Username}\" detected"));
|
|
continue;
|
|
}
|
|
|
|
user.Account = account;
|
|
options.Users.Add(user);
|
|
}
|
|
}
|
|
|
|
if (errors is { Count: > 0 })
|
|
return null;
|
|
|
|
var accountLookup = options.Accounts.ToDictionary(a => a.Name, StringComparer.Ordinal);
|
|
|
|
foreach (var streamExport in pendingExportStreams)
|
|
{
|
|
if (streamExport.Account == null)
|
|
continue;
|
|
|
|
streamExport.Account.Exports.Streams ??= new Dictionary<string, StreamExport>(StringComparer.Ordinal);
|
|
var export = new StreamExport
|
|
{
|
|
AccountPosition = streamExport.AccountTokenPosition,
|
|
};
|
|
|
|
if (streamExport.AccountNames.Count > 0)
|
|
{
|
|
export.Approved = new Dictionary<string, Account>(StringComparer.Ordinal);
|
|
foreach (var accountName in streamExport.AccountNames)
|
|
{
|
|
if (!accountLookup.TryGetValue(accountName, out var approvedAccount))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"\"{accountName}\" account not defined for stream export"));
|
|
continue;
|
|
}
|
|
|
|
export.Approved[accountName] = approvedAccount;
|
|
}
|
|
}
|
|
|
|
streamExport.Account.Exports.Streams[streamExport.Subject] = export;
|
|
}
|
|
|
|
foreach (var serviceExport in pendingExportServices)
|
|
{
|
|
if (serviceExport.Account == null)
|
|
continue;
|
|
|
|
serviceExport.Account.Exports.Services ??= new Dictionary<string, ServiceExportEntry>(StringComparer.Ordinal);
|
|
var export = new ServiceExportEntry
|
|
{
|
|
Account = serviceExport.Account,
|
|
ResponseType = serviceExport.ResponseType,
|
|
Latency = serviceExport.Latency as InternalServiceLatency,
|
|
ResponseThreshold = serviceExport.ResponseThreshold,
|
|
AllowTrace = serviceExport.AllowTrace,
|
|
AccountPosition = serviceExport.AccountTokenPosition,
|
|
};
|
|
|
|
if (serviceExport.AccountNames.Count > 0)
|
|
{
|
|
export.Approved = new Dictionary<string, Account>(StringComparer.Ordinal);
|
|
foreach (var accountName in serviceExport.AccountNames)
|
|
{
|
|
if (!accountLookup.TryGetValue(accountName, out var approvedAccount))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"\"{accountName}\" account not defined for service export"));
|
|
continue;
|
|
}
|
|
|
|
export.Approved[accountName] = approvedAccount;
|
|
}
|
|
}
|
|
|
|
serviceExport.Account.Exports.Services[serviceExport.Subject] = export;
|
|
}
|
|
|
|
foreach (var streamImport in pendingImportStreams)
|
|
{
|
|
if (streamImport.Account == null)
|
|
continue;
|
|
|
|
if (!accountLookup.TryGetValue(streamImport.AccountName, out var importedAccount))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"\"{streamImport.AccountName}\" account not defined for stream import"));
|
|
continue;
|
|
}
|
|
|
|
streamImport.Account.Imports.Streams ??= [];
|
|
var targetSubject = !string.IsNullOrEmpty(streamImport.To)
|
|
? streamImport.To
|
|
: !string.IsNullOrEmpty(streamImport.Prefix)
|
|
? streamImport.Prefix
|
|
: streamImport.Subject;
|
|
|
|
streamImport.Account.Imports.Streams.Add(new StreamImportEntry
|
|
{
|
|
Account = importedAccount,
|
|
From = streamImport.Subject,
|
|
To = targetSubject,
|
|
AllowTrace = streamImport.AllowTrace,
|
|
});
|
|
}
|
|
|
|
foreach (var serviceImport in pendingImportServices)
|
|
{
|
|
if (serviceImport.Account == null)
|
|
continue;
|
|
|
|
if (!accountLookup.TryGetValue(serviceImport.AccountName, out var importedAccount))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"\"{serviceImport.AccountName}\" account not defined for service import"));
|
|
continue;
|
|
}
|
|
|
|
var targetSubject = string.IsNullOrEmpty(serviceImport.To)
|
|
? serviceImport.Subject
|
|
: serviceImport.To;
|
|
|
|
serviceImport.Account.Imports.Services ??= new Dictionary<string, List<ServiceImportEntry>>(StringComparer.Ordinal);
|
|
if (!serviceImport.Account.Imports.Services.TryGetValue(targetSubject, out var importsForSubject))
|
|
{
|
|
importsForSubject = [];
|
|
serviceImport.Account.Imports.Services[targetSubject] = importsForSubject;
|
|
}
|
|
|
|
importsForSubject.Add(new ServiceImportEntry
|
|
{
|
|
Account = importedAccount,
|
|
From = serviceImport.Subject,
|
|
To = targetSubject,
|
|
Share = serviceImport.Share,
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account exports list.
|
|
/// Mirrors <c>parseAccountExports</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<AccountExportConfig> Streams, List<AccountExportConfig> Services, Exception? Error) ParseAccountExports(
|
|
object? value,
|
|
Account account,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
if (!TryGetArray(value, out var exportArray))
|
|
{
|
|
return ([], [], new InvalidOperationException(
|
|
$"Exports should be an array, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var streamExports = new List<AccountExportConfig>();
|
|
var serviceExports = new List<AccountExportConfig>();
|
|
foreach (var exportValue in exportArray)
|
|
{
|
|
var (stream, service, parseError) = ParseExportStreamOrService(exportValue, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
continue;
|
|
}
|
|
|
|
if (stream != null)
|
|
{
|
|
stream.Account = account;
|
|
streamExports.Add(stream);
|
|
}
|
|
|
|
if (service != null)
|
|
{
|
|
service.Account = account;
|
|
serviceExports.Add(service);
|
|
}
|
|
}
|
|
|
|
return (streamExports, serviceExports, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account imports list.
|
|
/// Mirrors <c>parseAccountImports</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<AccountImportStreamConfig> Streams, List<AccountImportServiceConfig> Services, Exception? Error) ParseAccountImports(
|
|
object? value,
|
|
Account account,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
if (!TryGetArray(value, out var importArray))
|
|
{
|
|
return ([], [], new InvalidOperationException(
|
|
$"Imports should be an array, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var streamImports = new List<AccountImportStreamConfig>();
|
|
var serviceImports = new List<AccountImportServiceConfig>();
|
|
var serviceSubjects = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
|
|
|
foreach (var importValue in importArray)
|
|
{
|
|
var (stream, service, parseError) = ParseImportStreamOrService(importValue, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
continue;
|
|
}
|
|
|
|
if (service != null)
|
|
{
|
|
var targetSubject = string.IsNullOrEmpty(service.To) ? service.Subject : service.To;
|
|
if (!serviceSubjects.TryGetValue(targetSubject, out var seenAccounts))
|
|
{
|
|
seenAccounts = new HashSet<string>(StringComparer.Ordinal);
|
|
serviceSubjects[targetSubject] = seenAccounts;
|
|
}
|
|
|
|
if (!seenAccounts.Add(service.AccountName))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Duplicate service import subject \"{targetSubject}\", previously used in import for account \"{service.AccountName}\""));
|
|
continue;
|
|
}
|
|
|
|
service.Account = account;
|
|
serviceImports.Add(service);
|
|
}
|
|
|
|
if (stream != null)
|
|
{
|
|
stream.Account = account;
|
|
streamImports.Add(stream);
|
|
}
|
|
}
|
|
|
|
return (streamImports, serviceImports, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses account descriptor maps used inside import entries.
|
|
/// Mirrors <c>parseAccount</c> in opts.go.
|
|
/// </summary>
|
|
public static (string AccountName, string Subject, Exception? Error) ParseAccount(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (string.Empty, string.Empty, new InvalidOperationException(
|
|
$"Expected account descriptor map, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var accountName = string.Empty;
|
|
var subject = string.Empty;
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "account":
|
|
accountName = entry as string ?? string.Empty;
|
|
break;
|
|
case "subject":
|
|
subject = entry as string ?? string.Empty;
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return (accountName, subject, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a single export entry (<c>stream</c> or <c>service</c>).
|
|
/// Mirrors <c>parseExportStreamOrService</c> in opts.go.
|
|
/// </summary>
|
|
public static (AccountExportConfig? Stream, AccountExportConfig? Service, Exception? Error) ParseExportStreamOrService(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, null, new InvalidOperationException(
|
|
$"Export items should be a map with type entry, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
AccountExportConfig? stream = null;
|
|
AccountExportConfig? service = null;
|
|
var accountNames = new List<string>();
|
|
var responseType = ServiceRespType.Singleton;
|
|
var responseTypeSeen = false;
|
|
var responseThreshold = TimeSpan.Zero;
|
|
var thresholdSeen = false;
|
|
object? latency = null;
|
|
uint accountTokenPosition = 0;
|
|
var allowTraceSeen = false;
|
|
var allowTrace = false;
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "stream":
|
|
{
|
|
if (service != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Detected stream \"{entry}\" but already saw a service"));
|
|
break;
|
|
}
|
|
|
|
var subject = entry as string;
|
|
if (string.IsNullOrEmpty(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected stream name to be string, got {entry?.GetType().Name ?? "null"}"));
|
|
break;
|
|
}
|
|
|
|
stream = new AccountExportConfig
|
|
{
|
|
Subject = subject,
|
|
AccountNames = [.. accountNames],
|
|
};
|
|
break;
|
|
}
|
|
case "service":
|
|
{
|
|
if (stream != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Detected service \"{entry}\" but already saw a stream"));
|
|
break;
|
|
}
|
|
|
|
var subject = entry as string;
|
|
if (string.IsNullOrEmpty(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected service name to be string, got {entry?.GetType().Name ?? "null"}"));
|
|
break;
|
|
}
|
|
|
|
service = new AccountExportConfig
|
|
{
|
|
Subject = subject,
|
|
AccountNames = [.. accountNames],
|
|
ResponseType = responseType,
|
|
Latency = latency,
|
|
ResponseThreshold = responseThreshold,
|
|
AllowTrace = allowTraceSeen && allowTrace,
|
|
};
|
|
break;
|
|
}
|
|
case "response":
|
|
case "response_type":
|
|
{
|
|
if (responseTypeSeen)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Duplicate response type definition"));
|
|
break;
|
|
}
|
|
|
|
responseTypeSeen = true;
|
|
var responseString = (entry as string ?? string.Empty).ToLowerInvariant();
|
|
var responseTypeParsed = responseString switch
|
|
{
|
|
"single" or "singleton" => ServiceRespType.Singleton,
|
|
"stream" => ServiceRespType.Streamed,
|
|
"chunk" or "chunked" => ServiceRespType.Chunked,
|
|
_ => (ServiceRespType?)null,
|
|
};
|
|
if (responseTypeParsed == null)
|
|
{
|
|
errors?.Add(new InvalidOperationException($"Unknown response type: \"{entry}\""));
|
|
break;
|
|
}
|
|
|
|
responseType = responseTypeParsed.Value;
|
|
|
|
if (stream != null)
|
|
errors?.Add(new InvalidOperationException("Detected response directive on non-service"));
|
|
if (service != null)
|
|
service.ResponseType = responseType;
|
|
break;
|
|
}
|
|
case "threshold":
|
|
case "response_threshold":
|
|
case "response_max_time":
|
|
case "response_time":
|
|
{
|
|
if (thresholdSeen)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Duplicate response threshold detected"));
|
|
break;
|
|
}
|
|
|
|
thresholdSeen = true;
|
|
responseThreshold = ParseDuration(rawKey, entry, errors, warnings: null);
|
|
if (stream != null)
|
|
errors?.Add(new InvalidOperationException("Detected response directive on non-service"));
|
|
if (service != null)
|
|
service.ResponseThreshold = responseThreshold;
|
|
break;
|
|
}
|
|
case "accounts":
|
|
accountNames = ParseStringList(entry);
|
|
if (stream != null)
|
|
stream.AccountNames = [.. accountNames];
|
|
if (service != null)
|
|
service.AccountNames = [.. accountNames];
|
|
break;
|
|
case "latency":
|
|
{
|
|
var (parsedLatency, latencyError) = ParseServiceLatency(rawKey, entry);
|
|
if (latencyError != null)
|
|
{
|
|
errors?.Add(latencyError);
|
|
break;
|
|
}
|
|
|
|
latency = parsedLatency;
|
|
if (stream != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Detected latency directive on non-service"));
|
|
break;
|
|
}
|
|
|
|
if (service != null)
|
|
service.Latency = latency;
|
|
break;
|
|
}
|
|
case "account_token_position":
|
|
if (TryConvertToLong(entry, out var tokenPosition))
|
|
accountTokenPosition = checked((uint)tokenPosition);
|
|
break;
|
|
case "allow_trace":
|
|
allowTraceSeen = true;
|
|
if (TryConvertToBool(entry, out var allowTraceValue))
|
|
allowTrace = allowTraceValue;
|
|
if (stream != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Detected allow_trace directive on non-service"));
|
|
break;
|
|
}
|
|
|
|
if (service != null)
|
|
service.AllowTrace = allowTrace;
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stream != null)
|
|
stream.AccountTokenPosition = accountTokenPosition;
|
|
if (service != null)
|
|
service.AccountTokenPosition = accountTokenPosition;
|
|
|
|
return (stream, service, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses service-export latency configuration.
|
|
/// Mirrors <c>parseServiceLatency</c> in opts.go.
|
|
/// </summary>
|
|
public static (object? Latency, Exception? Error) ParseServiceLatency(string rootField, object? value)
|
|
{
|
|
if (NormalizeConfigValue(value) is string subject)
|
|
{
|
|
return (new InternalServiceLatency
|
|
{
|
|
Subject = subject,
|
|
Sampling = ServerConstants.DefaultServiceLatencySampling,
|
|
}, null);
|
|
}
|
|
|
|
if (!TryGetMap(value, out var latencyMap))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected latency entry to be a map/struct or string, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var sampling = ServerConstants.DefaultServiceLatencySampling;
|
|
if (latencyMap.TryGetValue("sampling", out var samplingRaw))
|
|
{
|
|
var samplingValue = NormalizeConfigValue(samplingRaw);
|
|
var headerMode = false;
|
|
switch (samplingValue)
|
|
{
|
|
case long longSampling:
|
|
sampling = checked((int)longSampling);
|
|
break;
|
|
case string samplingString:
|
|
{
|
|
if (samplingString.Trim().Equals("headers", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
headerMode = true;
|
|
sampling = 0;
|
|
break;
|
|
}
|
|
|
|
var normalizedSampling = samplingString.Trim().TrimEnd('%');
|
|
if (!int.TryParse(normalizedSampling, out sampling))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Failed to parse latency sample \"{samplingString}\""));
|
|
}
|
|
|
|
break;
|
|
}
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
$"Expected latency sample to be a string or integer, got {samplingValue?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
if (!headerMode && (sampling < 1 || sampling > 100))
|
|
return (null, new InvalidOperationException("sampling value should be in range [1..100]"));
|
|
}
|
|
|
|
if (!latencyMap.TryGetValue("subject", out var subjectRaw))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Latency subject required in \"{rootField}\", but missing"));
|
|
}
|
|
|
|
var latencySubject = NormalizeConfigValue(subjectRaw) as string;
|
|
if (string.IsNullOrWhiteSpace(latencySubject))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected latency subject to be a string, got {subjectRaw?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
return (new InternalServiceLatency
|
|
{
|
|
Sampling = sampling,
|
|
Subject = latencySubject,
|
|
}, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a single import entry (<c>stream</c> or <c>service</c>).
|
|
/// Mirrors <c>parseImportStreamOrService</c> in opts.go.
|
|
/// </summary>
|
|
public static (AccountImportStreamConfig? Stream, AccountImportServiceConfig? Service, Exception? Error) ParseImportStreamOrService(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, null, new InvalidOperationException(
|
|
$"Import items should be a map with type entry, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
AccountImportStreamConfig? stream = null;
|
|
AccountImportServiceConfig? service = null;
|
|
var prefix = string.Empty;
|
|
var to = string.Empty;
|
|
var share = false;
|
|
var allowTraceSeen = false;
|
|
var allowTrace = false;
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "stream":
|
|
{
|
|
if (service != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Detected stream but already saw a service"));
|
|
break;
|
|
}
|
|
|
|
var (accountName, subject, parseError) = ParseAccount(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Expect an account name and a subject"));
|
|
break;
|
|
}
|
|
|
|
stream = new AccountImportStreamConfig
|
|
{
|
|
AccountName = accountName,
|
|
Subject = subject,
|
|
Prefix = prefix,
|
|
To = to,
|
|
AllowTrace = allowTraceSeen && allowTrace,
|
|
};
|
|
break;
|
|
}
|
|
case "service":
|
|
{
|
|
if (stream != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException("Detected service but already saw a stream"));
|
|
break;
|
|
}
|
|
|
|
if (allowTraceSeen)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
"Detected allow_trace directive on a non-stream"));
|
|
break;
|
|
}
|
|
|
|
var (accountName, subject, parseError) = ParseAccount(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(accountName) || string.IsNullOrEmpty(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException("Expect an account name and a subject"));
|
|
break;
|
|
}
|
|
|
|
service = new AccountImportServiceConfig
|
|
{
|
|
AccountName = accountName,
|
|
Subject = subject,
|
|
To = string.IsNullOrEmpty(to) ? subject : to,
|
|
Share = share,
|
|
};
|
|
break;
|
|
}
|
|
case "prefix":
|
|
prefix = entry as string ?? string.Empty;
|
|
if (stream != null)
|
|
stream.Prefix = prefix;
|
|
break;
|
|
case "to":
|
|
to = entry as string ?? string.Empty;
|
|
if (service != null)
|
|
service.To = to;
|
|
if (stream != null)
|
|
{
|
|
stream.To = to;
|
|
if (!string.IsNullOrEmpty(stream.Prefix))
|
|
errors?.Add(new InvalidOperationException(
|
|
"Stream import cannot have both 'prefix' and 'to' properties"));
|
|
}
|
|
|
|
break;
|
|
case "share":
|
|
if (TryConvertToBool(entry, out var shareValue))
|
|
share = shareValue;
|
|
if (service != null)
|
|
service.Share = share;
|
|
break;
|
|
case "allow_trace":
|
|
if (service != null)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
"Detected allow_trace directive on a non-stream"));
|
|
break;
|
|
}
|
|
|
|
allowTraceSeen = true;
|
|
if (TryConvertToBool(entry, out var allowTraceValue))
|
|
allowTrace = allowTraceValue;
|
|
if (stream != null)
|
|
stream.AllowTrace = allowTrace;
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return (stream, service, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies default permissions to users and nkeys that do not have explicit permissions.
|
|
/// Mirrors <c>applyDefaultPermissions</c> in opts.go.
|
|
/// </summary>
|
|
public static void ApplyDefaultPermissions(
|
|
IReadOnlyList<User>? users,
|
|
IReadOnlyList<NkeyUser>? nkeys,
|
|
Permissions? defaultPermissions)
|
|
{
|
|
if (defaultPermissions == null)
|
|
return;
|
|
|
|
if (users != null)
|
|
{
|
|
foreach (var user in users)
|
|
{
|
|
user.Permissions ??= defaultPermissions;
|
|
}
|
|
}
|
|
|
|
if (nkeys != null)
|
|
{
|
|
foreach (var user in nkeys)
|
|
{
|
|
user.Permissions ??= defaultPermissions;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses an authorization block.
|
|
/// Mirrors <c>parseAuthorization</c> in opts.go.
|
|
/// </summary>
|
|
public static (ParsedAuthorizationBlock? Authorization, Exception? Error) ParseAuthorization(
|
|
object? value,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
return (null, new InvalidOperationException("authorization should be a map"));
|
|
|
|
var auth = new ParsedAuthorizationBlock();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "user":
|
|
case "username":
|
|
auth.User = entry as string ?? string.Empty;
|
|
break;
|
|
case "pass":
|
|
case "password":
|
|
auth.Pass = entry as string ?? string.Empty;
|
|
break;
|
|
case "token":
|
|
auth.Token = entry as string ?? string.Empty;
|
|
break;
|
|
case "timeout":
|
|
{
|
|
double timeoutSeconds;
|
|
switch (entry)
|
|
{
|
|
case long longTimeout:
|
|
timeoutSeconds = longTimeout;
|
|
break;
|
|
case double doubleTimeout:
|
|
timeoutSeconds = doubleTimeout;
|
|
break;
|
|
case string duration:
|
|
timeoutSeconds = ParseDuration("timeout", duration, errors, warnings).TotalSeconds;
|
|
break;
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
"error parsing authorization config, 'timeout' wrong type"));
|
|
}
|
|
|
|
auth.TimeoutSeconds = timeoutSeconds;
|
|
if (timeoutSeconds > TimeSpan.FromSeconds(60).TotalSeconds)
|
|
{
|
|
warnings?.Add(new InvalidOperationException(
|
|
$"timeout of {entry} ({timeoutSeconds} seconds) is high, consider keeping it under 60 seconds"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
case "users":
|
|
{
|
|
var (nkeys, users, parseError) = ParseUsers(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
auth.Users = users;
|
|
auth.Nkeys = nkeys;
|
|
break;
|
|
}
|
|
case "default_permission":
|
|
case "default_permissions":
|
|
case "permissions":
|
|
auth.DefaultPermissions = ParsePermissionsValue(entry, errors);
|
|
break;
|
|
case "auth_callout":
|
|
case "auth_hook":
|
|
{
|
|
var (callout, parseError) = ParseAuthCallout(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
auth.Callout = callout;
|
|
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;
|
|
}
|
|
}
|
|
|
|
ApplyDefaultPermissions(auth.Users, auth.Nkeys, auth.DefaultPermissions);
|
|
return (auth, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses users/nkeys block.
|
|
/// Mirrors <c>parseUsers</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<NkeyUser> Nkeys, List<User> Users, Exception? Error) ParseUsers(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetArray(value, out var usersArray))
|
|
{
|
|
return ([], [], new InvalidOperationException(
|
|
$"Expected users field to be an array, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var users = new List<User>();
|
|
var nkeys = new List<NkeyUser>();
|
|
foreach (var userRaw in usersArray)
|
|
{
|
|
if (!TryGetMap(userRaw, out var userMap))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected user entry to be a map/struct, got {userRaw?.GetType().Name ?? "null"}"));
|
|
continue;
|
|
}
|
|
|
|
var user = new User();
|
|
var nkey = new NkeyUser();
|
|
Permissions? permissions = null;
|
|
foreach (var (rawKey, rawValue) in userMap)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "nkey":
|
|
nkey.Nkey = entry as string ?? string.Empty;
|
|
break;
|
|
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 "permission":
|
|
case "permissions":
|
|
case "authorization":
|
|
permissions = ParsePermissionsValue(entry, errors);
|
|
break;
|
|
case "allowed_connection_types":
|
|
case "connection_types":
|
|
case "clients":
|
|
{
|
|
var connectionTypes = ParseAllowedConnectionTypes(entry, errors);
|
|
nkey.AllowedConnectionTypes = connectionTypes;
|
|
user.AllowedConnectionTypes = connectionTypes;
|
|
break;
|
|
}
|
|
case "proxy_required":
|
|
if (TryConvertToBool(entry, out var proxyRequired))
|
|
{
|
|
nkey.ProxyRequired = proxyRequired;
|
|
user.ProxyRequired = proxyRequired;
|
|
}
|
|
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (permissions != null)
|
|
{
|
|
if (!string.IsNullOrEmpty(nkey.Nkey))
|
|
nkey.Permissions = permissions;
|
|
else
|
|
user.Permissions = permissions;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(nkey.Nkey) && string.IsNullOrEmpty(user.Username))
|
|
{
|
|
return ([], [], new InvalidOperationException("User entry requires a user"));
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(nkey.Nkey))
|
|
{
|
|
if (!IsLikelyPublicNkey(nkey.Nkey, 'U'))
|
|
return ([], [], new InvalidOperationException("Not a valid public nkey for a user"));
|
|
|
|
if (!string.IsNullOrEmpty(user.Username) || !string.IsNullOrEmpty(user.Password))
|
|
{
|
|
return ([], [], new InvalidOperationException(
|
|
"Nkey users do not take usernames or passwords"));
|
|
}
|
|
|
|
nkeys.Add(nkey);
|
|
}
|
|
else
|
|
{
|
|
users.Add(user);
|
|
}
|
|
}
|
|
|
|
return (nkeys, users, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses allowed connection types map.
|
|
/// Mirrors <c>parseAllowedConnectionTypes</c> in opts.go.
|
|
/// </summary>
|
|
public static HashSet<string>? ParseAllowedConnectionTypes(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetArray(value, out var values))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected allowed connection types to be an array, got {value?.GetType().Name ?? "null"}"));
|
|
return null;
|
|
}
|
|
|
|
var result = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var item in values)
|
|
{
|
|
var connectionType = NormalizeConfigValue(item) as string;
|
|
if (string.IsNullOrEmpty(connectionType))
|
|
{
|
|
errors?.Add(new InvalidOperationException("allowed connection type entries must be strings"));
|
|
continue;
|
|
}
|
|
|
|
result.Add(connectionType);
|
|
}
|
|
|
|
var validateError = AuthHandler.ValidateAllowedConnectionTypes(result);
|
|
if (validateError != null)
|
|
errors?.Add(validateError);
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses authorization callout configuration.
|
|
/// Mirrors <c>parseAuthCallout</c> in opts.go.
|
|
/// </summary>
|
|
public static (AuthCalloutOpts? Callout, Exception? Error) ParseAuthCallout(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected authorization callout to be a map/struct, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var callout = new AuthCalloutOpts();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "issuer":
|
|
callout.Issuer = entry as string ?? string.Empty;
|
|
if (!IsLikelyPublicNkey(callout.Issuer, 'A'))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected callout user to be a valid public account nkey, got \"{callout.Issuer}\""));
|
|
}
|
|
|
|
break;
|
|
case "account":
|
|
case "acc":
|
|
callout.Account = entry as string ?? string.Empty;
|
|
break;
|
|
case "auth_users":
|
|
case "users":
|
|
if (!TryGetArray(entry, out var authUsersArray))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected auth_users field to be an array, got {entry?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
foreach (var userValue in authUsersArray)
|
|
{
|
|
var authUser = NormalizeConfigValue(userValue) as string;
|
|
if (!string.IsNullOrEmpty(authUser))
|
|
callout.AuthUsers.Add(authUser);
|
|
}
|
|
|
|
break;
|
|
case "xkey":
|
|
case "key":
|
|
callout.XKey = entry as string ?? string.Empty;
|
|
if (!string.IsNullOrEmpty(callout.XKey) && !IsLikelyPublicNkey(callout.XKey, 'X'))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected callout xkey to be a valid public xkey, got \"{callout.XKey}\""));
|
|
}
|
|
|
|
break;
|
|
case "allowed_accounts":
|
|
if (!TryGetArray(entry, out var allowedAccountsArray))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected allowed accounts field to be an array, got {entry?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
foreach (var accountValue in allowedAccountsArray)
|
|
{
|
|
var accountName = NormalizeConfigValue(accountValue) as string;
|
|
if (!string.IsNullOrEmpty(accountName))
|
|
callout.AllowedAccounts.Add(accountName);
|
|
}
|
|
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" parsing authorization callout"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(callout.Account))
|
|
callout.Account = ServerConstants.DefaultGlobalAccount;
|
|
if (string.IsNullOrEmpty(callout.Issuer))
|
|
return (null, new InvalidOperationException("Authorization callouts require an issuer to be specified"));
|
|
if (callout.AuthUsers.Count == 0)
|
|
return (null, new InvalidOperationException("Authorization callouts require authorized users to be specified"));
|
|
|
|
return (callout, null);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Batch 6: opts.go package-level parse/config helpers (F4)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Parses user permission blocks.
|
|
/// Mirrors <c>parseUserPermissions</c> in opts.go.
|
|
/// </summary>
|
|
public static (Permissions? Permissions, Exception? Error) ParseUserPermissions(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var permissions = new Permissions();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "pub":
|
|
case "publish":
|
|
case "import":
|
|
{
|
|
var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
permissions.Publish = subjectPermissions;
|
|
break;
|
|
}
|
|
case "sub":
|
|
case "subscribe":
|
|
case "export":
|
|
{
|
|
var (subjectPermissions, parseError) = ParseVariablePermissions(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
permissions.Subscribe = subjectPermissions;
|
|
break;
|
|
}
|
|
case "publish_allow_responses":
|
|
case "allow_responses":
|
|
if (TryConvertToBool(entry, out var responsesEnabled))
|
|
{
|
|
if (responsesEnabled)
|
|
{
|
|
permissions.Response = new ResponsePermission
|
|
{
|
|
MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
|
|
Expires = ServerConstants.DefaultAllowResponseExpiration,
|
|
};
|
|
}
|
|
}
|
|
else
|
|
{
|
|
permissions.Response = ParseAllowResponses(entry, errors);
|
|
}
|
|
|
|
if (permissions.Response != null)
|
|
{
|
|
permissions.Publish ??= new SubjectPermission();
|
|
permissions.Publish.Allow ??= [];
|
|
}
|
|
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" parsing permissions"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
AuthHandler.ValidateResponsePermissions(permissions);
|
|
return (permissions, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses variable-style publish/subscribe permission values.
|
|
/// Mirrors <c>parseVariablePermissions</c> in opts.go.
|
|
/// </summary>
|
|
public static (SubjectPermission? Permissions, Exception? Error) ParseVariablePermissions(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
return TryGetMap(value, out _)
|
|
? ParseSubjectPermission(value, errors)
|
|
: ParseOldPermissionStyle(value, errors);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses single or array subject values used in permissions.
|
|
/// Mirrors <c>parsePermSubjects</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<string>? Subjects, Exception? Error) ParsePermSubjects(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
var subjects = new List<string>();
|
|
|
|
switch (normalized)
|
|
{
|
|
case string single:
|
|
subjects.Add(single);
|
|
break;
|
|
case IEnumerable<object?> array:
|
|
foreach (var entry in array)
|
|
{
|
|
var subject = NormalizeConfigValue(entry) as string;
|
|
if (subject == null)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"Subject in permissions array cannot be cast to string"));
|
|
}
|
|
|
|
subjects.Add(subject);
|
|
}
|
|
|
|
break;
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
$"Expected subject permissions to be a subject, or array of subjects, got {normalized?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var validateError = CheckPermSubjectArray(subjects);
|
|
if (validateError != null)
|
|
return (null, validateError);
|
|
|
|
return (subjects, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses response permissions.
|
|
/// Mirrors <c>parseAllowResponses</c> in opts.go.
|
|
/// </summary>
|
|
public static ResponsePermission? ParseAllowResponses(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (TryConvertToBool(normalized, out var enabled))
|
|
{
|
|
return enabled
|
|
? new ResponsePermission
|
|
{
|
|
MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
|
|
Expires = ServerConstants.DefaultAllowResponseExpiration,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
if (!TryGetMap(normalized, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
"error parsing response permissions, expected a boolean or a map"));
|
|
return null;
|
|
}
|
|
|
|
var responsePermission = new ResponsePermission
|
|
{
|
|
MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
|
|
Expires = ServerConstants.DefaultAllowResponseExpiration,
|
|
};
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "max":
|
|
case "max_msgs":
|
|
case "max_messages":
|
|
case "max_responses":
|
|
if (!TryConvertToLong(entry, out var maxMessages))
|
|
{
|
|
errors?.Add(new InvalidOperationException("error parsing max responses"));
|
|
break;
|
|
}
|
|
|
|
if (maxMessages != 0)
|
|
responsePermission.MaxMsgs = checked((int)maxMessages);
|
|
break;
|
|
case "expires":
|
|
case "expiration":
|
|
case "ttl":
|
|
{
|
|
var ttl = ParseDuration("expires", entry, errors, warnings: null);
|
|
if (ttl != TimeSpan.Zero)
|
|
responsePermission.Expires = ttl;
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field \"{rawKey}\" parsing permissions"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return responsePermission;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses old-style allow-only permission syntax.
|
|
/// Mirrors <c>parseOldPermissionStyle</c> in opts.go.
|
|
/// </summary>
|
|
public static (SubjectPermission? Permissions, Exception? Error) ParseOldPermissionStyle(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
var (subjects, parseError) = ParsePermSubjects(value, errors);
|
|
if (parseError != null)
|
|
return (null, parseError);
|
|
|
|
return (new SubjectPermission { Allow = subjects }, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses new-style allow/deny subject permissions.
|
|
/// Mirrors <c>parseSubjectPermission</c> in opts.go.
|
|
/// </summary>
|
|
public static (SubjectPermission? Permissions, Exception? Error) ParseSubjectPermission(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"Expected subject permission map, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
if (map.Count == 0)
|
|
return (null, null);
|
|
|
|
var permission = new SubjectPermission();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "allow":
|
|
{
|
|
var (subjects, parseError) = ParsePermSubjects(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
permission.Allow = subjects;
|
|
break;
|
|
}
|
|
case "deny":
|
|
{
|
|
var (subjects, parseError) = ParsePermSubjects(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
permission.Deny = subjects;
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Unknown field name \"{rawKey}\" parsing subject permissions, only 'allow' or 'deny' are permitted"));
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
return (permission, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates permission subjects.
|
|
/// Mirrors <c>checkPermSubjectArray</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? CheckPermSubjectArray(IReadOnlyList<string> subjects)
|
|
{
|
|
foreach (var subject in subjects)
|
|
{
|
|
if (SubscriptionIndex.IsValidSubject(subject))
|
|
continue;
|
|
|
|
var parts = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length != 2)
|
|
return new InvalidOperationException($"subject \"{subject}\" is not a valid subject");
|
|
if (!SubscriptionIndex.IsValidSubject(parts[0]))
|
|
return new InvalidOperationException($"subject \"{parts[0]}\" is not a valid subject");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prints TLS help text.
|
|
/// Mirrors <c>PrintTLSHelpAndDie</c> in opts.go.
|
|
/// </summary>
|
|
public static void PrintTLSHelpAndDie()
|
|
{
|
|
Console.WriteLine("TLS configuration help");
|
|
Console.WriteLine("Available cipher suites include:");
|
|
foreach (var cipher in CipherSuites.CipherMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
|
Console.WriteLine($" {cipher}");
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine("Available curve preferences include:");
|
|
foreach (var curve in CipherSuites.CurvePreferenceMap.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
|
|
Console.WriteLine($" {curve}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a configured cipher-suite name.
|
|
/// Mirrors <c>parseCipher</c> in opts.go.
|
|
/// </summary>
|
|
public static (TlsCipherSuite? Cipher, Exception? Error) ParseCipher(string cipherName)
|
|
{
|
|
if (!CipherSuites.CipherMap.TryGetValue(cipherName, out var cipher))
|
|
return (null, new InvalidOperationException($"unrecognized cipher {cipherName}"));
|
|
|
|
return (cipher, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a configured curve-preference name.
|
|
/// Mirrors <c>parseCurvePreferences</c> in opts.go.
|
|
/// </summary>
|
|
public static (SslApplicationProtocol? Curve, Exception? Error) ParseCurvePreferences(string curveName)
|
|
{
|
|
if (!CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve))
|
|
return (null, new InvalidOperationException($"unrecognized curve preference {curveName}"));
|
|
|
|
return (curve, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses minimum TLS version config value.
|
|
/// Mirrors <c>parseTLSVersion</c> in opts.go.
|
|
/// </summary>
|
|
public static (SslProtocols Version, Exception? Error) ParseTLSVersion(object? value)
|
|
{
|
|
if (NormalizeConfigValue(value) is not string versionText)
|
|
return (SslProtocols.None, new InvalidOperationException($"'min_version' wrong type: {value}"));
|
|
|
|
SslProtocols minVersion;
|
|
try
|
|
{
|
|
minVersion = TlsVersionJsonConverter.Parse(versionText);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (SslProtocols.None, ex);
|
|
}
|
|
|
|
if (minVersion != SslProtocols.Tls12 && minVersion != SslProtocols.Tls13)
|
|
{
|
|
return (SslProtocols.None, new InvalidOperationException(
|
|
$"unsupported TLS version: {versionText}"));
|
|
}
|
|
|
|
return (minVersion, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses TLS config options from map values.
|
|
/// Mirrors <c>parseTLS</c> in opts.go.
|
|
/// </summary>
|
|
public static (TlsConfigOpts? Options, Exception? Error) ParseTLS(object? value, bool isClientCtx)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
return (null, new InvalidOperationException("TLS options should be a map"));
|
|
|
|
var tlsOptions = new TlsConfigOpts();
|
|
var insecureConfigured = new List<string>();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "cert_file":
|
|
tlsOptions.CertFile = entry as string ?? string.Empty;
|
|
break;
|
|
case "key_file":
|
|
tlsOptions.KeyFile = entry as string ?? string.Empty;
|
|
break;
|
|
case "ca_file":
|
|
tlsOptions.CaFile = entry as string ?? string.Empty;
|
|
break;
|
|
case "insecure":
|
|
if (!TryConvertToBool(entry, out var insecure))
|
|
return (null, new InvalidOperationException("error parsing tls config, expected 'insecure' to be a boolean"));
|
|
tlsOptions.Insecure = insecure;
|
|
break;
|
|
case "verify":
|
|
if (!TryConvertToBool(entry, out var verify))
|
|
return (null, new InvalidOperationException("error parsing tls config, expected 'verify' to be a boolean"));
|
|
tlsOptions.Verify = verify;
|
|
break;
|
|
case "verify_and_map":
|
|
if (!TryConvertToBool(entry, out var verifyAndMap))
|
|
return (null, new InvalidOperationException("error parsing tls config, expected 'verify_and_map' to be a boolean"));
|
|
if (verifyAndMap)
|
|
tlsOptions.Verify = true;
|
|
tlsOptions.Map = verifyAndMap;
|
|
break;
|
|
case "verify_cert_and_check_known_urls":
|
|
if (!TryConvertToBool(entry, out var verifyKnownUrls))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, expected 'verify_cert_and_check_known_urls' to be a boolean"));
|
|
}
|
|
|
|
if (verifyKnownUrls && isClientCtx)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"verify_cert_and_check_known_urls not supported in this context"));
|
|
}
|
|
|
|
if (verifyKnownUrls)
|
|
tlsOptions.Verify = true;
|
|
tlsOptions.TlsCheckKnownUrls = verifyKnownUrls;
|
|
break;
|
|
case "allow_insecure_cipher_suites":
|
|
if (!TryConvertToBool(entry, out var allowInsecureCiphers))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, expected 'allow_insecure_cipher_suites' to be a boolean"));
|
|
}
|
|
|
|
tlsOptions.AllowInsecureCiphers = allowInsecureCiphers;
|
|
break;
|
|
case "cipher_suites":
|
|
if (!TryGetArray(entry, out var cipherArray) || cipherArray.Count == 0)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, 'cipher_suites' cannot be empty"));
|
|
}
|
|
|
|
tlsOptions.Ciphers.Clear();
|
|
foreach (var cipherEntry in cipherArray)
|
|
{
|
|
if (NormalizeConfigValue(cipherEntry) is not string cipherName)
|
|
return (null, new InvalidOperationException("cipher suite name should be a string"));
|
|
|
|
var (cipher, parseError) = ParseCipher(cipherName);
|
|
if (parseError != null || cipher == null)
|
|
return (null, parseError);
|
|
|
|
tlsOptions.Ciphers.Add(cipher.Value);
|
|
if (IsLikelyInsecureCipherSuite(cipher.Value))
|
|
insecureConfigured.Add(cipherName);
|
|
}
|
|
|
|
break;
|
|
case "curve_preferences":
|
|
if (!TryGetArray(entry, out var curveArray) || curveArray.Count == 0)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, 'curve_preferences' cannot be empty"));
|
|
}
|
|
|
|
tlsOptions.CurvePreferences.Clear();
|
|
foreach (var curveEntry in curveArray)
|
|
{
|
|
if (NormalizeConfigValue(curveEntry) is not string curveName)
|
|
return (null, new InvalidOperationException("curve preference should be a string"));
|
|
|
|
var (curve, parseError) = ParseCurvePreferences(curveName);
|
|
if (parseError != null || curve == null)
|
|
return (null, parseError);
|
|
|
|
tlsOptions.CurvePreferences.Add(curve.Value);
|
|
}
|
|
|
|
break;
|
|
case "timeout":
|
|
switch (entry)
|
|
{
|
|
case long timeoutLong:
|
|
tlsOptions.Timeout = timeoutLong;
|
|
break;
|
|
case double timeoutDouble:
|
|
tlsOptions.Timeout = timeoutDouble;
|
|
break;
|
|
case string timeoutString:
|
|
tlsOptions.Timeout = ParseDuration("tls timeout", timeoutString).TotalSeconds;
|
|
break;
|
|
default:
|
|
return (null, new InvalidOperationException("error parsing tls config, 'timeout' wrong type"));
|
|
}
|
|
|
|
break;
|
|
case "connection_rate_limit":
|
|
if (!TryConvertToLong(entry, out var rateLimit))
|
|
return (null, new InvalidOperationException("error parsing tls config, 'connection_rate_limit' wrong type"));
|
|
tlsOptions.RateLimit = rateLimit;
|
|
break;
|
|
case "pinned_certs":
|
|
if (!TryGetArray(entry, out var pinArray))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, expected 'pinned_certs' to be a list"));
|
|
}
|
|
|
|
if (pinArray.Count > 0)
|
|
{
|
|
var pinned = new PinnedCertSet();
|
|
var pinRegex = new Regex("^[A-Fa-f0-9]{64}$", RegexOptions.Compiled);
|
|
foreach (var pinEntry in pinArray)
|
|
{
|
|
var pin = (NormalizeConfigValue(pinEntry) as string ?? string.Empty).ToLowerInvariant();
|
|
if (!pinRegex.IsMatch(pin))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing tls config, 'pinned_certs' key {pin} does not look like hex-encoded sha256"));
|
|
}
|
|
|
|
pinned.Add(pin);
|
|
}
|
|
|
|
tlsOptions.PinnedCerts = pinned;
|
|
}
|
|
|
|
break;
|
|
case "handshake_first":
|
|
case "first":
|
|
case "immediate":
|
|
switch (entry)
|
|
{
|
|
case bool handshakeFirst:
|
|
tlsOptions.HandshakeFirst = handshakeFirst;
|
|
break;
|
|
case string handshakeValue:
|
|
switch (handshakeValue.Trim().ToLowerInvariant())
|
|
{
|
|
case "true":
|
|
case "on":
|
|
tlsOptions.HandshakeFirst = true;
|
|
break;
|
|
case "false":
|
|
case "off":
|
|
tlsOptions.HandshakeFirst = false;
|
|
break;
|
|
case "auto":
|
|
case "auto_fallback":
|
|
tlsOptions.HandshakeFirst = true;
|
|
tlsOptions.FallbackDelay = ServerConstants.DefaultTlsHandshakeFirstFallbackDelay;
|
|
break;
|
|
default:
|
|
{
|
|
var delay = ParseDuration("handshake_first", handshakeValue);
|
|
if (delay == TimeSpan.Zero)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"field \"{rawKey}\" value \"{handshakeValue}\" is invalid"));
|
|
}
|
|
|
|
tlsOptions.HandshakeFirst = true;
|
|
tlsOptions.FallbackDelay = delay;
|
|
break;
|
|
}
|
|
}
|
|
|
|
break;
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
$"field \"{rawKey}\" should be a boolean or a string, got {entry?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
break;
|
|
case "certs":
|
|
case "certificates":
|
|
if (!TryGetArray(entry, out var certsArray))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing certificates config: unsupported type {entry?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
tlsOptions.Certificates.Clear();
|
|
foreach (var certEntry in certsArray)
|
|
{
|
|
if (!TryGetMap(certEntry, out var certMap))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing certificates config: unsupported type {certEntry?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var pair = new TlsCertPairOpt();
|
|
foreach (var (certKey, certValueRaw) in certMap)
|
|
{
|
|
var certValue = NormalizeConfigValue(certValueRaw) as string;
|
|
if (string.IsNullOrEmpty(certValue))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing certificates config: unsupported type {certValueRaw?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
switch (certKey)
|
|
{
|
|
case "cert_file":
|
|
pair.CertFile = certValue;
|
|
break;
|
|
case "key_file":
|
|
pair.KeyFile = certValue;
|
|
break;
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing tls certs config, unknown field \"{certKey}\""));
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(pair.CertFile) || string.IsNullOrEmpty(pair.KeyFile))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing certificates config: both 'cert_file' and 'key_file' options are required"));
|
|
}
|
|
|
|
tlsOptions.Certificates.Add(pair);
|
|
}
|
|
|
|
break;
|
|
case "min_version":
|
|
{
|
|
var (minVersion, parseError) = ParseTLSVersion(entry);
|
|
if (parseError != null)
|
|
return (null, new InvalidOperationException($"error parsing tls config: {parseError.Message}", parseError));
|
|
tlsOptions.MinVersion = minVersion;
|
|
break;
|
|
}
|
|
case "cert_match":
|
|
tlsOptions.CertMatch = entry as string ?? string.Empty;
|
|
break;
|
|
case "cert_match_skip_invalid":
|
|
if (!TryConvertToBool(entry, out var certMatchSkipInvalid))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, expected 'cert_match_skip_invalid' to be a boolean"));
|
|
}
|
|
|
|
tlsOptions.CertMatchSkipInvalid = certMatchSkipInvalid;
|
|
break;
|
|
case "ca_certs_match":
|
|
{
|
|
var (caCertsMatch, parseError) = ParseStringArray("ca_certs_match", entry, errors: null);
|
|
if (parseError != null || caCertsMatch == null)
|
|
return (null, parseError);
|
|
tlsOptions.CaCertsMatch = caCertsMatch;
|
|
break;
|
|
}
|
|
default:
|
|
return (null, new InvalidOperationException(
|
|
$"error parsing tls config, unknown field \"{rawKey}\""));
|
|
}
|
|
}
|
|
|
|
if (tlsOptions.Certificates.Count > 0 && !string.IsNullOrEmpty(tlsOptions.CertFile))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"error parsing tls config, cannot combine 'cert_file' option with 'certs' option"));
|
|
}
|
|
|
|
if (tlsOptions.Ciphers.Count == 0)
|
|
tlsOptions.Ciphers.AddRange(CipherSuites.DefaultCipherSuites());
|
|
|
|
if (tlsOptions.CurvePreferences.Count == 0)
|
|
{
|
|
foreach (var curveName in CipherSuites.DefaultCurvePreferences())
|
|
{
|
|
if (CipherSuites.CurvePreferenceMap.TryGetValue(curveName, out var curve))
|
|
tlsOptions.CurvePreferences.Add(curve);
|
|
}
|
|
}
|
|
|
|
if (!tlsOptions.AllowInsecureCiphers && insecureConfigured.Count > 0)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"insecure cipher suites configured without 'allow_insecure_cipher_suites' option set: {string.Join(", ", insecureConfigured)}"));
|
|
}
|
|
|
|
return (tlsOptions, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses simple auth objects (<c>user/pass/token/timeout</c>).
|
|
/// Mirrors <c>parseSimpleAuth</c> in opts.go.
|
|
/// </summary>
|
|
public static AuthorizationConfig ParseSimpleAuth(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
var auth = new AuthorizationConfig();
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException("authorization should be a map"));
|
|
return auth;
|
|
}
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "user":
|
|
case "username":
|
|
auth.User = entry as string ?? string.Empty;
|
|
break;
|
|
case "pass":
|
|
case "password":
|
|
auth.Pass = entry as string ?? string.Empty;
|
|
break;
|
|
case "token":
|
|
auth.Token = entry as string ?? string.Empty;
|
|
break;
|
|
case "timeout":
|
|
if (TryConvertToDouble(entry, out var timeout))
|
|
auth.Timeout = timeout;
|
|
else
|
|
errors?.Add(new InvalidOperationException("error parsing authorization timeout"));
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return auth;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses string or array-of-string fields.
|
|
/// Mirrors <c>parseStringArray</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<string>? Values, Exception? Error) ParseStringArray(
|
|
string fieldName,
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
switch (NormalizeConfigValue(value))
|
|
{
|
|
case string text:
|
|
return ([text], null);
|
|
case IEnumerable<object?> array:
|
|
{
|
|
var values = new List<string>();
|
|
foreach (var entry in array)
|
|
{
|
|
if (NormalizeConfigValue(entry) is string item)
|
|
{
|
|
values.Add(item);
|
|
continue;
|
|
}
|
|
|
|
var parseError = new InvalidOperationException(
|
|
$"error parsing {fieldName}: unsupported type in array {entry?.GetType().Name ?? "null"}");
|
|
errors?.Add(parseError);
|
|
}
|
|
|
|
return (values, null);
|
|
}
|
|
default:
|
|
{
|
|
var parseError = new InvalidOperationException(
|
|
$"error parsing {fieldName}: unsupported type {value?.GetType().Name ?? "null"}");
|
|
errors?.Add(parseError);
|
|
return (null, parseError);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses websocket configuration block.
|
|
/// Mirrors <c>parseWebsocket</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseWebsocket(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
if (!TryGetMap(value, out var map))
|
|
return new InvalidOperationException($"Expected websocket to be a map, 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.Websocket.Host = host;
|
|
options.Websocket.Port = port;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(ex);
|
|
}
|
|
|
|
break;
|
|
case "port":
|
|
if (TryConvertToLong(entry, out var websocketPort))
|
|
options.Websocket.Port = checked((int)websocketPort);
|
|
break;
|
|
case "host":
|
|
case "net":
|
|
options.Websocket.Host = entry as string ?? string.Empty;
|
|
break;
|
|
case "advertise":
|
|
options.Websocket.Advertise = entry as string ?? string.Empty;
|
|
break;
|
|
case "no_tls":
|
|
if (TryConvertToBool(entry, out var noTls))
|
|
options.Websocket.NoTls = noTls;
|
|
break;
|
|
case "tls":
|
|
{
|
|
var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true);
|
|
if (parseError != null || tlsOptions == null)
|
|
{
|
|
errors?.Add(parseError ?? new InvalidOperationException("unable to parse websocket tls options"));
|
|
break;
|
|
}
|
|
|
|
var (tlsConfig, genError) = GenTLSConfig(tlsOptions);
|
|
if (genError != null)
|
|
{
|
|
errors?.Add(genError);
|
|
break;
|
|
}
|
|
|
|
options.Websocket.TlsConfig = tlsConfig;
|
|
options.Websocket.TlsMap = tlsOptions.Map;
|
|
options.Websocket.TlsPinnedCerts = tlsOptions.PinnedCerts;
|
|
options.Websocket.TlsConfigOpts = tlsOptions;
|
|
break;
|
|
}
|
|
case "same_origin":
|
|
if (TryConvertToBool(entry, out var sameOrigin))
|
|
options.Websocket.SameOrigin = sameOrigin;
|
|
break;
|
|
case "allowed_origins":
|
|
case "allowed_origin":
|
|
case "allow_origins":
|
|
case "allow_origin":
|
|
case "origins":
|
|
case "origin":
|
|
{
|
|
var (origins, parseError) = ParseStringArray("allowed origins", entry, errors);
|
|
if (parseError == null && origins != null)
|
|
options.Websocket.AllowedOrigins = origins;
|
|
break;
|
|
}
|
|
case "handshake_timeout":
|
|
options.Websocket.HandshakeTimeout = ParseDuration("handshake timeout", entry, errors, warnings);
|
|
break;
|
|
case "compress":
|
|
case "compression":
|
|
if (TryConvertToBool(entry, out var websocketCompression))
|
|
options.Websocket.Compression = websocketCompression;
|
|
break;
|
|
case "authorization":
|
|
case "authentication":
|
|
{
|
|
var auth = ParseSimpleAuth(entry, errors);
|
|
options.Websocket.Username = auth.User;
|
|
options.Websocket.Password = auth.Pass;
|
|
options.Websocket.Token = auth.Token;
|
|
options.Websocket.AuthTimeout = auth.Timeout;
|
|
break;
|
|
}
|
|
case "jwt_cookie":
|
|
options.Websocket.JwtCookie = entry as string ?? string.Empty;
|
|
break;
|
|
case "user_cookie":
|
|
options.Websocket.UsernameCookie = entry as string ?? string.Empty;
|
|
break;
|
|
case "pass_cookie":
|
|
options.Websocket.PasswordCookie = entry as string ?? string.Empty;
|
|
break;
|
|
case "token_cookie":
|
|
options.Websocket.TokenCookie = entry as string ?? string.Empty;
|
|
break;
|
|
case "no_auth_user":
|
|
options.Websocket.NoAuthUser = entry as string ?? string.Empty;
|
|
break;
|
|
case "headers":
|
|
if (!TryGetMap(entry, out var headerMap))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"error parsing headers: unsupported type {entry?.GetType().Name ?? "null"}"));
|
|
break;
|
|
}
|
|
|
|
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var (headerName, headerValueRaw) in headerMap)
|
|
{
|
|
if (NormalizeConfigValue(headerValueRaw) is string headerValue)
|
|
{
|
|
headers[headerName] = headerValue;
|
|
}
|
|
else
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"error parsing header key {headerName}: unsupported type {headerValueRaw?.GetType().Name ?? "null"}"));
|
|
}
|
|
}
|
|
|
|
options.Websocket.Headers = headers;
|
|
break;
|
|
case "ping_interval":
|
|
options.Websocket.PingInterval = ParseDuration("ping_interval", entry, errors, warnings);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses MQTT configuration block.
|
|
/// Mirrors <c>parseMQTT</c> in opts.go.
|
|
/// </summary>
|
|
public static Exception? ParseMQTT(
|
|
object? value,
|
|
ServerOptions options,
|
|
ICollection<Exception>? errors = null,
|
|
ICollection<Exception>? warnings = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
if (!TryGetMap(value, out var map))
|
|
return new InvalidOperationException($"Expected mqtt to be a map, 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.Mqtt.Host = host;
|
|
options.Mqtt.Port = port;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
errors?.Add(ex);
|
|
}
|
|
|
|
break;
|
|
case "port":
|
|
if (TryConvertToLong(entry, out var mqttPort))
|
|
options.Mqtt.Port = checked((int)mqttPort);
|
|
break;
|
|
case "host":
|
|
case "net":
|
|
options.Mqtt.Host = entry as string ?? string.Empty;
|
|
break;
|
|
case "tls":
|
|
{
|
|
var (tlsOptions, parseError) = ParseTLS(entry, isClientCtx: true);
|
|
if (parseError != null || tlsOptions == null)
|
|
{
|
|
errors?.Add(parseError ?? new InvalidOperationException("unable to parse mqtt tls options"));
|
|
break;
|
|
}
|
|
|
|
var (tlsConfig, genError) = GenTLSConfig(tlsOptions);
|
|
if (genError != null)
|
|
{
|
|
errors?.Add(genError);
|
|
break;
|
|
}
|
|
|
|
options.Mqtt.TlsConfig = tlsConfig;
|
|
options.Mqtt.TlsTimeout = tlsOptions.Timeout;
|
|
options.Mqtt.TlsMap = tlsOptions.Map;
|
|
options.Mqtt.TlsPinnedCerts = tlsOptions.PinnedCerts;
|
|
options.Mqtt.TlsConfigOpts = tlsOptions;
|
|
break;
|
|
}
|
|
case "authorization":
|
|
case "authentication":
|
|
{
|
|
var auth = ParseSimpleAuth(entry, errors);
|
|
options.Mqtt.Username = auth.User;
|
|
options.Mqtt.Password = auth.Pass;
|
|
options.Mqtt.Token = auth.Token;
|
|
options.Mqtt.AuthTimeout = auth.Timeout;
|
|
break;
|
|
}
|
|
case "no_auth_user":
|
|
options.Mqtt.NoAuthUser = entry as string ?? string.Empty;
|
|
break;
|
|
case "ack_wait":
|
|
case "ackwait":
|
|
options.Mqtt.AckWait = ParseDuration("ack_wait", entry, errors, warnings);
|
|
break;
|
|
case "js_api_timeout":
|
|
case "api_timeout":
|
|
options.Mqtt.JsApiTimeout = ParseDuration("js_api_timeout", entry, errors, warnings);
|
|
break;
|
|
case "max_ack_pending":
|
|
case "max_pending":
|
|
case "max_inflight":
|
|
if (!TryConvertToLong(entry, out var maxAckPending))
|
|
{
|
|
errors?.Add(new InvalidOperationException("invalid max_ack_pending value"));
|
|
break;
|
|
}
|
|
|
|
if (maxAckPending is < 0 or > ushort.MaxValue)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"invalid value {maxAckPending}, should be in [0..{ushort.MaxValue}] range"));
|
|
break;
|
|
}
|
|
|
|
options.Mqtt.MaxAckPending = (ushort)maxAckPending;
|
|
break;
|
|
case "js_domain":
|
|
options.Mqtt.JsDomain = entry as string ?? string.Empty;
|
|
break;
|
|
case "stream_replicas":
|
|
if (TryConvertToLong(entry, out var streamReplicas))
|
|
options.Mqtt.StreamReplicas = checked((int)streamReplicas);
|
|
break;
|
|
case "consumer_replicas":
|
|
warnings?.Add(new InvalidOperationException(
|
|
"consumer replicas setting ignored in this server version"));
|
|
break;
|
|
case "consumer_memory_storage":
|
|
if (TryConvertToBool(entry, out var consumerMemoryStorage))
|
|
options.Mqtt.ConsumerMemoryStorage = consumerMemoryStorage;
|
|
break;
|
|
case "consumer_inactive_threshold":
|
|
case "consumer_auto_cleanup":
|
|
options.Mqtt.ConsumerInactiveThreshold =
|
|
ParseDuration("consumer_inactive_threshold", entry, errors, warnings);
|
|
break;
|
|
case "reject_qos2_publish":
|
|
if (TryConvertToBool(entry, out var rejectQoS2Publish))
|
|
options.Mqtt.RejectQoS2Pub = rejectQoS2Publish;
|
|
break;
|
|
case "downgrade_qos2_subscribe":
|
|
if (TryConvertToBool(entry, out var downgradeQoS2Subscribe))
|
|
options.Mqtt.DowngradeQoS2Sub = downgradeQoS2Subscribe;
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses proxy configuration block.
|
|
/// Mirrors <c>parseProxies</c> in opts.go.
|
|
/// </summary>
|
|
public static (ProxiesConfig? Proxies, Exception? Error) ParseProxies(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
$"expected proxies to be a map/struct, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var proxies = new ProxiesConfig();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "trusted":
|
|
{
|
|
var (trusted, parseError) = ParseProxiesTrusted(entry, errors);
|
|
if (parseError != null)
|
|
{
|
|
errors?.Add(parseError);
|
|
break;
|
|
}
|
|
|
|
proxies.Trusted = trusted;
|
|
break;
|
|
}
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return (proxies, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses trusted proxy entries.
|
|
/// Mirrors <c>parseProxiesTrusted</c> in opts.go.
|
|
/// </summary>
|
|
public static (List<ProxyConfig> Trusted, Exception? Error) ParseProxiesTrusted(
|
|
object? value,
|
|
ICollection<Exception>? errors = null)
|
|
{
|
|
if (!TryGetArray(value, out var array))
|
|
{
|
|
return ([], new InvalidOperationException(
|
|
$"expected proxies' trusted field to be an array, got {value?.GetType().Name ?? "null"}"));
|
|
}
|
|
|
|
var trusted = new List<ProxyConfig>();
|
|
foreach (var entry in array)
|
|
{
|
|
if (!TryGetMap(entry, out var proxyMap))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"expected proxies' trusted entry to be a map/struct, got {entry?.GetType().Name ?? "null"}"));
|
|
continue;
|
|
}
|
|
|
|
var proxy = new ProxyConfig();
|
|
foreach (var (rawKey, rawValue) in proxyMap)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var normalized = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "key":
|
|
case "public_key":
|
|
proxy.Key = normalized as string ?? string.Empty;
|
|
if (!IsValidNatsPublicKey(proxy.Key))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"invalid proxy key \"{proxy.Key}\""));
|
|
}
|
|
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
trusted.Add(proxy);
|
|
}
|
|
|
|
return (trusted, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates runtime TLS options from parsed TLS config.
|
|
/// Mirrors <c>GenTLSConfig</c> in opts.go.
|
|
/// </summary>
|
|
public static (SslServerAuthenticationOptions? Config, Exception? Error) GenTLSConfig(TlsConfigOpts options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
var config = new SslServerAuthenticationOptions
|
|
{
|
|
EnabledSslProtocols = options.MinVersion == SslProtocols.Tls13
|
|
? SslProtocols.Tls13
|
|
: SslProtocols.Tls12 | SslProtocols.Tls13,
|
|
CertificateRevocationCheckMode = X509RevocationMode.NoCheck,
|
|
ClientCertificateRequired = options.Verify,
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(options.CertFile) && string.IsNullOrEmpty(options.KeyFile))
|
|
return (null, new InvalidOperationException("missing 'key_file' in TLS configuration"));
|
|
if (string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile))
|
|
return (null, new InvalidOperationException("missing 'cert_file' in TLS configuration"));
|
|
|
|
try
|
|
{
|
|
if (!string.IsNullOrEmpty(options.CertFile) && !string.IsNullOrEmpty(options.KeyFile))
|
|
{
|
|
var certificate = X509Certificate2.CreateFromPemFile(
|
|
ExpandPath(options.CertFile),
|
|
ExpandPath(options.KeyFile));
|
|
config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12));
|
|
}
|
|
else if (options.Certificates.Count > 0)
|
|
{
|
|
var pair = options.Certificates[0];
|
|
var certificate = X509Certificate2.CreateFromPemFile(
|
|
ExpandPath(pair.CertFile),
|
|
ExpandPath(pair.KeyFile));
|
|
config.ServerCertificate = new X509Certificate2(certificate.Export(X509ContentType.Pkcs12));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return (null, new InvalidOperationException($"error parsing X509 certificate/key pair: {ex.Message}", ex));
|
|
}
|
|
|
|
if (options.Ciphers.Count > 0)
|
|
{
|
|
try
|
|
{
|
|
config.CipherSuitesPolicy = new CipherSuitesPolicy(options.Ciphers);
|
|
}
|
|
catch (PlatformNotSupportedException)
|
|
{
|
|
// Some platforms do not allow explicit cipher suite policies.
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(options.CaFile) && !File.Exists(ExpandPath(options.CaFile)))
|
|
return (null, new FileNotFoundException("failed to parse root ca certificate", ExpandPath(options.CaFile)));
|
|
|
|
return (config, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Configures options from command-line arguments.
|
|
/// Mirrors <c>ConfigureOptions</c> in opts.go.
|
|
/// </summary>
|
|
public static (ServerOptions? Options, Exception? Error) ConfigureOptions(
|
|
IReadOnlyList<string> args,
|
|
Action? printVersion = null,
|
|
Action? printHelp = null,
|
|
Action? printTLSHelp = null)
|
|
{
|
|
var flagOptions = new ServerOptions();
|
|
var explicitBooleans = new Dictionary<string, bool>(StringComparer.Ordinal);
|
|
var nonFlags = new List<string>();
|
|
string configFile = string.Empty;
|
|
var tlsFlagsSeen = false;
|
|
var routesFlagSeen = false;
|
|
|
|
for (var i = 0; i < args.Count; i++)
|
|
{
|
|
var rawArg = args[i];
|
|
if (!rawArg.StartsWith('-'))
|
|
{
|
|
nonFlags.Add(rawArg);
|
|
continue;
|
|
}
|
|
|
|
var inlineValue = default(string);
|
|
var arg = rawArg;
|
|
var equalsIndex = rawArg.IndexOf('=');
|
|
if (equalsIndex > 0)
|
|
{
|
|
arg = rawArg[..equalsIndex];
|
|
inlineValue = rawArg[(equalsIndex + 1)..];
|
|
}
|
|
|
|
string? ReadValue()
|
|
{
|
|
if (!string.IsNullOrEmpty(inlineValue))
|
|
return inlineValue;
|
|
if (i + 1 >= args.Count)
|
|
return null;
|
|
i++;
|
|
return args[i];
|
|
}
|
|
|
|
bool ReadBoolean(bool defaultValue = true)
|
|
{
|
|
var value = ReadValue();
|
|
if (value == null || value.StartsWith('-'))
|
|
{
|
|
if (value != null)
|
|
i--;
|
|
return defaultValue;
|
|
}
|
|
|
|
return bool.TryParse(value, out var parsed) ? parsed : defaultValue;
|
|
}
|
|
|
|
switch (arg)
|
|
{
|
|
case "-h":
|
|
case "--help":
|
|
printHelp?.Invoke();
|
|
return (null, null);
|
|
case "-v":
|
|
case "--version":
|
|
printVersion?.Invoke();
|
|
return (null, null);
|
|
case "--help_tls":
|
|
printTLSHelp?.Invoke();
|
|
return (null, null);
|
|
case "-p":
|
|
case "--port":
|
|
if (!int.TryParse(ReadValue(), out var port))
|
|
return (null, new InvalidOperationException("Invalid value for port"));
|
|
flagOptions.Port = port;
|
|
break;
|
|
case "-a":
|
|
case "--addr":
|
|
case "--net":
|
|
flagOptions.Host = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--client_advertise":
|
|
flagOptions.ClientAdvertise = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "-D":
|
|
case "--debug":
|
|
flagOptions.Debug = ReadBoolean();
|
|
explicitBooleans["Debug"] = flagOptions.Debug;
|
|
break;
|
|
case "-V":
|
|
case "--trace":
|
|
flagOptions.Trace = ReadBoolean();
|
|
explicitBooleans["Trace"] = flagOptions.Trace;
|
|
break;
|
|
case "-VV":
|
|
flagOptions.Trace = ReadBoolean();
|
|
flagOptions.TraceVerbose = flagOptions.Trace;
|
|
explicitBooleans["Trace"] = flagOptions.Trace;
|
|
explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose;
|
|
break;
|
|
case "-DV":
|
|
flagOptions.Debug = ReadBoolean();
|
|
flagOptions.Trace = flagOptions.Debug;
|
|
explicitBooleans["Debug"] = flagOptions.Debug;
|
|
explicitBooleans["Trace"] = flagOptions.Trace;
|
|
break;
|
|
case "-DVV":
|
|
flagOptions.Debug = ReadBoolean();
|
|
flagOptions.Trace = flagOptions.Debug;
|
|
flagOptions.TraceVerbose = flagOptions.Debug;
|
|
explicitBooleans["Debug"] = flagOptions.Debug;
|
|
explicitBooleans["Trace"] = flagOptions.Trace;
|
|
explicitBooleans["TraceVerbose"] = flagOptions.TraceVerbose;
|
|
break;
|
|
case "-T":
|
|
case "--logtime":
|
|
flagOptions.Logtime = ReadBoolean(defaultValue: true);
|
|
explicitBooleans["Logtime"] = flagOptions.Logtime;
|
|
break;
|
|
case "--logtime_utc":
|
|
flagOptions.LogtimeUtc = ReadBoolean();
|
|
break;
|
|
case "--user":
|
|
flagOptions.Username = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--pass":
|
|
flagOptions.Password = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--auth":
|
|
flagOptions.Authorization = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "-m":
|
|
case "--http_port":
|
|
if (!int.TryParse(ReadValue(), out var httpPort))
|
|
return (null, new InvalidOperationException("Invalid value for http_port"));
|
|
flagOptions.HttpPort = httpPort;
|
|
break;
|
|
case "--ms":
|
|
case "--https_port":
|
|
if (!int.TryParse(ReadValue(), out var httpsPort))
|
|
return (null, new InvalidOperationException("Invalid value for https_port"));
|
|
flagOptions.HttpsPort = httpsPort;
|
|
break;
|
|
case "-c":
|
|
case "--config":
|
|
configFile = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "-t":
|
|
flagOptions.CheckConfig = ReadBoolean();
|
|
break;
|
|
case "-P":
|
|
case "--pid":
|
|
flagOptions.PidFile = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--ports_file_dir":
|
|
flagOptions.PortsFileDir = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "-l":
|
|
case "--log":
|
|
flagOptions.LogFile = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--log_size_limit":
|
|
if (!long.TryParse(ReadValue(), out var logSizeLimit))
|
|
return (null, new InvalidOperationException("Invalid value for log_size_limit"));
|
|
flagOptions.LogSizeLimit = logSizeLimit;
|
|
break;
|
|
case "-s":
|
|
case "--syslog":
|
|
flagOptions.Syslog = ReadBoolean();
|
|
explicitBooleans["Syslog"] = flagOptions.Syslog;
|
|
break;
|
|
case "-r":
|
|
case "--remote_syslog":
|
|
flagOptions.RemoteSyslog = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--profile":
|
|
if (!int.TryParse(ReadValue(), out var profilePort))
|
|
return (null, new InvalidOperationException("Invalid value for profile"));
|
|
flagOptions.ProfPort = profilePort;
|
|
break;
|
|
case "--routes":
|
|
flagOptions.RoutesStr = ReadValue() ?? string.Empty;
|
|
routesFlagSeen = true;
|
|
break;
|
|
case "--cluster":
|
|
case "--cluster_listen":
|
|
flagOptions.Cluster.ListenStr = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--cluster_advertise":
|
|
flagOptions.Cluster.Advertise = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--no_advertise":
|
|
flagOptions.Cluster.NoAdvertise = ReadBoolean();
|
|
explicitBooleans["Cluster.NoAdvertise"] = flagOptions.Cluster.NoAdvertise;
|
|
break;
|
|
case "--connect_retries":
|
|
if (!int.TryParse(ReadValue(), out var connectRetries))
|
|
return (null, new InvalidOperationException("Invalid value for connect_retries"));
|
|
flagOptions.Cluster.ConnectRetries = connectRetries;
|
|
break;
|
|
case "--cluster_name":
|
|
flagOptions.Cluster.Name = ReadValue() ?? string.Empty;
|
|
break;
|
|
case "--tls":
|
|
flagOptions.Tls = ReadBoolean();
|
|
tlsFlagsSeen = true;
|
|
break;
|
|
case "--tlsverify":
|
|
flagOptions.TlsVerify = ReadBoolean();
|
|
tlsFlagsSeen = true;
|
|
break;
|
|
case "--tlscert":
|
|
flagOptions.TlsCert = ReadValue() ?? string.Empty;
|
|
tlsFlagsSeen = true;
|
|
break;
|
|
case "--tlskey":
|
|
flagOptions.TlsKey = ReadValue() ?? string.Empty;
|
|
tlsFlagsSeen = true;
|
|
break;
|
|
case "--tlscacert":
|
|
flagOptions.TlsCaCert = ReadValue() ?? string.Empty;
|
|
tlsFlagsSeen = true;
|
|
break;
|
|
case "--max_traced_msg_len":
|
|
if (!int.TryParse(ReadValue(), out var maxTraceLength))
|
|
return (null, new InvalidOperationException("Invalid value for max_traced_msg_len"));
|
|
flagOptions.MaxTracedMsgLen = maxTraceLength;
|
|
break;
|
|
case "--js":
|
|
case "--jetstream":
|
|
flagOptions.JetStream = ReadBoolean();
|
|
explicitBooleans["JetStream"] = flagOptions.JetStream;
|
|
break;
|
|
case "--sd":
|
|
case "--store_dir":
|
|
flagOptions.StoreDir = ReadValue() ?? string.Empty;
|
|
break;
|
|
default:
|
|
nonFlags.Add(rawArg);
|
|
break;
|
|
}
|
|
}
|
|
|
|
var (showVersion, showHelp, parseError) = NatsServer.ProcessCommandLineArgs(nonFlags.ToArray());
|
|
if (parseError != null)
|
|
return (null, parseError);
|
|
if (showVersion)
|
|
{
|
|
printVersion?.Invoke();
|
|
return (null, null);
|
|
}
|
|
|
|
if (showHelp)
|
|
{
|
|
printHelp?.Invoke();
|
|
return (null, null);
|
|
}
|
|
|
|
FlagSnapshot = flagOptions.Clone();
|
|
foreach (var (name, value) in explicitBooleans)
|
|
TrackExplicitVal(FlagSnapshot.InCmdLine, name, value);
|
|
|
|
ServerOptions resolvedOptions;
|
|
if (!string.IsNullOrEmpty(configFile))
|
|
{
|
|
resolvedOptions = ProcessConfigFile(configFile);
|
|
resolvedOptions = MergeOptions(resolvedOptions, flagOptions);
|
|
}
|
|
else
|
|
{
|
|
resolvedOptions = flagOptions;
|
|
}
|
|
|
|
if (resolvedOptions.CheckConfig && string.IsNullOrEmpty(configFile))
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"must specify [-c, --config] option to check configuration file syntax"));
|
|
}
|
|
|
|
foreach (var (name, value) in explicitBooleans)
|
|
{
|
|
TrackExplicitVal(resolvedOptions.InCmdLine, name, value);
|
|
switch (name)
|
|
{
|
|
case "Debug":
|
|
resolvedOptions.Debug = value;
|
|
break;
|
|
case "Trace":
|
|
resolvedOptions.Trace = value;
|
|
break;
|
|
case "TraceVerbose":
|
|
resolvedOptions.TraceVerbose = value;
|
|
break;
|
|
case "Logtime":
|
|
resolvedOptions.Logtime = value;
|
|
break;
|
|
case "Syslog":
|
|
resolvedOptions.Syslog = value;
|
|
break;
|
|
case "Cluster.NoAdvertise":
|
|
resolvedOptions.Cluster.NoAdvertise = value;
|
|
break;
|
|
case "JetStream":
|
|
resolvedOptions.JetStream = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr))
|
|
{
|
|
var clusterError = resolvedOptions.OverrideCluster();
|
|
if (clusterError != null)
|
|
return (null, clusterError);
|
|
}
|
|
|
|
if (routesFlagSeen)
|
|
{
|
|
resolvedOptions.Routes = string.IsNullOrEmpty(resolvedOptions.RoutesStr)
|
|
? []
|
|
: RoutesFromStr(resolvedOptions.RoutesStr);
|
|
}
|
|
|
|
if (tlsFlagsSeen && resolvedOptions.Tls)
|
|
{
|
|
var tlsError = resolvedOptions.OverrideTls();
|
|
if (tlsError != null)
|
|
return (null, tlsError);
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(resolvedOptions.RoutesStr) &&
|
|
string.IsNullOrEmpty(resolvedOptions.Cluster.ListenStr) &&
|
|
string.IsNullOrEmpty(resolvedOptions.Cluster.Host) &&
|
|
resolvedOptions.Cluster.Port == 0)
|
|
{
|
|
return (null, new InvalidOperationException(
|
|
"solicited routes require cluster capabilities, e.g. --cluster"));
|
|
}
|
|
|
|
return (resolvedOptions, null);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Private helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private static Dictionary<string, object> 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<User>? Users { get; set; }
|
|
public string AccountName { get; set; } = string.Empty;
|
|
public bool ProxyRequired { get; set; }
|
|
}
|
|
|
|
private static Permissions? ParsePermissionsValue(object? value, ICollection<Exception>? errors)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected permissions to be a map/struct, got {value?.GetType().Name ?? "null"}"));
|
|
return null;
|
|
}
|
|
|
|
var permissions = new Permissions();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "publish":
|
|
case "import":
|
|
permissions.Publish = ParseSubjectPermissionValue(entry, errors);
|
|
break;
|
|
case "subscribe":
|
|
case "export":
|
|
permissions.Subscribe = ParseSubjectPermissionValue(entry, errors);
|
|
break;
|
|
case "responses":
|
|
case "allow_responses":
|
|
permissions.Response = ParseAllowResponsesValue(entry, errors);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
AuthHandler.ValidateResponsePermissions(permissions);
|
|
return permissions;
|
|
}
|
|
|
|
private static SubjectPermission? ParseSubjectPermissionValue(object? value, ICollection<Exception>? errors)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (TryGetMap(normalized, out var map))
|
|
{
|
|
var permission = new SubjectPermission();
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "allow":
|
|
permission.Allow = ParsePermissionSubjects(entry, errors);
|
|
break;
|
|
case "deny":
|
|
permission.Deny = ParsePermissionSubjects(entry, errors);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return permission;
|
|
}
|
|
|
|
return new SubjectPermission
|
|
{
|
|
Allow = ParsePermissionSubjects(normalized, errors),
|
|
};
|
|
}
|
|
|
|
private static List<string> ParsePermissionSubjects(object? value, ICollection<Exception>? errors)
|
|
{
|
|
if (TryGetArray(value, out var array))
|
|
{
|
|
var subjects = new List<string>(array.Count);
|
|
foreach (var entry in array)
|
|
{
|
|
var subject = NormalizeConfigValue(entry) as string;
|
|
if (string.IsNullOrWhiteSpace(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException("permission subjects must be non-empty strings"));
|
|
continue;
|
|
}
|
|
|
|
if (!SubscriptionIndex.IsValidSubject(subject))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"invalid subject \"{subject}\" in permissions"));
|
|
continue;
|
|
}
|
|
|
|
subjects.Add(subject);
|
|
}
|
|
|
|
return subjects;
|
|
}
|
|
|
|
if (NormalizeConfigValue(value) is string singleSubject)
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(singleSubject))
|
|
{
|
|
errors?.Add(new InvalidOperationException($"invalid subject \"{singleSubject}\" in permissions"));
|
|
return [];
|
|
}
|
|
|
|
return [singleSubject];
|
|
}
|
|
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected permission subject array/string, got {value?.GetType().Name ?? "null"}"));
|
|
return [];
|
|
}
|
|
|
|
private static ResponsePermission? ParseAllowResponsesValue(object? value, ICollection<Exception>? errors)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (TryConvertToBool(normalized, out var enabled))
|
|
{
|
|
return enabled
|
|
? new ResponsePermission
|
|
{
|
|
MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
|
|
Expires = ServerConstants.DefaultAllowResponseExpiration,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
if (!TryGetMap(normalized, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"Expected allow_responses to be a boolean or map, got {normalized?.GetType().Name ?? "null"}"));
|
|
return null;
|
|
}
|
|
|
|
var response = new ResponsePermission
|
|
{
|
|
MaxMsgs = ServerConstants.DefaultAllowResponseMaxMsgs,
|
|
Expires = ServerConstants.DefaultAllowResponseExpiration,
|
|
};
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "max":
|
|
case "max_msgs":
|
|
if (TryConvertToLong(entry, out var maxMessages))
|
|
response.MaxMsgs = checked((int)maxMessages);
|
|
else
|
|
errors?.Add(new InvalidOperationException("allow_responses.max should be an integer"));
|
|
break;
|
|
case "expires":
|
|
response.Expires = ParseDuration("allow_responses.expires", entry, errors, warnings: null);
|
|
break;
|
|
default:
|
|
if (!ConfigFlags.AllowUnknownTopLevelField)
|
|
errors?.Add(new InvalidOperationException($"unknown field \"{rawKey}\""));
|
|
break;
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private static bool IsLikelyPublicNkey(string value, char prefix) =>
|
|
!string.IsNullOrWhiteSpace(value) &&
|
|
value.Length >= 10 &&
|
|
value[0] == prefix;
|
|
|
|
private static bool IsValidNatsPublicKey(string value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
return false;
|
|
|
|
try
|
|
{
|
|
_ = NATS.NKeys.KeyPair.FromPublicKey(value.AsSpan());
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsLikelyInsecureCipherSuite(TlsCipherSuite cipher)
|
|
{
|
|
var name = cipher.ToString();
|
|
return name.Contains("_CBC_", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("RC4", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("3DES", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("DES", StringComparison.OrdinalIgnoreCase) ||
|
|
name.Contains("NULL", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private static WriteTimeoutPolicy ParseWriteDeadlinePolicyFallback(string value, ICollection<Exception>? errors)
|
|
{
|
|
errors?.Add(new InvalidOperationException(
|
|
$"write_timeout must be 'default', 'close' or 'retry' (received '{value}')"));
|
|
return WriteTimeoutPolicy.Default;
|
|
}
|
|
|
|
private sealed record ParsedAuthorization(
|
|
string Username,
|
|
string Password,
|
|
string Token,
|
|
double TimeoutSeconds,
|
|
bool HasUsers,
|
|
bool HasCallout);
|
|
|
|
private static ParsedAuthorization? ParseSimpleAuthorization(
|
|
object? value,
|
|
ICollection<Exception>? errors,
|
|
ICollection<Exception>? warnings)
|
|
{
|
|
if (!TryGetMap(value, out var map))
|
|
{
|
|
errors?.Add(new InvalidOperationException("authorization should be a map"));
|
|
return null;
|
|
}
|
|
|
|
string user = string.Empty;
|
|
string pass = string.Empty;
|
|
string token = string.Empty;
|
|
double timeout = 0;
|
|
var hasUsers = false;
|
|
var hasCallout = false;
|
|
|
|
foreach (var (rawKey, rawValue) in map)
|
|
{
|
|
var key = rawKey.ToLowerInvariant();
|
|
var entry = NormalizeConfigValue(rawValue);
|
|
switch (key)
|
|
{
|
|
case "user":
|
|
case "username":
|
|
user = entry as string ?? string.Empty;
|
|
break;
|
|
case "pass":
|
|
case "password":
|
|
pass = entry as string ?? string.Empty;
|
|
break;
|
|
case "token":
|
|
token = entry as string ?? string.Empty;
|
|
break;
|
|
case "timeout":
|
|
case "auth_timeout":
|
|
if (entry is string timeoutAsString)
|
|
{
|
|
timeout = ParseDuration("auth_timeout", timeoutAsString, errors, warnings).TotalSeconds;
|
|
}
|
|
else if (TryConvertToDouble(entry, out var timeoutSeconds))
|
|
{
|
|
timeout = timeoutSeconds;
|
|
}
|
|
|
|
break;
|
|
case "users":
|
|
case "nkeys":
|
|
hasUsers = true;
|
|
break;
|
|
case "callout":
|
|
case "auth_callout":
|
|
hasCallout = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return new ParsedAuthorization(user, pass, token, timeout, hasUsers, hasCallout);
|
|
}
|
|
|
|
private static bool TrySplitHostPort(string value, out string host, out int port)
|
|
{
|
|
host = string.Empty;
|
|
port = 0;
|
|
|
|
if (Uri.TryCreate($"tcp://{value}", UriKind.Absolute, out var uri))
|
|
{
|
|
host = uri.Host;
|
|
if (uri.Port >= 0)
|
|
{
|
|
port = uri.Port;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
var idx = value.LastIndexOf(':');
|
|
if (idx <= 0 || idx >= value.Length - 1)
|
|
return false;
|
|
|
|
host = value[..idx];
|
|
return int.TryParse(value[(idx + 1)..], out port);
|
|
}
|
|
|
|
private static bool TryGetFirst(
|
|
IReadOnlyDictionary<string, object?> map,
|
|
IEnumerable<string> keys,
|
|
out object? value)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (map.TryGetValue(key, out value))
|
|
{
|
|
value = NormalizeConfigValue(value);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
value = null;
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetMap(object? value, out IReadOnlyDictionary<string, object?> map)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (normalized is IReadOnlyDictionary<string, object?> readonlyMap)
|
|
{
|
|
map = readonlyMap;
|
|
return true;
|
|
}
|
|
|
|
if (normalized is Dictionary<string, object?> dict)
|
|
{
|
|
map = dict;
|
|
return true;
|
|
}
|
|
|
|
map = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
|
return false;
|
|
}
|
|
|
|
private static bool TryGetArray(object? value, out IReadOnlyList<object?> values)
|
|
{
|
|
var normalized = NormalizeConfigValue(value);
|
|
if (normalized is IReadOnlyList<object?> readonlyValues)
|
|
{
|
|
values = readonlyValues;
|
|
return true;
|
|
}
|
|
|
|
if (normalized is List<object?> listValues)
|
|
{
|
|
values = listValues;
|
|
return true;
|
|
}
|
|
|
|
values = [];
|
|
return false;
|
|
}
|
|
|
|
private static List<string> ParseStringList(object? value)
|
|
{
|
|
if (TryGetArray(value, out var items))
|
|
{
|
|
var result = new List<string>(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)
|
|
return value;
|
|
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.Object => element.EnumerateObject()
|
|
.ToDictionary(p => p.Name, p => NormalizeConfigValue(p.Value), StringComparer.OrdinalIgnoreCase),
|
|
JsonValueKind.Array => element.EnumerateArray()
|
|
.Select(item => NormalizeConfigValue(item))
|
|
.ToList(),
|
|
JsonValueKind.String => element.GetString(),
|
|
JsonValueKind.Number => element.TryGetInt64(out var l) ? l : element.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null => null,
|
|
_ => element.GetRawText(),
|
|
};
|
|
}
|
|
|
|
private static bool TryConvertToLong(object? value, out long converted)
|
|
{
|
|
switch (NormalizeConfigValue(value))
|
|
{
|
|
case long longValue:
|
|
converted = longValue;
|
|
return true;
|
|
case int intValue:
|
|
converted = intValue;
|
|
return true;
|
|
case short shortValue:
|
|
converted = shortValue;
|
|
return true;
|
|
case byte byteValue:
|
|
converted = byteValue;
|
|
return true;
|
|
case double doubleValue when doubleValue >= long.MinValue && doubleValue <= long.MaxValue:
|
|
converted = checked((long)doubleValue);
|
|
return true;
|
|
case float floatValue when floatValue >= long.MinValue && floatValue <= long.MaxValue:
|
|
converted = checked((long)floatValue);
|
|
return true;
|
|
case string s when long.TryParse(s, out var parsed):
|
|
converted = parsed;
|
|
return true;
|
|
default:
|
|
converted = 0;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryConvertToDouble(object? value, out double converted)
|
|
{
|
|
switch (NormalizeConfigValue(value))
|
|
{
|
|
case double d:
|
|
converted = d;
|
|
return true;
|
|
case float f:
|
|
converted = f;
|
|
return true;
|
|
case long l:
|
|
converted = l;
|
|
return true;
|
|
case int i:
|
|
converted = i;
|
|
return true;
|
|
case string s when double.TryParse(s, out var parsed):
|
|
converted = parsed;
|
|
return true;
|
|
default:
|
|
converted = 0;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryConvertToBool(object? value, out bool converted)
|
|
{
|
|
switch (NormalizeConfigValue(value))
|
|
{
|
|
case bool b:
|
|
converted = b;
|
|
return true;
|
|
case string s when bool.TryParse(s, out var parsed):
|
|
converted = parsed;
|
|
return true;
|
|
default:
|
|
converted = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static string ComputeConfigDigest(string configContent)
|
|
{
|
|
var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(configContent));
|
|
return Convert.ToHexString(bytes).ToLowerInvariant();
|
|
}
|
|
|
|
private static void MergeRoutes(ServerOptions opts, ServerOptions flagOpts)
|
|
{
|
|
var routeUrls = RoutesFromStr(flagOpts.RoutesStr);
|
|
if (routeUrls.Count == 0) return;
|
|
opts.Routes = routeUrls;
|
|
opts.RoutesStr = flagOpts.RoutesStr;
|
|
}
|
|
|
|
internal static void TrackExplicitVal(Dictionary<string, bool> pm, string name, bool val) =>
|
|
pm[name] = val;
|
|
|
|
private static ClusterOpts CloneClusterOpts(ClusterOpts src) => new()
|
|
{
|
|
Name = src.Name,
|
|
Host = src.Host,
|
|
Port = src.Port,
|
|
Username = src.Username,
|
|
Password = src.Password,
|
|
AuthTimeout = src.AuthTimeout,
|
|
TlsTimeout = src.TlsTimeout,
|
|
TlsConfig = src.TlsConfig,
|
|
TlsMap = src.TlsMap,
|
|
TlsCheckKnownUrls = src.TlsCheckKnownUrls,
|
|
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
|
|
TlsHandshakeFirst = src.TlsHandshakeFirst,
|
|
TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback,
|
|
ListenStr = src.ListenStr,
|
|
Advertise = src.Advertise,
|
|
NoAdvertise = src.NoAdvertise,
|
|
ConnectRetries = src.ConnectRetries,
|
|
ConnectBackoff = src.ConnectBackoff,
|
|
PoolSize = src.PoolSize,
|
|
PinnedAccounts = [.. src.PinnedAccounts],
|
|
Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] },
|
|
PingInterval = src.PingInterval,
|
|
MaxPingsOut = src.MaxPingsOut,
|
|
WriteDeadline = src.WriteDeadline,
|
|
WriteTimeout = src.WriteTimeout,
|
|
};
|
|
|
|
private static GatewayOpts CloneGatewayOpts(GatewayOpts src) => new()
|
|
{
|
|
Name = src.Name,
|
|
Host = src.Host,
|
|
Port = src.Port,
|
|
Username = src.Username,
|
|
Password = src.Password,
|
|
AuthTimeout = src.AuthTimeout,
|
|
TlsConfig = src.TlsConfig,
|
|
TlsTimeout = src.TlsTimeout,
|
|
TlsMap = src.TlsMap,
|
|
TlsCheckKnownUrls = src.TlsCheckKnownUrls,
|
|
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
|
|
Advertise = src.Advertise,
|
|
ConnectRetries = src.ConnectRetries,
|
|
ConnectBackoff = src.ConnectBackoff,
|
|
Gateways = src.Gateways.Select(g => new RemoteGatewayOpts
|
|
{
|
|
Name = g.Name,
|
|
TlsConfig = g.TlsConfig,
|
|
TlsTimeout = g.TlsTimeout,
|
|
Urls = g.Urls.Select(u => new Uri(u.ToString())).ToList(),
|
|
}).ToList(),
|
|
RejectUnknown = src.RejectUnknown,
|
|
WriteDeadline = src.WriteDeadline,
|
|
WriteTimeout = src.WriteTimeout,
|
|
};
|
|
|
|
private static LeafNodeOpts CloneLeafNodeOpts(LeafNodeOpts src) => new()
|
|
{
|
|
Host = src.Host,
|
|
Port = src.Port,
|
|
Username = src.Username,
|
|
Password = src.Password,
|
|
ProxyRequired = src.ProxyRequired,
|
|
Nkey = src.Nkey,
|
|
Account = src.Account,
|
|
AuthTimeout = src.AuthTimeout,
|
|
TlsConfig = src.TlsConfig,
|
|
TlsTimeout = src.TlsTimeout,
|
|
TlsMap = src.TlsMap,
|
|
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
|
|
TlsHandshakeFirst = src.TlsHandshakeFirst,
|
|
TlsHandshakeFirstFallback = src.TlsHandshakeFirstFallback,
|
|
Advertise = src.Advertise,
|
|
NoAdvertise = src.NoAdvertise,
|
|
ReconnectInterval = src.ReconnectInterval,
|
|
WriteDeadline = src.WriteDeadline,
|
|
WriteTimeout = src.WriteTimeout,
|
|
Compression = new CompressionOpts { Mode = src.Compression.Mode, RttThresholds = [.. src.Compression.RttThresholds] },
|
|
Remotes = src.Remotes.Select(r => new RemoteLeafOpts
|
|
{
|
|
LocalAccount = r.LocalAccount,
|
|
NoRandomize = r.NoRandomize,
|
|
Urls = r.Urls.Select(u => new Uri(u.ToString())).ToList(),
|
|
Credentials = r.Credentials,
|
|
Nkey = r.Nkey,
|
|
Tls = r.Tls,
|
|
TlsTimeout = r.TlsTimeout,
|
|
TlsHandshakeFirst = r.TlsHandshakeFirst,
|
|
Hub = r.Hub,
|
|
DenyImports = [.. r.DenyImports],
|
|
DenyExports = [.. r.DenyExports],
|
|
FirstInfoTimeout = r.FirstInfoTimeout,
|
|
Compression = new CompressionOpts { Mode = r.Compression.Mode, RttThresholds = [.. r.Compression.RttThresholds] },
|
|
JetStreamClusterMigrate = r.JetStreamClusterMigrate,
|
|
JetStreamClusterMigrateDelay = r.JetStreamClusterMigrateDelay,
|
|
LocalIsolation = r.LocalIsolation,
|
|
RequestIsolation = r.RequestIsolation,
|
|
Disabled = r.Disabled,
|
|
}).ToList(),
|
|
MinVersion = src.MinVersion,
|
|
IsolateLeafnodeInterest = src.IsolateLeafnodeInterest,
|
|
};
|
|
|
|
private static WebsocketOpts CloneWebsocketOpts(WebsocketOpts src) => new()
|
|
{
|
|
Host = src.Host,
|
|
Port = src.Port,
|
|
Advertise = src.Advertise,
|
|
NoAuthUser = src.NoAuthUser,
|
|
JwtCookie = src.JwtCookie,
|
|
UsernameCookie = src.UsernameCookie,
|
|
PasswordCookie = src.PasswordCookie,
|
|
TokenCookie = src.TokenCookie,
|
|
Username = src.Username,
|
|
Password = src.Password,
|
|
Token = src.Token,
|
|
AuthTimeout = src.AuthTimeout,
|
|
NoTls = src.NoTls,
|
|
TlsConfig = src.TlsConfig,
|
|
TlsMap = src.TlsMap,
|
|
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
|
|
SameOrigin = src.SameOrigin,
|
|
AllowedOrigins = [.. src.AllowedOrigins],
|
|
Compression = src.Compression,
|
|
HandshakeTimeout = src.HandshakeTimeout,
|
|
PingInterval = src.PingInterval,
|
|
Headers = new Dictionary<string, string>(src.Headers),
|
|
};
|
|
|
|
private static MqttOpts CloneMqttOpts(MqttOpts src) => new()
|
|
{
|
|
Host = src.Host,
|
|
Port = src.Port,
|
|
NoAuthUser = src.NoAuthUser,
|
|
Username = src.Username,
|
|
Password = src.Password,
|
|
Token = src.Token,
|
|
JsDomain = src.JsDomain,
|
|
StreamReplicas = src.StreamReplicas,
|
|
ConsumerReplicas = src.ConsumerReplicas,
|
|
ConsumerMemoryStorage = src.ConsumerMemoryStorage,
|
|
ConsumerInactiveThreshold = src.ConsumerInactiveThreshold,
|
|
AuthTimeout = src.AuthTimeout,
|
|
TlsConfig = src.TlsConfig,
|
|
TlsMap = src.TlsMap,
|
|
TlsTimeout = src.TlsTimeout,
|
|
TlsPinnedCerts = src.TlsPinnedCerts != null ? new PinnedCertSet(src.TlsPinnedCerts) : null,
|
|
AckWait = src.AckWait,
|
|
JsApiTimeout = src.JsApiTimeout,
|
|
MaxAckPending = src.MaxAckPending,
|
|
};
|
|
}
|