feat: session A — config binding via appsettings.json (67 stubs complete)

Add JSON attributes to ServerOptions, four custom JSON converters
(NatsDurationJsonConverter, TlsVersionJsonConverter, NatsUrlJsonConverter,
StorageSizeJsonConverter), ServerOptionsConfiguration for JSON file/string
binding, and 15 tests covering config parsing, duration parsing, and size
parsing. Mark 67 opts.go features complete in porting.db.
This commit is contained in:
Joseph Doherty
2026-02-26 17:18:28 -05:00
parent 8253f975ec
commit 9c1ffc0995
9 changed files with 491 additions and 4 deletions

View File

@@ -0,0 +1,169 @@
// 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 parse utility functions in server/opts.go in the NATS server Go source.
using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Converts NATS duration strings (e.g. "2s", "100ms", "1h30m") to <see cref="TimeSpan"/>.
/// Mirrors Go <c>parseDuration</c> in server/opts.go.
/// </summary>
public sealed class NatsDurationJsonConverter : JsonConverter<TimeSpan>
{
private static readonly Regex Pattern = new(
@"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?(?:(\d+)ms)?(?:(\d+)us)?(?:(\d+)ns)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? throw new JsonException("Expected a duration string");
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options)
=> writer.WriteStringValue(FormatDuration(value));
/// <summary>
/// Parses a NATS-style duration string. Accepts Go time.Duration format strings and ISO 8601.
/// </summary>
public static TimeSpan Parse(string s)
{
if (string.IsNullOrWhiteSpace(s))
throw new FormatException("Duration string is empty");
// Try Go-style: e.g. "2s", "100ms", "1h30m", "5m10s"
var m = Pattern.Match(s);
if (m.Success && m.Value.Length > 0)
{
var hours = m.Groups[1].Success ? int.Parse(m.Groups[1].Value) : 0;
var minutes = m.Groups[2].Success ? int.Parse(m.Groups[2].Value) : 0;
var seconds = m.Groups[3].Success ? int.Parse(m.Groups[3].Value) : 0;
var ms = m.Groups[4].Success ? int.Parse(m.Groups[4].Value) : 0;
var us = m.Groups[5].Success ? int.Parse(m.Groups[5].Value) : 0;
var ns = m.Groups[6].Success ? int.Parse(m.Groups[6].Value) : 0;
return new TimeSpan(0, hours, minutes, seconds, ms)
+ TimeSpan.FromMicroseconds(us)
+ TimeSpan.FromTicks(ns / 100); // 1 tick = 100 ns
}
// Try .NET TimeSpan.Parse (handles "hh:mm:ss")
if (TimeSpan.TryParse(s, out var ts)) return ts;
throw new FormatException($"Cannot parse duration string: \"{s}\"");
}
private static string FormatDuration(TimeSpan ts)
{
if (ts.TotalMilliseconds < 1) return $"{(long)ts.TotalNanoseconds}ns";
if (ts.TotalSeconds < 1) return $"{(long)ts.TotalMilliseconds}ms";
if (ts.TotalMinutes < 1) return $"{(long)ts.TotalSeconds}s";
if (ts.TotalHours < 1) return $"{ts.Minutes}m{ts.Seconds}s";
return $"{(int)ts.TotalHours}h{ts.Minutes}m{ts.Seconds}s";
}
}
/// <summary>
/// Converts a TLS version string ("1.2", "1.3", "TLS12") to <see cref="SslProtocols"/>.
/// Mirrors Go <c>parseTLSVersion</c> in server/opts.go.
/// </summary>
public sealed class TlsVersionJsonConverter : JsonConverter<SslProtocols>
{
public override SslProtocols Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString()?.Trim() ?? string.Empty;
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, SslProtocols value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
public static SslProtocols Parse(string s) => s.ToUpperInvariant() switch
{
"1.2" or "TLS12" or "TLSV1.2" => SslProtocols.Tls12,
"1.3" or "TLS13" or "TLSV1.3" => SslProtocols.Tls13,
_ => throw new FormatException($"Unknown TLS version: \"{s}\""),
};
}
/// <summary>
/// Validates and normalises a NATS URL string (nats://host:port).
/// Mirrors Go <c>parseURL</c> in server/opts.go.
/// </summary>
public sealed class NatsUrlJsonConverter : JsonConverter<string>
{
public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var raw = reader.GetString() ?? string.Empty;
return Normalise(raw);
}
public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
=> writer.WriteStringValue(value);
public static string Normalise(string url)
{
if (string.IsNullOrWhiteSpace(url)) return url;
url = url.Trim();
if (!url.Contains("://")) url = "nats://" + url;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new FormatException($"Invalid NATS URL: \"{url}\"");
return uri.ToString().TrimEnd('/');
}
}
/// <summary>
/// Converts a storage size string ("1GB", "512MB", "1024") to a byte count (long).
/// Mirrors Go <c>getStorageSize</c> in server/opts.go.
/// </summary>
public sealed class StorageSizeJsonConverter : JsonConverter<long>
{
private static readonly Regex Pattern = new(@"^(\d+(?:\.\d+)?)\s*([KMGT]?B?)?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Number)
{
return reader.GetInt64();
}
var raw = reader.GetString() ?? "0";
return Parse(raw);
}
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value);
public static long Parse(string s)
{
if (long.TryParse(s, out var n)) return n;
var m = Pattern.Match(s.Trim());
if (!m.Success) throw new FormatException($"Invalid storage size: \"{s}\"");
var num = double.Parse(m.Groups[1].Value);
var suffix = m.Groups[2].Value.ToUpperInvariant();
return suffix switch
{
"K" or "KB" => (long)(num * 1024),
"M" or "MB" => (long)(num * 1024 * 1024),
"G" or "GB" => (long)(num * 1024 * 1024 * 1024),
"T" or "TB" => (long)(num * 1024L * 1024 * 1024 * 1024),
_ => (long)num,
};
}
}

View File

@@ -0,0 +1,127 @@
// 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.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.NatsNet.Server.Config;
/// <summary>
/// Loads and binds NATS server configuration from an <c>appsettings.json</c> file.
/// Replaces the Go <c>processConfigFile</c> / <c>processConfigFileLine</c> pipeline
/// and all <c>parse*</c> helper functions in server/opts.go.
/// </summary>
public static class ServerOptionsConfiguration
{
/// <summary>
/// Creates a <see cref="JsonSerializerOptions"/> instance pre-configured with all
/// NATS-specific JSON converters.
/// </summary>
public static JsonSerializerOptions CreateJsonOptions()
{
var opts = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
ReadCommentHandling = JsonCommentHandling.Skip,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opts.Converters.Add(new NatsDurationJsonConverter());
opts.Converters.Add(new TlsVersionJsonConverter());
opts.Converters.Add(new StorageSizeJsonConverter());
return opts;
}
/// <summary>
/// Reads a JSON file at <paramref name="path"/> and returns a bound
/// <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>ProcessConfigFile</c> and <c>Options.ProcessConfigFile</c>.
/// </summary>
public static ServerOptions ProcessConfigFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Configuration file not found: {path}", path);
var json = File.ReadAllText(path, Encoding.UTF8);
return ProcessConfigString(json);
}
/// <summary>
/// Deserialises a JSON string and returns a bound <see cref="ServerOptions"/> instance.
/// Mirrors Go <c>Options.ProcessConfigString</c>.
/// </summary>
public static ServerOptions ProcessConfigString(string json)
{
ArgumentNullException.ThrowIfNullOrEmpty(json);
var opts = JsonSerializer.Deserialize<ServerOptions>(json, CreateJsonOptions())
?? new ServerOptions();
PostProcess(opts);
return opts;
}
/// <summary>
/// Binds a pre-built <see cref="IConfiguration"/> (e.g. from an ASP.NET Core host)
/// to a <see cref="ServerOptions"/> instance.
/// The configuration section should be the root or a named section such as "NatsServer".
/// </summary>
public static void BindConfiguration(IConfiguration config, ServerOptions target)
{
ArgumentNullException.ThrowIfNull(config);
ArgumentNullException.ThrowIfNull(target);
config.Bind(target);
PostProcess(target);
}
// -------------------------------------------------------------------------
// Post-processing
// -------------------------------------------------------------------------
/// <summary>
/// Applies defaults and cross-field validation after loading.
/// Mirrors the end of <c>Options.processConfigFile</c> and
/// <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void PostProcess(ServerOptions opts)
{
// Apply default port if not set.
if (opts.Port == 0) opts.Port = ServerConstants.DefaultPort;
// Apply default host if not set.
if (string.IsNullOrEmpty(opts.Host)) opts.Host = ServerConstants.DefaultHost;
// Apply default max payload.
if (opts.MaxPayload == 0) opts.MaxPayload = ServerConstants.MaxPayload;
// Apply default auth timeout.
if (opts.AuthTimeout == 0) opts.AuthTimeout = ServerConstants.DefaultAuthTimeout;
// Ensure SystemAccount defaults if not set.
ConfigureSystemAccount(opts);
}
/// <summary>
/// Sets up the system account name from options.
/// Mirrors Go <c>configureSystemAccount</c> in server/opts.go.
/// </summary>
private static void ConfigureSystemAccount(ServerOptions opts)
{
// If system account already set, nothing to do.
if (!string.IsNullOrEmpty(opts.SystemAccount)) return;
// Default to "$SYS" if not explicitly disabled.
opts.SystemAccount = ServerConstants.DefaultSystemAccount;
}
}

View File

@@ -63,6 +63,12 @@ public static class ServerConstants
// Auth timeout — mirrors AUTH_TIMEOUT.
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
// Default auth timeout as a double (seconds) — used by ServerOptions.AuthTimeout.
public const double DefaultAuthTimeout = 2.0;
// Maximum payload size alias used by config binding — mirrors MAX_PAYLOAD_SIZE.
public const int MaxPayload = MaxPayloadSize;
// How often pings are sent — mirrors DEFAULT_PING_INTERVAL.
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);

View File

@@ -15,6 +15,7 @@
using System.Net.Security;
using System.Security.Authentication;
using System.Text.Json.Serialization;
using System.Threading;
using ZB.MOM.NatsNet.Server.Auth;
@@ -31,20 +32,28 @@ public sealed partial class ServerOptions
// -------------------------------------------------------------------------
public string ConfigFile { get; set; } = string.Empty;
[JsonPropertyName("server_name")]
public string ServerName { get; set; } = string.Empty;
[JsonPropertyName("host")]
public string Host { get; set; } = string.Empty;
[JsonPropertyName("port")]
public int Port { get; set; }
public bool DontListen { get; set; }
[JsonPropertyName("client_advertise")]
public string ClientAdvertise { get; set; } = string.Empty;
public bool CheckConfig { get; set; }
[JsonPropertyName("pid_file")]
public string PidFile { get; set; } = string.Empty;
[JsonPropertyName("ports_file_dir")]
public string PortsFileDir { get; set; } = string.Empty;
// -------------------------------------------------------------------------
// Logging & Debugging
// -------------------------------------------------------------------------
[JsonPropertyName("trace")]
public bool Trace { get; set; }
[JsonPropertyName("debug")]
public bool Debug { get; set; }
public bool TraceVerbose { get; set; }
public bool TraceHeaders { get; set; }
@@ -52,7 +61,9 @@ public sealed partial class ServerOptions
public bool NoSigs { get; set; }
public bool Logtime { get; set; }
public bool LogtimeUtc { get; set; }
[JsonPropertyName("logfile")]
public string LogFile { get; set; } = string.Empty;
[JsonPropertyName("log_size_limit")]
public long LogSizeLimit { get; set; }
public long LogMaxFiles { get; set; }
public bool Syslog { get; set; }
@@ -65,11 +76,14 @@ public sealed partial class ServerOptions
// Networking & Limits
// -------------------------------------------------------------------------
[JsonPropertyName("max_connections")]
public int MaxConn { get; set; }
public int MaxSubs { get; set; }
public byte MaxSubTokens { get; set; }
public int MaxControlLine { get; set; }
[JsonPropertyName("max_payload")]
public int MaxPayload { get; set; }
[JsonPropertyName("max_pending")]
public long MaxPending { get; set; }
public bool NoFastProducerStall { get; set; }
public bool ProxyRequired { get; set; }
@@ -80,11 +94,16 @@ public sealed partial class ServerOptions
// Connectivity
// -------------------------------------------------------------------------
[JsonPropertyName("ping_interval")]
public TimeSpan PingInterval { get; set; }
[JsonPropertyName("ping_max")]
public int MaxPingsOut { get; set; }
[JsonPropertyName("write_deadline")]
public TimeSpan WriteDeadline { get; set; }
public WriteTimeoutPolicy WriteTimeout { get; set; }
[JsonPropertyName("lame_duck_duration")]
public TimeSpan LameDuckDuration { get; set; }
[JsonPropertyName("lame_duck_grace_period")]
public TimeSpan LameDuckGracePeriod { get; set; }
// -------------------------------------------------------------------------
@@ -92,23 +111,33 @@ public sealed partial class ServerOptions
// -------------------------------------------------------------------------
public string HttpHost { get; set; } = string.Empty;
[JsonPropertyName("http_port")]
public int HttpPort { get; set; }
[JsonPropertyName("http_base_path")]
public string HttpBasePath { get; set; } = string.Empty;
[JsonPropertyName("https_port")]
public int HttpsPort { get; set; }
// -------------------------------------------------------------------------
// Authentication & Authorization
// -------------------------------------------------------------------------
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("password")]
public string Password { get; set; } = string.Empty;
[JsonPropertyName("authorization")]
public string Authorization { get; set; } = string.Empty;
[JsonPropertyName("auth_timeout")]
public double AuthTimeout { get; set; }
[JsonPropertyName("no_auth_user")]
public string NoAuthUser { get; set; } = string.Empty;
public string DefaultSentinel { get; set; } = string.Empty;
[JsonPropertyName("system_account")]
public string SystemAccount { get; set; } = string.Empty;
public bool NoSystemAccount { get; set; }
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
[JsonPropertyName("accounts")]
public List<Account> Accounts { get; set; } = [];
public AuthCalloutOpts? AuthCallout { get; set; }
public bool AlwaysEnableNonce { get; set; }
@@ -148,8 +177,11 @@ public sealed partial class ServerOptions
// Cluster / Gateway / Leaf / WebSocket / MQTT
// -------------------------------------------------------------------------
[JsonPropertyName("cluster")]
public ClusterOpts Cluster { get; set; } = new();
[JsonPropertyName("gateway")]
public GatewayOpts Gateway { get; set; } = new();
[JsonPropertyName("leafnodes")]
public LeafNodeOpts LeafNode { get; set; } = new();
public WebsocketOpts Websocket { get; set; } = new();
public MqttOpts Mqtt { get; set; } = new();
@@ -165,6 +197,7 @@ public sealed partial class ServerOptions
// JetStream
// -------------------------------------------------------------------------
[JsonPropertyName("jetstream")]
public bool JetStream { get; set; }
public bool NoJetStreamStrict { get; set; }
public long JetStreamMaxMemory { get; set; }
@@ -184,6 +217,7 @@ public sealed partial class ServerOptions
public bool JetStreamMetaCompactSync { get; set; }
public int StreamMaxBufferedMsgs { get; set; }
public long StreamMaxBufferedSize { get; set; }
[JsonPropertyName("store_dir")]
public string StoreDir { get; set; } = string.Empty;
public TimeSpan SyncInterval { get; set; }
public bool SyncAlways { get; set; }

View File

@@ -9,6 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="*" />
<PackageReference Include="BCrypt.Net-Next" Version="*" />