From 11b387e442e50039756ea389b50575b28cfa91c5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 26 Feb 2026 13:50:38 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20port=20session=2008=20=E2=80=94=20Clien?= =?UTF-8?q?t=20Connection=20&=20PROXY=20Protocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClientConnection: full connection lifecycle, string/identity helpers, SplitSubjectQueue, KindString, MsgParts, SetHeader, message header manipulation (GenHeader, RemoveHeader, SliceHeader, GetHeader) - ClientTypes: ClientConnectionType, ClientProtocol, ClientFlags, ReadCacheFlags, ClosedState, PmrFlags, DenyType, ClientOptions, ClientInfo, NbPool, RouteTarget, ClientKindHelpers - NatsMessageHeaders: complete header utility class (GenHeader, RemoveHeaderIfPrefixPresent, RemoveHeaderIfPresent, SliceHeader, GetHeader, SetHeader, GetHeaderKeyIndex) - ProxyProtocol: PROXY protocol v1/v2 parser (ReadV1Header, ParseV2Header, ReadProxyProtoHeader sync entry point) - ServerErrors: add ErrAuthorization sentinel - Tests: 32 standalone unit tests (proxy protocol: IDs 159-168, 171-178, 180-181; client: IDs 200-201, 247-256) - DB: 195 features → complete (387-581); 32 tests → complete; 81 server-dependent tests → n/a Features: 667 complete, 274 unit tests complete (17.2% overall) --- .../ZB.MOM.NatsNet.Server/ClientConnection.cs | 1211 +++++++++++++++++ .../src/ZB.MOM.NatsNet.Server/ClientTypes.cs | 375 +++++ .../NatsMessageHeaders.cs | 389 ++++++ .../Protocol/ProxyProtocol.cs | 604 ++++++++ .../src/ZB.MOM.NatsNet.Server/ServerErrors.cs | 4 + .../ClientTests.cs | 320 +++++ .../Protocol/ProxyProtocolTests.cs | 430 ++++++ porting.db | Bin 2469888 -> 2473984 bytes reports/current.md | 14 +- reports/report_88b1391.md | 39 + 10 files changed, 3379 insertions(+), 7 deletions(-) create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs create mode 100644 dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs create mode 100644 dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Protocol/ProxyProtocolTests.cs create mode 100644 reports/report_88b1391.md diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs new file mode 100644 index 0000000..dfa891b --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientConnection.cs @@ -0,0 +1,1211 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client.go in the NATS server Go source. + +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using Microsoft.Extensions.Logging; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; +using ZB.MOM.NatsNet.Server.Internal.DataStructures; +using ZB.MOM.NatsNet.Server.Protocol; + +namespace ZB.MOM.NatsNet.Server; + +// Wire protocol constants (also in ServerConstants; kept here for local use). +file static class Wires +{ + internal const string PingProto = "PING\r\n"; + internal const string PongProto = "PONG\r\n"; + internal const string ErrProto = "-ERR '{0}'\r\n"; + internal const string OkProto = "+OK\r\n"; + internal const string MsgHead = "RMSG "; + internal const int MsgScratch = 1024; + + // Buffer size tuning. + internal const int StartBufSize = 512; + internal const int MinBufSize = 64; + internal const int MaxBufSize = 65536; + internal const int ShortsToShrink = 2; + internal const int MaxFlushPending = 10; + internal const int MaxVectorSize = 1024; // IOV_MAX + + internal static readonly TimeSpan ReadLoopReport = TimeSpan.FromSeconds(2); + internal static readonly TimeSpan MaxNoRttPingBeforePong = TimeSpan.FromSeconds(2); + internal static readonly TimeSpan StallMin = TimeSpan.FromMilliseconds(2); + internal static readonly TimeSpan StallMax = TimeSpan.FromMilliseconds(5); + internal static readonly TimeSpan StallTotal = TimeSpan.FromMilliseconds(10); + + // Cache / pruning limits. + internal const int MaxResultCacheSize = 512; + internal const int MaxDenyPermCacheSize = 256; + internal const int MaxPermCacheSize = 128; + internal const int PruneSize = 32; + internal const int RouteTargetInit = 8; + internal const int ReplyPermLimit = 4096; + internal static readonly TimeSpan ReplyPruneTime = TimeSpan.FromSeconds(1); + + // Per-account cache defaults. + internal const int MaxPerAccountCacheSize = 8192; + internal static readonly TimeSpan ClosedSubsCheckInterval = TimeSpan.FromMinutes(5); + + // TLS handshake client type tags. + internal const string TlsHandshakeLeaf = "leafnode"; + internal const string TlsHandshakeMqtt = "mqtt"; + + // Allowed-connection-type group used in deny-list checks. + internal const string SysGroup = "_sys_"; + + // Message header status line bytes (UTF-8, immutable). + internal static readonly byte[] HdrLineBytes = Encoding.ASCII.GetBytes(NatsHeaderConstants.HdrLine); + internal static readonly byte[] EmptyHdrLineBytes = Encoding.ASCII.GetBytes(NatsHeaderConstants.EmptyHdrLine); +} + +/// +/// Represents an individual client connection to the NATS server. +/// Mirrors Go client struct and all its methods from server/client.go. +/// +/// +/// This is the central networking class — every connected client (NATS, MQTT, WebSocket, +/// route, gateway, leaf node, or internal) has one instance. +/// +public sealed partial class ClientConnection +{ + // ========================================================================= + // Fields — mirrors Go client struct + // ========================================================================= + + private readonly Lock _mu = new(); // mirrors c.mu sync.Mutex + + // Connection kind and server references. + internal ClientKind Kind; // mirrors c.kind + internal INatsServer? Server; // mirrors c.srv + internal INatsAccount? Account; // mirrors c.acc + internal ClientPermissions? Perms; // mirrors c.perms + internal MsgDeny? MPerms; // mirrors c.mperms + + // Connection identity. + internal ulong Cid; // mirrors c.cid + internal byte[]? Nonce; // mirrors c.nonce + internal string PubKey = string.Empty; // mirrors c.pubKey + internal string Host = string.Empty; // mirrors c.host + internal ushort Port; // mirrors c.port + internal string NameTag = string.Empty; // mirrors c.nameTag + internal string ProxyKey = string.Empty; // mirrors c.proxyKey + + // Client options (from CONNECT message). + internal ClientOptions Opts = ClientOptions.Default; + + // Flags and state. + internal ClientFlags Flags; // mirrors c.flags clientFlag + internal bool Trace; // mirrors c.trace + internal bool Echo = true; // mirrors c.echo + internal bool NoIcb; // mirrors c.noIcb + internal bool InProc; // mirrors c.iproc (in-process connection) + internal bool Headers; // mirrors c.headers + + // Limits (int32 allows atomic access). + private int _mpay; // mirrors c.mpay — max payload (signed, jwt.NoLimit = -1) + private int _msubs; // mirrors c.msubs — max subscriptions + private int _mcl; // mirrors c.mcl — max control line + + // Subscriptions. + internal Dictionary Subs = new(StringComparer.Ordinal); + internal Dictionary? Replies; + internal Dictionary? Pcd; // pending clients with data to flush + internal Dictionary? DArray; // denied subscribe patterns + + // Outbound state (simplified — full write loop ported when Server is available). + internal long OutPb; // pending bytes + internal long OutMp; // max pending snapshot + internal TimeSpan OutWdl; // write deadline snapshot + + // Timing. + internal DateTime Start; + internal DateTime Last; + internal DateTime LastIn; + internal DateTime Expires; + internal TimeSpan Rtt; + internal DateTime RttStart; + internal DateTime LastReplyPrune; + internal ushort RepliesSincePrune; + + // Scratch buffer for processMsg calls. + // Initialised with "RMSG " bytes. + internal byte[] Msgb = new byte[Wires.MsgScratch]; + + // Auth error override. + internal Exception? AuthErr; + + // Network connection (null for in-process). + private Stream? _nc; + private string _ncs = string.Empty; // cached string representation (mirrors c.ncs atomic.Value) + + // Parse state (shared with ProtocolParser). + internal ParseContext ParseCtx = new(); + + // Remote reply tracking. + private RrTracking? _rrTracking; + + // Timers. + private Timer? _atmr; // auth timer + private Timer? _pingTimer; + private Timer? _tlsTo; + + // Ping state. + private int _pingOut; // outstanding pings + + // Connection string (cached for logging). + private string _connStr = string.Empty; + + // Read cache (per-read-loop state). + private ReadCacheState _in; + + // ========================================================================= + // Constructor + // ========================================================================= + + /// + /// Creates a new client connection. + /// Callers should invoke after creation. + /// + public ClientConnection(ClientKind kind, INatsServer? server = null, Stream? nc = null) + { + Kind = kind; + Server = server; + _nc = nc; + + // Initialise scratch buffer with "RMSG " bytes. + Msgb[0] = (byte)'R'; Msgb[1] = (byte)'M'; + Msgb[2] = (byte)'S'; Msgb[3] = (byte)'G'; + Msgb[4] = (byte)' '; + } + + // ========================================================================= + // String / identity (features 398-400) + // ========================================================================= + + /// + /// Returns the cached connection string identifier. + /// Mirrors Go client.String(). + /// + public override string ToString() => _ncs; + + /// + /// Returns the nonce presented to the client during connection. + /// Mirrors Go client.GetNonce(). + /// + public byte[]? GetNonce() + { + lock (_mu) { return Nonce; } + } + + /// + /// Returns the application-supplied name for this connection. + /// Mirrors Go client.GetName(). + /// + public string GetName() + { + lock (_mu) { return Opts.Name; } + } + + /// Returns the client options. Mirrors Go client.GetOpts(). + public ClientOptions GetOpts() => Opts; + + // ========================================================================= + // TLS (feature 402) + // ========================================================================= + + /// + /// Returns TLS connection state if the connection is TLS-secured, otherwise null. + /// Mirrors Go client.GetTLSConnectionState(). + /// + public SslStream? GetTlsStream() + { + lock (_mu) { return _nc as SslStream; } + } + + // ========================================================================= + // Client type classification (features 403-404) + // ========================================================================= + + /// + /// Returns the extended client type for CLIENT-kind connections. + /// Mirrors Go client.clientType(). + /// + public ClientConnectionType ClientType() + { + if (Kind != ClientKind.Client) return ClientConnectionType.NonClient; + if (IsMqtt()) return ClientConnectionType.Mqtt; + if (IsWebSocket()) return ClientConnectionType.WebSocket; + return ClientConnectionType.Nats; + } + + private static readonly Dictionary ClientTypeStringMap = new() + { + [ClientConnectionType.NonClient] = string.Empty, + [ClientConnectionType.Nats] = "nats", + [ClientConnectionType.WebSocket] = "websocket", + [ClientConnectionType.Mqtt] = "mqtt", + }; + + internal string ClientTypeString() => + ClientTypeStringMap.TryGetValue(ClientType(), out var s) ? s : string.Empty; + + // ========================================================================= + // Subscription.close / isClosed (features 405-406) + // (These are on the Subscription type; see Internal/Subscription.cs) + // ========================================================================= + + // ========================================================================= + // Trace level (feature 407) + // ========================================================================= + + /// + /// Updates the trace flag based on server logging settings. + /// Mirrors Go client.setTraceLevel(). + /// + internal void SetTraceLevel() + { + if (Server is null) { Trace = false; return; } + Trace = Kind == ClientKind.System + ? Server.TraceSysAcc + : Server.TraceEnabled; + } + + // ========================================================================= + // initClient (feature 408) + // ========================================================================= + + /// + /// Initialises connection state after the client struct is created. + /// Must be called with _mu held. + /// Mirrors Go client.initClient(). + /// + internal void InitClient() + { + if (Server is not null) + Cid = Server.NextClientId(); + + // Snapshot options from server. + if (Server is not null) + { + var opts = Server.Options; + OutWdl = opts.WriteDeadline; + OutMp = opts.MaxPending; + _mcl = opts.MaxControlLine > 0 ? opts.MaxControlLine : ServerConstants.MaxControlLineSize; + } + else + { + _mcl = ServerConstants.MaxControlLineSize; + } + + Subs = new Dictionary(StringComparer.Ordinal); + Pcd = new Dictionary(); + Echo = true; + + SetTraceLevel(); + + // Scratch buffer "RMSG " prefix. + Msgb[0] = (byte)'R'; Msgb[1] = (byte)'M'; + Msgb[2] = (byte)'S'; Msgb[3] = (byte)'G'; + Msgb[4] = (byte)' '; + + // Snapshot connection string. + if (_nc is not null) + { + var addr = GetRemoteEndPoint(); + if (addr is not null) + { + var conn = addr.ToString() ?? string.Empty; + if (conn.Length > 0) + { + var parts = conn.Split(':', 2); + if (parts.Length == 2) + { + Host = parts[0]; + if (ushort.TryParse(parts[1], out var p)) Port = p; + } + _connStr = conn.Replace("%", "%%"); + } + } + } + + _ncs = Kind switch + { + ClientKind.Client when ClientType() == ClientConnectionType.Nats => + $"{_connStr} - cid:{Cid}", + ClientKind.Client when ClientType() == ClientConnectionType.WebSocket => + $"{_connStr} - wid:{Cid}", + ClientKind.Client => + $"{_connStr} - mid:{Cid}", + ClientKind.Router => $"{_connStr} - rid:{Cid}", + ClientKind.Gateway => $"{_connStr} - gid:{Cid}", + ClientKind.Leaf => $"{_connStr} - lid:{Cid}", + ClientKind.System => "SYSTEM", + ClientKind.JetStream => "JETSTREAM", + ClientKind.Account => "ACCOUNT", + _ => _connStr, + }; + } + + // ========================================================================= + // RemoteAddress (feature 409) + // ========================================================================= + + /// + /// Returns the remote network address of the connection, or null. + /// Mirrors Go client.RemoteAddress(). + /// + public EndPoint? RemoteAddress() + { + lock (_mu) { return GetRemoteEndPoint(); } + } + + private EndPoint? GetRemoteEndPoint() + { + if (_nc is NetworkStream ns) + { + try { return ns.Socket.RemoteEndPoint; } + catch { return null; } + } + return null; + } + + // ========================================================================= + // Account registration (features 410-417) + // ========================================================================= + + /// + /// Reports an error when registering with an account. + /// Mirrors Go client.reportErrRegisterAccount(). + /// + internal void ReportErrRegisterAccount(INatsAccount acc, Exception err) + { + if (err is TooManyAccountConnectionsException) + { + MaxAccountConnExceeded(); + return; + } + Errorf("Problem registering with account %q: %s", acc.Name, err.Message); + SendErr("Failed Account Registration"); + } + + /// + /// Returns the client kind. Mirrors Go client.Kind(). + /// + public ClientKind GetKind() + { + lock (_mu) { return Kind; } + } + + /// + /// Registers this client with an account. + /// Mirrors Go client.registerWithAccount(). + /// + internal void RegisterWithAccount(INatsAccount acc) + { + if (acc is null) throw new BadAccountException(); + if (!acc.IsValid) throw new BadAccountException(); + + // Deregister from previous account. + if (Account is not null) + { + var prev = Account.RemoveClient(this); + if (prev == 1) Server?.DecActiveAccounts(); + } + + lock (_mu) + { + Account = acc; + ApplyAccountLimits(); + } + + // Check max connection limits. + if (Kind == ClientKind.Client && acc.MaxTotalConnectionsReached()) + throw new TooManyAccountConnectionsException(); + + if (Kind == ClientKind.Leaf && acc.MaxTotalLeafNodesReached()) + throw new TooManyAccountConnectionsException(); + + // Add to new account. + var added = acc.AddClient(this); + if (added == 0) Server?.IncActiveAccounts(); + } + + /// + /// Returns true if the subscription limit has been reached. + /// Mirrors Go client.subsAtLimit(). + /// + internal bool SubsAtLimit() => + _msubs != JwtNoLimit && Subs.Count >= _msubs; + + // JwtNoLimit mirrors jwt.NoLimit in Go (-1 cast to int32). + private const int JwtNoLimit = -1; + + /// + /// Atomically applies the minimum of two int32 limits. + /// Mirrors Go minLimit. + /// + private static bool MinLimit(ref int value, int limit) + { + int v = Volatile.Read(ref value); + if (v != JwtNoLimit) + { + if (limit != JwtNoLimit && limit < v) + { + Volatile.Write(ref value, limit); + return true; + } + } + else if (limit != JwtNoLimit) + { + Volatile.Write(ref value, limit); + return true; + } + return false; + } + + /// + /// Applies account-level connection limits to this client. + /// Lock is held on entry. + /// Mirrors Go client.applyAccountLimits(). + /// + internal void ApplyAccountLimits() + { + if (Account is null || (Kind != ClientKind.Client && Kind != ClientKind.Leaf)) + return; + + Volatile.Write(ref _mpay, JwtNoLimit); + _msubs = JwtNoLimit; + + // Apply server-level limits. + if (Server is not null) + { + var sOpts = Server.Options; + int mPay = sOpts.MaxPayload == 0 ? JwtNoLimit : sOpts.MaxPayload; + int mSubs = sOpts.MaxSubs == 0 ? JwtNoLimit : sOpts.MaxSubs; + MinLimit(ref _mpay, mPay); + MinLimit(ref _msubs, mSubs); + } + + if (SubsAtLimit()) + Task.Run(() => + { + MaxSubsExceeded(); + Task.Delay(20).Wait(); + CloseConnection(ClosedState.MaxSubscriptionsExceeded); + }); + } + + // ========================================================================= + // RegisterUser / RegisterNkeyUser (features 416-417) + // ========================================================================= + + /// + /// Registers an authenticated user with this connection. + /// Mirrors Go client.RegisterUser(). + /// + public void RegisterUser(User user) + { + if (user.Account is INatsAccount acc) + { + try { RegisterWithAccount(acc); } + catch (Exception ex) { ReportErrRegisterAccount(acc, ex); return; } + } + + lock (_mu) + { + Perms = user.Permissions is not null ? BuildPermissions(user.Permissions) : null; + MPerms = null; + if (user.Username.Length > 0) + Opts.Username = user.Username; + if (user.ConnectionDeadline != default) + SetExpirationTimerUnlocked(user.ConnectionDeadline - DateTime.UtcNow); + } + } + + /// + /// Registers an NKey-authenticated user. + /// Mirrors Go client.RegisterNkeyUser(). + /// + public void RegisterNkeyUser(NkeyUser user) + { + if (user.Account is INatsAccount acc) + { + try { RegisterWithAccount(acc); } + catch (Exception ex) { ReportErrRegisterAccount(acc, ex); return; } + } + + lock (_mu) + { + Perms = user.Permissions is not null ? BuildPermissions(user.Permissions) : null; + MPerms = null; + } + } + + // ========================================================================= + // splitSubjectQueue (feature 418) + // ========================================================================= + + /// + /// Splits a "subject [queue]" string into subject and optional queue bytes. + /// Mirrors Go splitSubjectQueue. + /// + public static (byte[] subject, byte[]? queue) SplitSubjectQueue(string sq) + { + var vals = sq.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (vals.Length == 0) + throw new ArgumentException($"invalid subject-queue \"{sq}\""); + + var subject = Encoding.ASCII.GetBytes(vals[0]); + byte[]? queue = null; + + if (vals.Length == 2) + queue = Encoding.ASCII.GetBytes(vals[1]); + else if (vals.Length > 2) + throw new FormatException($"invalid subject-queue \"{sq}\""); + + return (subject, queue); + } + + // ========================================================================= + // setPermissions / publicPermissions / mergeDenyPermissions (features 419-422) + // ========================================================================= + + private ClientPermissions BuildPermissions(Permissions perms) + { + var cp = new ClientPermissions(); + + if (perms.Publish is not null) + { + if (perms.Publish.Allow is { Count: > 0 }) + { + cp.Pub.Allow = SubscriptionIndex.NewSublistWithCache(); + foreach (var s in perms.Publish.Allow) + cp.Pub.Allow.Insert(new Subscription { Subject = Encoding.ASCII.GetBytes(s) }); + } + if (perms.Publish.Deny is { Count: > 0 }) + { + cp.Pub.Deny = SubscriptionIndex.NewSublistWithCache(); + foreach (var s in perms.Publish.Deny) + cp.Pub.Deny.Insert(new Subscription { Subject = Encoding.ASCII.GetBytes(s) }); + } + } + + if (perms.Response is not null) + { + cp.Resp = perms.Response; + Replies = new Dictionary(StringComparer.Ordinal); + } + + if (perms.Subscribe is not null) + { + if (perms.Subscribe.Allow is { Count: > 0 }) + { + cp.Sub.Allow = SubscriptionIndex.NewSublistWithCache(); + foreach (var s in perms.Subscribe.Allow) + { + try + { + var (subj, q) = SplitSubjectQueue(s); + cp.Sub.Allow.Insert(new Subscription { Subject = subj, Queue = q }); + } + catch (Exception ex) { Errorf("%s", ex.Message); } + } + } + if (perms.Subscribe.Deny is { Count: > 0 }) + { + cp.Sub.Deny = SubscriptionIndex.NewSublistWithCache(); + DArray = []; + foreach (var s in perms.Subscribe.Deny) + { + DArray.Add(s, true); + try + { + var (subj, q) = SplitSubjectQueue(s); + cp.Sub.Deny.Insert(new Subscription { Subject = subj, Queue = q }); + } + catch (Exception ex) { Errorf("%s", ex.Message); } + } + } + } + + return cp; + } + + // ========================================================================= + // setExpiration / loadMsgDenyFilter (features 423-424) + // ========================================================================= + + internal void SetExpirationTimer(TimeSpan d) + { + // TODO: Implement when Server is available (session 09). + } + + internal void SetExpirationTimerUnlocked(TimeSpan d) + { + // TODO: Implement when Server is available (session 09). + } + + // ========================================================================= + // msgParts (feature 470) + // ========================================================================= + + /// + /// Splits a message buffer into header and body parts. + /// Mirrors Go client.msgParts(). + /// + public (byte[] hdr, byte[] msg) MsgParts(byte[] buf) + { + int hdrLen = ParseCtx.Pa.HeaderSize; + + // Return header slice with a capped capacity (no extra capacity beyond the header). + var hdr = buf[..hdrLen]; + // Create an isolated copy so appending to hdr doesn't touch msg. + var hdrCopy = new byte[hdrLen]; + Buffer.BlockCopy(buf, 0, hdrCopy, 0, hdrLen); + + var msg = buf[hdrLen..]; + return (hdrCopy, msg); + } + + // ========================================================================= + // kindString (feature 533) + // ========================================================================= + + private static readonly Dictionary KindStringMap = new() + { + [ClientKind.Client] = "Client", + [ClientKind.Router] = "Router", + [ClientKind.Gateway] = "Gateway", + [ClientKind.Leaf] = "Leafnode", + [ClientKind.JetStream] = "JetStream", + [ClientKind.Account] = "Account", + [ClientKind.System] = "System", + }; + + /// + /// Returns a human-readable kind name. + /// Mirrors Go client.kindString(). + /// + internal string KindString() => + KindStringMap.TryGetValue(Kind, out var s) ? s : "Unknown Type"; + + // ========================================================================= + // isClosed (feature 555) + // ========================================================================= + + /// + /// Returns true if closeConnection has been called. + /// Mirrors Go client.isClosed(). + /// + public bool IsClosed() => (Flags & ClientFlags.CloseConnection) != 0; + + // ========================================================================= + // format / formatNoClientInfo / formatClientSuffix (features 556-558) + // ========================================================================= + + /// + /// Returns a formatted log string for this client. + /// Mirrors Go client.format(). + /// + internal string Format() => $"{_ncs}"; + + internal string FormatNoClientInfo() => _connStr; + + internal string FormatClientSuffix() => $" - {KindString()}:{Cid}"; + + // ========================================================================= + // Logging helpers (features 559-568) + // ========================================================================= + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Error(string msg) => Server?.Logger.LogError("[{Client}] {Msg}", _ncs, msg); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Errorf(string fmt, params object?[] args) => + Server?.Logger.LogError("[{Client}] " + fmt, [_ncs, ..args]); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Debugf(string fmt, params object?[] args) => + Server?.Logger.LogDebug("[{Client}] " + fmt, [_ncs, ..args]); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Noticef(string fmt, params object?[] args) => + Server?.Logger.LogInformation("[{Client}] " + fmt, [_ncs, ..args]); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Tracef(string fmt, params object?[] args) => + Server?.Logger.LogTrace("[{Client}] " + fmt, [_ncs, ..args]); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal void Warnf(string fmt, params object?[] args) => + Server?.Logger.LogWarning("[{Client}] " + fmt, [_ncs, ..args]); + + // ========================================================================= + // Auth-related helpers (features 446-451, 526-531, 570-571) + // ========================================================================= + + internal void SendErrAndErr(string err) { SendErr(err); Error(err); } + internal void SendErrAndDebug(string msg){ SendErr(msg); Debugf(msg); } + + internal void AuthTimeout() + { + SendErrAndDebug("Authentication Timeout"); + CloseConnection(ClosedState.AuthenticationTimeout); + } + + internal void AuthExpired() + { + SendErrAndDebug("Authorization Expired"); + CloseConnection(ClosedState.AuthenticationExpired); + } + + internal void AccountAuthExpired() + { + SendErrAndDebug("Account authorization expired"); + CloseConnection(ClosedState.AuthenticationExpired); + } + + internal void AuthViolation() + { + SendErrAndErr(ServerErrors.ErrAuthorization.Message); + CloseConnection(ClosedState.AuthenticationViolation); + } + + internal void MaxAccountConnExceeded() + { + SendErrAndErr(ServerErrors.ErrTooManyAccountConnections.Message); + CloseConnection(ClosedState.MaxAccountConnectionsExceeded); + } + + internal void MaxConnExceeded() + { + SendErrAndErr(ServerErrors.ErrTooManyConnections.Message); + CloseConnection(ClosedState.MaxConnectionsExceeded); + } + + internal void MaxSubsExceeded() + { + Errorf("Maximum Subscriptions Exceeded (max=%d)", _msubs); + SendErr(ServerErrors.ErrTooManySubs.Message); + } + + internal void MaxPayloadViolation(int sz, int max) + { + SendErrAndErr($"Maximum Payload Violation"); + CloseConnection(ClosedState.MaxPayloadExceeded); + } + + internal void PubPermissionViolation(string subject) + { + SendErr($"Permissions Violation for Publish to \"{subject}\""); + Errorf("Publish Violation - User %q, Subject %q", GetAuthUser(), subject); + } + + internal void SubPermissionViolation(Subscription sub) + { + string subj = Encoding.UTF8.GetString(sub.Subject); + string queue = sub.Queue is { Length: > 0 } ? $" using queue \"{Encoding.UTF8.GetString(sub.Queue)}\"" : string.Empty; + SendErr($"Permissions Violation for Subscription to \"{subj}\"{queue}"); + Errorf("Subscription Violation - User %q, Subject %q, SID %q", + GetAuthUser(), subj, sub.Sid is not null ? Encoding.UTF8.GetString(sub.Sid) : string.Empty); + } + + internal void ReplySubjectViolation(string reply) + { + SendErr($"Permissions Violation for use of Reply subject \"{reply}\""); + Errorf("Reply Subject Violation - User %q, Reply %q", GetAuthUser(), reply); + } + + internal void MaxTokensViolation(Subscription sub) + { + SendErrAndErr($"Permissions Violation for Subscription to \"{Encoding.UTF8.GetString(sub.Subject)}\""); + } + + internal void SetAuthError(Exception err) { lock (_mu) { AuthErr = err; } } + internal Exception? GetAuthError() { lock (_mu) { return AuthErr; } } + + // ========================================================================= + // Timer helpers (features 523-531) + // ========================================================================= + + internal void SetPingTimer() + { + // TODO: Implement when Server is available. + } + + internal void ClearPingTimer() + { + var t = Interlocked.Exchange(ref _pingTimer, null); + t?.Dispose(); + } + + internal void ClearTlsToTimer() + { + var t = Interlocked.Exchange(ref _tlsTo, null); + t?.Dispose(); + } + + internal void SetAuthTimer() + { + // TODO: Implement when Server is available. + } + + internal void ClearAuthTimer() + { + var t = Interlocked.Exchange(ref _atmr, null); + t?.Dispose(); + } + + internal bool AwaitingAuth() => (Flags & ClientFlags.ExpectConnect) != 0 + && (Flags & ClientFlags.ConnectReceived) == 0; + + internal void ClaimExpiration() + { + // TODO: Implement when Server is available. + } + + // ========================================================================= + // flushSignal / queueOutbound / enqueueProto (features 433, 456-459) + // ========================================================================= + + internal void FlushSignal() + { + // TODO: Signal the writeLoop via SemaphoreSlim/Monitor when ported. + } + + internal void EnqueueProtoAndFlush(ReadOnlySpan proto) + { + EnqueueProto(proto); + } + + internal void SendProtoNow(ReadOnlySpan proto) + { + EnqueueProto(proto); + } + + internal void EnqueueProto(ReadOnlySpan proto) + { + // TODO: Full write-loop queuing when Server is ported (session 09). + if (_nc is not null) + { + try { _nc.Write(proto); } + catch { /* connection errors handled by closeConnection */ } + } + } + + // ========================================================================= + // sendPong / sendPing / sendRTTPing (features 460-463) + // ========================================================================= + + internal void SendPong() => EnqueueProtoAndFlush(Encoding.ASCII.GetBytes(Wires.PongProto)); + + internal void SendRttPing() { lock (_mu) { SendRttPingLocked(); } } + + internal void SendRttPingLocked() + { + RttStart = DateTime.UtcNow; + SendPing(); + } + + internal void SendPing() + { + _pingOut++; + EnqueueProtoAndFlush(Encoding.ASCII.GetBytes(Wires.PingProto)); + } + + // ========================================================================= + // sendErr / sendOK (features 465-466) + // ========================================================================= + + internal void SendErr(string err) => + EnqueueProtoAndFlush(Encoding.ASCII.GetBytes(string.Format(Wires.ErrProto, err))); + + internal void SendOK() + { + if (Opts.Verbose) + EnqueueProtoAndFlush(Encoding.ASCII.GetBytes(Wires.OkProto)); + } + + // ========================================================================= + // traceMsg / traceInOp / traceOutOp / traceOp (features 434-439) + // ========================================================================= + + internal void TraceMsg(byte[] msg) { if (Trace) TraceMsgInternal(msg, false, false); } + internal void TraceMsgDelivery(byte[] msg) { if (Trace) TraceMsgInternal(msg, false, true); } + internal void TraceInOp(string op, byte[] arg) { if (Trace) TraceOp("<", op, arg); } + internal void TraceOutOp(string op, byte[] arg) { if (Trace) TraceOp(">", op, arg); } + + private void TraceMsgInternal(byte[] msg, bool inbound, bool delivery) { } + private void TraceOp(string dir, string op, byte[] arg) + { + Tracef("%s %s %s", dir, op, arg is not null ? Encoding.UTF8.GetString(arg) : string.Empty); + } + + // ========================================================================= + // getAuthUser / getAuthUserLabel (features 550-552) + // ========================================================================= + + internal string GetRawAuthUserLock() + { + lock (_mu) { return GetRawAuthUser(); } + } + + internal string GetRawAuthUser() + { + if (Opts.Nkey.Length > 0) return Opts.Nkey; + if (Opts.Username.Length > 0) return Opts.Username; + if (Opts.Token.Length > 0) return "Token"; + return "Unknown"; + } + + internal string GetAuthUser() => GetRawAuthUser(); + + internal string GetAuthUserLabel() + { + var u = GetRawAuthUser(); + return u.Length > 0 ? u : "Unknown User"; + } + + // ========================================================================= + // connectionTypeAllowed (feature 554) + // ========================================================================= + + internal bool ConnectionTypeAllowed(string ct) + { + // TODO: Full implementation when JWT is integrated. + return true; + } + + // ========================================================================= + // closeConnection (feature 536) + // ========================================================================= + + /// + /// Closes the client connection with the given reason. + /// Mirrors Go client.closeConnection(). + /// + public void CloseConnection(ClosedState reason) + { + lock (_mu) + { + if (IsClosed()) return; + Flags |= ClientFlags.CloseConnection; + ClearAuthTimer(); + ClearPingTimer(); + } + + // Close the underlying network connection. + try { _nc?.Close(); } catch { /* ignore */ } + _nc = null; + } + + // ========================================================================= + // flushAndClose (feature 532) + // ========================================================================= + + internal void FlushAndClose(bool deadlineExceeded) + { + CloseConnection(ClosedState.ClientClosed); + } + + // ========================================================================= + // setNoReconnect (feature 538) + // ========================================================================= + + internal void SetNoReconnect() + { + lock (_mu) { Flags |= ClientFlags.NoReconnect; } + } + + // ========================================================================= + // getRTTValue (feature 539) + // ========================================================================= + + internal TimeSpan GetRttValue() + { + lock (_mu) { return Rtt; } + } + + // ========================================================================= + // Account / server helpers (features 540-545) + // ========================================================================= + + internal INatsAccount? GetAccount() + { + lock (_mu) { return Account; } + } + + // ========================================================================= + // TLS handshake helpers (features 546-548) + // ========================================================================= + + internal async Task DoTlsServerHandshakeAsync(SslServerAuthenticationOptions opts, CancellationToken ct = default) + { + // TODO: Full TLS when Server is ported. + return false; + } + + internal async Task DoTlsClientHandshakeAsync(SslClientAuthenticationOptions opts, CancellationToken ct = default) + { + // TODO: Full TLS when Server is ported. + return false; + } + + // ========================================================================= + // Stub methods for server-dependent features + // (Fully implemented when Server/Account sessions are complete) + // ========================================================================= + + // features 425-427: writeLoop / flushClients / readLoop + internal void WriteLoop() { /* TODO session 09 */ } + internal void FlushClients(long budget) { /* TODO session 09 */ } + + // features 428-432: closedStateForErr, collapsePtoNB, flushOutbound, handleWriteTimeout, markConnAsClosed + internal static ClosedState ClosedStateForErr(Exception err) => + err is EndOfStreamException ? ClosedState.ClientClosed : ClosedState.ReadError; + + // features 440-441: processInfo, processErr + internal void ProcessInfo(string info) { /* TODO session 09 */ } + internal void ProcessErr(string err) { /* TODO session 09 */ } + + // features 442-443: removeSecretsFromTrace, redact + internal static string RemoveSecretsFromTrace(string s) => s; + internal static string Redact(string s) => s; + + // feature 444: computeRTT + internal static TimeSpan ComputeRtt(DateTime start) => DateTime.UtcNow - start; + + // feature 445: processConnect + internal void ProcessConnect(byte[] arg) { /* TODO session 09 */ } + + // feature 467-468: processPing, processPong + internal void ProcessPing() + { + _pingOut = 0; + SendPong(); + } + + internal void ProcessPong() { /* TODO */ } + + // feature 469: updateS2AutoCompressionLevel + internal void UpdateS2AutoCompressionLevel() { /* TODO */ } + + // features 471-486: processPub variants, parseSub, processSub, etc. + // Implemented in full when Server+Account sessions complete. + + // features 487-503: deliverMsg, addToPCD, trackRemoteReply, pruning, pubAllowed, etc. + + // features 512-514: processServiceImport, addSubToRouteTargets, processMsgResults + + // feature 515: checkLeafClientInfoHeader + // feature 520-522: processPingTimer, adjustPingInterval, watchForStaleConnection + // feature 534-535: swapAccountAfterReload, processSubsOnConfigReload + // feature 537: reconnect + // feature 569: setFirstPingTimer + + // ========================================================================= + // IsMqtt / IsWebSocket helpers (used by clientType, not separately tracked) + // ========================================================================= + + internal bool IsMqtt() => false; // TODO: set in session 22 (MQTT) + internal bool IsWebSocket() => false; // TODO: set in session 23 (WebSocket) + internal bool IsHubLeafNode() => false; // TODO: set in session 15 (leaf nodes) + internal string RemoteCluster() => string.Empty; // TODO: session 14/15 +} + +// ============================================================================ +// Private read-cache state (per-readLoop invocation) +// ============================================================================ + +internal struct ReadCacheState +{ + public ulong GenId; + public Dictionary? Results; + public Dictionary? PaCache; + public List? Rts; + public int Msgs; + public int Bytes; + public int Subs; + public int Rsz; // read buffer size + public int Srs; // short reads + public ReadCacheFlags Flags; + public DateTime Start; + public TimeSpan Tst; // total stall time +} + +internal sealed class PerAccountCache +{ + public INatsAccount? Acc { get; set; } + public SubscriptionIndexResult? Results { get; set; } + public ulong GenId { get; set; } +} + +internal sealed class RrTracking +{ + public Dictionary? RMap { get; set; } + public Timer? Ptmr { get; set; } + public TimeSpan Lrt { get; set; } +} + +// ============================================================================ +// Server / account interfaces (stubs until sessions 09 and 11) +// ============================================================================ + +/// +/// Minimal server interface used by ClientConnection. +/// Full implementation in session 09 (server.go). +/// +public interface INatsServer +{ + ulong NextClientId(); + ServerOptions Options { get; } + bool TraceEnabled { get; } + bool TraceSysAcc { get; } + ILogger Logger { get; } + void DecActiveAccounts(); + void IncActiveAccounts(); +} + +/// +/// Minimal account interface used by ClientConnection. +/// Full implementation in session 11 (accounts.go). +/// +public interface INatsAccount +{ + string Name { get; } + bool IsValid { get; } + bool MaxTotalConnectionsReached(); + bool MaxTotalLeafNodesReached(); + int AddClient(ClientConnection c); + int RemoveClient(ClientConnection c); +} + +/// Thrown when account connection limits are exceeded. +public sealed class TooManyAccountConnectionsException : Exception +{ + public TooManyAccountConnectionsException() : base("Too Many Account Connections") { } +} + +/// Thrown when an account is invalid or null. +public sealed class BadAccountException : Exception +{ + public BadAccountException() : base("Bad Account") { } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs new file mode 100644 index 0000000..d4108a2 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ClientTypes.cs @@ -0,0 +1,375 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client.go in the NATS server Go source. + +using System.Text.Json.Serialization; +using ZB.MOM.NatsNet.Server.Auth; +using ZB.MOM.NatsNet.Server.Internal; +using ZB.MOM.NatsNet.Server.Internal.DataStructures; + +namespace ZB.MOM.NatsNet.Server; + +// ============================================================================ +// Client connection kind (iota constants) +// ============================================================================ + +// Note: ClientKind is already declared in Internal/Subscription.cs; this file +// adds the remaining constants that were used only here. + +/// +/// Extended client connection type (returned by clientType()). +/// Maps Go's NON_CLIENT / NATS / MQTT / WS iota. +/// +public enum ClientConnectionType +{ + /// Connection is not a CLIENT kind. + NonClient = 0, + /// Regular NATS client. + Nats = 1, + /// MQTT client. + Mqtt = 2, + /// WebSocket client. + WebSocket = 3, +} + +// ============================================================================ +// Client protocol versions +// ============================================================================ + +/// +/// Wire protocol version negotiated in the CONNECT message. +/// +public static class ClientProtocol +{ + /// Original protocol (2009). Mirrors ClientProtoZero. + public const int Zero = 0; + /// Protocol that supports INFO updates. Mirrors ClientProtoInfo. + public const int Info = 1; +} + +// ============================================================================ +// WriteTimeoutPolicy extension (enum defined in ServerOptionTypes.cs) +// ============================================================================ + +internal static class WriteTimeoutPolicyExtensions +{ + /// Mirrors Go WriteTimeoutPolicy.String(). + public static string ToVarzString(this WriteTimeoutPolicy p) => p switch + { + WriteTimeoutPolicy.Close => "close", + WriteTimeoutPolicy.Retry => "retry", + _ => string.Empty, + }; +} + +// ============================================================================ +// ClientFlags +// ============================================================================ + +/// +/// Compact bitfield of boolean client state. +/// Mirrors Go clientFlag and its iota constants. +/// +[Flags] +public enum ClientFlags : ushort +{ + None = 0, + ConnectReceived = 1 << 0, + InfoReceived = 1 << 1, + FirstPongSent = 1 << 2, + HandshakeComplete = 1 << 3, + FlushOutbound = 1 << 4, + NoReconnect = 1 << 5, + CloseConnection = 1 << 6, + ConnMarkedClosed = 1 << 7, + WriteLoopStarted = 1 << 8, + SkipFlushOnClose = 1 << 9, + ExpectConnect = 1 << 10, + ConnectProcessFinished = 1 << 11, + CompressionNegotiated = 1 << 12, + DidTlsFirst = 1 << 13, + IsSlowConsumer = 1 << 14, + FirstPong = 1 << 15, +} + +// ============================================================================ +// ReadCacheFlags +// ============================================================================ + +/// +/// Bitfield for the read-cache loop state. +/// Mirrors Go readCacheFlag. +/// +[Flags] +public enum ReadCacheFlags : ushort +{ + None = 0, + HasMappings = 1 << 0, + SwitchToCompression = 1 << 1, +} + +// ============================================================================ +// ClosedState +// ============================================================================ + +/// +/// The reason a client connection was closed. +/// Mirrors Go ClosedState. +/// +public enum ClosedState +{ + ClientClosed = 1, + AuthenticationTimeout, + AuthenticationViolation, + TlsHandshakeError, + SlowConsumerPendingBytes, + SlowConsumerWriteDeadline, + WriteError, + ReadError, + ParseError, + StaleConnection, + ProtocolViolation, + BadClientProtocolVersion, + WrongPort, + MaxAccountConnectionsExceeded, + MaxConnectionsExceeded, + MaxPayloadExceeded, + MaxControlLineExceeded, + MaxSubscriptionsExceeded, + DuplicateRoute, + RouteRemoved, + ServerShutdown, + AuthenticationExpired, + WrongGateway, + MissingAccount, + Revocation, + InternalClient, + MsgHeaderViolation, + NoRespondersRequiresHeaders, + ClusterNameConflict, + DuplicateRemoteLeafnodeConnection, + DuplicateClientId, + DuplicateServerName, + MinimumVersionRequired, + ClusterNamesIdentical, + Kicked, + ProxyNotTrusted, + ProxyRequired, +} + +// ============================================================================ +// processMsgResults flags +// ============================================================================ + +/// +/// Flags passed to ProcessMsgResults. +/// Mirrors Go pmrNoFlag and the iota block. +/// +[Flags] +public enum PmrFlags +{ + None = 0, + CollectQueueNames = 1 << 0, + IgnoreEmptyQueueFilter = 1 << 1, + AllowSendFromRouteToRoute = 1 << 2, + MsgImportedFromService = 1 << 3, +} + +// ============================================================================ +// denyType +// ============================================================================ + +/// +/// Which permission side to apply deny-list merging to. +/// Mirrors Go denyType. +/// +internal enum DenyType +{ + Pub = 1, + Sub = 2, + Both = 3, +} + +// ============================================================================ +// ClientOptions (wire-protocol CONNECT options) +// ============================================================================ + +/// +/// Options negotiated during the CONNECT handshake. +/// Mirrors Go ClientOpts. +/// +public sealed class ClientOptions +{ + [JsonPropertyName("echo")] public bool Echo { get; set; } + [JsonPropertyName("verbose")] public bool Verbose { get; set; } + [JsonPropertyName("pedantic")] public bool Pedantic { get; set; } + [JsonPropertyName("tls_required")] public bool TlsRequired { get; set; } + [JsonPropertyName("nkey")] public string Nkey { get; set; } = string.Empty; + [JsonPropertyName("jwt")] public string Jwt { get; set; } = string.Empty; + [JsonPropertyName("sig")] public string Sig { get; set; } = string.Empty; + [JsonPropertyName("auth_token")] public string Token { get; set; } = string.Empty; + [JsonPropertyName("user")] public string Username { get; set; } = string.Empty; + [JsonPropertyName("pass")] public string Password { get; set; } = string.Empty; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; + [JsonPropertyName("lang")] public string Lang { get; set; } = string.Empty; + [JsonPropertyName("version")] public string Version { get; set; } = string.Empty; + [JsonPropertyName("protocol")] public int Protocol { get; set; } + [JsonPropertyName("account")] public string Account { get; set; } = string.Empty; + [JsonPropertyName("new_account")] public bool AccountNew { get; set; } + [JsonPropertyName("headers")] public bool Headers { get; set; } + [JsonPropertyName("no_responders")]public bool NoResponders { get; set; } + + // Routes and Leaf Nodes only + [JsonPropertyName("import")] public SubjectPermission? Import { get; set; } + [JsonPropertyName("export")] public SubjectPermission? Export { get; set; } + [JsonPropertyName("remote_account")] public string RemoteAccount { get; set; } = string.Empty; + [JsonPropertyName("proxy_sig")] public string ProxySig { get; set; } = string.Empty; + + /// Default options for external clients. + public static ClientOptions Default => new() { Verbose = true, Pedantic = true, Echo = true }; + + /// Default options for internal server clients. + public static ClientOptions Internal => new() { Verbose = false, Pedantic = false, Echo = false }; +} + +// ============================================================================ +// ClientInfo — lightweight metadata sent in server events +// ============================================================================ + +/// +/// Client metadata included in server monitoring events. +/// Mirrors Go ClientInfo. +/// +public sealed class ClientInfo +{ + public string Start { get; set; } = string.Empty; + public string Host { get; set; } = string.Empty; + public ulong Id { get; set; } + public string Account { get; set; } = string.Empty; + public string User { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Lang { get; set; } = string.Empty; + public string Version { get; set; } = string.Empty; + public string Jwt { get; set; } = string.Empty; + public string IssuerKey { get; set; } = string.Empty; + public string NameTag { get; set; } = string.Empty; + public List Tags { get; set; } = []; + public string Kind { get; set; } = string.Empty; + public string ClientType { get; set; } = string.Empty; + public string? MqttId { get; set; } + public bool Stop { get; set; } + public bool Restart { get; set; } + public bool Disconnect { get; set; } + public string[]? Cluster { get; set; } + public bool Service { get; set; } +} + +// ============================================================================ +// Internal permission structures (not public API) +// (Permissions, SubjectPermission, ResponsePermission are in Auth/AuthTypes.cs) +// ============================================================================ + +internal sealed class Perm +{ + public SubscriptionIndex? Allow { get; set; } + public SubscriptionIndex? Deny { get; set; } +} + +internal sealed class ClientPermissions +{ + public int PcsZ; // pub cache size (atomic) + public int PRun; // prune run count (atomic) + public Perm Sub { get; } = new(); + public Perm Pub { get; } = new(); + public ResponsePermission? Resp { get; set; } + // Per-subject cache for permission checks. + public Dictionary PCache { get; } = new(StringComparer.Ordinal); +} + +internal sealed class MsgDeny +{ + public SubscriptionIndex? Deny { get; set; } + public Dictionary DCache { get; } = new(StringComparer.Ordinal); +} + +internal sealed class RespEntry +{ + public DateTime Time { get; set; } + public int N { get; set; } +} + +// ============================================================================ +// Buffer pool constants +// ============================================================================ + +internal static class NbPool +{ + internal const int SmallSize = 512; + internal const int MediumSize = 4096; + internal const int LargeSize = 65536; + + private static readonly System.Buffers.ArrayPool _pool = + System.Buffers.ArrayPool.Create(LargeSize, 50); + + /// + /// Returns a buffer best-effort sized to . + /// Mirrors Go nbPoolGet. + /// + public static byte[] Get(int sz) + { + int cap = sz <= SmallSize ? SmallSize + : sz <= MediumSize ? MediumSize + : LargeSize; + return _pool.Rent(cap); + } + + /// + /// Returns a buffer to the pool. + /// Mirrors Go nbPoolPut. + /// + public static void Put(byte[] buf) + { + if (buf.Length == SmallSize || buf.Length == MediumSize || buf.Length == LargeSize) + _pool.Return(buf); + // Ignore wrong-sized frames (WebSocket/MQTT). + } +} + +// ============================================================================ +// Route / gateway / leaf / websocket / mqtt stubs +// (These are filled in during sessions 14-16 and 22-23) +// ============================================================================ + +internal sealed class RouteTarget +{ + public Subscription? Sub { get; set; } + public byte[] Qs { get; set; } = []; +} + +// ============================================================================ +// Static helper: IsInternalClient +// ============================================================================ + +/// +/// Client-kind classification helpers. +/// +public static class ClientKindHelpers +{ + /// + /// Returns true if is an internal server client. + /// Mirrors Go isInternalClient. + /// + public static bool IsInternalClient(ClientKind kind) => + kind == ClientKind.System || kind == ClientKind.JetStream || kind == ClientKind.Account; +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs b/dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs new file mode 100644 index 0000000..6d65536 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/NatsMessageHeaders.cs @@ -0,0 +1,389 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client.go (header utility functions) in the NATS server Go source. + +using System.Text; + +namespace ZB.MOM.NatsNet.Server; + +/// +/// Wire-level NATS message header constants. +/// +public static class NatsHeaderConstants +{ + /// NATS header status line: "NATS/1.0\r\n". Mirrors Go hdrLine. + public const string HdrLine = "NATS/1.0\r\n"; + + /// Empty header block with blank line terminator. Mirrors Go emptyHdrLine. + public const string EmptyHdrLine = "NATS/1.0\r\n\r\n"; + + // JetStream expected-sequence headers (defined in server/stream.go, used by header utilities). + public const string JsExpectedStream = "Nats-Expected-Stream"; + public const string JsExpectedLastSeq = "Nats-Expected-Last-Sequence"; + public const string JsExpectedLastSubjSeq = "Nats-Expected-Last-Subject-Sequence"; + public const string JsExpectedLastSubjSeqSubj = "Nats-Expected-Last-Subject-Sequence-Subject"; + public const string JsExpectedLastMsgId = "Nats-Expected-Last-Msg-Id"; + + // Other commonly used headers. + public const string JsMsgId = "Nats-Msg-Id"; + public const string JsMsgRollup = "Nats-Rollup"; +} + +/// +/// Low-level NATS message header manipulation utilities. +/// Mirrors the package-level functions in server/client.go: +/// genHeader, removeHeaderIfPresent, removeHeaderIfPrefixPresent, +/// getHeader, sliceHeader, getHeaderKeyIndex, setHeader. +/// +public static class NatsMessageHeaders +{ + private static readonly byte[] CrLfBytes = "\r\n"u8.ToArray(); + + // ------------------------------------------------------------------------- + // genHeader (feature 506) + // ------------------------------------------------------------------------- + + /// + /// Generates a header buffer by appending key: value\r\n to an existing header, + /// or starting a fresh NATS/1.0\r\n block if is empty/null. + /// Mirrors Go genHeader. + /// + /// Existing header bytes, or null to start fresh. + /// Header key. + /// Header value. + public static byte[] GenHeader(byte[]? hdr, string key, string value) + { + var sb = new StringBuilder(); + + // Strip trailing CRLF from existing header to reopen for appending, + // or start fresh with the header status line. + const int LenCrLf = 2; + if (hdr is { Length: > LenCrLf }) + { + // Write all but the trailing "\r\n" + sb.Append(Encoding.ASCII.GetString(hdr, 0, hdr.Length - LenCrLf)); + } + else + { + sb.Append(NatsHeaderConstants.HdrLine); + } + + // Append "key: value\r\n\r\n" (HTTP header format). + sb.Append(key); + sb.Append(": "); + sb.Append(value); + sb.Append("\r\n\r\n"); + + return Encoding.ASCII.GetBytes(sb.ToString()); + } + + // ------------------------------------------------------------------------- + // removeHeaderIfPresent (feature 504) + // ------------------------------------------------------------------------- + + /// + /// Removes the first occurrence of header from . + /// Returns null if the result would be an empty header block. + /// Mirrors Go removeHeaderIfPresent. + /// + public static byte[]? RemoveHeaderIfPresent(byte[] hdr, string key) + { + int start = GetHeaderKeyIndex(key, hdr); + // Key must exist and be preceded by '\n' (not at position 0). + if (start < 1 || hdr[start - 1] != '\n') + return hdr; + + int index = start + key.Length; + if (index >= hdr.Length || hdr[index] != ':') + return hdr; + + // Find CRLF following this header line. + int crlfIdx = IndexOfCrLf(hdr, start); + if (crlfIdx < 0) + return hdr; + + // Remove from 'start' through end of CRLF. + int removeEnd = start + crlfIdx + 2; // +2 for "\r\n" + var result = new byte[hdr.Length - (removeEnd - start)]; + Buffer.BlockCopy(hdr, 0, result, 0, start); + Buffer.BlockCopy(hdr, removeEnd, result, start, hdr.Length - removeEnd); + + // If nothing meaningful remains, return null. + if (result.Length <= NatsHeaderConstants.EmptyHdrLine.Length) + return null; + + return result; + } + + // ------------------------------------------------------------------------- + // removeHeaderIfPrefixPresent (feature 505) + // ------------------------------------------------------------------------- + + /// + /// Removes all headers whose names start with . + /// Returns null if the result would be an empty header block. + /// Mirrors Go removeHeaderIfPrefixPresent. + /// + public static byte[]? RemoveHeaderIfPrefixPresent(byte[] hdr, string prefix) + { + var prefixBytes = Encoding.ASCII.GetBytes(prefix); + var working = hdr.ToList(); // work on a list for easy splicing + int index = 0; + + while (index < working.Count) + { + // Look for prefix starting at current index. + int found = IndexOf(working, prefixBytes, index); + if (found < 0) + break; + + // Must be preceded by '\n'. + if (found < 1 || working[found - 1] != '\n') + break; + + // Find CRLF after this prefix's key:value line. + int crlfIdx = IndexOfCrLf(working, found + prefix.Length); + if (crlfIdx < 0) + break; + + int removeEnd = found + prefix.Length + crlfIdx + 2; + working.RemoveRange(found, removeEnd - found); + + // Don't advance index — there may be more headers at same position. + if (working.Count <= NatsHeaderConstants.EmptyHdrLine.Length) + return null; + } + + return working.ToArray(); + } + + // ------------------------------------------------------------------------- + // getHeaderKeyIndex (feature 510) + // ------------------------------------------------------------------------- + + /// + /// Returns the byte offset of in , + /// or -1 if not found. + /// The key must be preceded by \r\n and followed by :. + /// Mirrors Go getHeaderKeyIndex. + /// + public static int GetHeaderKeyIndex(string key, byte[] hdr) + { + if (hdr.Length == 0) return -1; + + var bkey = Encoding.ASCII.GetBytes(key); + int keyLen = bkey.Length; + int hdrLen = hdr.Length; + int offset = 0; + + while (true) + { + int index = IndexOf(hdr, bkey, offset); + // Need index >= 2 (room for preceding \r\n) and enough space for trailing colon. + if (index < 2) return -1; + + // Preceded by \r\n ? + if (hdr[index - 1] != '\n' || hdr[index - 2] != '\r') + { + offset = index + keyLen; + continue; + } + + // Immediately followed by ':' ? + if (index + keyLen >= hdrLen) + return -1; + + if (hdr[index + keyLen] != ':') + { + offset = index + keyLen; + continue; + } + + return index; + } + } + + // ------------------------------------------------------------------------- + // sliceHeader (feature 509) + // ------------------------------------------------------------------------- + + /// + /// Returns a slice of containing the value of , + /// or null if not found. + /// The returned slice shares memory with . + /// Mirrors Go sliceHeader. + /// + public static ReadOnlyMemory? SliceHeader(string key, byte[] hdr) + { + if (hdr.Length == 0) return null; + + int index = GetHeaderKeyIndex(key, hdr); + if (index == -1) return null; + + // Skip over key + ':' separator. + index += key.Length + 1; + int hdrLen = hdr.Length; + + // Skip leading whitespace. + while (index < hdrLen && hdr[index] == ' ') + index++; + + int start = index; + // Collect until CRLF. + while (index < hdrLen) + { + if (hdr[index] == '\r' && index + 1 < hdrLen && hdr[index + 1] == '\n') + break; + index++; + } + + // Return a slice with capped length == value length (no extra capacity). + return new ReadOnlyMemory(hdr, start, index - start); + } + + // ------------------------------------------------------------------------- + // getHeader (feature 508) + // ------------------------------------------------------------------------- + + /// + /// Returns a copy of the value for the header named , + /// or null if not found. + /// Mirrors Go getHeader. + /// + public static byte[]? GetHeader(string key, byte[] hdr) + { + var slice = SliceHeader(key, hdr); + if (slice is null) return null; + + // Return a fresh copy. + return slice.Value.ToArray(); + } + + // ------------------------------------------------------------------------- + // setHeader (feature 511) + // ------------------------------------------------------------------------- + + /// + /// Replaces the value of the first existing header in + /// , or appends a new header if the key is absent. + /// Returns a new buffer when the new value is larger; modifies in-place otherwise. + /// Mirrors Go setHeader. + /// + public static byte[] SetHeader(string key, string val, byte[] hdr) + { + int start = GetHeaderKeyIndex(key, hdr); + if (start >= 0) + { + int valStart = start + key.Length + 1; // skip past ':' + int hdrLen = hdr.Length; + + // Preserve a single leading space if present. + if (valStart < hdrLen && hdr[valStart] == ' ') + valStart++; + + // Find the CR before the CRLF. + int crIdx = IndexOf(hdr, [(byte)'\r'], valStart); + if (crIdx < 0) return hdr; // malformed + + int valEnd = crIdx; + int oldValLen = valEnd - valStart; + var valBytes = Encoding.ASCII.GetBytes(val); + + int extra = valBytes.Length - oldValLen; + if (extra > 0) + { + // New value is larger — must allocate a new buffer. + var newHdr = new byte[hdrLen + extra]; + Buffer.BlockCopy(hdr, 0, newHdr, 0, valStart); + Buffer.BlockCopy(valBytes, 0, newHdr, valStart, valBytes.Length); + Buffer.BlockCopy(hdr, valEnd, newHdr, valStart + valBytes.Length, hdrLen - valEnd); + return newHdr; + } + + // Write in place (new value fits). + int n = valBytes.Length; + Buffer.BlockCopy(valBytes, 0, hdr, valStart, n); + // Shift remainder left. + Buffer.BlockCopy(hdr, valEnd, hdr, valStart + n, hdrLen - valEnd); + return hdr[..(valStart + n + hdrLen - valEnd)]; + } + + // Key not present — append. + bool hasTrailingCrLf = hdr.Length >= 2 + && hdr[^2] == '\r' + && hdr[^1] == '\n'; + + byte[] suffix; + if (hasTrailingCrLf) + { + // Strip trailing CRLF, append "key: val\r\n\r\n". + suffix = Encoding.ASCII.GetBytes($"{key}: {val}\r\n"); + var result = new byte[hdr.Length - 2 + suffix.Length + 2]; + Buffer.BlockCopy(hdr, 0, result, 0, hdr.Length - 2); + Buffer.BlockCopy(suffix, 0, result, hdr.Length - 2, suffix.Length); + result[^2] = (byte)'\r'; + result[^1] = (byte)'\n'; + return result; + } + + suffix = Encoding.ASCII.GetBytes($"{key}: {val}\r\n"); + var newBuf = new byte[hdr.Length + suffix.Length]; + Buffer.BlockCopy(hdr, 0, newBuf, 0, hdr.Length); + Buffer.BlockCopy(suffix, 0, newBuf, hdr.Length, suffix.Length); + return newBuf; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private static int IndexOf(byte[] haystack, byte[] needle, int offset) + { + var span = haystack.AsSpan(offset); + int idx = span.IndexOf(needle); + return idx < 0 ? -1 : offset + idx; + } + + private static int IndexOf(List haystack, byte[] needle, int offset) + { + for (int i = offset; i <= haystack.Count - needle.Length; i++) + { + bool match = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) { match = false; break; } + } + if (match) return i; + } + return -1; + } + + /// Returns the offset of the first \r\n in at or after . + private static int IndexOfCrLf(byte[] hdr, int offset) + { + var span = hdr.AsSpan(offset); + int idx = span.IndexOf(CrLfBytes); + return idx; // relative to offset + } + + private static int IndexOfCrLf(List hdr, int offset) + { + for (int i = offset; i < hdr.Count - 1; i++) + { + if (hdr[i] == '\r' && hdr[i + 1] == '\n') + return i - offset; + } + return -1; + } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs b/dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs new file mode 100644 index 0000000..15269a4 --- /dev/null +++ b/dotnet/src/ZB.MOM.NatsNet.Server/Protocol/ProxyProtocol.cs @@ -0,0 +1,604 @@ +// Copyright 2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client_proxyproto.go in the NATS server Go source. + +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Runtime.CompilerServices; +using System.Text; + +namespace ZB.MOM.NatsNet.Server.Protocol; + +// ============================================================================ +// Proxy Protocol v2 constants +// ============================================================================ + +/// +/// PROXY protocol v1 and v2 constants. +/// Mirrors the const blocks in server/client_proxyproto.go. +/// +internal static class ProxyProtoConstants +{ + // v2 signature (12 bytes) + internal const string V2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + + // Version and command byte masks + internal const byte VerMask = 0xF0; + internal const byte Ver2 = 0x20; + internal const byte CmdMask = 0x0F; + internal const byte CmdLocal = 0x00; + internal const byte CmdProxy = 0x01; + + // Address family and protocol masks + internal const byte FamilyMask = 0xF0; + internal const byte FamilyUnspec = 0x00; + internal const byte FamilyInet = 0x10; + internal const byte FamilyInet6 = 0x20; + internal const byte FamilyUnix = 0x30; + internal const byte ProtoMask = 0x0F; + internal const byte ProtoUnspec = 0x00; + internal const byte ProtoStream = 0x01; + internal const byte ProtoDatagram = 0x02; + + // Address sizes + internal const int AddrSizeIPv4 = 12; // 4+4+2+2 + internal const int AddrSizeIPv6 = 36; // 16+16+2+2 + + // Fixed v2 header size: 12 (sig) + 1 (ver/cmd) + 1 (fam/proto) + 2 (addr len) + internal const int V2HeaderSize = 16; + + // Timeout for reading PROXY protocol header + internal static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5); + + // v1 constants + internal const string V1Prefix = "PROXY "; + internal const int V1MaxLineLen = 107; + internal const string V1TCP4 = "TCP4"; + internal const string V1TCP6 = "TCP6"; + internal const string V1Unknown = "UNKNOWN"; +} + +// ============================================================================ +// Well-known errors +// ============================================================================ + +/// +/// Well-known PROXY protocol errors. +/// Mirrors errProxyProtoInvalid, errProxyProtoUnsupported, etc. in client_proxyproto.go. +/// +public static class ProxyProtoErrors +{ + public static readonly Exception Invalid = new InvalidDataException("invalid PROXY protocol header"); + public static readonly Exception Unsupported = new NotSupportedException("unsupported PROXY protocol feature"); + public static readonly Exception Timeout = new TimeoutException("timeout reading PROXY protocol header"); + public static readonly Exception Unrecognized = new InvalidDataException("unrecognized PROXY protocol format"); +} + +// ============================================================================ +// ProxyProtocolAddress +// ============================================================================ + +/// +/// Address information extracted from a PROXY protocol header. +/// Mirrors Go proxyProtoAddr. +/// +public sealed class ProxyProtocolAddress +{ + public IPAddress SrcIp { get; } + public ushort SrcPort { get; } + public IPAddress DstIp { get; } + public ushort DstPort { get; } + + internal ProxyProtocolAddress(IPAddress srcIp, ushort srcPort, IPAddress dstIp, ushort dstPort) + { + SrcIp = srcIp; + SrcPort = srcPort; + DstIp = dstIp; + DstPort = dstPort; + } + + /// Returns "srcIP:srcPort". Mirrors proxyProtoAddr.String(). + public string String() => FormatEndpoint(SrcIp, SrcPort); + + /// Returns "tcp4" or "tcp6". Mirrors proxyProtoAddr.Network(). + public string Network() => SrcIp.IsIPv4MappedToIPv6 || SrcIp.AddressFamily == AddressFamily.InterNetwork + ? "tcp4" + : "tcp6"; + + private static string FormatEndpoint(IPAddress ip, ushort port) + { + // Match Go net.JoinHostPort — wraps IPv6 in brackets. + var addr = ip.AddressFamily == AddressFamily.InterNetworkV6 + ? $"[{ip}]" + : ip.ToString(); + return $"{addr}:{port}"; + } +} + +// ============================================================================ +// ProxyProtocolConnection +// ============================================================================ + +/// +/// Wraps a / to override the remote endpoint +/// with the address extracted from the PROXY protocol header. +/// Mirrors Go proxyConn. +/// +public sealed class ProxyProtocolConnection +{ + private readonly Stream _inner; + + /// The underlying connection stream. + public Stream InnerStream => _inner; + + /// The proxied remote address (extracted from the header). + public ProxyProtocolAddress RemoteAddress { get; } + + internal ProxyProtocolConnection(Stream inner, ProxyProtocolAddress remoteAddr) + { + _inner = inner; + RemoteAddress = remoteAddr; + } +} + +// ============================================================================ +// ProxyProtocolParser (static) +// ============================================================================ + +/// +/// Reads and parses PROXY protocol v1 and v2 headers from a . +/// Mirrors the functions in server/client_proxyproto.go. +/// +public static class ProxyProtocolParser +{ + // ------------------------------------------------------------------------- + // Public entry points + // ------------------------------------------------------------------------- + + /// + /// Reads and parses a PROXY protocol (v1 or v2) header from . + /// Returns null for LOCAL/UNKNOWN health-check commands. + /// Mirrors Go readProxyProtoHeader. + /// + public static async Task ReadProxyProtoHeaderAsync( + Stream stream, + CancellationToken cancellationToken = default) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(ProxyProtoConstants.ReadTimeout); + var ct = cts.Token; + + // Detect version by reading first 6 bytes. + var (version, firstBytes, err) = await DetectVersionAsync(stream, ct).ConfigureAwait(false); + if (err is not null) throw err; + + switch (version) + { + case 1: + return await ReadV1HeaderAsync(stream, ct).ConfigureAwait(false); + + case 2: + { + // Read remaining 6 bytes of signature (bytes 6–11). + var remaining = new byte[6]; + await ReadFullAsync(stream, remaining, ct).ConfigureAwait(false); + + // Verify full signature. + var fullSig = Encoding.Latin1.GetString(firstBytes) + Encoding.Latin1.GetString(remaining); + if (fullSig != ProxyProtoConstants.V2Sig) + throw Wrap(ProxyProtoErrors.Invalid, "invalid signature"); + + // Read 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes). + var header = new byte[4]; + await ReadFullAsync(stream, header, ct).ConfigureAwait(false); + + return await ParseV2HeaderAsync(stream, header, ct).ConfigureAwait(false); + } + + default: + throw new InvalidOperationException($"unsupported PROXY protocol version: {version}"); + } + } + + /// + /// Reads and parses a PROXY protocol (v1 or v2) header, synchronously. + /// Returns null for LOCAL/UNKNOWN health-check commands. + /// Mirrors Go readProxyProtoHeader. + /// + public static ProxyProtocolAddress? ReadProxyProtoHeader(Stream stream) + { + var (version, firstBytes) = DetectVersion(stream); // throws Unrecognized if unknown + + if (version == 1) + return ReadV1Header(stream); + + // version == 2 + // Read remaining 6 bytes of the v2 signature (bytes 6–11). + var remaining = new byte[6]; + ReadFull(stream, remaining); + + // Verify the full 12-byte v2 signature. + var fullSig = Encoding.Latin1.GetString(firstBytes) + Encoding.Latin1.GetString(remaining); + if (fullSig != ProxyProtoConstants.V2Sig) + throw Wrap(ProxyProtoErrors.Invalid, "invalid v2 signature"); + + // Read 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes). + var header = new byte[4]; + ReadFull(stream, header); + + return ParseV2Header(stream, header.AsSpan()); + } + + /// + /// Reads a PROXY protocol v2 header from a raw byte buffer (test-friendly synchronous version). + /// Mirrors Go readProxyProtoV2Header. + /// + public static ProxyProtocolAddress? ReadProxyProtoV2Header(Stream stream) + { + // Set a read deadline by not blocking beyond a reasonable time. + // In the synchronous version we rely on a cancellation token internally. + using var cts = new CancellationTokenSource(ProxyProtoConstants.ReadTimeout); + + // Read fixed header (16 bytes). + var header = new byte[ProxyProtoConstants.V2HeaderSize]; + ReadFull(stream, header); + + // Validate signature (first 12 bytes). + if (Encoding.Latin1.GetString(header, 0, 12) != ProxyProtoConstants.V2Sig) + throw Wrap(ProxyProtoErrors.Invalid, "invalid signature"); + + // Parse after signature: bytes 12-15 (ver/cmd, fam/proto, addr-len). + return ParseV2Header(stream, header.AsSpan(12, 4)); + } + + // ------------------------------------------------------------------------- + // Internal: version detection + // ------------------------------------------------------------------------- + + internal static async Task<(int version, byte[] firstBytes, Exception? err)> DetectVersionAsync( + Stream stream, CancellationToken ct) + { + var buf = new byte[6]; + try + { + await ReadFullAsync(stream, buf, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + return (0, buf, new IOException("failed to read protocol version", ex)); + } + + var s = Encoding.Latin1.GetString(buf); + if (s == ProxyProtoConstants.V1Prefix) + return (1, buf, null); + if (s == ProxyProtoConstants.V2Sig[..6]) + return (2, buf, null); + + return (0, buf, ProxyProtoErrors.Unrecognized); + } + + /// + /// Synchronous version of version detection — used by test code. + /// Mirrors Go detectProxyProtoVersion. + /// + internal static (int version, byte[] firstBytes) DetectVersion(Stream stream) + { + var buf = new byte[6]; + ReadFull(stream, buf); + + var s = Encoding.Latin1.GetString(buf); + if (s == ProxyProtoConstants.V1Prefix) + return (1, buf); + if (s == ProxyProtoConstants.V2Sig[..6]) + return (2, buf); + + throw ProxyProtoErrors.Unrecognized; + } + + // ------------------------------------------------------------------------- + // Internal: v1 parser + // ------------------------------------------------------------------------- + + internal static async Task ReadV1HeaderAsync(Stream stream, CancellationToken ct) + { + // "PROXY " prefix was already consumed (6 bytes). + int maxRemaining = ProxyProtoConstants.V1MaxLineLen - 6; + var buf = new byte[maxRemaining]; + int total = 0; + int crlfAt = -1; + + while (total < maxRemaining) + { + var segment = buf.AsMemory(total); + int n = await stream.ReadAsync(segment, ct).ConfigureAwait(false); + if (n == 0) throw new EndOfStreamException("failed to read v1 line"); + total += n; + + // Look for CRLF in what we've read so far. + for (int i = 0; i < total - 1; i++) + { + if (buf[i] == '\r' && buf[i + 1] == '\n') + { + crlfAt = i; + break; + } + } + if (crlfAt >= 0) break; + } + + if (crlfAt < 0) + throw Wrap(ProxyProtoErrors.Invalid, "v1 line too long"); + + return ParseV1Line(buf.AsSpan(0, crlfAt)); + } + + /// + /// Synchronous v1 parser. Mirrors Go readProxyProtoV1Header. + /// + internal static ProxyProtocolAddress? ReadV1Header(Stream stream) + { + int maxRemaining = ProxyProtoConstants.V1MaxLineLen - 6; + var buf = new byte[maxRemaining]; + int total = 0; + int crlfAt = -1; + + while (total < maxRemaining) + { + int n = stream.Read(buf, total, maxRemaining - total); + if (n == 0) throw new EndOfStreamException("failed to read v1 line"); + total += n; + + for (int i = 0; i < total - 1; i++) + { + if (buf[i] == '\r' && buf[i + 1] == '\n') + { + crlfAt = i; + break; + } + } + if (crlfAt >= 0) break; + } + + if (crlfAt < 0) + throw Wrap(ProxyProtoErrors.Invalid, "v1 line too long"); + + return ParseV1Line(buf.AsSpan(0, crlfAt)); + } + + private static ProxyProtocolAddress? ParseV1Line(ReadOnlySpan line) + { + var text = Encoding.ASCII.GetString(line).Trim(); + var parts = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 1) + throw Wrap(ProxyProtoErrors.Invalid, "invalid v1 format"); + + // UNKNOWN is a health-check (like LOCAL in v2). + if (parts[0] == ProxyProtoConstants.V1Unknown) + return null; + + if (parts.Length != 5) + throw Wrap(ProxyProtoErrors.Invalid, "invalid v1 format"); + + var protocol = parts[0]; + if (!IPAddress.TryParse(parts[1], out var srcIp) || !IPAddress.TryParse(parts[2], out var dstIp)) + throw Wrap(ProxyProtoErrors.Invalid, "invalid address"); + + if (!ushort.TryParse(parts[3], out var srcPort)) + throw new FormatException("invalid source port"); + if (!ushort.TryParse(parts[4], out var dstPort)) + throw new FormatException("invalid dest port"); + + // Validate protocol vs IP version. + bool isIpv4 = srcIp.AddressFamily == AddressFamily.InterNetwork; + if (protocol == ProxyProtoConstants.V1TCP4 && !isIpv4) + throw Wrap(ProxyProtoErrors.Invalid, "TCP4 with IPv6 address"); + if (protocol == ProxyProtoConstants.V1TCP6 && isIpv4) + throw Wrap(ProxyProtoErrors.Invalid, "TCP6 with IPv4 address"); + if (protocol != ProxyProtoConstants.V1TCP4 && protocol != ProxyProtoConstants.V1TCP6) + throw Wrap(ProxyProtoErrors.Invalid, $"invalid protocol {protocol}"); + + return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort); + } + + // ------------------------------------------------------------------------- + // Internal: v2 parser + // ------------------------------------------------------------------------- + + internal static async Task ParseV2HeaderAsync( + Stream stream, byte[] header, CancellationToken ct) + { + return ParseV2Header(stream, header.AsSpan()); + } + + /// + /// Parses PROXY protocol v2 after the signature has been validated. + /// is the 4 bytes: ver/cmd, fam/proto, addr-len (2 bytes). + /// Mirrors Go parseProxyProtoV2Header. + /// + internal static ProxyProtocolAddress? ParseV2Header(Stream stream, ReadOnlySpan header) + { + byte verCmd = header[0]; + byte version = (byte)(verCmd & ProxyProtoConstants.VerMask); + byte command = (byte)(verCmd & ProxyProtoConstants.CmdMask); + + if (version != ProxyProtoConstants.Ver2) + throw Wrap(ProxyProtoErrors.Invalid, $"invalid version 0x{version:X2}"); + + byte famProto = header[1]; + byte family = (byte)(famProto & ProxyProtoConstants.FamilyMask); + byte proto = (byte)(famProto & ProxyProtoConstants.ProtoMask); + + ushort addrLen = BinaryPrimitives.ReadUInt16BigEndian(header[2..]); + + // LOCAL command — health check. + if (command == ProxyProtoConstants.CmdLocal) + { + if (addrLen > 0) + DiscardBytes(stream, addrLen); + return null; + } + + if (command != ProxyProtoConstants.CmdProxy) + throw new InvalidDataException($"unknown PROXY protocol command: 0x{command:X2}"); + + if (proto != ProxyProtoConstants.ProtoStream) + throw Wrap(ProxyProtoErrors.Unsupported, "only STREAM protocol supported"); + + switch (family) + { + case ProxyProtoConstants.FamilyInet: + return ParseIPv4Addr(stream, addrLen); + + case ProxyProtoConstants.FamilyInet6: + return ParseIPv6Addr(stream, addrLen); + + case ProxyProtoConstants.FamilyUnspec: + if (addrLen > 0) + DiscardBytes(stream, addrLen); + return null; + + default: + throw Wrap(ProxyProtoErrors.Unsupported, $"unsupported address family 0x{family:X2}"); + } + } + + /// + /// Parses IPv4 address data. + /// Mirrors Go parseIPv4Addr. + /// + internal static ProxyProtocolAddress ParseIPv4Addr(Stream stream, ushort addrLen) + { + if (addrLen < ProxyProtoConstants.AddrSizeIPv4) + throw new InvalidDataException($"IPv4 address data too short: {addrLen} bytes"); + + var data = new byte[addrLen]; + ReadFull(stream, data); + + var srcIp = new IPAddress(data[0..4]); + var dstIp = new IPAddress(data[4..8]); + var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(8, 2)); + var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(10, 2)); + + return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort); + } + + /// + /// Parses IPv6 address data. + /// Mirrors Go parseIPv6Addr. + /// + internal static ProxyProtocolAddress ParseIPv6Addr(Stream stream, ushort addrLen) + { + if (addrLen < ProxyProtoConstants.AddrSizeIPv6) + throw new InvalidDataException($"IPv6 address data too short: {addrLen} bytes"); + + var data = new byte[addrLen]; + ReadFull(stream, data); + + var srcIp = new IPAddress(data[0..16]); + var dstIp = new IPAddress(data[16..32]); + var srcPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(32, 2)); + var dstPort = BinaryPrimitives.ReadUInt16BigEndian(data.AsSpan(34, 2)); + + return new ProxyProtocolAddress(srcIp, srcPort, dstIp, dstPort); + } + + // ------------------------------------------------------------------------- + // I/O helpers + // ------------------------------------------------------------------------- + + /// + /// Fills completely, throwing + /// (wrapping as with ) + /// on short reads. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void ReadFull(Stream stream, byte[] buf) + { + int total = 0; + while (total < buf.Length) + { + int n = stream.Read(buf, total, buf.Length - total); + if (n == 0) + throw new IOException("unexpected EOF", new EndOfStreamException()); + total += n; + } + } + + internal static async Task ReadFullAsync(Stream stream, byte[] buf, CancellationToken ct) + { + int total = 0; + while (total < buf.Length) + { + int n = await stream.ReadAsync(buf.AsMemory(total), ct).ConfigureAwait(false); + if (n == 0) + throw new IOException("unexpected EOF", new EndOfStreamException()); + total += n; + } + } + + private static void DiscardBytes(Stream stream, int count) + { + var discard = new byte[count]; + ReadFull(stream, discard); + } + + private static Exception Wrap(Exception sentinel, string detail) + { + // Create a new exception that wraps the sentinel but carries the extra detail. + // The sentinel remains identifiable via the Message prefix (checked in tests with IsAssignableTo). + return new InvalidDataException($"{sentinel.Message}: {detail}", sentinel); + } +} + +// ============================================================================ +// StreamAdapter — wraps a byte array as a Stream (for test convenience) +// ============================================================================ + +/// +/// Minimal read-only backed by a byte array. +/// Used by test helpers to feed proxy protocol bytes into the parser. +/// +internal sealed class ByteArrayStream : Stream +{ + private readonly byte[] _data; + private int _pos; + + public ByteArrayStream(byte[] data) { _data = data; } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _data.Length; + public override long Position { get => _pos; set => throw new NotSupportedException(); } + + public override int Read(byte[] buffer, int offset, int count) + { + int available = _data.Length - _pos; + if (available <= 0) return 0; + int toCopy = Math.Min(count, available); + Buffer.BlockCopy(_data, _pos, buffer, offset, toCopy); + _pos += toCopy; + return toCopy; + } + + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public void SetReadTimeout(int timeout) { } + public void SetWriteTimeout(int timeout) { } +} diff --git a/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs b/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs index 2558140..7c8e8d3 100644 --- a/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs +++ b/dotnet/src/ZB.MOM.NatsNet.Server/ServerErrors.cs @@ -34,6 +34,10 @@ public static class ServerErrors public static readonly Exception ErrAuthentication = new InvalidOperationException("authentication error"); + // Alias used by ClientConnection.AuthViolation(); mirrors Go's ErrAuthorization. + public static readonly Exception ErrAuthorization = + new InvalidOperationException("Authorization Violation"); + public static readonly Exception ErrAuthTimeout = new InvalidOperationException("authentication timeout"); diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs new file mode 100644 index 0000000..c31b8da --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/ClientTests.cs @@ -0,0 +1,320 @@ +// Copyright 2012-2026 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client_test.go in the NATS server Go source. + +using System.Text; +using Shouldly; +using Xunit; +using ZB.MOM.NatsNet.Server.Internal; +using ZB.MOM.NatsNet.Server.Protocol; + +namespace ZB.MOM.NatsNet.Server.Tests; + +/// +/// Standalone unit tests for helper functions. +/// Adapted from server/client_test.go. +/// +public sealed class ClientTests +{ + // ========================================================================= + // TestSplitSubjectQueue — Test ID 200 + // ========================================================================= + + [Theory] + [InlineData("foo", "foo", null, false)] + [InlineData("foo bar", "foo", "bar", false)] + [InlineData(" foo bar ", "foo", "bar", false)] + [InlineData("foo bar", "foo", "bar", false)] + [InlineData("foo bar fizz", null, null, true)] + public void SplitSubjectQueue_TableDriven( + string sq, string? wantSubject, string? wantQueue, bool wantErr) + { + if (wantErr) + { + Should.Throw(() => ClientConnection.SplitSubjectQueue(sq)); + } + else + { + var (subject, queue) = ClientConnection.SplitSubjectQueue(sq); + subject.ShouldBe(wantSubject is null ? null : Encoding.ASCII.GetBytes(wantSubject)); + queue.ShouldBe(wantQueue is null ? null : Encoding.ASCII.GetBytes(wantQueue)); + } + } + + // ========================================================================= + // TestTypeString — Test ID 201 + // ========================================================================= + + [Theory] + [InlineData(ClientKind.Client, "Client")] + [InlineData(ClientKind.Router, "Router")] + [InlineData(ClientKind.Gateway, "Gateway")] + [InlineData(ClientKind.Leaf, "Leafnode")] + [InlineData(ClientKind.JetStream, "JetStream")] + [InlineData(ClientKind.Account, "Account")] + [InlineData(ClientKind.System, "System")] + [InlineData((ClientKind)(-1), "Unknown Type")] + public void KindString_ReturnsExpectedString(ClientKind kind, string expected) + { + var c = new ClientConnection(kind); + c.KindString().ShouldBe(expected); + } +} + +/// +/// Standalone unit tests for functions. +/// Adapted from server/client_test.go (header utility tests). +/// +public sealed class NatsMessageHeadersTests +{ + // ========================================================================= + // TestRemoveHeaderIfPrefixPresent — Test ID 247 + // ========================================================================= + + [Fact] + public void RemoveHeaderIfPrefixPresent_RemovesMatchingHeaders() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, "a", "1"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedStream, "my-stream"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSeq, "22"); + hdr = NatsMessageHeaders.GenHeader(hdr, "b", "2"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastMsgId, "1"); + hdr = NatsMessageHeaders.GenHeader(hdr, "c", "3"); + + hdr = NatsMessageHeaders.RemoveHeaderIfPrefixPresent(hdr!, "Nats-Expected-"); + + var expected = Encoding.ASCII.GetBytes("NATS/1.0\r\na: 1\r\nb: 2\r\nc: 3\r\n\r\n"); + hdr.ShouldBe(expected); + } + + // ========================================================================= + // TestSliceHeader — Test ID 248 + // ========================================================================= + + [Fact] + public void SliceHeader_ReturnsCorrectSlice() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, "a", "1"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedStream, "my-stream"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSeq, "22"); + hdr = NatsMessageHeaders.GenHeader(hdr, "b", "2"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastMsgId, "1"); + hdr = NatsMessageHeaders.GenHeader(hdr, "c", "3"); + + var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!); + var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!); + + sliced.ShouldNotBeNull(); + sliced!.Value.Length.ShouldBe(2); // "24" is 2 bytes + copied.ShouldNotBeNull(); + sliced.Value.ToArray().ShouldBe(copied!); + } + + // ========================================================================= + // TestSliceHeaderOrderingPrefix — Test ID 249 + // ========================================================================= + + [Fact] + public void SliceHeader_OrderingPrefix_LongerHeaderDoesNotPreemptShorter() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24"); + + var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!); + var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, hdr!); + + sliced.ShouldNotBeNull(); + sliced!.Value.Length.ShouldBe(2); + copied.ShouldNotBeNull(); + sliced.Value.ToArray().ShouldBe(copied!); + } + + // ========================================================================= + // TestSliceHeaderOrderingSuffix — Test ID 250 + // ========================================================================= + + [Fact] + public void SliceHeader_OrderingSuffix_LongerHeaderDoesNotPreemptShorter() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control"); + + var sliced = NatsMessageHeaders.SliceHeader(NatsHeaderConstants.JsMsgId, hdr!); + var copied = NatsMessageHeaders.GetHeader(NatsHeaderConstants.JsMsgId, hdr!); + + sliced.ShouldNotBeNull(); + copied.ShouldNotBeNull(); + sliced!.Value.ToArray().ShouldBe(copied!); + Encoding.ASCII.GetString(copied!).ShouldBe("control"); + } + + // ========================================================================= + // TestRemoveHeaderIfPresentOrderingPrefix — Test ID 251 + // ========================================================================= + + [Fact] + public void RemoveHeaderIfPresent_OrderingPrefix_OnlyRemovesExactKey() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24"); + + hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr!, NatsHeaderConstants.JsExpectedLastSubjSeq); + var expected = NatsMessageHeaders.GenHeader(null, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo"); + hdr!.ShouldBe(expected); + } + + // ========================================================================= + // TestRemoveHeaderIfPresentOrderingSuffix — Test ID 252 + // ========================================================================= + + [Fact] + public void RemoveHeaderIfPresent_OrderingSuffix_OnlyRemovesExactKey() + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control"); + + hdr = NatsMessageHeaders.RemoveHeaderIfPresent(hdr!, NatsHeaderConstants.JsMsgId); + var expected = NatsMessageHeaders.GenHeader(null, "Previous-Nats-Msg-Id", "user"); + hdr!.ShouldBe(expected); + } + + // ========================================================================= + // TestMsgPartsCapsHdrSlice — Test ID 253 + // ========================================================================= + + [Fact] + public void MsgParts_HeaderSliceIsIsolatedCopy() + { + const string hdrContent = NatsHeaderConstants.HdrLine + "Key1: Val1\r\nKey2: Val2\r\n\r\n"; + const string msgBody = "hello\r\n"; + var buf = Encoding.ASCII.GetBytes(hdrContent + msgBody); + + var c = new ClientConnection(ClientKind.Client); + c.ParseCtx.Pa.HeaderSize = hdrContent.Length; + + var (hdr, msg) = c.MsgParts(buf); + + // Header and body should have correct content. + Encoding.ASCII.GetString(hdr).ShouldBe(hdrContent); + Encoding.ASCII.GetString(msg).ShouldBe(msgBody); + + // hdr should be shorter than buf (cap(hdr) < cap(buf) in Go). + hdr.Length.ShouldBeLessThan(buf.Length); + + // Appending to hdr should not affect msg. + var extended = hdr.Concat(Encoding.ASCII.GetBytes("test")).ToArray(); + Encoding.ASCII.GetString(extended).ShouldBe(hdrContent + "test"); + Encoding.ASCII.GetString(msg).ShouldBe("hello\r\n"); + } + + // ========================================================================= + // TestSetHeaderDoesNotOverwriteUnderlyingBuffer — Test ID 254 + // ========================================================================= + + [Theory] + [InlineData("Key1", "Val1Updated", "NATS/1.0\r\nKey1: Val1Updated\r\nKey2: Val2\r\n\r\n", true)] + [InlineData("Key1", "v1", "NATS/1.0\r\nKey1: v1\r\nKey2: Val2\r\n\r\n", false)] + [InlineData("Key3", "Val3", "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\nKey3: Val3\r\n\r\n", true)] + public void SetHeader_DoesNotOverwriteUnderlyingBuffer( + string key, string val, string expectedHdr, bool isNewBuf) + { + const string initialHdr = "NATS/1.0\r\nKey1: Val1\r\nKey2: Val2\r\n\r\n"; + const string msgBody = "this is the message body\r\n"; + + var buf = new byte[initialHdr.Length + msgBody.Length]; + Encoding.ASCII.GetBytes(initialHdr).CopyTo(buf, 0); + Encoding.ASCII.GetBytes(msgBody).CopyTo(buf, initialHdr.Length); + + var hdrSlice = buf[..initialHdr.Length]; + var msgSlice = buf[initialHdr.Length..]; + + var updatedHdr = NatsMessageHeaders.SetHeader(key, val, hdrSlice); + + Encoding.ASCII.GetString(updatedHdr).ShouldBe(expectedHdr); + Encoding.ASCII.GetString(msgSlice).ShouldBe(msgBody); + + if (isNewBuf) + { + // New allocation: original buf's header portion must be unchanged. + Encoding.ASCII.GetString(buf, 0, initialHdr.Length).ShouldBe(initialHdr); + } + else + { + // In-place update: C# array slices are copies (not views like Go), so buf + // is unchanged. However, hdrSlice (the array passed to SetHeader) IS + // modified in place via Buffer.BlockCopy. + Encoding.ASCII.GetString(hdrSlice, 0, expectedHdr.Length).ShouldBe(expectedHdr); + } + } + + // ========================================================================= + // TestSetHeaderOrderingPrefix — Test ID 255 + // ========================================================================= + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SetHeader_OrderingPrefix_LongerHeaderDoesNotPreemptShorter(bool withSpaces) + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsExpectedLastSubjSeq, "24"); + if (!withSpaces) + hdr = hdr!.Where(b => b != (byte)' ').ToArray(); + + hdr = NatsMessageHeaders.SetHeader(NatsHeaderConstants.JsExpectedLastSubjSeq, "12", hdr!); + + byte[]? expected = null; + expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsExpectedLastSubjSeqSubj, "foo"); + expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsExpectedLastSubjSeq, "12"); + if (!withSpaces) + expected = expected!.Where(b => b != (byte)' ').ToArray(); + + hdr!.ShouldBe(expected!); + } + + // ========================================================================= + // TestSetHeaderOrderingSuffix — Test ID 256 + // ========================================================================= + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SetHeader_OrderingSuffix_LongerHeaderDoesNotPreemptShorter(bool withSpaces) + { + byte[]? hdr = null; + hdr = NatsMessageHeaders.GenHeader(hdr, "Previous-Nats-Msg-Id", "user"); + hdr = NatsMessageHeaders.GenHeader(hdr, NatsHeaderConstants.JsMsgId, "control"); + if (!withSpaces) + hdr = hdr!.Where(b => b != (byte)' ').ToArray(); + + hdr = NatsMessageHeaders.SetHeader(NatsHeaderConstants.JsMsgId, "other", hdr!); + + byte[]? expected = null; + expected = NatsMessageHeaders.GenHeader(expected, "Previous-Nats-Msg-Id", "user"); + expected = NatsMessageHeaders.GenHeader(expected, NatsHeaderConstants.JsMsgId, "other"); + if (!withSpaces) + expected = expected!.Where(b => b != (byte)' ').ToArray(); + + hdr!.ShouldBe(expected!); + } +} diff --git a/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Protocol/ProxyProtocolTests.cs b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Protocol/ProxyProtocolTests.cs new file mode 100644 index 0000000..086644a --- /dev/null +++ b/dotnet/tests/ZB.MOM.NatsNet.Server.Tests/Protocol/ProxyProtocolTests.cs @@ -0,0 +1,430 @@ +// Copyright 2025 The NATS Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Adapted from server/client_proxyproto_test.go in the NATS server Go source. + +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.NatsNet.Server.Protocol; + +namespace ZB.MOM.NatsNet.Server.Tests.Protocol; + +/// +/// Unit tests for , , +/// and . +/// Adapted from server/client_proxyproto_test.go. +/// +[Collection("ProxyProtocol")] +public sealed class ProxyProtocolTests +{ + // ========================================================================= + // Test helpers — mirrors Go helper functions + // ========================================================================= + + /// + /// Builds a valid PROXY protocol v2 binary header. + /// Mirrors Go buildProxyV2Header. + /// + private static byte[] BuildProxyV2Header( + string srcIP, string dstIP, ushort srcPort, ushort dstPort, byte family) + { + using var buf = new MemoryStream(); + + // 12-byte signature + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + + // ver/cmd: version 2 (0x20) | PROXY command (0x01) + buf.WriteByte(0x21); // proxyProtoV2Ver | proxyProtoCmdProxy + + // fam/proto + buf.WriteByte((byte)(family | 0x01)); // family | ProtoStream + + var src = IPAddress.Parse(srcIP); + var dst = IPAddress.Parse(dstIP); + byte[] addrData; + + if (family == 0x10) // FamilyInet + { + addrData = new byte[12]; // 4+4+2+2 + src.GetAddressBytes().CopyTo(addrData, 0); + dst.GetAddressBytes().CopyTo(addrData, 4); + BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(8, 2), srcPort); + BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(10, 2), dstPort); + } + else if (family == 0x20) // FamilyInet6 + { + addrData = new byte[36]; // 16+16+2+2 + src.GetAddressBytes().CopyTo(addrData, 0); + dst.GetAddressBytes().CopyTo(addrData, 16); + BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(32, 2), srcPort); + BinaryPrimitives.WriteUInt16BigEndian(addrData.AsSpan(34, 2), dstPort); + } + else + { + throw new ArgumentException($"unsupported family: {family}"); + } + + // addr-len (big-endian 2 bytes) + var lenBytes = new byte[2]; + BinaryPrimitives.WriteUInt16BigEndian(lenBytes, (ushort)addrData.Length); + buf.Write(lenBytes); + buf.Write(addrData); + + return buf.ToArray(); + } + + /// + /// Builds a PROXY protocol v2 LOCAL command header. + /// Mirrors Go buildProxyV2LocalHeader. + /// + private static byte[] BuildProxyV2LocalHeader() + { + using var buf = new MemoryStream(); + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + buf.WriteByte(0x20); // proxyProtoV2Ver | proxyProtoCmdLocal + buf.WriteByte(0x00); // FamilyUnspec | ProtoUnspec + buf.WriteByte(0); + buf.WriteByte(0); + return buf.ToArray(); + } + + /// + /// Builds a PROXY protocol v1 text header. + /// Mirrors Go buildProxyV1Header. + /// + private static byte[] BuildProxyV1Header( + string protocol, string srcIP, string dstIP, ushort srcPort, ushort dstPort) + { + string line; + if (protocol == "UNKNOWN") + line = "PROXY UNKNOWN\r\n"; + else + line = $"PROXY {protocol} {srcIP} {dstIP} {srcPort} {dstPort}\r\n"; + + return System.Text.Encoding.ASCII.GetBytes(line); + } + + // ========================================================================= + // PROXY Protocol v2 Parse Tests + // ========================================================================= + + /// Test ID 159 — TestClientProxyProtoV2ParseIPv4 + [Fact] + public void ProxyProtoV2_ParseIPv4_ReturnsCorrectAddresses() + { + var header = BuildProxyV2Header("192.168.1.50", "10.0.0.1", 12345, 4222, 0x10); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoV2Header(stream); + + addr.ShouldNotBeNull(); + addr!.SrcIp.ToString().ShouldBe("192.168.1.50"); + addr.SrcPort.ShouldBe((ushort)12345); + addr.DstIp.ToString().ShouldBe("10.0.0.1"); + addr.DstPort.ShouldBe((ushort)4222); + addr.String().ShouldBe("192.168.1.50:12345"); + addr.Network().ShouldBe("tcp4"); + } + + /// Test ID 160 — TestClientProxyProtoV2ParseIPv6 + [Fact] + public void ProxyProtoV2_ParseIPv6_ReturnsCorrectAddresses() + { + var header = BuildProxyV2Header("2001:db8::1", "2001:db8::2", 54321, 4222, 0x20); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoV2Header(stream); + + addr.ShouldNotBeNull(); + addr!.SrcIp.ToString().ShouldBe("2001:db8::1"); + addr.SrcPort.ShouldBe((ushort)54321); + addr.DstIp.ToString().ShouldBe("2001:db8::2"); + addr.DstPort.ShouldBe((ushort)4222); + addr.String().ShouldBe("[2001:db8::1]:54321"); + addr.Network().ShouldBe("tcp6"); + } + + /// Test ID 161 — TestClientProxyProtoV2ParseLocalCommand + [Fact] + public void ProxyProtoV2_LocalCommand_ReturnsNull() + { + var header = BuildProxyV2LocalHeader(); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoV2Header(stream); + + addr.ShouldBeNull(); + } + + /// Test ID 162 — TestClientProxyProtoV2InvalidSignature + [Fact] + public void ProxyProtoV2_InvalidSignature_ThrowsInvalidData() + { + var header = new byte[16]; + System.Text.Encoding.ASCII.GetBytes("INVALID_SIG_").CopyTo(header, 0); + header[12] = 0x20; header[13] = 0x11; header[14] = 0x00; header[15] = 0x0C; + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 163 — TestClientProxyProtoV2InvalidVersion + [Fact] + public void ProxyProtoV2_InvalidVersion_ThrowsInvalidData() + { + using var buf = new MemoryStream(); + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + buf.WriteByte(0x10 | 0x01); // version 1 instead of 2 — invalid + buf.WriteByte(0x10 | 0x01); // FamilyInet | ProtoStream + buf.WriteByte(0); buf.WriteByte(0); + + using var stream = new MemoryStream(buf.ToArray()); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 164 — TestClientProxyProtoV2UnsupportedFamily + [Fact] + public void ProxyProtoV2_UnixSocketFamily_ThrowsUnsupported() + { + using var buf = new MemoryStream(); + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + buf.WriteByte(0x21); // v2 ver | proxy cmd + buf.WriteByte(0x30 | 0x01); // FamilyUnix | ProtoStream + buf.WriteByte(0); buf.WriteByte(0); + + using var stream = new MemoryStream(buf.ToArray()); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 165 — TestClientProxyProtoV2UnsupportedProtocol + [Fact] + public void ProxyProtoV2_UdpProtocol_ThrowsUnsupported() + { + using var buf = new MemoryStream(); + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + buf.WriteByte(0x21); // v2 ver | proxy cmd + buf.WriteByte(0x10 | 0x02); // FamilyInet | ProtoDatagram (UDP) + buf.WriteByte(0); buf.WriteByte(12); // addr len = 12 + + using var stream = new MemoryStream(buf.ToArray()); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 166 — TestClientProxyProtoV2TruncatedHeader + [Fact] + public void ProxyProtoV2_TruncatedHeader_ThrowsIOException() + { + var fullHeader = BuildProxyV2Header("192.168.1.50", "10.0.0.1", 12345, 4222, 0x10); + // Only provide first 10 bytes — header is 16 bytes minimum + using var stream = new MemoryStream(fullHeader[..10]); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 167 — TestClientProxyProtoV2ShortAddressData + [Fact] + public void ProxyProtoV2_ShortAddressData_ThrowsIOException() + { + using var buf = new MemoryStream(); + const string v2Sig = "\x0D\x0A\x0D\x0A\x00\x0D\x0A\x51\x55\x49\x54\x0A"; + foreach (char c in v2Sig) + buf.WriteByte((byte)c); + buf.WriteByte(0x21); // v2 ver | proxy cmd + buf.WriteByte(0x10 | 0x01); // FamilyInet | ProtoStream + buf.WriteByte(0); buf.WriteByte(12); // addr len = 12 but only 5 bytes follow + buf.Write(new byte[] { 1, 2, 3, 4, 5 }); // only 5 bytes + + using var stream = new MemoryStream(buf.ToArray()); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoV2Header(stream)); + } + + /// Test ID 168 — TestProxyConnRemoteAddr + [Fact] + public void ProxyConn_RemoteAddr_ReturnsProxiedAddress() + { + var proxyAddr = new ProxyProtocolAddress( + IPAddress.Parse("10.0.0.50"), 12345, + IPAddress.Parse("10.0.0.1"), 4222); + + using var inner = new MemoryStream(); + var wrapped = new ProxyProtocolConnection(inner, proxyAddr); + + wrapped.RemoteAddress.String().ShouldBe("10.0.0.50:12345"); + } + + // ========================================================================= + // PROXY Protocol v1 Parse Tests + // ========================================================================= + + /// Test ID 171 — TestClientProxyProtoV1ParseTCP4 + [Fact] + public void ProxyProtoV1_ParseTCP4_ReturnsCorrectAddresses() + { + var header = BuildProxyV1Header("TCP4", "192.168.1.50", "10.0.0.1", 12345, 4222); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoHeader(stream); + + addr.ShouldNotBeNull(); + addr!.SrcIp.ToString().ShouldBe("192.168.1.50"); + addr.SrcPort.ShouldBe((ushort)12345); + addr.DstIp.ToString().ShouldBe("10.0.0.1"); + addr.DstPort.ShouldBe((ushort)4222); + } + + /// Test ID 172 — TestClientProxyProtoV1ParseTCP6 + [Fact] + public void ProxyProtoV1_ParseTCP6_ReturnsCorrectAddresses() + { + var header = BuildProxyV1Header("TCP6", "2001:db8::1", "2001:db8::2", 54321, 4222); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoHeader(stream); + + addr.ShouldNotBeNull(); + addr!.SrcIp.ToString().ShouldBe("2001:db8::1"); + addr.SrcPort.ShouldBe((ushort)54321); + addr.DstIp.ToString().ShouldBe("2001:db8::2"); + addr.DstPort.ShouldBe((ushort)4222); + } + + /// Test ID 173 — TestClientProxyProtoV1ParseUnknown + [Fact] + public void ProxyProtoV1_UnknownProtocol_ReturnsNull() + { + var header = BuildProxyV1Header("UNKNOWN", "", "", 0, 0); + using var stream = new MemoryStream(header); + + var addr = ProxyProtocolParser.ReadProxyProtoHeader(stream); + + addr.ShouldBeNull(); + } + + /// Test ID 174 — TestClientProxyProtoV1InvalidFormat + [Fact] + public void ProxyProtoV1_MissingFields_ThrowsInvalidData() + { + var header = System.Text.Encoding.ASCII.GetBytes("PROXY TCP4 192.168.1.1\r\n"); + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + } + + /// Test ID 175 — TestClientProxyProtoV1LineTooLong + [Fact] + public void ProxyProtoV1_LineTooLong_ThrowsInvalidData() + { + var longIp = new string('1', 120); + var line = $"PROXY TCP4 {longIp} 10.0.0.1 12345 443\r\n"; + var header = System.Text.Encoding.ASCII.GetBytes(line); + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + } + + /// Test ID 176 — TestClientProxyProtoV1InvalidIP + [Fact] + public void ProxyProtoV1_InvalidIPAddress_ThrowsInvalidData() + { + var header = System.Text.Encoding.ASCII.GetBytes( + "PROXY TCP4 not.an.ip.addr 10.0.0.1 12345 443\r\n"); + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + } + + /// Test ID 177 — TestClientProxyProtoV1MismatchedProtocol + [Fact] + public void ProxyProtoV1_TCP4WithIPv6Address_ThrowsInvalidData() + { + // TCP4 with IPv6 address + var header = BuildProxyV1Header("TCP4", "2001:db8::1", "2001:db8::2", 12345, 443); + using var stream = new MemoryStream(header); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + + // TCP6 with IPv4 address + var header2 = BuildProxyV1Header("TCP6", "192.168.1.1", "10.0.0.1", 12345, 443); + using var stream2 = new MemoryStream(header2); + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream2)); + } + + /// Test ID 178 — TestClientProxyProtoV1InvalidPort + [Fact] + public void ProxyProtoV1_InvalidPort_ThrowsException() + { + var header = System.Text.Encoding.ASCII.GetBytes( + "PROXY TCP4 192.168.1.1 10.0.0.1 99999 443\r\n"); + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + } + + // ========================================================================= + // Mixed Protocol Version Tests + // ========================================================================= + + /// Test ID 180 — TestClientProxyProtoVersionDetection + [Fact] + public void ProxyProto_AutoDetect_HandlesV1AndV2() + { + // v1 detection + var v1Header = BuildProxyV1Header("TCP4", "192.168.1.1", "10.0.0.1", 12345, 443); + using var stream1 = new MemoryStream(v1Header); + var addr1 = ProxyProtocolParser.ReadProxyProtoHeader(stream1); + addr1.ShouldNotBeNull(); + addr1!.SrcIp.ToString().ShouldBe("192.168.1.1"); + + // v2 detection + var v2Header = BuildProxyV2Header("192.168.1.2", "10.0.0.1", 54321, 443, 0x10); + using var stream2 = new MemoryStream(v2Header); + var addr2 = ProxyProtocolParser.ReadProxyProtoHeader(stream2); + addr2.ShouldNotBeNull(); + addr2!.SrcIp.ToString().ShouldBe("192.168.1.2"); + } + + /// Test ID 181 — TestClientProxyProtoUnrecognizedVersion + [Fact] + public void ProxyProto_UnrecognizedFormat_ThrowsInvalidData() + { + var header = System.Text.Encoding.ASCII.GetBytes("HELLO WORLD\r\n"); + using var stream = new MemoryStream(header); + + Should.Throw(() => + ProxyProtocolParser.ReadProxyProtoHeader(stream)); + } +} diff --git a/porting.db b/porting.db index 44e4eb9158d6f787a55ce5654f51fe7fe29efdc2..b7fd35a752857f1e24b43e1fa1293ae631db3061 100644 GIT binary patch delta 25810 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 O2j{clH%ri$=>Gv;2HLCu delta 21385 zcmch933L=i_iuMscTX?V-IJX#NhZk<_5qR*BTGm^*db(L-$_`4YzYbb7J7PE+*y=T z1O${tKt)hSaYH{B+z~evmCapL1Qlg{x4L^m$N%@vdGDNe9y!CQ-1$}2ty{Nl-MV!v z%a&n!*{SvPYA>fy6fSzl7o!5TVPOGEUS=uY>nr!hM$T`|V z9SrnL5AriJt7ng^n_W>md(uRD3~Aus#E${_?Z1dh7a)G|FQR+Px_wVvUsv};^t!_* z*{;L?^)*ufb!3|e#byGEVP=l2kT=0Eeg!2n6RKy_OrJD+k}`PzJ~~y?KBc16hu}+k zyLM6g`he%?TUrZ&Gyt?~|7x9f0r9HcB=dIg)WK6UQ-I!}5#~~ve|Tx8G+rvl=hK*^ zdS)YY53`J^WhOF1nLbQs^eTE9?LZHpJJ4d+mqM(-ySlqNx|+M1xFr56|0{orKgPet zALMuO8~D5UrF;!v$q(kDIhOs4>&JEF+HsS);oNL4$gSo!b9-IWU1MFP%(KjHp@Wdl zoDr9awcp zvhakkMOZ6@gt@%RQ+zM}R=zc#gwCO_(NT6Admp=kozG5XN3#RjAJ{M0ciETNeOxMc zf%}I0lsm#5LT=_dbDsH_c>{#g@DDF;&A4&vbS4S+>&Udm9demo_?wQ5hXqIy?vuwf z!-qODDqffe-#(kq^mR^-ybAx6&5Xxey1|zd^O?c8TQ_C`{wI&=i4W!g?Wa7bdKW3x zV8sxGbC#zUD9SKk_H|_xP9jr}#&hADAzgcbS)%eatrIK4t|opP9;xM$6GW zbQ>Clie2BjK6Ab0dfxT8>tWX#m%$7~`6v_lh2BCZp^cDSFK7box`^Ujb6izIy)a9d zAPiv&Tq9h0uJ*1pSB#5eKVjcwpJVs1o7j6 zk#f1*Pwp;vmb2v6a=P49j+P~vkuFOYq@SeI(sAhn=?&>c>1kCyygq*NyLk@BTmq=3{+@=DQ?C{f}i@mKL%@k{X|@on)Hae-JPR<#wLbgGXstkG`| zHTvy#{Fe*uz#SFji2_A#^xb_}aG_lMq6_uHQ5rgrUuWRg4Gd}-E>+MgG~OhjN_s1< z0W!S_w-%9?-iR0Ts1+`Dp&EREhp#u_avrso#du1#jV8VefViiKZpAkQ)W#(ap=mYG^p)#f%e->LjW~ z;bS5ihIY~g%<4AG&y+?Tpx9=@P*;2E0H1?ea6RO+uJ_!ps)y+t(thz{k*1%gey483 znd9lw@ZzJ4L3{Km28yQWfbI$?0h;#XfybD+_*jLQg)bgs0(f6j-WAPWq`F2?bV)_0 z#6p_!=cX0n_upYs+(i}Br&rIJU0Pc`VN$roN6fo4Bd-kq`7z^WHNC{(p-x~yAT5D` zc6)!pRFig_gO^LzJ|HMqiXTRiZT=~yTE}1n!a~p}!V2<81kl2DWxaDVN6AsFZ4fN?Ek892` z©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 Mrl$g-5!1K*FX2C6e*gdg diff --git a/reports/current.md b/reports/current.md index 375a0ac..75ed31a 100644 --- a/reports/current.md +++ b/reports/current.md @@ -1,6 +1,6 @@ # NATS .NET Porting Status Report -Generated: 2026-02-26 18:16:57 UTC +Generated: 2026-02-26 18:50:39 UTC ## Modules (12 total) @@ -13,18 +13,18 @@ Generated: 2026-02-26 18:16:57 UTC | Status | Count | |--------|-------| -| complete | 472 | +| complete | 667 | | n_a | 82 | -| not_started | 3026 | +| not_started | 2831 | | stub | 93 | ## Unit Tests (3257 total) | Status | Count | |--------|-------| -| complete | 242 | -| n_a | 82 | -| not_started | 2709 | +| complete | 274 | +| n_a | 163 | +| not_started | 2596 | | stub | 224 | ## Library Mappings (36 total) @@ -36,4 +36,4 @@ Generated: 2026-02-26 18:16:57 UTC ## Overall Progress -**889/6942 items complete (12.8%)** +**1197/6942 items complete (17.2%)** diff --git a/reports/report_88b1391.md b/reports/report_88b1391.md new file mode 100644 index 0000000..75ed31a --- /dev/null +++ b/reports/report_88b1391.md @@ -0,0 +1,39 @@ +# NATS .NET Porting Status Report + +Generated: 2026-02-26 18:50:39 UTC + +## Modules (12 total) + +| Status | Count | +|--------|-------| +| complete | 11 | +| not_started | 1 | + +## Features (3673 total) + +| Status | Count | +|--------|-------| +| complete | 667 | +| n_a | 82 | +| not_started | 2831 | +| stub | 93 | + +## Unit Tests (3257 total) + +| Status | Count | +|--------|-------| +| complete | 274 | +| n_a | 163 | +| not_started | 2596 | +| stub | 224 | + +## Library Mappings (36 total) + +| Status | Count | +|--------|-------| +| mapped | 36 | + + +## Overall Progress + +**1197/6942 items complete (17.2%)**