1645 lines
57 KiB
Markdown
1645 lines
57 KiB
Markdown
# 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):
|
|
|
|
```csharp
|
|
[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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```csharp
|
|
// 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`**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```csharp
|
|
// 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)**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
./reports/generate-report.sh
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
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-Next` is already in the csproj. Only `NATS.NKeys` is missing.
|
|
|
|
**Step 1: Add the package reference**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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 `}`).
|
|
|
|
```csharp
|
|
/// <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:** `TrustedKeys` field — check if it exists on `ServerOptions`. If not, skip the trustedKeys conflict check.
|
|
|
|
**Step 2: Build to verify**
|
|
|
|
```bash
|
|
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)**
|
|
|
|
```bash
|
|
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 `}`)
|
|
|
|
```csharp
|
|
/// <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:** `UserPermissionLimits` and `ClosedState` — confirm these types exist in `AuthTypes.cs`. If `ClosedState` is not defined, check `ClientTypes.cs`. Add any missing minimal types.
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```csharp
|
|
// 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` → check `ClientConnection.Kind` property
|
|
- `c.GetAuthToken()`, `c.GetNkey()`, `c.GetNkeySig()`, `c.GetNonce()` → may need adding
|
|
- `c.GetUsername()`, `c.GetPassword()` → may need adding
|
|
- `c.SetAccount(acc)`, `c.SetPermissions(p)`, `c.SetAuthError(e)` → may need adding
|
|
- `c.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:
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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)**
|
|
|
|
```bash
|
|
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`
|
|
|
|
```csharp
|
|
// 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:
|
|
|
|
```csharp
|
|
internal bool ProcessClientOrLeafCallout(ClientConnection c, ServerOptions opts)
|
|
=> AuthCallout.ProcessClientOrLeafCallout(this, c, opts);
|
|
```
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cat /Users/dohertj2/Desktop/natsnet/golang/nats-server/server/signal.go
|
|
```
|
|
|
|
**Step 2: Write NatsServer.Signals.cs**
|
|
|
|
```csharp
|
|
// 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()` and `ShutdownAsync()` exist on `NatsServer`. If `Reload()` doesn't exist, add a stub: `internal void Reload() => throw new NotImplementedException("TODO: config reload");`
|
|
|
|
**Step 3: Build to verify**
|
|
|
|
```bash
|
|
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`
|
|
|
|
```csharp
|
|
// 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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
./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**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
# 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`
|