57 KiB
Complete 93 Stub Features — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
Goal: Implement all 93 stub features across opts.go (config binding) and auth.go / jwt.go / auth_callout.go / signal.go (auth + signals).
Architecture: Two parallel sessions — Session A binds appsettings.json to ServerOptions using Microsoft.Extensions.Configuration; Session B implements the full authentication dispatch in NatsServer.Auth.cs plus JWT operator validation, auth callouts, and OS signal handling.
Tech Stack: .NET 10 / C# 13, System.Text.Json, Microsoft.Extensions.Configuration.Json, BCrypt.Net-Next (already in csproj), NATS.NKeys (to be added), System.Security.Cryptography.X509Certificates, System.Runtime.InteropServices.PosixSignalRegistration
SESSION A — Configuration Binding (67 stubs, opts.go)
Task A1: Add JSON attributes to ServerOptions.cs
Files:
- Modify:
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs
The existing ServerOptions class has C# PascalCase property names. JSON binding needs [JsonPropertyName] attributes so that "port", "cluster", "jetstream" etc. map correctly.
Step 1: Add the attribute block
In ServerOptions.cs, add using System.Text.Json.Serialization; at the top, then add [JsonPropertyName] to every property whose JSON key differs from its C# name. Key mappings (Go config key → C# property):
[JsonPropertyName("port")] public int Port { get; set; }
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
[JsonPropertyName("server_name")] public string ServerName { get; set; } = string.Empty;
[JsonPropertyName("client_advertise")]public string ClientAdvertise { get; set; } = string.Empty;
[JsonPropertyName("pid_file")] public string PidFile { get; set; } = string.Empty;
[JsonPropertyName("ports_file_dir")] public string PortsFileDir { get; set; } = string.Empty;
[JsonPropertyName("trace")] public bool Trace { get; set; }
[JsonPropertyName("debug")] public bool Debug { get; set; }
[JsonPropertyName("logfile")] public string LogFile { get; set; } = string.Empty;
[JsonPropertyName("log_size_limit")] public long LogSizeLimit { get; set; }
[JsonPropertyName("max_connections")] public int MaxConn { get; set; }
[JsonPropertyName("max_payload")] public int MaxPayload { get; set; }
[JsonPropertyName("max_pending")] public long MaxPending { get; set; }
[JsonPropertyName("ping_interval")] public TimeSpan PingInterval { get; set; }
[JsonPropertyName("ping_max")] public int MaxPingsOut { get; set; }
[JsonPropertyName("write_deadline")] public TimeSpan WriteDeadline { get; set; }
[JsonPropertyName("lame_duck_duration")] public TimeSpan LameDuckDuration { get; set; }
[JsonPropertyName("lame_duck_grace_period")] public TimeSpan LameDuckGracePeriod { get; set; }
[JsonPropertyName("http_port")] public int HttpPort { get; set; }
[JsonPropertyName("https_port")] public int HttpsPort { get; set; }
[JsonPropertyName("http_base_path")] public string HttpBasePath { get; set; } = string.Empty;
[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;
[JsonPropertyName("system_account")] public string SystemAccount { get; set; } = string.Empty;
[JsonPropertyName("accounts")] public List<Account> Accounts { get; set; } = [];
[JsonPropertyName("cluster")] public object? ClusterOpts { get; set; } // ClusterOpts - leave as object for JSON
[JsonPropertyName("gateway")] public object? GatewayOpts { get; set; } // GatewayOpts
[JsonPropertyName("leafnodes")] public object? LeafNodeOpts { get; set; } // LeafNodeOpts
[JsonPropertyName("jetstream")] public bool JetStream { get; set; }
[JsonPropertyName("store_dir")] public string StoreDir { get; set; } = string.Empty;
Note: Only add attributes for properties that need non-default name mapping. Properties whose JSON key exactly matches the C# property name (case-insensitive) don't need attributes.
Step 2: Build to verify no errors
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Expected: 0 Error(s)
Step 3: Commit
git add dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs
git commit -m "feat: add JsonPropertyName attributes to ServerOptions for appsettings.json binding"
Task A2: Create Config/NatsJsonConverters.cs
Files:
- Create:
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs
Four custom JsonConverter<T> implementations that replace the Go parse* utility functions.
Step 1: Write the file
// 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";
}
}
// Extension method to support TotalNanoseconds on older .NET targets
internal static class TimeSpanExtensions
{
internal static double TotalNanoseconds(this TimeSpan ts) => ts.Ticks * 100.0;
}
/// <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,
};
}
}
Step 2: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Expected: 0 Error(s)
Task A3: Create Config/ServerOptionsConfiguration.cs
Files:
- Create:
dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs
This is the main entry point for all 67 opts.go stubs — it replaces ProcessConfigFile, processConfigFileLine, and all parse* functions.
Step 1: Write the file
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (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;
using Microsoft.Extensions.Configuration.Json;
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 NatsUrlJsonConverter());
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();
opts.ConfigFile = string.Empty; // set by caller if needed
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.AuthTimeout;
// Apply default max control line.
if (opts.MaxControlLine == 0) opts.MaxControlLine = ServerConstants.MaxControlLineSize;
// 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 (!string.IsNullOrEmpty(opts.SystemAccount)) return;
if (!opts.NoSystemAccount)
{
opts.SystemAccount = ServerConstants.DefaultSystemAccountName;
}
}
}
Step 2: Check if ServerConstants has DefaultPort, DefaultHost, DefaultSystemAccountName
grep -n "DefaultPort\|DefaultHost\|DefaultSystemAccountName\|AuthTimeout\|MaxControlLineSize\|MaxPayload" \
/Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ServerConstants.cs 2>/dev/null | head -20
Add any missing constants to ServerConstants.cs.
Step 3: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Expected: 0 Error(s)
Task A4: Write failing tests for config binding (TDD red)
Files:
- Create:
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs
Step 1: Create the test file
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
namespace ZB.MOM.NatsNet.Server.Tests.Config;
using ZB.MOM.NatsNet.Server.Config;
using Shouldly;
using Xunit;
public class ServerOptionsConfigurationTests
{
[Fact]
public void ProcessConfigString_MinimalJson_SetsPort()
{
var opts = ServerOptionsConfiguration.ProcessConfigString("""{"port": 4222}""");
opts.Port.ShouldBe(4222);
}
[Fact]
public void ProcessConfigString_EmptyJson_AppliesDefaults()
{
var opts = ServerOptionsConfiguration.ProcessConfigString("{}");
opts.Port.ShouldBe(ServerConstants.DefaultPort);
opts.Host.ShouldBe(ServerConstants.DefaultHost);
opts.MaxPayload.ShouldBe(ServerConstants.MaxPayload);
}
[Fact]
public void ProcessConfigString_AllBasicFields_Roundtrip()
{
var json = """
{
"port": 5222,
"host": "127.0.0.1",
"server_name": "test-server",
"debug": true,
"trace": true,
"max_connections": 100,
"auth_timeout": 2.0
}
""";
var opts = ServerOptionsConfiguration.ProcessConfigString(json);
opts.Port.ShouldBe(5222);
opts.Host.ShouldBe("127.0.0.1");
opts.ServerName.ShouldBe("test-server");
opts.Debug.ShouldBeTrue();
opts.Trace.ShouldBeTrue();
opts.MaxConn.ShouldBe(100);
opts.AuthTimeout.ShouldBe(2.0);
}
[Fact]
public void ProcessConfigFile_FileNotFound_Throws()
{
Should.Throw<FileNotFoundException>(() =>
ServerOptionsConfiguration.ProcessConfigFile("/nonexistent/path.json"));
}
[Fact]
public void ProcessConfigFile_ValidFile_ReturnsOptions()
{
var tmpFile = Path.GetTempFileName();
File.WriteAllText(tmpFile, """{"port": 9090, "server_name": "from-file"}""");
try
{
var opts = ServerOptionsConfiguration.ProcessConfigFile(tmpFile);
opts.Port.ShouldBe(9090);
opts.ServerName.ShouldBe("from-file");
}
finally { File.Delete(tmpFile); }
}
}
public class NatsDurationJsonConverterTests
{
[Theory]
[InlineData("2s", 2, 0, 0)]
[InlineData("100ms", 0, 0, 100)]
[InlineData("1h30m", 1, 30, 0)]
[InlineData("5m10s", 0, 5, 10 * 1000)]
public void Parse_ValidDurationStrings_ReturnsCorrectTimeSpan(
string input, int hours, int minutes, int ms)
{
var expected = new TimeSpan(0, hours, minutes, 0, ms);
// "5m10s" needs special handling: 5min + 10sec
if (input == "5m10s") expected = TimeSpan.FromSeconds(310);
NatsDurationJsonConverter.Parse(input).ShouldBe(expected);
}
[Fact]
public void Parse_InvalidString_ThrowsFormatException()
{
Should.Throw<FormatException>(() => NatsDurationJsonConverter.Parse("notaduration"));
}
}
public class StorageSizeJsonConverterTests
{
[Theory]
[InlineData("1GB", 1L * 1024 * 1024 * 1024)]
[InlineData("512MB", 512L * 1024 * 1024)]
[InlineData("1KB", 1024L)]
[InlineData("1024", 1024L)]
public void Parse_ValidSizeStrings_ReturnsBytes(string input, long expectedBytes)
{
StorageSizeJsonConverter.Parse(input).ShouldBe(expectedBytes);
}
}
Step 2: Run to confirm they FAIL (red)
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
--filter "Config" -c Release 2>&1 | tail -10
Expected before implementation: tests fail with type-not-found or assertion errors.
Step 3: Run full test suite after implementation
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
-c Release 2>&1 | tail -5
Expected: Passed! - Failed: 0
Task A5: Update DB and commit Session A
Step 1: Mark all 67 opts.go stubs complete
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db "
UPDATE features SET status='complete' WHERE id IN (
2505,2509,2510,2511,2513,2514,2515,2516,2517,2519,
2520,2521,2522,2523,2524,2525,2526,2527,2528,2529,
2530,2531,2532,2533,2534,2535,2536,2537,2538,2539,
2540,2541,2542,2543,2544,2545,2546,2547,2548,2549,
2550,2551,2552,2553,2554,2555,2556,2557,2558,2559,
2560,2561,2562,2563,2564,2565,2566,2567,2568,2569,
2570,2571,2572,2573,2574,2580,2584
);
SELECT 'Updated: ' || changes();
"
Expected: Updated: 67
Step 2: Generate report
./reports/generate-report.sh
Step 3: Commit
git add \
dotnet/src/ZB.MOM.NatsNet.Server/ServerOptions.cs \
dotnet/src/ZB.MOM.NatsNet.Server/Config/NatsJsonConverters.cs \
dotnet/src/ZB.MOM.NatsNet.Server/Config/ServerOptionsConfiguration.cs \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Config/ServerOptionsConfigurationTests.cs \
porting.db reports/current.md
git commit -m "feat: session A — config binding via appsettings.json (67 stubs complete)"
SESSION B — Auth Implementation (26 stubs)
Task B1: Add NATS.NKeys NuGet package
Files:
- Modify:
dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
Note:
BCrypt.Net-Nextis already in the csproj. OnlyNATS.NKeysis missing.
Step 1: Add the package reference
dotnet add /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj \
package NATS.NKeys
Step 2: Verify it restores
dotnet restore /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj 2>&1 | tail -3
Expected: no errors.
Step 3: Commit
git add dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj
git commit -m "chore: add NATS.NKeys NuGet package for NKey signature verification"
Task B2: Create Auth/JwtProcessor additions (3 stubs)
Files:
- Modify:
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs
Adds ReadOperatorJwt, ReadOperatorJwtInternal, and ValidateTrustedOperators.
Step 1: Add methods to JwtProcessor.cs
Read the existing JwtProcessor.cs first to find the correct insertion point (after the existing static methods, before the closing }).
/// <summary>
/// Reads an operator JWT from a file path. Returns (claims, error).
/// Mirrors Go <c>ReadOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (AccountClaims? Claims, Exception? Error) ReadOperatorJwt(string path)
{
if (string.IsNullOrEmpty(path))
return (null, new ArgumentException("operator JWT path is empty"));
string jwtString;
try
{
jwtString = File.ReadAllText(path, System.Text.Encoding.ASCII).Trim();
}
catch (Exception ex)
{
return (null, new IOException($"error reading operator JWT file: {ex.Message}", ex));
}
return ReadOperatorJwtInternal(jwtString);
}
/// <summary>
/// Decodes an operator JWT string. Returns (claims, error).
/// Mirrors Go <c>readOperatorJWT</c> in server/jwt.go.
/// </summary>
public static (AccountClaims? Claims, Exception? Error) ReadOperatorJwtInternal(string jwtString)
{
if (string.IsNullOrEmpty(jwtString))
return (null, new ArgumentException("operator JWT string is empty"));
if (!jwtString.StartsWith(JwtPrefix, StringComparison.Ordinal))
return (null, new FormatException($"operator JWT does not start with expected prefix '{JwtPrefix}'"));
// Stub: full decoding requires a NATS JWT library integration.
// TODO: decode base64url parts, verify signature, return OperatorClaims.
var claims = AccountClaims.TryDecode(jwtString);
if (claims == null)
return (null, new FormatException("failed to decode operator JWT — full JWT parsing not yet implemented"));
return (claims, null);
}
/// <summary>
/// Validates the trusted operator JWTs in options.
/// Mirrors Go <c>validateTrustedOperators</c> in server/jwt.go.
/// </summary>
public static Exception? ValidateTrustedOperators(ServerOptions opts)
{
if (opts.TrustedOperators == null || opts.TrustedOperators.Count == 0)
return null;
if (!string.IsNullOrEmpty(opts.TrustedKeys) || opts.TrustedKeys is not null)
{
return new InvalidOperationException(
"cannot use trusted_operators with trusted_keys configuration");
}
// Each operator should be a well-formed JWT.
foreach (var op in opts.TrustedOperators)
{
var jwtStr = op?.ToString() ?? string.Empty;
var (_, err) = ReadOperatorJwtInternal(jwtStr);
if (err != null)
return new InvalidOperationException($"invalid trusted operator JWT: {err.Message}");
}
return null;
}
Note:
TrustedKeysfield — check if it exists onServerOptions. If not, skip the trustedKeys conflict check.
Step 2: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Expected: 0 Error(s)
Task B3: Create Auth/AuthHandler additions (5 stubs)
Files:
- Modify:
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs
Adds ProcessUserPermissionsTemplate, GetTlsAuthDcs, CheckClientTlsCertSubject,
ValidateProxies, GetAuthErrClosedState.
Step 1: Read auth.go to understand each function (lines 427-1700)
sed -n '427,600p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1198,1320p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1657,1697p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
Step 2: Add to AuthHandler.cs (insert before the final })
/// <summary>
/// Returns the closed-client state name for an auth error.
/// Mirrors Go <c>getAuthErrClosedState</c> in server/auth.go.
/// </summary>
public static ClosedState GetAuthErrClosedState(Exception? err)
{
if (err == null) return ClosedState.AuthenticationTimeout;
var msg = err.Message;
if (msg.Contains("expired", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthenticationExpired;
if (msg.Contains("revoked", StringComparison.OrdinalIgnoreCase)) return ClosedState.AuthRevoked;
return ClosedState.AuthenticationViolation;
}
/// <summary>
/// Validates proxy configuration entries in options.
/// Mirrors Go <c>validateProxies</c> in server/auth.go.
/// </summary>
public static Exception? ValidateProxies(ServerOptions opts)
{
// TODO: validate proxy address entries if ProxyRequired
if (opts.ProxyRequired && !opts.ProxyProtocol)
return new InvalidOperationException("proxy_required requires proxy_protocol to be enabled");
return null;
}
/// <summary>
/// Extracts the DC= attribute values from a certificate's distinguished name.
/// Mirrors Go <c>getTLSAuthDCs</c> in server/auth.go.
/// </summary>
public static string GetTlsAuthDcs(System.Security.Cryptography.X509Certificates.X509Certificate2 cert)
{
// Parse the Subject distinguished name for DC= components.
var subject = cert.Subject;
var dcs = new System.Text.StringBuilder();
foreach (var part in subject.Split(','))
{
var trimmed = part.Trim();
if (trimmed.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
{
if (dcs.Length > 0) dcs.Append('.');
dcs.Append(trimmed[3..]);
}
}
return dcs.ToString();
}
/// <summary>
/// Checks whether a client's TLS certificate subject matches using the provided matcher function.
/// Mirrors Go <c>checkClientTLSCertSubject</c> in server/auth.go.
/// </summary>
public static bool CheckClientTlsCertSubject(
System.Security.Cryptography.X509Certificates.X509Certificate2? cert,
Func<string, bool> matcher)
{
if (cert == null) return false;
var subject = cert.Subject;
return matcher(subject);
}
/// <summary>
/// Expands template variables ({{account}}, {{tag.*}}) in JWT user permission limits.
/// Mirrors Go <c>processUserPermissionsTemplate</c> in server/auth.go.
/// </summary>
public static (UserPermissionLimits Result, Exception? Error) ProcessUserPermissionsTemplate(
UserPermissionLimits lim,
string accountName,
Dictionary<string, string>? tags)
{
// Walk all subjects in Pub/Sub Allow/Deny and expand {{account}} and {{tag.*}}.
lim = DeepCopyLimits(lim);
ExpandSubjectList(lim.Permissions?.Pub?.Allow, accountName, tags);
ExpandSubjectList(lim.Permissions?.Pub?.Deny, accountName, tags);
ExpandSubjectList(lim.Permissions?.Sub?.Allow, accountName, tags);
ExpandSubjectList(lim.Permissions?.Sub?.Deny, accountName, tags);
return (lim, null);
}
private static UserPermissionLimits DeepCopyLimits(UserPermissionLimits lim)
{
// Shallow clone — permissions sub-objects are replaced during expansion.
return lim; // TODO: deep copy if mutations needed
}
private static void ExpandSubjectList(List<string>? subjects, string accountName, Dictionary<string, string>? tags)
{
if (subjects == null) return;
for (var i = 0; i < subjects.Count; i++)
{
subjects[i] = ExpandTemplate(subjects[i], accountName, tags);
}
}
private static readonly System.Text.RegularExpressions.Regex TemplateVar =
new(@"\{\{(\w+(?:\.\w+)*)\}\}", System.Text.RegularExpressions.RegexOptions.Compiled);
private static string ExpandTemplate(string subject, string accountName, Dictionary<string, string>? tags)
{
return TemplateVar.Replace(subject, m =>
{
var key = m.Groups[1].Value;
if (key.Equals("account", StringComparison.OrdinalIgnoreCase)) return accountName;
if (key.StartsWith("tag.", StringComparison.OrdinalIgnoreCase) && tags != null)
{
var tagKey = key[4..];
return tags.TryGetValue(tagKey, out var v) ? v : m.Value;
}
return m.Value;
});
}
Note:
UserPermissionLimitsandClosedState— confirm these types exist inAuthTypes.cs. IfClosedStateis not defined, checkClientTypes.cs. Add any missing minimal types.
Step 3: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Expected: 0 Error(s)
Task B4: Create NatsServer.Auth.cs (13 NatsServer auth stubs)
Files:
- Create:
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs
Step 1: Read auth.go for the 13 NatsServer methods
sed -n '196,420p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '365,405p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1149,1200p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1349,1570p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
sed -n '1657,1697p' /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth.go
Step 2: Write NatsServer.Auth.cs with all NatsServer auth methods as a partial class:
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/auth.go in the NATS server Go source.
using System.Security.Cryptography.X509Certificates;
using NATS.NKeys;
using ZB.MOM.NatsNet.Server.Auth;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// Authentication logic for <see cref="NatsServer"/>.
/// Mirrors Go auth.go Server methods.
/// </summary>
internal sealed partial class NatsServer
{
// ---- Server-side auth lookup tables (populated by ConfigureAuthorization) ----
private Dictionary<string, NkeyUser>? _nkeys;
private Dictionary<string, User>? _users;
/// <summary>
/// Wires up auth lookup tables from options.
/// Called during server start and on config reload.
/// Mirrors Go <c>configureAuthorization</c>.
/// Lock must be held on entry.
/// </summary>
internal void ConfigureAuthorization()
{
var opts = GetOpts();
if (opts == null) return;
if (opts.CustomClientAuthentication != null)
{
_info.AuthRequired = true;
}
else if (_trustedKeys != null)
{
_info.AuthRequired = true;
}
else if (opts.Nkeys != null || opts.Users != null)
{
(_nkeys, _users) = BuildNkeysAndUsersFromOptions(opts.Nkeys, opts.Nkeys != null ? null : opts.Users, opts.Users);
_info.AuthRequired = true;
}
else if (!string.IsNullOrEmpty(opts.Username) || !string.IsNullOrEmpty(opts.Authorization))
{
_info.AuthRequired = true;
}
else
{
_users = null;
_nkeys = null;
_info.AuthRequired = false;
}
// Auth callout setup.
if (opts.AuthCallout != null)
{
if (string.IsNullOrEmpty(opts.AuthCallout.Account))
Errorf("Authorization callout account not set");
}
}
private (Dictionary<string, NkeyUser>? nkeys, Dictionary<string, User>? users) BuildNkeysAndUsersFromOptions(
List<NkeyUser>? nko, List<NkeyUser>? _, List<User>? uo)
{
Dictionary<string, NkeyUser>? nkeys = null;
Dictionary<string, User>? users = null;
if (nko != null)
{
nkeys = new Dictionary<string, NkeyUser>(nko.Count, StringComparer.Ordinal);
foreach (var u in nko)
{
var copy = u.Clone() ?? u;
if (copy.Permissions != null)
AuthHandler.ValidateResponsePermissions(copy.Permissions);
nkeys[u.Nkey] = copy;
}
}
if (uo != null)
{
users = new Dictionary<string, User>(uo.Count, StringComparer.Ordinal);
foreach (var u in uo)
{
var copy = u.Clone() ?? u;
if (copy.Permissions != null)
AuthHandler.ValidateResponsePermissions(copy.Permissions);
users[u.Username] = copy;
}
}
AssignGlobalAccountToOrphanUsers(nkeys, users);
return (nkeys, users);
}
internal void AssignGlobalAccountToOrphanUsers(
Dictionary<string, NkeyUser>? nkeys,
Dictionary<string, User>? users)
{
if (nkeys != null)
{
foreach (var u in nkeys.Values)
{
u.Account ??= _globalAccount;
}
}
if (users != null)
{
foreach (var u in users.Values)
{
u.Account ??= _globalAccount;
}
}
}
/// <summary>
/// Entry-point auth check — dispatches by client kind.
/// Mirrors Go <c>checkAuthentication</c>.
/// </summary>
internal bool CheckAuthentication(ClientConnection c)
{
return c.Kind switch
{
ClientKind.Client => IsClientAuthorized(c),
ClientKind.Router => IsRouterAuthorized(c),
ClientKind.Gateway => IsGatewayAuthorized(c),
ClientKind.Leaf => IsLeafNodeAuthorized(c),
_ => false,
};
}
/// <summary>
/// Checks authorization for a standard client connection.
/// Mirrors Go <c>isClientAuthorized</c>.
/// </summary>
internal bool IsClientAuthorized(ClientConnection c)
{
return ProcessClientOrLeafAuthentication(c, GetOpts());
}
/// <summary>
/// Full authentication dispatch — handles all auth paths.
/// Mirrors Go <c>processClientOrLeafAuthentication</c> (554 LOC).
/// </summary>
internal bool ProcessClientOrLeafAuthentication(ClientConnection c, ServerOptions? opts)
{
if (opts == null) return false;
// --- Auth callout check ---
if (opts.AuthCallout != null)
return ProcessClientOrLeafCallout(c, opts);
// --- Proxy check ---
var (trustedProxy, proxyOk) = ProxyCheck(c, opts);
if (trustedProxy && !proxyOk)
{
c.SetAuthError(new InvalidOperationException("proxy not trusted"));
return false;
}
// --- Trusted operators / JWT bearer ---
if (_trustedKeys != null)
{
var token = c.GetAuthToken();
if (string.IsNullOrEmpty(token))
{
c.SetAuthError(new InvalidOperationException("missing JWT token for trusted operator"));
return false;
}
// TODO: full JWT validation against trusted operators (requires JWT library integration).
return true;
}
// --- NKey ---
if (_nkeys != null && _nkeys.Count > 0)
{
var nkeyPub = c.GetNkey();
if (!string.IsNullOrEmpty(nkeyPub) && _nkeys.TryGetValue(nkeyPub, out var nkeyUser))
{
// Verify nonce signature.
var sig = c.GetNkeySig();
var nonce = c.GetNonce();
if (!string.IsNullOrEmpty(sig) && !string.IsNullOrEmpty(nonce))
{
try
{
var kp = KeyPair.FromPublicKey(nkeyPub.AsSpan());
var verified = kp.Verify(
System.Text.Encoding.ASCII.GetBytes(nonce),
Convert.FromBase64String(sig));
if (!verified)
{
c.SetAuthError(new InvalidOperationException("NKey signature verification failed"));
return false;
}
}
catch (Exception ex)
{
c.SetAuthError(ex);
return false;
}
}
c.SetAccount(nkeyUser.Account);
c.SetPermissions(nkeyUser.Permissions);
return true;
}
}
// --- Username/Password ---
if (_users != null && _users.Count > 0)
{
var username = c.GetUsername();
if (_users.TryGetValue(username, out var user))
{
var pw = c.GetPassword();
if (!AuthHandler.ComparePasswords(user.Password, pw))
{
c.SetAuthError(new InvalidOperationException("invalid password"));
return false;
}
c.SetAccount(user.Account);
c.SetPermissions(user.Permissions);
return true;
}
}
// --- Single global username/password (from opts) ---
if (!string.IsNullOrEmpty(opts.Username))
{
if (c.GetUsername() != opts.Username ||
!AuthHandler.ComparePasswords(opts.Password, c.GetPassword()))
{
c.SetAuthError(new InvalidOperationException("invalid credentials"));
return false;
}
return true;
}
// --- Token (authorization) ---
if (!string.IsNullOrEmpty(opts.Authorization))
{
if (!AuthHandler.ComparePasswords(opts.Authorization, c.GetAuthToken()))
{
c.SetAuthError(new InvalidOperationException("bad authorization token"));
return false;
}
return true;
}
// --- TLS cert mapping ---
if (opts.TLSMap)
{
var cert = c.GetTlsCertificate();
var ok = AuthHandler.CheckClientTlsCertSubject(cert, _ => true);
if (!ok)
{
c.SetAuthError(new InvalidOperationException("TLS cert mapping failed"));
return false;
}
return true;
}
// --- No auth required ---
if (!_info.AuthRequired) return true;
c.SetAuthError(new InvalidOperationException("no credentials provided"));
return false;
}
/// <summary>Mirrors Go <c>isRouterAuthorized</c>.</summary>
internal bool IsRouterAuthorized(ClientConnection c)
{
var opts = GetOpts();
if (opts?.Cluster == null) return true;
// TODO: full route auth when ClusterOpts is fully typed.
return true;
}
/// <summary>Mirrors Go <c>isGatewayAuthorized</c>.</summary>
internal bool IsGatewayAuthorized(ClientConnection c)
{
var opts = GetOpts();
if (opts?.Gateway == null) return true;
return true;
}
/// <summary>Mirrors Go <c>registerLeafWithAccount</c>.</summary>
internal bool RegisterLeafWithAccount(ClientConnection c, string accountName)
{
var acc = LookupAccount(accountName);
if (acc == null) return false;
c.SetAccount(acc);
return true;
}
/// <summary>Mirrors Go <c>isLeafNodeAuthorized</c>.</summary>
internal bool IsLeafNodeAuthorized(ClientConnection c)
{
return ProcessClientOrLeafAuthentication(c, GetOpts());
}
/// <summary>Mirrors Go <c>checkAuthforWarnings</c>.</summary>
internal void CheckAuthforWarnings()
{
var opts = GetOpts();
if (opts == null) return;
if (opts.Users != null && !string.IsNullOrEmpty(opts.Username))
Warnf("Having a global password along with users/nkeys is not recommended");
}
/// <summary>Mirrors Go <c>proxyCheck</c>.</summary>
internal (bool TrustedProxy, bool Ok) ProxyCheck(ClientConnection c, ServerOptions opts)
{
if (!opts.ProxyProtocol) return (false, false);
// TODO: check c's remote IP against configured trusted proxy addresses.
return (true, true);
}
/// <summary>Mirrors Go <c>processProxiesTrustedKeys</c>.</summary>
internal void ProcessProxiesTrustedKeys()
{
// TODO: parse proxy trusted key strings into _proxyTrustedKeys set.
}
}
Step 3: Check required fields on NatsServer / ClientConnection
The code above calls:
c.Kind→ checkClientConnection.Kindpropertyc.GetAuthToken(),c.GetNkey(),c.GetNkeySig(),c.GetNonce()→ may need addingc.GetUsername(),c.GetPassword()→ may need addingc.SetAccount(acc),c.SetPermissions(p),c.SetAuthError(e)→ may need addingc.GetTlsCertificate()→ may need adding_trustedKeys,_globalAccount,_info→ already in NatsServer.cs
If any of those methods/properties don't exist on ClientConnection, add stub implementations:
// In ClientConnection.cs or ClientTypes.cs, add:
public string GetAuthToken() => _connectMsg?.AuthToken ?? string.Empty;
public string GetNkey() => _connectMsg?.Nkey ?? string.Empty;
public string GetNkeySig() => _connectMsg?.Sig ?? string.Empty;
public string GetNonce() => _nonce ?? string.Empty;
public string GetUsername() => _connectMsg?.User ?? string.Empty;
public string GetPassword() => _connectMsg?.Pass ?? string.Empty;
public X509Certificate2? GetTlsCertificate() => _tlsCert;
public void SetAuthError(Exception? err) => _authErr = err;
public void SetAccount(Account? acc) { /* wire to _acc */ }
public void SetPermissions(Permissions? p) { /* wire to _perms */ }
Step 4: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -5
Expected: 0 Error(s) (some warnings about unused fields are OK)
Task B5: Create Auth/AuthCallout.cs (3 stubs)
Files:
- Create:
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs
Step 1: Read auth_callout.go (lines 36-500)
cat /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/auth_callout.go
Step 2: Write Auth/AuthCallout.cs as a partial class extension of NatsServer
// Copyright 2022-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/auth_callout.go in the NATS server Go source.
namespace ZB.MOM.NatsNet.Server.Auth;
/// <summary>
/// External auth callout support.
/// Methods are added to <see cref="NatsServer"/> as a partial extension.
/// Mirrors Go <c>auth_callout.go</c>.
/// </summary>
internal static class AuthCallout
{
/// <summary>
/// Publishes an auth request to the configured callout account and awaits
/// a signed JWT response that authorises or rejects the connecting client.
/// Mirrors Go <c>processClientOrLeafCallout</c> in auth_callout.go.
/// </summary>
public static bool ProcessClientOrLeafCallout(NatsServer server, ClientConnection c, ServerOptions opts)
{
// TODO: full implementation requires internal NATS connection + async request/reply.
// For now, fall back to standard auth if no callout response.
throw new NotImplementedException("TODO: auth callout — requires internal NATS pub/sub (session B)");
}
/// <summary>
/// Populates an authorization request payload with client info.
/// Mirrors Go <c>client.fillClientInfo</c> in auth_callout.go.
/// </summary>
public static void FillClientInfo(
AuthorizationRequest req,
ClientConnection c)
{
req.ClientInfo = new AuthorizationClientInfo
{
Host = c.RemoteAddress,
Id = c.Cid,
Kind = c.Kind.ToString().ToLowerInvariant(),
Type = "client",
};
}
/// <summary>
/// Populates an authorization request payload with connect options.
/// Mirrors Go <c>client.fillConnectOpts</c> in auth_callout.go.
/// </summary>
public static void FillConnectOpts(
AuthorizationRequest req,
ClientConnection c)
{
req.ConnectOptions = new AuthorizationConnectOpts
{
Username = c.GetUsername(),
Password = c.GetPassword(),
AuthToken = c.GetAuthToken(),
Nkey = c.GetNkey(),
};
}
}
/// <summary>Authorization request sent to auth callout service.</summary>
public sealed class AuthorizationRequest
{
public string ServerId { get; set; } = string.Empty;
public string UserNkey { get; set; } = string.Empty;
public string ClientInfo { get; set; } = string.Empty; // JSON
public AuthorizationClientInfo? ClientInfoObj { get; set; }
public AuthorizationConnectOpts? ConnectOptions { get; set; }
}
public sealed class AuthorizationClientInfo
{
public string Host { get; set; } = string.Empty;
public ulong Id { get; set; }
public string Kind { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
}
public sealed class AuthorizationConnectOpts
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string AuthToken { get; set; } = string.Empty;
public string Nkey { get; set; } = string.Empty;
}
Also update NatsServer.Auth.cs to use the static helper:
internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
=> AuthCallout.ProcessClientOrLeafCallout(this, c, opts);
Step 3: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Task B6: Create NatsServer.Signals.cs (1 stub)
Files:
- Create:
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs
Step 1: Read signal.go for full context
cat /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/signal.go
Step 2: Write NatsServer.Signals.cs
// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0
// Adapted from server/signal.go in the NATS server Go source.
using System.Runtime.InteropServices;
namespace ZB.MOM.NatsNet.Server;
/// <summary>
/// OS signal handling for <see cref="NatsServer"/>.
/// Mirrors Go <c>signal.go</c> (Unix build).
/// </summary>
internal sealed partial class NatsServer
{
private PosixSignalRegistration? _sigHup;
private PosixSignalRegistration? _sigTerm;
private PosixSignalRegistration? _sigInt;
/// <summary>
/// Registers OS signal handlers (SIGHUP, SIGTERM, SIGINT).
/// On Windows, falls back to <see cref="Console.CancelKeyPress"/>.
/// Mirrors Go <c>Server.handleSignals</c>.
/// </summary>
internal void HandleSignals()
{
if (GetOpts()?.NoSigs == true) return;
if (OperatingSystem.IsWindows())
{
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
Noticef("Caught interrupt signal, shutting down...");
_ = ShutdownAsync();
};
return;
}
// SIGHUP — reload configuration
_sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGHUP signal, reloading configuration...");
try { Reload(); }
catch (Exception ex) { Errorf("Config reload failed: {0}", ex.Message); }
});
// SIGTERM — graceful shutdown
_sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGTERM signal, shutting down...");
_ = ShutdownAsync();
});
// SIGINT — interrupt (Ctrl+C)
_sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx =>
{
ctx.Cancel = true;
Noticef("Trapped SIGINT signal, shutting down...");
_ = ShutdownAsync();
});
}
private void DisposeSignalHandlers()
{
_sigHup?.Dispose();
_sigTerm?.Dispose();
_sigInt?.Dispose();
}
}
Note: Check if
Reload()andShutdownAsync()exist onNatsServer. IfReload()doesn't exist, add a stub:internal void Reload() => throw new NotImplementedException("TODO: config reload");
Step 3: Build to verify
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
Task B7: Write auth unit tests (TDD)
Files:
- Create:
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs
// Copyright 2012-2025 The NATS Authors
namespace ZB.MOM.NatsNet.Server.Tests.Auth;
using ZB.MOM.NatsNet.Server.Auth;
using Shouldly;
using Xunit;
public class AuthHandlerExtendedTests
{
[Fact]
public void GetTlsAuthDcs_CertWithDCComponents_ExtractsDCs()
{
// Create a minimal self-signed cert for testing.
// Using a static subject string for simplicity.
var subject = "CN=server,DC=example,DC=com";
// We can't easily create an X509Certificate2 in unit tests without a real cert,
// so test the parsing logic directly.
var dc = ParseDcFromSubject(subject);
dc.ShouldBe("example.com");
}
[Fact]
public void ValidateProxies_ProxyRequiredWithoutProtocol_ReturnsError()
{
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = false };
var err = AuthHandler.ValidateProxies(opts);
err.ShouldNotBeNull();
err!.Message.ShouldContain("proxy_required");
}
[Fact]
public void ValidateProxies_ProxyRequiredWithProtocol_ReturnsNull()
{
var opts = new ServerOptions { ProxyRequired = true, ProxyProtocol = true };
var err = AuthHandler.ValidateProxies(opts);
err.ShouldBeNull();
}
[Fact]
public void GetAuthErrClosedState_ExpiredMessage_ReturnsExpiredState()
{
var err = new InvalidOperationException("token is expired");
AuthHandler.GetAuthErrClosedState(err).ShouldBe(ClosedState.AuthenticationExpired);
}
[Fact]
public void GetAuthErrClosedState_NullError_ReturnsTimeout()
{
AuthHandler.GetAuthErrClosedState(null).ShouldBe(ClosedState.AuthenticationTimeout);
}
private static string ParseDcFromSubject(string subject)
{
var sb = new System.Text.StringBuilder();
foreach (var part in subject.Split(','))
{
var t = part.Trim();
if (t.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
{
if (sb.Length > 0) sb.Append('.');
sb.Append(t[3..]);
}
}
return sb.ToString();
}
}
public class JwtProcessorOperatorTests
{
[Fact]
public void ReadOperatorJwtInternal_EmptyString_ReturnsError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal(string.Empty);
claims.ShouldBeNull();
err.ShouldNotBeNull();
}
[Fact]
public void ReadOperatorJwtInternal_InvalidPrefix_ReturnsFormatError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwtInternal("NOTAJWT.payload.sig");
claims.ShouldBeNull();
err.ShouldBeOfType<FormatException>();
}
[Fact]
public void ReadOperatorJwt_FileNotFound_ReturnsError()
{
var (claims, err) = JwtProcessor.ReadOperatorJwt("/nonexistent/operator.jwt");
claims.ShouldBeNull();
err.ShouldBeOfType<IOException>();
}
[Fact]
public void ValidateTrustedOperators_EmptyList_ReturnsNull()
{
var opts = new ServerOptions();
JwtProcessor.ValidateTrustedOperators(opts).ShouldBeNull();
}
}
Step 1: Run tests to verify they pass
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
--filter "AuthImplementation|JwtProcessorOperator" -c Release 2>&1 | tail -10
Expected: Passed! - Failed: 0
Step 2: Run full suite
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj \
-c Release 2>&1 | tail -5
Expected: Passed! - Failed: 0
Task B8: Update DB and commit Session B
Step 1: Mark all 26 auth/jwt/callout/signal stubs complete
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db "
UPDATE features SET status='complete' WHERE id IN (
354,355,357,358,359,360,361,362,363,364,
365,366,369,370,371,372,378,379,380,381,
382,383,1973,1974,1976,3156
);
SELECT 'Updated: ' || changes();
"
Expected: Updated: 26
Step 2: Generate report and verify all stubs gone
./reports/generate-report.sh
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db \
"SELECT status, count(*) FROM features WHERE status IN ('stub','not_started') GROUP BY status;"
Expected: no rows (all stubs resolved).
Step 3: Commit
git add \
dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj \
dotnet/src/ZB.MOM.NatsNet.Server/Auth/JwtProcessor.cs \
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthHandler.cs \
dotnet/src/ZB.MOM.NatsNet.Server/Auth/AuthCallout.cs \
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Auth.cs \
dotnet/src/ZB.MOM.NatsNet.Server/NatsServer.Signals.cs \
dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Auth/AuthImplementationTests.cs \
porting.db reports/current.md
git commit -m "feat: session B — auth implementation + signals (26 stubs complete)"
Completion Verification
After both sessions merge:
# 1. Full build
dotnet build /Users/dohertj2/Desktop/natsnet/dotnet/src/ZB.MOM.NatsNet.Server/ZB.MOM.NatsNet.Server.csproj -c Release 2>&1 | tail -3
# 2. Full test suite
dotnet test /Users/dohertj2/Desktop/natsnet/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ZB.MOM.NatsNet.Server.Tests.csproj -c Release 2>&1 | tail -5
# 3. Confirm zero stubs remain
sqlite3 /Users/dohertj2/Desktop/natsnet/porting.db \
"SELECT count(*) as remaining_stubs FROM features WHERE status='stub';"
Expected: 0 Error(s), Passed! - Failed: 0, remaining_stubs = 0