diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs
index 32f16b1..879416a 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/Config/ReloadOptions.cs
@@ -13,6 +13,11 @@
//
// Adapted from server/reload.go in the NATS server Go source.
+using System.Reflection;
+using System.Net.Security;
+using ZB.MOM.NatsNet.Server.Auth;
+using ZB.MOM.NatsNet.Server.Internal;
+
namespace ZB.MOM.NatsNet.Server;
// =============================================================================
@@ -74,7 +79,7 @@ public interface IReloadOption
public abstract class NoopReloadOption : IReloadOption
{
///
- public virtual void Apply(NatsServer server) { }
+ public virtual void Apply(NatsServer server) => _ = server;
///
public virtual bool IsLoggingChange() => false;
@@ -196,7 +201,7 @@ internal sealed class DebugReloadOption : LoggingReloadOption
public override void Apply(NatsServer server)
{
server.Noticef("Reloaded: debug = {0}", _newValue);
- // TODO: session 13 — call server.ReloadDebugRaftNodes(_newValue)
+ // DEFERRED: session 13 — call server.ReloadDebugRaftNodes(_newValue)
}
}
@@ -217,10 +222,10 @@ internal sealed class LogtimeReloadOption : LoggingReloadOption
/// Reload option for the logtime_utc setting.
/// Mirrors Go logtimeUTCOption struct in reload.go.
///
-internal sealed class LogtimeUtcReloadOption : LoggingReloadOption
+internal sealed class LogtimeUTCOption : LoggingReloadOption
{
private readonly bool _newValue;
- public LogtimeUtcReloadOption(bool newValue) => _newValue = newValue;
+ public LogtimeUTCOption(bool newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: logtime_utc = {0}", _newValue);
@@ -230,10 +235,10 @@ internal sealed class LogtimeUtcReloadOption : LoggingReloadOption
/// Reload option for the log_file setting.
/// Mirrors Go logfileOption struct in reload.go.
///
-internal sealed class LogFileReloadOption : LoggingReloadOption
+internal sealed class LogfileOption : LoggingReloadOption
{
private readonly string _newValue;
- public LogFileReloadOption(string newValue) => _newValue = newValue;
+ public LogfileOption(string newValue) => _newValue = newValue;
public override void Apply(NatsServer server)
=> server.Noticef("Reloaded: log_file = {0}", _newValue);
@@ -274,11 +279,11 @@ internal sealed class RemoteSyslogReloadOption : LoggingReloadOption
/// Mirrors Go tlsOption struct in reload.go.
/// The TLS config is stored as object? because the full
/// TlsConfig type is not yet ported.
-/// TODO: session 13 — replace object? with the ported TlsConfig type.
+/// DEFERRED: session 13 — replace object? with the ported TlsConfig type.
///
internal sealed class TlsReloadOption : NoopReloadOption
{
- // TODO: session 13 — replace object? with ported TlsConfig type
+ // DEFERRED: session 13 — replace object? with ported TlsConfig type
private readonly object? _newValue;
public TlsReloadOption(object? newValue) => _newValue = newValue;
@@ -288,7 +293,7 @@ internal sealed class TlsReloadOption : NoopReloadOption
{
var message = _newValue is null ? "disabled" : "enabled";
server.Noticef("Reloaded: tls = {0}", message);
- // TODO: session 13 — update server.Info.TLSRequired / TLSVerify
+ // DEFERRED: session 13 — update server.Info.TLSRequired / TLSVerify
}
}
@@ -310,11 +315,11 @@ internal sealed class TlsTimeoutReloadOption : NoopReloadOption
/// Mirrors Go tlsPinnedCertOption struct in reload.go.
/// The pinned cert set is stored as object? pending the port
/// of the PinnedCertSet type.
-/// TODO: session 13 — replace object? with ported PinnedCertSet type.
+/// DEFERRED: session 13 — replace object? with ported PinnedCertSet type.
///
internal sealed class TlsPinnedCertReloadOption : NoopReloadOption
{
- // TODO: session 13 — replace object? with ported PinnedCertSet type
+ // DEFERRED: session 13 — replace object? with ported PinnedCertSet type
private readonly object? _newValue;
public TlsPinnedCertReloadOption(object? newValue) => _newValue = newValue;
@@ -459,17 +464,17 @@ internal sealed class AccountsReloadOption : AuthReloadOption
/// Reload option for the cluster setting.
/// Stores cluster options as object? pending the port of ClusterOpts.
/// Mirrors Go clusterOption struct in reload.go.
-/// TODO: session 13 — replace object? with ported ClusterOpts type.
+/// DEFERRED: session 13 — replace object? with ported ClusterOpts type.
///
internal sealed class ClusterReloadOption : AuthReloadOption
{
- // TODO: session 13 — replace object? with ported ClusterOpts type
+ // DEFERRED: session 13 — replace object? with ported ClusterOpts type
private readonly object? _newValue;
private readonly bool _permsChanged;
- private readonly bool _poolSizeChanged;
+ private bool _poolSizeChanged;
private readonly bool _compressChanged;
- private readonly string[] _accsAdded;
- private readonly string[] _accsRemoved;
+ private string[] _accsAdded;
+ private string[] _accsRemoved;
public ClusterReloadOption(
object? newValue,
@@ -493,9 +498,34 @@ internal sealed class ClusterReloadOption : AuthReloadOption
public override bool IsClusterPoolSizeOrAccountsChange()
=> _poolSizeChanged || _accsAdded.Length > 0 || _accsRemoved.Length > 0;
+ ///
+ /// Computes pool/account deltas used by reload orchestration.
+ /// Mirrors Go clusterOption.diffPoolAndAccounts.
+ ///
+ public void DiffPoolAndAccounts(ClusterOpts oldValue)
+ {
+ ArgumentNullException.ThrowIfNull(oldValue);
+
+ if (_newValue is not ClusterOpts newValue)
+ {
+ _poolSizeChanged = false;
+ _accsAdded = [];
+ _accsRemoved = [];
+ return;
+ }
+
+ _poolSizeChanged = newValue.PoolSize != oldValue.PoolSize;
+
+ var oldAccounts = new HashSet(oldValue.PinnedAccounts, StringComparer.Ordinal);
+ var newAccounts = new HashSet(newValue.PinnedAccounts, StringComparer.Ordinal);
+
+ _accsAdded = newAccounts.Except(oldAccounts).ToArray();
+ _accsRemoved = oldAccounts.Except(newAccounts).ToArray();
+ }
+
public override void Apply(NatsServer server)
{
- // TODO: session 13 — full cluster apply logic (TLS, route info, compression)
+ // DEFERRED: session 13 — full cluster apply logic (TLS, route info, compression)
server.Noticef("Reloaded: cluster");
}
}
@@ -504,11 +534,11 @@ internal sealed class ClusterReloadOption : AuthReloadOption
/// Reload option for the cluster routes setting.
/// Routes to add/remove are stored as object[] pending the port of URL handling.
/// Mirrors Go routesOption struct in reload.go.
-/// TODO: session 13 — replace object[] with Uri[] when route types are ported.
+/// DEFERRED: session 13 — replace object[] with Uri[] when route types are ported.
///
internal sealed class RoutesReloadOption : NoopReloadOption
{
- // TODO: session 13 — replace object[] with Uri[] when route URL types are ported
+ // DEFERRED: session 13 — replace object[] with Uri[] when route URL types are ported
private readonly object[] _add;
private readonly object[] _remove;
@@ -520,7 +550,7 @@ internal sealed class RoutesReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — add/remove routes, update varzUpdateRouteURLs
+ // DEFERRED: session 13 — add/remove routes, update varzUpdateRouteURLs
server.Noticef("Reloaded: cluster routes");
}
}
@@ -540,7 +570,7 @@ internal sealed class MaxConnReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — close random connections if over limit
+ // DEFERRED: session 13 — close random connections if over limit
server.Noticef("Reloaded: max_connections = {0}", _newValue);
}
}
@@ -558,7 +588,7 @@ internal sealed class PidFileReloadOption : NoopReloadOption
{
if (string.IsNullOrEmpty(_newValue))
return;
- // TODO: session 13 — call server.LogPid()
+ // DEFERRED: session 13 — call server.LogPid()
server.Noticef("Reloaded: pid_file = {0}", _newValue);
}
}
@@ -580,7 +610,7 @@ internal sealed class PortsFileDirReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts()
+ // DEFERRED: session 13 — call server.DeletePortsFile(_oldValue) and server.LogPorts()
server.Noticef("Reloaded: ports_file_dir = {0}", _newValue);
}
}
@@ -596,7 +626,7 @@ internal sealed class MaxControlLineReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — update mcl on each connected client
+ // DEFERRED: session 13 — update mcl on each connected client
server.Noticef("Reloaded: max_control_line = {0}", _newValue);
}
}
@@ -612,7 +642,7 @@ internal sealed class MaxPayloadReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — update server info and mpay on each client
+ // DEFERRED: session 13 — update server info and mpay on each client
server.Noticef("Reloaded: max_payload = {0}", _newValue);
}
}
@@ -667,7 +697,7 @@ internal sealed class ClientAdvertiseReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — call server.SetInfoHostPort()
+ // DEFERRED: session 13 — call server.SetInfoHostPort()
server.Noticef("Reload: client_advertise = {0}", _newValue);
}
}
@@ -713,11 +743,11 @@ internal sealed class DefaultSentinelReloadOption : NoopReloadOption
/// Reload option for the OCSP setting.
/// The new value is stored as object? pending the port of OCSPConfig.
/// Mirrors Go ocspOption struct in reload.go.
-/// TODO: session 13 — replace object? with ported OcspConfig type.
+/// DEFERRED: session 13 — replace object? with ported OcspConfig type.
///
internal sealed class OcspReloadOption : TlsBaseReloadOption
{
- // TODO: session 13 — replace object? with ported OcspConfig type
+ // DEFERRED: session 13 — replace object? with ported OcspConfig type
private readonly object? _newValue;
public OcspReloadOption(object? newValue) => _newValue = newValue;
@@ -730,11 +760,11 @@ internal sealed class OcspReloadOption : TlsBaseReloadOption
/// The new value is stored as object? pending the port of
/// OCSPResponseCacheConfig.
/// Mirrors Go ocspResponseCacheOption struct in reload.go.
-/// TODO: session 13 — replace object? with ported OcspResponseCacheConfig type.
+/// DEFERRED: session 13 — replace object? with ported OcspResponseCacheConfig type.
///
internal sealed class OcspResponseCacheReloadOption : TlsBaseReloadOption
{
- // TODO: session 13 — replace object? with ported OcspResponseCacheConfig type
+ // DEFERRED: session 13 — replace object? with ported OcspResponseCacheConfig type
private readonly object? _newValue;
public OcspResponseCacheReloadOption(object? newValue) => _newValue = newValue;
@@ -779,7 +809,7 @@ internal sealed class MaxTracedMsgLenReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — update server.Opts.MaxTracedMsgLen under lock
+ // DEFERRED: session 13 — update server.Opts.MaxTracedMsgLen under lock
server.Noticef("Reloaded: max_traced_msg_len = {0}", _newValue);
}
}
@@ -812,7 +842,7 @@ internal sealed class MqttMaxAckPendingReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — call server.MqttUpdateMaxAckPending(_newValue)
+ // DEFERRED: session 13 — call server.MqttUpdateMaxAckPending(_newValue)
server.Noticef("Reloaded: MQTT max_ack_pending = {0}", _newValue);
}
}
@@ -884,7 +914,7 @@ internal sealed class ProfBlockRateReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — call server.SetBlockProfileRate(_newValue)
+ // DEFERRED: session 13 — call server.SetBlockProfileRate(_newValue)
server.Noticef("Reloaded: prof_block_rate = {0}", _newValue);
}
}
@@ -912,7 +942,7 @@ internal sealed class LeafNodeReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply()
+ // DEFERRED: session 13 — full leaf-node apply logic from Go leafNodeOption.Apply()
if (_tlsFirstChanged)
server.Noticef("Reloaded: LeafNode TLS HandshakeFirst settings");
if (_compressionChanged)
@@ -963,7 +993,7 @@ internal sealed class ProxiesReloadOption : NoopReloadOption
public override void Apply(NatsServer server)
{
- // TODO: session 13 — disconnect proxied clients for removed keys,
+ // DEFERRED: session 13 — disconnect proxied clients for removed keys,
// call server.ProcessProxiesTrustedKeys()
if (_del.Length > 0)
server.Noticef("Reloaded: proxies trusted keys {0} were removed", string.Join(", ", _del));
@@ -985,7 +1015,7 @@ internal sealed class ProxiesReloadOption : NoopReloadOption
///
internal sealed class ConfigReloader
{
- // TODO: session 13 — full reload logic
+ // DEFERRED: session 13 — full reload logic
// Mirrors Go server.Reload() / server.ReloadOptions() in server/reload.go
///
@@ -993,4 +1023,300 @@ internal sealed class ConfigReloader
/// Returns null on success; a non-null Exception describes the failure.
///
public Exception? Reload(NatsServer server) => null;
+
+ ///
+ /// Applies bool-valued config precedence for reload:
+ /// config-file explicit values first, then explicit command-line flags.
+ /// Mirrors Go applyBoolFlags.
+ ///
+ public static void ApplyBoolFlags(ServerOptions newOptions, ServerOptions flagOptions)
+ {
+ ArgumentNullException.ThrowIfNull(newOptions);
+ ArgumentNullException.ThrowIfNull(flagOptions);
+
+ foreach (var (name, value) in newOptions.InConfig)
+ SetBooleanMember(newOptions, name, value);
+
+ foreach (var (name, value) in flagOptions.InCmdLine)
+ SetBooleanMember(newOptions, name, value);
+ }
+
+ ///
+ /// Sorts order-insensitive values in place prior to deep comparisons.
+ /// Mirrors Go imposeOrder.
+ ///
+ public static Exception? ImposeOrder(object? value)
+ {
+ switch (value)
+ {
+ case List accounts:
+ accounts.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
+ break;
+ case List users:
+ users.Sort((a, b) => string.CompareOrdinal(a.Username, b.Username));
+ break;
+ case List nkeys:
+ nkeys.Sort((a, b) => string.CompareOrdinal(a.Nkey, b.Nkey));
+ break;
+ case List urls:
+ urls.Sort((a, b) => string.CompareOrdinal(a.ToString(), b.ToString()));
+ break;
+ case List strings:
+ strings.Sort(StringComparer.Ordinal);
+ break;
+ case GatewayOpts gateway:
+ gateway.Gateways.Sort((a, b) => string.CompareOrdinal(a.Name, b.Name));
+ break;
+ case WebsocketOpts websocket:
+ websocket.AllowedOrigins.Sort(StringComparer.Ordinal);
+ break;
+ case null:
+ case string:
+ case bool:
+ case byte:
+ case ushort:
+ case uint:
+ case ulong:
+ case int:
+ case long:
+ case TimeSpan:
+ case float:
+ case double:
+ case LeafNodeOpts:
+ case ClusterOpts:
+ case SslServerAuthenticationOptions:
+ case PinnedCertSet:
+ case IAccountResolver:
+ case MqttOpts:
+ case Dictionary:
+ case JsLimitOpts:
+ case StoreCipher:
+ case OcspResponseCacheConfig:
+ case ProxiesConfig:
+ case WriteTimeoutPolicy:
+ case AuthCalloutOpts:
+ case JsTpmOpts:
+ break;
+ default:
+ return new InvalidOperationException(
+ $"OnReload, sort or explicitly skip type: {value.GetType().FullName}");
+ }
+
+ return null;
+ }
+
+ ///
+ /// Returns remote gateway configs copied for diffing, with TLS runtime fields stripped.
+ /// Mirrors Go copyRemoteGWConfigsWithoutTLSConfig.
+ ///
+ public static List? CopyRemoteGWConfigsWithoutTLSConfig(List? current)
+ {
+ if (current is not { Count: > 0 })
+ return null;
+
+ var copied = new List(current.Count);
+ foreach (var config in current)
+ {
+ copied.Add(new RemoteGatewayOpts
+ {
+ Name = config.Name,
+ TlsConfig = null,
+ TlsConfigOpts = null,
+ TlsTimeout = config.TlsTimeout,
+ Urls = [.. config.Urls.Select(static u => new Uri(u.ToString(), UriKind.Absolute))],
+ });
+ }
+
+ return copied;
+ }
+
+ ///
+ /// Returns remote leaf-node configs copied for diffing, with runtime-mutated
+ /// fields stripped or normalized.
+ /// Mirrors Go copyRemoteLNConfigForReloadCompare.
+ ///
+ public static List? CopyRemoteLNConfigForReloadCompare(List? current)
+ {
+ if (current is not { Count: > 0 })
+ return null;
+
+ var copied = new List(current.Count);
+ foreach (var config in current)
+ {
+ copied.Add(new RemoteLeafOpts
+ {
+ LocalAccount = config.LocalAccount,
+ NoRandomize = config.NoRandomize,
+ Urls = [.. config.Urls.Select(static u => new Uri(u.ToString(), UriKind.Absolute))],
+ Credentials = config.Credentials,
+ Nkey = config.Nkey,
+ SignatureCb = config.SignatureCb,
+ Tls = false,
+ TlsConfig = null,
+ TlsConfigOpts = null,
+ TlsTimeout = config.TlsTimeout,
+ TlsHandshakeFirst = false,
+ Hub = config.Hub,
+ DenyImports = [],
+ DenyExports = [],
+ FirstInfoTimeout = config.FirstInfoTimeout,
+ Compression = new CompressionOpts(),
+ Websocket = new RemoteLeafWebsocketOpts
+ {
+ Compression = config.Websocket.Compression,
+ NoMasking = config.Websocket.NoMasking,
+ },
+ Proxy = new RemoteLeafProxyOpts
+ {
+ Url = config.Proxy.Url,
+ Username = config.Proxy.Username,
+ Password = config.Proxy.Password,
+ Timeout = config.Proxy.Timeout,
+ },
+ JetStreamClusterMigrate = config.JetStreamClusterMigrate,
+ JetStreamClusterMigrateDelay = config.JetStreamClusterMigrateDelay,
+ LocalIsolation = config.LocalIsolation,
+ RequestIsolation = config.RequestIsolation,
+ Disabled = false,
+ });
+ }
+
+ return copied;
+ }
+
+ ///
+ /// Validates non-reloadable cluster settings and advertise syntax.
+ /// Mirrors Go validateClusterOpts.
+ ///
+ public static Exception? ValidateClusterOpts(ClusterOpts oldValue, ClusterOpts newValue)
+ {
+ ArgumentNullException.ThrowIfNull(oldValue);
+ ArgumentNullException.ThrowIfNull(newValue);
+
+ if (!string.Equals(oldValue.Host, newValue.Host, StringComparison.Ordinal))
+ {
+ return new InvalidOperationException(
+ $"config reload not supported for cluster host: old={oldValue.Host}, new={newValue.Host}");
+ }
+
+ if (oldValue.Port != newValue.Port)
+ {
+ return new InvalidOperationException(
+ $"config reload not supported for cluster port: old={oldValue.Port}, new={newValue.Port}");
+ }
+
+ if (!string.IsNullOrEmpty(newValue.Advertise))
+ {
+ var (_, _, err) = ServerUtilities.ParseHostPort(newValue.Advertise, 0);
+ if (err != null)
+ return new InvalidOperationException(
+ $"invalid Cluster.Advertise value of {newValue.Advertise}, err={err.Message}", err);
+ }
+
+ return null;
+ }
+
+ ///
+ /// Diffs old/new route lists and returns routes to add/remove.
+ /// Mirrors Go diffRoutes.
+ ///
+ public static (List Add, List Remove) DiffRoutes(IReadOnlyList oldRoutes, IReadOnlyList newRoutes)
+ {
+ ArgumentNullException.ThrowIfNull(oldRoutes);
+ ArgumentNullException.ThrowIfNull(newRoutes);
+
+ var add = new List();
+ var remove = new List();
+
+ foreach (var oldRoute in oldRoutes)
+ {
+ if (!newRoutes.Any(newRoute => ServerUtilities.UrlsAreEqual(oldRoute, newRoute)))
+ remove.Add(oldRoute);
+ }
+
+ foreach (var newRoute in newRoutes)
+ {
+ if (!oldRoutes.Any(oldRoute => ServerUtilities.UrlsAreEqual(oldRoute, newRoute)))
+ add.Add(newRoute);
+ }
+
+ return (add, remove);
+ }
+
+ ///
+ /// Diffs proxy trusted keys and returns added/removed key sets.
+ /// Mirrors Go diffProxiesTrustedKeys.
+ ///
+ public static (List Add, List Del) DiffProxiesTrustedKeys(
+ IReadOnlyList? oldTrusted,
+ IReadOnlyList? newTrusted)
+ {
+ var oldList = oldTrusted ?? [];
+ var newList = newTrusted ?? [];
+
+ var add = new List();
+ var del = new List();
+
+ foreach (var oldProxy in oldList)
+ {
+ if (!newList.Any(np => string.Equals(np.Key, oldProxy.Key, StringComparison.Ordinal)))
+ del.Add(oldProxy.Key);
+ }
+
+ foreach (var newProxy in newList)
+ {
+ if (!oldList.Any(op => string.Equals(op.Key, newProxy.Key, StringComparison.Ordinal)))
+ add.Add(newProxy.Key);
+ }
+
+ return (add, del);
+ }
+
+ private static void SetBooleanMember(ServerOptions options, string path, bool value)
+ {
+ var segments = path.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (segments.Length == 0)
+ return;
+
+ object? target = options;
+ for (int i = 0; i < segments.Length - 1; i++)
+ {
+ if (target == null)
+ return;
+
+ var segment = segments[i];
+ var targetType = target.GetType();
+ var prop = targetType.GetProperty(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (prop != null)
+ {
+ target = prop.GetValue(target);
+ continue;
+ }
+
+ var field = targetType.GetField(segment, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (field != null)
+ {
+ target = field.GetValue(target);
+ continue;
+ }
+
+ return;
+ }
+
+ if (target == null)
+ return;
+
+ var leaf = segments[^1];
+ var leafType = target.GetType();
+ var leafProperty = leafType.GetProperty(leaf, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (leafProperty?.PropertyType == typeof(bool) && leafProperty.CanWrite)
+ {
+ leafProperty.SetValue(target, value);
+ return;
+ }
+
+ var leafField = leafType.GetField(leaf, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+ if (leafField?.FieldType == typeof(bool))
+ leafField.SetValue(target, value);
+ }
}
diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
index 776f82d..0731e2e 100644
--- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
+++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.Methods.cs
@@ -18,6 +18,7 @@ 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;
@@ -439,6 +440,368 @@ public sealed partial class ServerOptions
public static ServerOptions ProcessConfigFile(string configFile) =>
ServerOptionsConfiguration.ProcessConfigFile(configFile);
+ ///
+ /// Receiver-style config loader that updates this instance with values from
+ /// .
+ /// Mirrors Go Options.ProcessConfigFile.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// Receiver-style config loader from in-memory content.
+ /// Mirrors Go Options.ProcessConfigString.
+ ///
+ 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
+ ?? normalized as Dictionary;
+ if (configMap == null)
+ return new InvalidOperationException("configuration root must be a key/value object");
+
+ return ProcessConfigFileInternal(string.Empty, configMap);
+ }
+ catch (Exception ex)
+ {
+ return ex;
+ }
+ }
+
+ ///
+ /// Internal receiver config pipeline that processes each top-level config key.
+ /// Mirrors Go Options.processConfigFile.
+ ///
+ public Exception? ProcessConfigFileInternal(string configFile, IReadOnlyDictionary config)
+ {
+ var errors = new List();
+ var warnings = new List();
+
+ 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(
+ 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);
+ }
+
+ ///
+ /// Processes a single top-level config key.
+ /// Mirrors Go Options.processConfigFileLine.
+ ///
+ public void ProcessConfigFileLine(
+ string key,
+ object? value,
+ ICollection errors,
+ ICollection 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(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);
+ }
+ }
+
///
/// Normalizes token-like values to plain CLR values.
/// Mirrors unwrapValue intent from opts.go.
@@ -5914,6 +6277,12 @@ public sealed partial class ServerOptions
}
}
+ 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);
diff --git a/porting.db b/porting.db
index 87c0251..a069405 100644
Binary files a/porting.db and b/porting.db differ