Port 80 features from server/events.go including the full events infrastructure: internal send/receive loops, system subscription machinery, statsz heartbeats, remote server tracking, connection event advisories, user-info handler, OCSP peer reject events, remote latency merge, kick/ldm client, and helper functions. Add ClearConnectionHeartbeatTimer/SetConnectionHeartbeatTimer to Account, add MsgHandler/SysMsgHandler delegates and supporting types (ServerApiResponse, EventFilterOptions, StatszEventOptions, UserInfo, KickClientReq, LdmClientReq, AccNumSubsReq) to EventTypes.cs, and add Seq field to ServerInfo for heartbeat sequence tracking.
4669 lines
154 KiB
C#
4669 lines
154 KiB
C#
// Copyright 2018-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/accounts.go in the NATS server Go source.
|
|
|
|
using ZB.MOM.NatsNet.Server.Auth;
|
|
using ZB.MOM.NatsNet.Server.Internal;
|
|
using ZB.MOM.NatsNet.Server.Internal.DataStructures;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace ZB.MOM.NatsNet.Server;
|
|
|
|
// ============================================================================
|
|
// Account — full implementation
|
|
// Mirrors Go `Account` struct in server/accounts.go lines 52-119.
|
|
// ============================================================================
|
|
|
|
/// <summary>
|
|
/// Represents a NATS account, tracking clients, subscriptions, imports, exports,
|
|
/// and subject mappings. Implements <see cref="INatsAccount"/> so that
|
|
/// <see cref="ClientConnection"/> can interact with it without a hard dependency.
|
|
/// Mirrors Go <c>Account</c> struct in server/accounts.go.
|
|
/// </summary>
|
|
public sealed partial class Account : INatsAccount
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// Constants
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// jwt.NoLimit equivalent: -1 means no limit applied.
|
|
/// </summary>
|
|
private const int NoLimit = -1;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Identity fields
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Account name. Mirrors Go <c>Name string</c>.</summary>
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
/// <summary>NKey public key. Mirrors Go <c>Nkey string</c>.</summary>
|
|
public string Nkey { get; set; } = string.Empty;
|
|
|
|
/// <summary>JWT issuer key. Mirrors Go <c>Issuer string</c>.</summary>
|
|
public string Issuer { get; set; } = string.Empty;
|
|
|
|
/// <summary>Raw JWT claim string. Mirrors Go <c>claimJWT string</c>.</summary>
|
|
internal string ClaimJwt { get; set; } = string.Empty;
|
|
|
|
/// <summary>Time of last update from resolver. Mirrors Go <c>updated time.Time</c>.</summary>
|
|
internal DateTime Updated { get; set; }
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Locks
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Primary account read/write lock. Mirrors Go <c>mu sync.RWMutex</c>.</summary>
|
|
private readonly ReaderWriterLockSlim _mu = new(LockRecursionPolicy.NoRecursion);
|
|
|
|
/// <summary>Send-queue mutex. Mirrors Go <c>sqmu sync.Mutex</c>.</summary>
|
|
private readonly object _sqmu = new();
|
|
|
|
/// <summary>Leaf-node list lock. Mirrors Go <c>lmu sync.RWMutex</c>.</summary>
|
|
private readonly ReaderWriterLockSlim _lmu = new(LockRecursionPolicy.NoRecursion);
|
|
|
|
/// <summary>Event ID mutex. Mirrors Go <c>eventIdsMu sync.Mutex</c>.</summary>
|
|
private readonly object _eventIdsMu = new();
|
|
|
|
/// <summary>JetStream migration/clear-observer mutex. Mirrors Go <c>jscmMu sync.Mutex</c>.</summary>
|
|
private readonly object _jscmMu = new();
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Subscription index
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Subscription trie for this account. Mirrors Go <c>sl *Sublist</c>.
|
|
/// Set by the server when the account is registered.
|
|
/// </summary>
|
|
internal SubscriptionIndex? Sublist { get; set; }
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal client and send queue (stubs)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Internal account client. Mirrors Go <c>ic *client</c>.
|
|
/// TODO: session 12 — full internal client wiring.
|
|
/// </summary>
|
|
internal ClientConnection? InternalClient { get; set; }
|
|
|
|
/// <summary>
|
|
/// Per-account send queue. Mirrors Go <c>sq *sendq</c>.
|
|
/// </summary>
|
|
internal SendQueue? SendQueue { get; set; }
|
|
|
|
internal SendQueue? GetSendQueue()
|
|
{
|
|
lock (_sqmu)
|
|
{
|
|
return SendQueue;
|
|
}
|
|
}
|
|
|
|
internal void SetSendQueue(SendQueue? sendQueue)
|
|
{
|
|
lock (_sqmu)
|
|
{
|
|
SendQueue = sendQueue;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Eventing timers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Expiration timer. Mirrors Go <c>etmr *time.Timer</c>.</summary>
|
|
private Timer? _etmr;
|
|
|
|
/// <summary>Connection-count timer. Mirrors Go <c>ctmr *time.Timer</c>.</summary>
|
|
private Timer? _ctmr;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Remote server tracking
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Per-server connection and leaf-node counts.
|
|
/// Key is server ID. Mirrors Go <c>strack map[string]sconns</c>.
|
|
/// </summary>
|
|
private Dictionary<string, SConns>? _strack;
|
|
|
|
/// <summary>
|
|
/// Remote client count (sum of strack[*].Conns).
|
|
/// Mirrors Go <c>nrclients int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private int _nrclients;
|
|
|
|
/// <summary>
|
|
/// System client count.
|
|
/// Mirrors Go <c>sysclients int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private int _sysclients;
|
|
|
|
/// <summary>
|
|
/// Local leaf-node count.
|
|
/// Mirrors Go <c>nleafs int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private int _nleafs;
|
|
|
|
/// <summary>
|
|
/// Remote leaf-node count (sum of strack[*].Leafs).
|
|
/// Mirrors Go <c>nrleafs int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private int _nrleafs;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Client set
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Active local clients. Mirrors Go <c>clients map[*client]struct{}</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private HashSet<ClientConnection>? _clients;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Route and leaf-queue maps
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Route map: subject → reference count.
|
|
/// Mirrors Go <c>rm map[string]int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private Dictionary<string, int>? _rm;
|
|
|
|
/// <summary>
|
|
/// Leaf queue weights: subject → weight.
|
|
/// Mirrors Go <c>lqws map[string]int32</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private Dictionary<string, int>? _lqws;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// User revocations
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Revoked user nkeys: key → revocation timestamp (Unix seconds).
|
|
/// Mirrors Go <c>usersRevoked map[string]int64</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
internal Dictionary<string, long>? UsersRevoked { get; set; }
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Subject mappings
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Ordered list of subject mappings. Mirrors Go <c>mappings []*mapping</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private List<SubjectMapping> _mappings = [];
|
|
|
|
/// <summary>
|
|
/// Atomic flag: 1 when <see cref="_mappings"/> is non-empty.
|
|
/// Mirrors Go <c>hasMapped atomic.Bool</c>.
|
|
/// </summary>
|
|
private int _hasMapped; // 0 = false, 1 = true
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Leaf nodes
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Ordered list of local leaf-node clients.
|
|
/// Mirrors Go <c>lleafs []*client</c>.
|
|
/// Protected by <see cref="_lmu"/>.
|
|
/// </summary>
|
|
private List<ClientConnection> _lleafs = [];
|
|
|
|
/// <summary>
|
|
/// Cluster name → count of leaf-node connections from that cluster.
|
|
/// Mirrors Go <c>leafClusters map[string]uint64</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private Dictionary<string, ulong>? _leafClusters;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Import / export maps
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Import tracking. Mirrors Go <c>imports importMap</c>.</summary>
|
|
internal ImportMap Imports { get; set; } = new();
|
|
|
|
/// <summary>Export tracking. Mirrors Go <c>exports exportMap</c>.</summary>
|
|
internal ExportMap Exports { get; set; } = new();
|
|
|
|
// -------------------------------------------------------------------------
|
|
// JetStream (stubs)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// JetStream account state. Mirrors Go <c>js *jsAccount</c>.
|
|
/// TODO: session 19 — JetStream implementation.
|
|
/// </summary>
|
|
internal JsAccount? JetStream { get; set; }
|
|
|
|
/// <summary>
|
|
/// Per-domain JetStream limits. Mirrors Go <c>jsLimits map[string]JetStreamAccountLimits</c>.
|
|
/// TODO: session 19 — JetStream implementation.
|
|
/// </summary>
|
|
internal Dictionary<string, object>? JetStreamLimits { get; set; }
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Misc identity fields
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>Non-routed gateway account name. Mirrors Go <c>nrgAccount string</c>.</summary>
|
|
internal string NrgAccount { get; set; } = string.Empty;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Limits (embedded `limits` struct in Go)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Maximum payload size (-1 = unlimited). Mirrors Go embedded <c>limits.mpay int32</c>.
|
|
/// </summary>
|
|
internal int MaxPayload { get; set; } = NoLimit;
|
|
|
|
/// <summary>
|
|
/// Maximum subscriptions (-1 = unlimited). Mirrors Go embedded <c>limits.msubs int32</c>.
|
|
/// </summary>
|
|
internal int MaxSubscriptions { get; set; } = NoLimit;
|
|
|
|
/// <summary>
|
|
/// Maximum connections (-1 = unlimited). Mirrors Go embedded <c>limits.mconns int32</c>.
|
|
/// </summary>
|
|
internal int MaxConnections { get; set; } = NoLimit;
|
|
|
|
/// <summary>
|
|
/// Maximum leaf nodes (-1 = unlimited). Mirrors Go embedded <c>limits.mleafs int32</c>.
|
|
/// </summary>
|
|
internal int MaxLeafNodes { get; set; } = NoLimit;
|
|
|
|
/// <summary>
|
|
/// When true, bearer tokens are not allowed.
|
|
/// Mirrors Go embedded <c>limits.disallowBearer bool</c>.
|
|
/// </summary>
|
|
internal bool DisallowBearer { get; set; }
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Expiration (atomic)
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// 1 when the account JWT has expired. Mirrors Go <c>expired atomic.Bool</c>.
|
|
/// </summary>
|
|
private int _expired; // 0 = not expired, 1 = expired
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Miscellaneous
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// When true, this account's config could not be fully resolved.
|
|
/// Mirrors Go <c>incomplete bool</c>.
|
|
/// </summary>
|
|
internal bool Incomplete { get; set; }
|
|
|
|
/// <summary>
|
|
/// Signing keys for JWT validation.
|
|
/// Mirrors Go <c>signingKeys map[string]jwt.Scope</c>.
|
|
/// Value is <c>object?</c> because JWT Scope is not yet fully ported.
|
|
/// </summary>
|
|
internal Dictionary<string, object?>? SigningKeys { get; set; }
|
|
|
|
/// <summary>
|
|
/// External authorization configuration stub.
|
|
/// Mirrors Go <c>extAuth *jwt.ExternalAuthorization</c>.
|
|
/// TODO: session 11 — JWT full integration.
|
|
/// </summary>
|
|
internal object? ExternalAuth { get; set; }
|
|
|
|
/// <summary>
|
|
/// The server this account is registered with, or null if not yet registered.
|
|
/// Stored as <c>object?</c> to avoid circular reference.
|
|
/// Mirrors Go <c>srv *Server</c>.
|
|
/// </summary>
|
|
internal object? Server { get; set; }
|
|
|
|
/// <summary>
|
|
/// Loop detection subject for leaf nodes.
|
|
/// Mirrors Go <c>lds string</c>.
|
|
/// </summary>
|
|
internal string LoopDetectionSubject { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Service reply prefix (wildcard subscription root).
|
|
/// Mirrors Go <c>siReply []byte</c>.
|
|
/// </summary>
|
|
internal byte[]? ServiceImportReply { get; set; }
|
|
|
|
/// <summary>
|
|
/// Gateway reply mapping table used for routed reply restoration.
|
|
/// Mirrors Go <c>gwReplyMapping</c>.
|
|
/// </summary>
|
|
internal GwReplyMapping GwReplyMapping { get; } = new();
|
|
|
|
/// <summary>
|
|
/// Subscription ID counter for internal use.
|
|
/// Mirrors Go <c>isid uint64</c>.
|
|
/// </summary>
|
|
private ulong _isid;
|
|
|
|
/// <summary>
|
|
/// Default permissions for users with no explicit permissions.
|
|
/// Mirrors Go <c>defaultPerms *Permissions</c>.
|
|
/// </summary>
|
|
internal Permissions? DefaultPerms { get; set; }
|
|
|
|
/// <summary>
|
|
/// Account tags from JWT. Mirrors Go <c>tags jwt.TagList</c>.
|
|
/// Stored as string array pending full JWT integration.
|
|
/// </summary>
|
|
internal string[] Tags { get; set; } = [];
|
|
|
|
/// <summary>
|
|
/// Human-readable name tag (distinct from <see cref="Name"/>).
|
|
/// Mirrors Go <c>nameTag string</c>.
|
|
/// </summary>
|
|
internal string NameTag { get; set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Unix-nanosecond timestamp of last max-subscription-limit log.
|
|
/// Mirrors Go <c>lastLimErr int64</c>.
|
|
/// </summary>
|
|
private long _lastLimErr;
|
|
|
|
/// <summary>
|
|
/// Route pool index (-1 = dedicated, -2 = transitioning, ≥ 0 = shared).
|
|
/// Mirrors Go <c>routePoolIdx int</c>.
|
|
/// </summary>
|
|
internal int RoutePoolIdx { get; set; }
|
|
|
|
/// <summary>
|
|
/// Message-tracing destination subject.
|
|
/// Mirrors Go <c>traceDest string</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private string _traceDest = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Tracing sampling percentage (0 = header-triggered, 1-100 = rate).
|
|
/// Mirrors Go <c>traceDestSampling int</c>.
|
|
/// Protected by <see cref="_mu"/>.
|
|
/// </summary>
|
|
private int _traceDestSampling;
|
|
|
|
/// <summary>
|
|
/// Sets account-level message trace destination subject.
|
|
/// Mirrors writes to Go <c>acc.traceDest</c> during config parsing.
|
|
/// </summary>
|
|
internal void SetMessageTraceDestination(string subject)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_traceDest = subject ?? string.Empty;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns account-level message trace destination subject.
|
|
/// Mirrors reads of Go <c>acc.traceDest</c> during config parsing.
|
|
/// </summary>
|
|
internal string GetMessageTraceDestination()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return _traceDest;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets account-level message trace sampling percentage.
|
|
/// Mirrors writes to Go <c>acc.traceDestSampling</c> during config parsing.
|
|
/// </summary>
|
|
internal void SetMessageTraceSampling(int sampling)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_traceDestSampling = sampling;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns account-level message trace sampling percentage.
|
|
/// Mirrors reads of Go <c>acc.traceDestSampling</c> during config parsing.
|
|
/// </summary>
|
|
internal int GetMessageTraceSampling()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return _traceDestSampling;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets account-level message trace destination subject.
|
|
/// Mirrors Go <c>(a *Account) setTraceDest(dest string)</c>.
|
|
/// </summary>
|
|
internal void SetTraceDest(string dest) => SetMessageTraceDestination(dest);
|
|
|
|
/// <summary>
|
|
/// Returns trace destination and sampling.
|
|
/// Mirrors Go <c>(a *Account) getTraceDestAndSampling() (string, int)</c>.
|
|
/// </summary>
|
|
internal (string Destination, int Sampling) GetTraceDestAndSampling()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return (_traceDest, _traceDestSampling);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Factory
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Creates a new unlimited account with the given name.
|
|
/// Mirrors Go <c>NewAccount(name string) *Account</c>.
|
|
/// </summary>
|
|
public static Account NewAccount(string name) =>
|
|
new()
|
|
{
|
|
Name = name,
|
|
MaxPayload = NoLimit,
|
|
MaxSubscriptions = NoLimit,
|
|
MaxConnections = NoLimit,
|
|
MaxLeafNodes = NoLimit,
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Object overrides
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns the account name. Mirrors Go <c>(a *Account) String() string</c>.
|
|
/// </summary>
|
|
public override string ToString() => Name;
|
|
|
|
/// <summary>
|
|
/// Returns the account name.
|
|
/// Mirrors Go <c>(a *Account) String() string</c>.
|
|
/// </summary>
|
|
public string String() => Name;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Shallow copy for config reload
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Copies identity and config fields from the options-struct account (<c>a</c>)
|
|
/// into the live server account (<c>na</c>). The write lock on <c>na</c> must
|
|
/// be held by the caller; <c>this</c> (the options account) requires no lock.
|
|
/// Mirrors Go <c>(a *Account) shallowCopy(na *Account)</c>.
|
|
/// </summary>
|
|
internal void ShallowCopy(Account na)
|
|
{
|
|
na.Nkey = Nkey;
|
|
na.Issuer = Issuer;
|
|
na._traceDest = _traceDest;
|
|
na._traceDestSampling = _traceDestSampling;
|
|
na.NrgAccount = NrgAccount;
|
|
|
|
// Stream imports — shallow-clone each entry.
|
|
if (Imports.Streams != null)
|
|
{
|
|
na.Imports.Streams = new List<StreamImportEntry>(Imports.Streams.Count);
|
|
foreach (var si in Imports.Streams)
|
|
{
|
|
// Struct-style shallow copy via record-style clone.
|
|
na.Imports.Streams.Add(new StreamImportEntry
|
|
{
|
|
Account = si.Account,
|
|
From = si.From,
|
|
To = si.To,
|
|
Transform = si.Transform,
|
|
ReverseTransform = si.ReverseTransform,
|
|
Claim = si.Claim,
|
|
UsePublishedSubject = si.UsePublishedSubject,
|
|
Invalid = si.Invalid,
|
|
AllowTrace = si.AllowTrace,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Service imports — shallow-clone each inner list.
|
|
if (Imports.Services != null)
|
|
{
|
|
na.Imports.Services = new Dictionary<string, List<ServiceImportEntry>>(Imports.Services.Count);
|
|
foreach (var (k, list) in Imports.Services)
|
|
{
|
|
var cloned = new List<ServiceImportEntry>(list.Count);
|
|
foreach (var si in list)
|
|
{
|
|
cloned.Add(new ServiceImportEntry
|
|
{
|
|
Account = si.Account,
|
|
Claim = si.Claim,
|
|
ServiceExport = si.ServiceExport,
|
|
SubscriptionId = si.SubscriptionId,
|
|
From = si.From,
|
|
To = si.To,
|
|
Transform = si.Transform,
|
|
Timestamp = si.Timestamp,
|
|
ResponseType = si.ResponseType,
|
|
Latency = si.Latency,
|
|
M1 = si.M1,
|
|
RequestingClient = si.RequestingClient,
|
|
UsePublishedSubject = si.UsePublishedSubject,
|
|
IsResponse = si.IsResponse,
|
|
Invalid = si.Invalid,
|
|
Share = si.Share,
|
|
Tracking = si.Tracking,
|
|
DidDeliver = si.DidDeliver,
|
|
AllowTrace = si.AllowTrace,
|
|
TrackingHeader = si.TrackingHeader,
|
|
});
|
|
}
|
|
na.Imports.Services[k] = cloned;
|
|
}
|
|
}
|
|
|
|
// Stream exports — shallow-clone each entry.
|
|
if (Exports.Streams != null)
|
|
{
|
|
na.Exports.Streams = new Dictionary<string, StreamExport>(Exports.Streams.Count);
|
|
foreach (var (k, se) in Exports.Streams)
|
|
{
|
|
na.Exports.Streams[k] = se == null ? null! : new StreamExport
|
|
{
|
|
TokenRequired = se.TokenRequired,
|
|
AccountPosition = se.AccountPosition,
|
|
Approved = se.Approved,
|
|
ActivationsRevoked = se.ActivationsRevoked,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Service exports — shallow-clone each entry.
|
|
if (Exports.Services != null)
|
|
{
|
|
na.Exports.Services = new Dictionary<string, ServiceExportEntry>(Exports.Services.Count);
|
|
foreach (var (k, se) in Exports.Services)
|
|
{
|
|
na.Exports.Services[k] = se == null ? null! : new ServiceExportEntry
|
|
{
|
|
Account = se.Account,
|
|
ResponseType = se.ResponseType,
|
|
Latency = se.Latency,
|
|
ResponseTimer = se.ResponseTimer,
|
|
ResponseThreshold = se.ResponseThreshold,
|
|
AllowTrace = se.AllowTrace,
|
|
TokenRequired = se.TokenRequired,
|
|
AccountPosition = se.AccountPosition,
|
|
Approved = se.Approved,
|
|
ActivationsRevoked = se.ActivationsRevoked,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Mappings and limits — copy by reference / value.
|
|
na._mappings = _mappings;
|
|
Interlocked.Exchange(ref na._hasMapped, _mappings.Count > 0 ? 1 : 0);
|
|
|
|
// JetStream limits — shared reference.
|
|
// TODO: session 19 — deep copy JetStream limits when ported.
|
|
na.JetStreamLimits = JetStreamLimits;
|
|
|
|
// Server-config account limits.
|
|
na.MaxPayload = MaxPayload;
|
|
na.MaxSubscriptions = MaxSubscriptions;
|
|
na.MaxConnections = MaxConnections;
|
|
na.MaxLeafNodes = MaxLeafNodes;
|
|
na.DisallowBearer = DisallowBearer;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Event ID generation
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Generates a unique event identifier using its own dedicated lock.
|
|
/// Mirrors Go <c>(a *Account) nextEventID() string</c>.
|
|
/// </summary>
|
|
internal string NextEventId()
|
|
{
|
|
lock (_eventIdsMu)
|
|
{
|
|
return Guid.NewGuid().ToString("N");
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Client accessors
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns a snapshot list of clients. Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) getClientsLocked() []*client</c>.
|
|
/// </summary>
|
|
internal List<ClientConnection> GetClientsLocked()
|
|
{
|
|
if (_clients == null || _clients.Count == 0)
|
|
return [];
|
|
|
|
return [.. _clients];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a thread-safe snapshot list of clients.
|
|
/// Mirrors Go <c>(a *Account) getClients() []*client</c>.
|
|
/// </summary>
|
|
internal List<ClientConnection> GetClients()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return GetClientsLocked();
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a snapshot of non-internal clients. Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) getExternalClientsLocked() []*client</c>.
|
|
/// </summary>
|
|
internal List<ClientConnection> GetExternalClientsLocked()
|
|
{
|
|
if (_clients == null || _clients.Count == 0)
|
|
return [];
|
|
|
|
var result = new List<ClientConnection>(_clients.Count);
|
|
foreach (var c in _clients)
|
|
{
|
|
if (!IsInternalClientKind(c.Kind))
|
|
result.Add(c);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Remote server tracking
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Updates the remote-server tracking table for this account based on an
|
|
/// incoming <see cref="AccountNumConns"/> message, and returns the set of
|
|
/// local clients that must be disconnected because a connection limit has
|
|
/// been exceeded (after accounting for remote connections).
|
|
/// Mirrors Go <c>(a *Account) updateRemoteServer(m *AccountNumConns) []*client</c>.
|
|
/// </summary>
|
|
internal List<ClientConnection> UpdateRemoteServer(AccountNumConns m)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_strack ??= new Dictionary<string, SConns>();
|
|
|
|
_strack.TryGetValue(m.Server.Id, out var prev);
|
|
_strack[m.Server.Id] = new SConns
|
|
{
|
|
Conns = m.Conns,
|
|
Leafs = m.LeafNodes,
|
|
};
|
|
|
|
_nrclients += m.Conns - (prev?.Conns ?? 0);
|
|
_nrleafs += m.LeafNodes - (prev?.Leafs ?? 0);
|
|
|
|
var localCount = _clients?.Count ?? 0;
|
|
|
|
// Check if total connections exceed the limit.
|
|
bool maxConnsExceeded = MaxConnections != NoLimit &&
|
|
(localCount - _sysclients + _nrclients) > MaxConnections;
|
|
|
|
List<ClientConnection> toDisconnect = [];
|
|
|
|
if (maxConnsExceeded)
|
|
{
|
|
var external = GetExternalClientsLocked();
|
|
|
|
// Sort: newest connections first (reverse chronological).
|
|
// TODO: session 12 — sort by c.Start once ClientConnection has a Start field.
|
|
// For now we cannot sort without the start time, so take from end.
|
|
|
|
int over = (localCount - _sysclients + _nrclients) - MaxConnections;
|
|
if (over < external.Count)
|
|
toDisconnect.AddRange(external.GetRange(0, over));
|
|
else
|
|
toDisconnect.AddRange(external);
|
|
}
|
|
|
|
// Check if total leaf nodes exceed the limit.
|
|
bool maxLeafsExceeded = MaxLeafNodes != NoLimit &&
|
|
(_nleafs + _nrleafs) > MaxLeafNodes;
|
|
|
|
if (maxLeafsExceeded)
|
|
{
|
|
_lmu.EnterReadLock();
|
|
try
|
|
{
|
|
int over = _nleafs + _nrleafs - MaxLeafNodes;
|
|
if (over > 0)
|
|
{
|
|
int start = Math.Max(0, _lleafs.Count - over);
|
|
toDisconnect.AddRange(_lleafs.GetRange(start, _lleafs.Count - start));
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_lmu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
return toDisconnect;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes tracking for a remote server that has shut down.
|
|
/// Mirrors Go <c>(a *Account) removeRemoteServer(sid string)</c>.
|
|
/// </summary>
|
|
internal void RemoveRemoteServer(string sid)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (_strack != null && _strack.TryGetValue(sid, out var prev))
|
|
{
|
|
_strack.Remove(sid);
|
|
_nrclients -= prev.Conns;
|
|
_nrleafs -= prev.Leafs;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of remote servers that have at least one connection or
|
|
/// leaf-node for this account.
|
|
/// Mirrors Go <c>(a *Account) expectedRemoteResponses() int32</c>.
|
|
/// </summary>
|
|
internal int ExpectedRemoteResponses()
|
|
{
|
|
int expected = 0;
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (_strack != null)
|
|
{
|
|
foreach (var sc in _strack.Values)
|
|
{
|
|
if (sc.Conns > 0 || sc.Leafs > 0)
|
|
expected++;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
return expected;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Eventing
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Clears eventing state including timers, clients, and remote tracking.
|
|
/// Mirrors Go <c>(a *Account) clearEventing()</c>.
|
|
/// </summary>
|
|
internal void ClearEventing()
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_nrclients = 0;
|
|
ClearTimerLocked(ref _etmr);
|
|
ClearTimerLocked(ref _ctmr);
|
|
_clients = null;
|
|
_strack = null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Name accessors
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns the account name, thread-safely.
|
|
/// Mirrors Go <c>(a *Account) GetName() string</c>.
|
|
/// </summary>
|
|
public string GetName()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return Name;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the <see cref="NameTag"/> if set, otherwise <see cref="Name"/>.
|
|
/// Acquires a read lock.
|
|
/// Mirrors Go <c>(a *Account) getNameTag() string</c>.
|
|
/// </summary>
|
|
internal string GetNameTag()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return GetNameTagLocked();
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the <see cref="NameTag"/> if set, otherwise <see cref="Name"/>.
|
|
/// Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) getNameTagLocked() string</c>.
|
|
/// </summary>
|
|
internal string GetNameTagLocked() =>
|
|
string.IsNullOrEmpty(NameTag) ? Name : NameTag;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Connection counts
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns the total number of active clients across all servers (local minus
|
|
/// system accounts plus remote).
|
|
/// Mirrors Go <c>(a *Account) NumConnections() int</c>.
|
|
/// </summary>
|
|
public int NumConnections()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return (_clients?.Count ?? 0) - _sysclients + _nrclients;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of client and leaf-node connections that are on
|
|
/// remote servers.
|
|
/// Mirrors Go <c>(a *Account) NumRemoteConnections() int</c>.
|
|
/// </summary>
|
|
public int NumRemoteConnections()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return _nrclients + _nrleafs;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the number of non-system, non-leaf clients on this server.
|
|
/// Mirrors Go <c>(a *Account) NumLocalConnections() int</c>.
|
|
/// </summary>
|
|
public int NumLocalConnections()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return NumLocalConnectionsLocked();
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns local non-system, non-leaf client count. Lock must be held.
|
|
/// Mirrors Go <c>(a *Account) numLocalConnections() int</c>.
|
|
/// </summary>
|
|
internal int NumLocalConnectionsLocked() =>
|
|
(_clients?.Count ?? 0) - _sysclients - _nleafs;
|
|
|
|
/// <summary>
|
|
/// Returns local non-system, non-leaf client count. Lock must be held.
|
|
/// Mirrors Go <c>(a *Account) numLocalConnections() int</c>.
|
|
/// </summary>
|
|
internal int NumLocalConnectionsInternal() => NumLocalConnectionsLocked();
|
|
|
|
/// <summary>
|
|
/// Returns all local connections including leaf nodes (minus system clients).
|
|
/// Mirrors Go <c>(a *Account) numLocalAndLeafConnections() int</c>.
|
|
/// </summary>
|
|
internal int NumLocalAndLeafConnections()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return (_clients?.Count ?? 0) - _sysclients;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the local leaf-node count.
|
|
/// Mirrors Go <c>(a *Account) numLocalLeafNodes() int</c>.
|
|
/// </summary>
|
|
internal int NumLocalLeafNodes() => _nleafs;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Connection limit checks
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true if the total (local + remote) client count has reached or
|
|
/// exceeded the configured limit.
|
|
/// Mirrors Go <c>(a *Account) MaxTotalConnectionsReached() bool</c>.
|
|
/// </summary>
|
|
public bool MaxTotalConnectionsReached()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (MaxConnections == NoLimit) return false;
|
|
return (_clients?.Count ?? 0) - _sysclients + _nrclients >= MaxConnections;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the configured maximum connections limit.
|
|
/// Mirrors Go <c>(a *Account) MaxActiveConnections() int</c>.
|
|
/// </summary>
|
|
public int MaxActiveConnections()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return MaxConnections; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Leaf-node limit checks
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true if the total (local + remote) leaf-node count has reached or
|
|
/// exceeded the configured limit.
|
|
/// Mirrors Go <c>(a *Account) MaxTotalLeafNodesReached() bool</c>.
|
|
/// </summary>
|
|
public bool MaxTotalLeafNodesReached()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return MaxTotalLeafNodesReachedLocked(); }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) maxTotalLeafNodesReached() bool</c>.
|
|
/// </summary>
|
|
internal bool MaxTotalLeafNodesReachedLocked()
|
|
{
|
|
if (MaxLeafNodes == NoLimit) return false;
|
|
return _nleafs + _nrleafs >= MaxLeafNodes;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if total leaf-node count reached the configured maximum.
|
|
/// Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) maxTotalLeafNodesReached() bool</c>.
|
|
/// </summary>
|
|
internal bool MaxTotalLeafNodesReachedInternal() => MaxTotalLeafNodesReachedLocked();
|
|
|
|
/// <summary>
|
|
/// Returns the total leaf-node count (local + remote).
|
|
/// Mirrors Go <c>(a *Account) NumLeafNodes() int</c>.
|
|
/// </summary>
|
|
public int NumLeafNodes()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return _nleafs + _nrleafs; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the remote leaf-node count.
|
|
/// Mirrors Go <c>(a *Account) NumRemoteLeafNodes() int</c>.
|
|
/// </summary>
|
|
public int NumRemoteLeafNodes()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return _nrleafs; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the configured maximum leaf-nodes limit.
|
|
/// Mirrors Go <c>(a *Account) MaxActiveLeafNodes() int</c>.
|
|
/// </summary>
|
|
public int MaxActiveLeafNodes()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return MaxLeafNodes; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Subscription counts
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns the number of route-map entries (subjects sent across routes).
|
|
/// Mirrors Go <c>(a *Account) RoutedSubs() int</c>.
|
|
/// </summary>
|
|
public int RoutedSubs()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return _rm?.Count ?? 0; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the total number of subscriptions in this account's subscription index.
|
|
/// Mirrors Go <c>(a *Account) TotalSubs() int</c>.
|
|
/// </summary>
|
|
public int TotalSubs()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Sublist == null) return 0;
|
|
return (int)Sublist.Count();
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when there is at least one matching subscription for <paramref name="subject"/>.
|
|
/// Mirrors Go <c>(a *Account) SubscriptionInterest(subject string) bool</c>.
|
|
/// </summary>
|
|
public bool SubscriptionInterest(string subject) => Interest(subject) > 0;
|
|
|
|
/// <summary>
|
|
/// Returns total number of plain and queue subscriptions matching <paramref name="subject"/>.
|
|
/// Mirrors Go <c>(a *Account) Interest(subject string) int</c>.
|
|
/// </summary>
|
|
public int Interest(string subject)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Sublist == null)
|
|
return 0;
|
|
|
|
var (np, nq) = Sublist.NumInterest(subject);
|
|
return np + nq;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Increments the leaf-node count for a remote cluster.
|
|
/// Mirrors Go <c>(a *Account) registerLeafNodeCluster(cluster string)</c>.
|
|
/// </summary>
|
|
internal void RegisterLeafNodeCluster(string cluster)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_leafClusters ??= new Dictionary<string, ulong>(StringComparer.Ordinal);
|
|
_leafClusters.TryGetValue(cluster, out var current);
|
|
_leafClusters[cluster] = current + 1;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when this account already tracks one or more leaf nodes from <paramref name="cluster"/>.
|
|
/// Mirrors Go <c>(a *Account) hasLeafNodeCluster(cluster string) bool</c>.
|
|
/// </summary>
|
|
internal bool HasLeafNodeCluster(string cluster)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return _leafClusters != null &&
|
|
_leafClusters.TryGetValue(cluster, out var count) &&
|
|
count > 0;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when the account is leaf-cluster isolated to <paramref name="cluster"/>.
|
|
/// Mirrors Go <c>(a *Account) isLeafNodeClusterIsolated(cluster string) bool</c>.
|
|
/// </summary>
|
|
internal bool IsLeafNodeClusterIsolated(string cluster)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (string.IsNullOrEmpty(cluster))
|
|
return false;
|
|
if (_leafClusters == null || _leafClusters.Count > 1)
|
|
return false;
|
|
|
|
return _leafClusters.TryGetValue(cluster, out var count) && count == (ulong)_nleafs;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Subscription limit error throttle
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true when it is appropriate to log a max-subscription-limit error.
|
|
/// Rate-limited to at most once per <see cref="AccountEventConstants.DefaultMaxSubLimitReportThreshold"/>.
|
|
/// Mirrors Go <c>(a *Account) shouldLogMaxSubErr() bool</c>.
|
|
/// </summary>
|
|
internal bool ShouldLogMaxSubErr()
|
|
{
|
|
_mu.EnterReadLock();
|
|
long last = Interlocked.Read(ref _lastLimErr);
|
|
_mu.ExitReadLock();
|
|
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000L; // nanoseconds
|
|
long threshold = (long)AccountEventConstants.DefaultMaxSubLimitReportThreshold.TotalMilliseconds * 1_000_000L;
|
|
|
|
if (now - last < threshold)
|
|
return false;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
Interlocked.Exchange(ref _lastLimErr, now);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Expiration
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true when the account JWT has expired.
|
|
/// Mirrors Go <c>(a *Account) IsExpired() bool</c>.
|
|
/// </summary>
|
|
public bool IsExpired() =>
|
|
Interlocked.CompareExchange(ref _expired, 0, 0) == 1;
|
|
|
|
/// <summary>
|
|
/// Returns true when this account is backed by a JWT claim.
|
|
/// Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) isClaimAccount() bool</c>.
|
|
/// </summary>
|
|
internal bool IsClaimAccount() =>
|
|
!string.IsNullOrEmpty(ClaimJwt);
|
|
|
|
/// <summary>
|
|
/// Invoked when the expiration timer fires: marks expired and collects clients.
|
|
/// Mirrors Go <c>(a *Account) expiredTimeout()</c>.
|
|
/// </summary>
|
|
private void ExpiredTimeout()
|
|
{
|
|
Interlocked.Exchange(ref _expired, 1);
|
|
|
|
var clients = GetClients();
|
|
foreach (var c in clients)
|
|
{
|
|
if (!IsInternalClientKind(c.Kind))
|
|
{
|
|
// TODO: session 12 — call c.AccountAuthExpired() once fully ported.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts or resets the JWT expiration timer.
|
|
/// Mirrors Go <c>(a *Account) setExpirationTimer(d time.Duration)</c>.
|
|
/// </summary>
|
|
internal void SetExpirationTimer(TimeSpan d)
|
|
{
|
|
_etmr = new Timer(_ => ExpiredTimeout(), null, d, Timeout.InfiniteTimeSpan);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the expiration timer. Returns true if it was active.
|
|
/// Lock must be held by the caller.
|
|
/// Mirrors Go <c>(a *Account) clearExpirationTimer() bool</c>.
|
|
/// </summary>
|
|
internal bool ClearExpirationTimer()
|
|
{
|
|
if (_etmr == null)
|
|
return true;
|
|
|
|
_etmr.Dispose();
|
|
_etmr = null;
|
|
return true;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Subject mappings — public API
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Adds a simple 1:1 subject mapping from <paramref name="src"/> to
|
|
/// <paramref name="dest"/> with weight 100.
|
|
/// Mirrors Go <c>(a *Account) AddMapping(src, dest string) error</c>.
|
|
/// </summary>
|
|
public Exception? AddMapping(string src, string dest) =>
|
|
AddWeightedMappings(src, MapDest.New(dest, 100));
|
|
|
|
/// <summary>
|
|
/// Adds weighted subject mappings for one or more destinations.
|
|
/// Total weights must not exceed 100 per cluster group. If the total is
|
|
/// less than 100 and the source was not listed as a destination, the
|
|
/// remainder is automatically routed back to the source.
|
|
/// Weights are converted to cumulative form and sorted ascending so that
|
|
/// random selection can use a single linear scan.
|
|
/// Mirrors Go <c>(a *Account) AddWeightedMappings(src string, dests ...*MapDest) error</c>.
|
|
/// </summary>
|
|
public Exception? AddWeightedMappings(string src, params MapDest[] dests)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(src))
|
|
return ServerErrors.ErrBadSubject;
|
|
|
|
bool hasWildcard = SubscriptionIndex.SubjectHasWildcard(src);
|
|
var m = new SubjectMapping
|
|
{
|
|
Source = src,
|
|
HasWildcard = hasWildcard,
|
|
Destinations = new List<Destination>(dests.Length + 1),
|
|
};
|
|
|
|
var seen = new HashSet<string>(dests.Length);
|
|
var totals = new Dictionary<string, byte>(); // cluster → cumulative weight
|
|
|
|
foreach (var d in dests)
|
|
{
|
|
if (!seen.Add(d.Subject))
|
|
return new InvalidOperationException($"duplicate entry for \"{d.Subject}\"");
|
|
|
|
if (d.Weight > 100)
|
|
return new InvalidOperationException("individual weights need to be <= 100");
|
|
|
|
totals.TryGetValue(d.Cluster, out byte tw);
|
|
int next = tw + d.Weight;
|
|
if (next > 100)
|
|
return new InvalidOperationException("total weight needs to be <= 100");
|
|
totals[d.Cluster] = (byte)next;
|
|
|
|
// Validate the transform is valid.
|
|
var validateErr = ValidateMapping(src, d.Subject);
|
|
if (validateErr != null)
|
|
return validateErr;
|
|
|
|
var (tr, trErr) = SubjectTransform.New(src, d.Subject);
|
|
if (trErr != null)
|
|
return trErr;
|
|
|
|
if (string.IsNullOrEmpty(d.Cluster))
|
|
{
|
|
m.Destinations.Add(new Destination { Transform = tr, Weight = d.Weight });
|
|
}
|
|
else
|
|
{
|
|
m.ClusterDestinations ??= new Dictionary<string, List<Destination>>();
|
|
if (!m.ClusterDestinations.TryGetValue(d.Cluster, out var clusterList))
|
|
{
|
|
clusterList = [];
|
|
m.ClusterDestinations[d.Cluster] = clusterList;
|
|
}
|
|
clusterList.Add(new Destination { Transform = tr, Weight = d.Weight });
|
|
}
|
|
}
|
|
|
|
// Process each destination list: fill remainder and convert to cumulative weights.
|
|
var destErr = ProcessDestinations(src, hasWildcard, seen, m.Destinations);
|
|
if (destErr != null) return destErr;
|
|
|
|
if (m.ClusterDestinations != null)
|
|
{
|
|
var clusterKeys = new List<string>(m.ClusterDestinations.Keys);
|
|
foreach (var cluster in clusterKeys)
|
|
{
|
|
destErr = ProcessDestinations(src, hasWildcard, seen, m.ClusterDestinations[cluster]);
|
|
if (destErr != null) return destErr;
|
|
}
|
|
}
|
|
|
|
// Replace existing entry for the same source, or append.
|
|
for (int i = 0; i < _mappings.Count; i++)
|
|
{
|
|
if (_mappings[i].Source == src)
|
|
{
|
|
_mappings[i] = m;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_mappings.Add(m);
|
|
Interlocked.Exchange(ref _hasMapped, _mappings.Count > 0 ? 1 : 0);
|
|
|
|
UpdateLeafNodesEx(src, 1, force: true);
|
|
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a subject mapping entry by source subject.
|
|
/// Returns true if an entry was removed.
|
|
/// Mirrors Go <c>(a *Account) RemoveMapping(src string) bool</c>.
|
|
/// </summary>
|
|
public bool RemoveMapping(string src)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
for (int i = 0; i < _mappings.Count; i++)
|
|
{
|
|
if (_mappings[i].Source == src)
|
|
{
|
|
// Swap with last element to avoid shifting (order may change).
|
|
_mappings[i] = _mappings[^1];
|
|
_mappings.RemoveAt(_mappings.Count - 1);
|
|
Interlocked.Exchange(ref _hasMapped, _mappings.Count > 0 ? 1 : 0);
|
|
|
|
UpdateLeafNodesEx(src, -1, force: true);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when there is at least one subject mapping entry.
|
|
/// Mirrors Go <c>(a *Account) hasMappings() bool</c>.
|
|
/// </summary>
|
|
internal bool HasMappings() =>
|
|
Interlocked.CompareExchange(ref _hasMapped, 0, 0) == 1;
|
|
|
|
/// <summary>
|
|
/// Selects a mapped destination subject using weighted random selection.
|
|
/// Returns (<paramref name="dest"/>, false) when no mapping matches.
|
|
/// Mirrors Go <c>(a *Account) selectMappedSubject(dest string) (string, bool)</c>.
|
|
/// </summary>
|
|
internal (string dest, bool mapped) SelectMappedSubject(string dest)
|
|
{
|
|
if (!HasMappings())
|
|
return (dest, false);
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
// Tokenise the destination for wildcard subset matching.
|
|
string[]? tts = null;
|
|
|
|
SubjectMapping? m = null;
|
|
foreach (var rm in _mappings)
|
|
{
|
|
if (!rm.HasWildcard && rm.Source == dest)
|
|
{
|
|
m = rm;
|
|
break;
|
|
}
|
|
|
|
// Lazy tokenise for subset matching.
|
|
tts ??= TokenizeSubjectForMapping(dest);
|
|
|
|
if (SubjectTransform.IsSubsetMatch(tts, rm.Source))
|
|
{
|
|
m = rm;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (m == null)
|
|
return (dest, false);
|
|
|
|
// Select the destination list (cluster-scoped or global).
|
|
List<Destination> dests = m.Destinations;
|
|
if (m.ClusterDestinations != null && m.ClusterDestinations.Count > 0)
|
|
{
|
|
string clusterName = GetCachedClusterName();
|
|
if (!string.IsNullOrEmpty(clusterName) &&
|
|
m.ClusterDestinations.TryGetValue(clusterName, out var cdests))
|
|
{
|
|
dests = cdests;
|
|
}
|
|
}
|
|
|
|
if (dests.Count == 0)
|
|
return (dest, false);
|
|
|
|
// Optimise single-entry case where the full weight is 100.
|
|
Destination? selected = null;
|
|
if (dests.Count == 1 && dests[0].Weight == 100)
|
|
{
|
|
selected = dests[0];
|
|
}
|
|
else
|
|
{
|
|
byte w = (byte)(Random.Shared.Next() % 100);
|
|
foreach (var d in dests)
|
|
{
|
|
if (w < d.Weight)
|
|
{
|
|
selected = d;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (selected == null)
|
|
return (dest, false);
|
|
|
|
string ndest;
|
|
if (selected.Transform == null)
|
|
{
|
|
ndest = dest;
|
|
}
|
|
else if (tts != null)
|
|
{
|
|
ndest = selected.Transform.TransformTokenizedSubject(tts);
|
|
}
|
|
else
|
|
{
|
|
ndest = selected.Transform.TransformSubject(dest);
|
|
}
|
|
|
|
return (ndest, true);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Service export configuration
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Configures an exported service with singleton response semantics.
|
|
/// Mirrors Go <c>(a *Account) AddServiceExport(subject string, accounts []*Account) error</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceExport(string subject, IReadOnlyList<Account>? accounts = null) =>
|
|
AddServiceExportWithResponseAndAccountPos(subject, ServiceRespType.Singleton, accounts, 0);
|
|
|
|
/// <summary>
|
|
/// Configures an exported service with singleton response semantics and account-position auth.
|
|
/// Mirrors Go <c>(a *Account) addServiceExportWithAccountPos(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceExportWithAccountPos(string subject, IReadOnlyList<Account>? accounts, uint accountPos) =>
|
|
AddServiceExportWithResponseAndAccountPos(subject, ServiceRespType.Singleton, accounts, accountPos);
|
|
|
|
/// <summary>
|
|
/// Configures an exported service with explicit response type.
|
|
/// Mirrors Go <c>(a *Account) AddServiceExportWithResponse(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceExportWithResponse(string subject, ServiceRespType respType, IReadOnlyList<Account>? accounts = null) =>
|
|
AddServiceExportWithResponseAndAccountPos(subject, respType, accounts, 0);
|
|
|
|
/// <summary>
|
|
/// Configures an exported service with explicit response type and account-position auth.
|
|
/// Mirrors Go <c>(a *Account) addServiceExportWithResponseAndAccountPos(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceExportWithResponseAndAccountPos(string subject, ServiceRespType respType, IReadOnlyList<Account>? accounts, uint accountPos)
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(subject))
|
|
return ServerErrors.ErrBadSubject;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
Exports.Services ??= new Dictionary<string, ServiceExportEntry>(StringComparer.Ordinal);
|
|
|
|
if (!Exports.Services.TryGetValue(subject, out var serviceExport) || serviceExport == null)
|
|
serviceExport = new ServiceExportEntry();
|
|
|
|
if (respType != ServiceRespType.Singleton)
|
|
serviceExport.ResponseType = respType;
|
|
|
|
if (accounts != null || accountPos > 0)
|
|
{
|
|
var authErr = SetExportAuth(serviceExport, subject, accounts, accountPos);
|
|
if (authErr != null)
|
|
return authErr;
|
|
}
|
|
|
|
serviceExport.Account = this;
|
|
serviceExport.ResponseThreshold = ServerConstants.DefaultServiceExportResponseThreshold;
|
|
Exports.Services[subject] = serviceExport;
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables latency tracking for <paramref name="service"/> with default sampling.
|
|
/// Mirrors Go <c>(a *Account) TrackServiceExport(service, results string) error</c>.
|
|
/// </summary>
|
|
public Exception? TrackServiceExport(string service, string results) =>
|
|
TrackServiceExportWithSampling(service, results, ServerConstants.DefaultServiceLatencySampling);
|
|
|
|
/// <summary>
|
|
/// Enables latency tracking for <paramref name="service"/> with explicit sampling.
|
|
/// Mirrors Go <c>(a *Account) TrackServiceExportWithSampling(...)</c>.
|
|
/// </summary>
|
|
public Exception? TrackServiceExportWithSampling(string service, string results, int sampling)
|
|
{
|
|
if (sampling != 0 && (sampling < 1 || sampling > 100))
|
|
return ServerErrors.ErrBadSampling;
|
|
if (!SubscriptionIndex.IsValidPublishSubject(results))
|
|
return ServerErrors.ErrBadPublishSubject;
|
|
if (IsExportService(results))
|
|
return ServerErrors.ErrBadPublishSubject;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Exports.Services == null)
|
|
return ServerErrors.ErrMissingService;
|
|
if (!Exports.Services.TryGetValue(service, out var serviceExport))
|
|
return ServerErrors.ErrMissingService;
|
|
|
|
serviceExport ??= new ServiceExportEntry();
|
|
if (serviceExport.ResponseType != ServiceRespType.Singleton)
|
|
return ServerErrors.ErrBadServiceType;
|
|
|
|
serviceExport.Latency = new InternalServiceLatency
|
|
{
|
|
Sampling = sampling,
|
|
Subject = results,
|
|
};
|
|
Exports.Services[service] = serviceExport;
|
|
|
|
if (Imports.Services != null)
|
|
{
|
|
foreach (var imports in Imports.Services.Values)
|
|
{
|
|
foreach (var import in imports)
|
|
{
|
|
if (import?.Account?.Name != Name)
|
|
continue;
|
|
if (SubjectTransform.IsSubsetMatch(SubjectTransform.TokenizeSubject(import.To), service))
|
|
import.Latency = serviceExport.Latency;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Disables latency tracking for the exported service.
|
|
/// Mirrors Go <c>(a *Account) UnTrackServiceExport(service string)</c>.
|
|
/// </summary>
|
|
public void UnTrackServiceExport(string service)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Exports.Services == null || !Exports.Services.TryGetValue(service, out var serviceExport) || serviceExport?.Latency == null)
|
|
return;
|
|
|
|
serviceExport.Latency = null;
|
|
|
|
if (Imports.Services == null)
|
|
return;
|
|
|
|
foreach (var imports in Imports.Services.Values)
|
|
{
|
|
foreach (var import in imports)
|
|
{
|
|
if (import?.Account?.Name != Name)
|
|
continue;
|
|
if (SubjectTransform.IsSubsetMatch(SubjectTransform.TokenizeSubject(import.To), service))
|
|
{
|
|
import.Latency = null;
|
|
import.M1 = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes a service-latency metric for an import.
|
|
/// Mirrors Go <c>(a *Account) sendLatencyResult(...)</c>.
|
|
/// </summary>
|
|
internal void SendLatencyResult(ServiceImportEntry si, ServiceLatency sl)
|
|
{
|
|
sl.Type = AccountEventConstants.ServiceLatencyType;
|
|
sl.Id = NextEventId();
|
|
sl.Time = DateTime.UtcNow;
|
|
|
|
string? latencySubject;
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
latencySubject = si.Latency?.Subject;
|
|
si.RequestingClient = null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(latencySubject) || Server is not NatsServer server)
|
|
return;
|
|
|
|
var payload = JsonSerializer.SerializeToUtf8Bytes(sl);
|
|
_ = server.SendInternalAccountMsg(this, latencySubject, payload);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes a bad-request latency metric (missing or invalid request shape).
|
|
/// Mirrors Go <c>(a *Account) sendBadRequestTrackingLatency(...)</c>.
|
|
/// </summary>
|
|
internal void SendBadRequestTrackingLatency(ServiceImportEntry si, ClientConnection requestor, Dictionary<string, string[]>? header)
|
|
{
|
|
var sl = new ServiceLatency
|
|
{
|
|
Status = 400,
|
|
Error = "Bad Request",
|
|
Requestor = CreateClientInfo(requestor, si.Share),
|
|
RequestHeader = header,
|
|
RequestStart = DateTime.UtcNow.Subtract(requestor.GetRttValue()),
|
|
};
|
|
SendLatencyResult(si, sl);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes timeout latency when requestor interest is lost before response delivery.
|
|
/// Mirrors Go <c>(a *Account) sendReplyInterestLostTrackLatency(...)</c>.
|
|
/// </summary>
|
|
internal void SendReplyInterestLostTrackLatency(ServiceImportEntry si)
|
|
{
|
|
var sl = new ServiceLatency
|
|
{
|
|
Status = 408,
|
|
Error = "Request Timeout",
|
|
};
|
|
|
|
ClientConnection? requestor;
|
|
bool share;
|
|
long timestamp;
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
requestor = si.RequestingClient;
|
|
share = si.Share;
|
|
timestamp = si.Timestamp;
|
|
sl.RequestHeader = si.TrackingHeader;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
if (requestor != null)
|
|
sl.Requestor = CreateClientInfo(requestor, share);
|
|
|
|
var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero;
|
|
sl.RequestStart = UnixNanoToDateTime(timestamp - TimeSpanToUnixNanos(reqRtt));
|
|
SendLatencyResult(si, sl);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes backend failure latency for response-service imports.
|
|
/// Mirrors Go <c>(a *Account) sendBackendErrorTrackingLatency(...)</c>.
|
|
/// </summary>
|
|
internal void SendBackendErrorTrackingLatency(ServiceImportEntry si, RsiReason reason)
|
|
{
|
|
var sl = new ServiceLatency();
|
|
|
|
ClientConnection? requestor;
|
|
bool share;
|
|
long timestamp;
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
requestor = si.RequestingClient;
|
|
share = si.Share;
|
|
timestamp = si.Timestamp;
|
|
sl.RequestHeader = si.TrackingHeader;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
if (requestor != null)
|
|
sl.Requestor = CreateClientInfo(requestor, share);
|
|
|
|
var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero;
|
|
sl.RequestStart = UnixNanoToDateTime(timestamp - TimeSpanToUnixNanos(reqRtt));
|
|
|
|
if (reason == RsiReason.NoDelivery)
|
|
{
|
|
sl.Status = 503;
|
|
sl.Error = "Service Unavailable";
|
|
}
|
|
else if (reason == RsiReason.Timeout)
|
|
{
|
|
sl.Status = 504;
|
|
sl.Error = "Service Timeout";
|
|
}
|
|
|
|
SendLatencyResult(si, sl);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends request/response latency metrics. Returns true when complete, false when waiting for remote-half merge.
|
|
/// Mirrors Go <c>(a *Account) sendTrackingLatency(...)</c>.
|
|
/// </summary>
|
|
internal bool SendTrackingLatency(ServiceImportEntry si, ClientConnection? responder)
|
|
{
|
|
_mu.EnterReadLock();
|
|
var requestor = si.RequestingClient;
|
|
_mu.ExitReadLock();
|
|
|
|
if (requestor == null)
|
|
return true;
|
|
|
|
var nowUnixNanos = UtcNowUnixNanos();
|
|
var serviceRtt = UnixNanosToTimeSpan(Math.Max(0, nowUnixNanos - si.Timestamp));
|
|
var sl = new ServiceLatency
|
|
{
|
|
Status = 200,
|
|
Requestor = CreateClientInfo(requestor, si.Share),
|
|
Responder = responder == null ? null : CreateClientInfo(responder, true),
|
|
RequestHeader = si.TrackingHeader,
|
|
};
|
|
|
|
var respRtt = sl.Responder?.Rtt ?? TimeSpan.Zero;
|
|
var reqRtt = sl.Requestor?.Rtt ?? TimeSpan.Zero;
|
|
sl.RequestStart = UnixNanoToDateTime(si.Timestamp - TimeSpanToUnixNanos(reqRtt));
|
|
sl.ServiceLatencyDuration = serviceRtt > respRtt ? serviceRtt - respRtt : TimeSpan.Zero;
|
|
sl.TotalLatency = reqRtt + serviceRtt;
|
|
if (respRtt > TimeSpan.Zero)
|
|
{
|
|
sl.SystemLatency = DateTime.UtcNow - UnixNanoToDateTime(nowUnixNanos);
|
|
if (sl.SystemLatency < TimeSpan.Zero)
|
|
sl.SystemLatency = TimeSpan.Zero;
|
|
sl.TotalLatency += sl.SystemLatency;
|
|
}
|
|
|
|
if (responder != null && responder.Kind != ClientKind.Client)
|
|
{
|
|
if (si.M1 != null)
|
|
{
|
|
SendLatencyResult(si, sl);
|
|
return true;
|
|
}
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
si.M1 = sl;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
SendLatencyResult(si, sl);
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the lowest response threshold configured across all service exports.
|
|
/// Mirrors Go <c>(a *Account) lowestServiceExportResponseTime() time.Duration</c>.
|
|
/// </summary>
|
|
internal TimeSpan LowestServiceExportResponseTime()
|
|
{
|
|
var lowest = TimeSpan.FromMinutes(5);
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Exports.Services == null)
|
|
return lowest;
|
|
|
|
foreach (var export in Exports.Services.Values)
|
|
{
|
|
if (export != null && export.ResponseThreshold < lowest)
|
|
lowest = export.ResponseThreshold;
|
|
}
|
|
|
|
return lowest;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a service import with claim authorization context.
|
|
/// Mirrors Go <c>(a *Account) AddServiceImportWithClaim(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceImportWithClaim(Account destination, string from, string to, object? imClaim) =>
|
|
AddServiceImportWithClaimInternal(destination, from, to, imClaim, false);
|
|
|
|
/// <summary>
|
|
/// Internal service-import add path with optional authorization bypass.
|
|
/// Mirrors Go <c>(a *Account) addServiceImportWithClaim(..., internal bool)</c>.
|
|
/// </summary>
|
|
internal Exception? AddServiceImportWithClaimInternal(Account destination, string from, string to, object? imClaim, bool internalRequest)
|
|
{
|
|
if (destination == null)
|
|
return ServerErrors.ErrMissingAccount;
|
|
|
|
if (string.IsNullOrEmpty(to))
|
|
to = from;
|
|
if (!SubscriptionIndex.IsValidSubject(from) || !SubscriptionIndex.IsValidSubject(to))
|
|
return SubscriptionIndex.ErrInvalidSubject;
|
|
|
|
if (!internalRequest && !destination.CheckServiceExportApproved(this, to, imClaim))
|
|
return ServerErrors.ErrServiceImportAuthorization;
|
|
|
|
var cycleErr = ServiceImportFormsCycle(destination, from);
|
|
if (cycleErr != null)
|
|
return cycleErr;
|
|
|
|
var (_, addErr) = AddServiceImportInternal(destination, from, to, imClaim);
|
|
return addErr;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether adding a service import forms an account cycle.
|
|
/// Mirrors Go <c>(a *Account) serviceImportFormsCycle(...)</c>.
|
|
/// </summary>
|
|
internal Exception? ServiceImportFormsCycle(Account destination, string from)
|
|
{
|
|
var visited = new HashSet<string>(StringComparer.Ordinal) { Name };
|
|
return destination.CheckServiceImportsForCycles(from, visited);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively checks service-import graph for cycles.
|
|
/// Mirrors Go <c>(a *Account) checkServiceImportsForCycles(...)</c>.
|
|
/// </summary>
|
|
internal Exception? CheckServiceImportsForCycles(string from, HashSet<string> visited)
|
|
{
|
|
if (visited.Count >= AccountConstants.MaxCycleSearchDepth)
|
|
return ServerErrors.ErrCycleSearchDepth;
|
|
|
|
List<ServiceImportEntry>? snapshot = null;
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Imports.Services == null || Imports.Services.Count == 0)
|
|
return null;
|
|
|
|
snapshot = [];
|
|
foreach (var entries in Imports.Services.Values)
|
|
snapshot.AddRange(entries);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
foreach (var import in snapshot)
|
|
{
|
|
if (import?.Account == null)
|
|
continue;
|
|
if (!SubscriptionIndex.SubjectsCollide(from, import.To))
|
|
continue;
|
|
|
|
if (visited.Contains(import.Account.Name))
|
|
return ServerErrors.ErrImportFormsCycle;
|
|
|
|
visited.Add(Name);
|
|
var nextFrom = SubscriptionIndex.SubjectIsSubsetMatch(import.From, from) ? import.From : from;
|
|
var err = import.Account.CheckServiceImportsForCycles(nextFrom, visited);
|
|
if (err != null)
|
|
return err;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether adding a stream import forms an account cycle.
|
|
/// Mirrors Go <c>(a *Account) streamImportFormsCycle(...)</c>.
|
|
/// </summary>
|
|
internal Exception? StreamImportFormsCycle(Account destination, string to)
|
|
{
|
|
var visited = new HashSet<string>(StringComparer.Ordinal) { Name };
|
|
return destination.CheckStreamImportsForCycles(to, visited);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when any service export subject can match <paramref name="to"/>.
|
|
/// Mirrors Go <c>(a *Account) hasServiceExportMatching(to string) bool</c>.
|
|
/// </summary>
|
|
internal bool HasServiceExportMatching(string to)
|
|
{
|
|
if (Exports.Services == null)
|
|
return false;
|
|
|
|
foreach (var subject in Exports.Services.Keys)
|
|
{
|
|
if (SubscriptionIndex.SubjectIsSubsetMatch(to, subject))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when any stream export subject can match <paramref name="to"/>.
|
|
/// Mirrors Go <c>(a *Account) hasStreamExportMatching(to string) bool</c>.
|
|
/// </summary>
|
|
internal bool HasStreamExportMatching(string to)
|
|
{
|
|
if (Exports.Streams == null)
|
|
return false;
|
|
|
|
foreach (var subject in Exports.Streams.Keys)
|
|
{
|
|
if (SubscriptionIndex.SubjectIsSubsetMatch(to, subject))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Recursively checks stream-import graph for cycles.
|
|
/// Mirrors Go <c>(a *Account) checkStreamImportsForCycles(...)</c>.
|
|
/// </summary>
|
|
internal Exception? CheckStreamImportsForCycles(string to, HashSet<string> visited)
|
|
{
|
|
if (visited.Count >= AccountConstants.MaxCycleSearchDepth)
|
|
return ServerErrors.ErrCycleSearchDepth;
|
|
|
|
_mu.EnterReadLock();
|
|
var hasMatchingExport = HasStreamExportMatching(to);
|
|
var streams = Imports.Streams == null ? null : new List<StreamImportEntry>(Imports.Streams);
|
|
_mu.ExitReadLock();
|
|
|
|
if (!hasMatchingExport || streams == null || streams.Count == 0)
|
|
return null;
|
|
|
|
foreach (var stream in streams)
|
|
{
|
|
if (stream?.Account == null)
|
|
continue;
|
|
if (!SubscriptionIndex.SubjectsCollide(to, stream.To))
|
|
continue;
|
|
|
|
if (visited.Contains(stream.Account.Name))
|
|
return ServerErrors.ErrImportFormsCycle;
|
|
|
|
visited.Add(Name);
|
|
var nextTo = SubscriptionIndex.SubjectIsSubsetMatch(stream.To, to) ? stream.To : to;
|
|
var err = stream.Account.CheckStreamImportsForCycles(nextTo, visited);
|
|
if (err != null)
|
|
return err;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Allows or disallows request metadata sharing for a service import.
|
|
/// Mirrors Go <c>(a *Account) SetServiceImportSharing(...)</c>.
|
|
/// </summary>
|
|
public Exception? SetServiceImportSharing(Account destination, string to, bool allow) =>
|
|
SetServiceImportSharingInternal(destination, to, true, allow);
|
|
|
|
/// <summary>
|
|
/// Internal service-import sharing setter with optional claim-account check bypass.
|
|
/// Mirrors Go <c>(a *Account) setServiceImportSharing(...)</c>.
|
|
/// </summary>
|
|
internal Exception? SetServiceImportSharingInternal(Account destination, string to, bool check, bool allow)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (check && IsClaimAccount())
|
|
return new InvalidOperationException("claim based accounts can not be updated directly");
|
|
|
|
if (Imports.Services == null)
|
|
return new InvalidOperationException("service import not found");
|
|
|
|
foreach (var imports in Imports.Services.Values)
|
|
{
|
|
foreach (var import in imports)
|
|
{
|
|
if (import?.Account?.Name == destination.Name && import.To == to)
|
|
{
|
|
import.Share = allow;
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new InvalidOperationException("service import not found");
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a service import from this account to <paramref name="destination"/>.
|
|
/// Mirrors Go <c>(a *Account) AddServiceImport(destination, from, to string) error</c>.
|
|
/// </summary>
|
|
public Exception? AddServiceImport(Account destination, string from, string to) =>
|
|
AddServiceImportWithClaim(destination, from, to, null);
|
|
|
|
/// <summary>
|
|
/// Number of pending reverse-response map entries.
|
|
/// Mirrors Go <c>(a *Account) NumPendingReverseResponses() int</c>.
|
|
/// </summary>
|
|
public int NumPendingReverseResponses()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return Imports.ReverseResponseMap?.Count ?? 0; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Total number of pending response imports across all service exports.
|
|
/// Mirrors Go <c>(a *Account) NumPendingAllResponses() int</c>.
|
|
/// </summary>
|
|
public int NumPendingAllResponses() => NumPendingResponses(string.Empty);
|
|
|
|
/// <summary>
|
|
/// Number of pending response imports, optionally filtered by exported service subject.
|
|
/// Mirrors Go <c>(a *Account) NumPendingResponses(filter string) int</c>.
|
|
/// </summary>
|
|
public int NumPendingResponses(string filter)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (string.IsNullOrEmpty(filter))
|
|
return Exports.Responses?.Count ?? 0;
|
|
|
|
var export = GetServiceExport(filter);
|
|
if (export == null || Exports.Responses == null)
|
|
return 0;
|
|
|
|
var count = 0;
|
|
foreach (var import in Exports.Responses.Values)
|
|
{
|
|
if (ReferenceEquals(import.ServiceExport, export))
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Number of configured service-import subject keys.
|
|
/// Mirrors Go <c>(a *Account) NumServiceImports() int</c>.
|
|
/// </summary>
|
|
public int NumServiceImports()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return Imports.Services?.Count ?? 0; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a response service import and performs reverse-map cleanup.
|
|
/// Mirrors Go <c>(a *Account) removeRespServiceImport(...)</c>.
|
|
/// </summary>
|
|
internal void RemoveRespServiceImport(ServiceImportEntry? serviceImport, RsiReason reason)
|
|
{
|
|
if (serviceImport == null)
|
|
return;
|
|
|
|
Account? destination;
|
|
string from;
|
|
string to;
|
|
bool tracking;
|
|
bool delivered;
|
|
ClientConnection? requestor;
|
|
byte[]? sid;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Exports.Responses != null)
|
|
Exports.Responses.Remove(serviceImport.From);
|
|
|
|
destination = serviceImport.Account;
|
|
from = serviceImport.From;
|
|
to = serviceImport.To;
|
|
tracking = serviceImport.Tracking;
|
|
delivered = serviceImport.DidDeliver;
|
|
requestor = serviceImport.RequestingClient;
|
|
sid = serviceImport.SubscriptionId;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (sid is { Length: > 0 } && InternalClient != null)
|
|
InternalClient.RemoveSubBySid(sid);
|
|
|
|
if (tracking && requestor != null && !delivered)
|
|
SendBackendErrorTrackingLatency(serviceImport, reason);
|
|
|
|
destination?.CheckForReverseEntry(to, serviceImport, false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a service import for a specific destination account and subject key.
|
|
/// Lock must be held by caller.
|
|
/// Mirrors Go <c>(a *Account) getServiceImportForAccountLocked(...)</c>.
|
|
/// </summary>
|
|
internal ServiceImportEntry? GetServiceImportForAccountLocked(string destinationAccountName, string subject)
|
|
{
|
|
if (Imports.Services == null || !Imports.Services.TryGetValue(subject, out var serviceImports))
|
|
return null;
|
|
|
|
if (serviceImports.Count == 1 && serviceImports[0].Account?.Name == destinationAccountName)
|
|
return serviceImports[0];
|
|
|
|
foreach (var serviceImport in serviceImports)
|
|
{
|
|
if (serviceImport.Account?.Name == destinationAccountName)
|
|
return serviceImport;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a service import mapping by destination account name and subject key.
|
|
/// Mirrors Go <c>(a *Account) removeServiceImport(dstAccName, subject string)</c>.
|
|
/// </summary>
|
|
internal void RemoveServiceImport(string destinationAccountName, string subject)
|
|
{
|
|
ServiceImportEntry? removed = null;
|
|
byte[]? sid = null;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Imports.Services == null || !Imports.Services.TryGetValue(subject, out var serviceImports))
|
|
return;
|
|
|
|
if (serviceImports.Count == 1)
|
|
{
|
|
if (serviceImports[0].Account?.Name == destinationAccountName)
|
|
{
|
|
removed = serviceImports[0];
|
|
Imports.Services.Remove(subject);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (var i = 0; i < serviceImports.Count; i++)
|
|
{
|
|
if (serviceImports[i].Account?.Name == destinationAccountName)
|
|
{
|
|
removed = serviceImports[i];
|
|
serviceImports.RemoveAt(i);
|
|
Imports.Services[subject] = serviceImports;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (removed?.SubscriptionId is { Length: > 0 })
|
|
sid = removed.SubscriptionId;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (sid != null && InternalClient != null)
|
|
InternalClient.RemoveSubBySid(sid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an entry to the reverse-response map for response cleanup.
|
|
/// Mirrors Go <c>(a *Account) addReverseRespMapEntry(...)</c>.
|
|
/// </summary>
|
|
internal void AddReverseRespMapEntry(Account account, string reply, string from)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
Imports.ReverseResponseMap ??= new Dictionary<string, List<ServiceRespEntry>>(StringComparer.Ordinal);
|
|
if (!Imports.ReverseResponseMap.TryGetValue(reply, out var entries))
|
|
{
|
|
entries = [];
|
|
Imports.ReverseResponseMap[reply] = entries;
|
|
}
|
|
|
|
entries.Add(new ServiceRespEntry
|
|
{
|
|
Account = account,
|
|
MappedSubject = from,
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks reverse-response entries for wildcard replies.
|
|
/// Mirrors Go <c>(a *Account) checkForReverseEntries(...)</c>.
|
|
/// </summary>
|
|
internal void CheckForReverseEntries(string reply, bool checkInterest, bool recursed)
|
|
{
|
|
if (!SubscriptionIndex.SubjectHasWildcard(reply))
|
|
{
|
|
CheckForReverseEntry(reply, null, checkInterest, recursed);
|
|
return;
|
|
}
|
|
|
|
List<string> replies;
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Imports.ReverseResponseMap == null || Imports.ReverseResponseMap.Count == 0)
|
|
return;
|
|
replies = [.. Imports.ReverseResponseMap.Keys];
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
var replyTokens = SubjectTransform.TokenizeSubject(reply);
|
|
foreach (var candidate in replies)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(SubjectTransform.TokenizeSubject(candidate), reply))
|
|
CheckForReverseEntry(candidate, null, checkInterest, recursed);
|
|
else if (SubjectTransform.IsSubsetMatch(replyTokens, candidate))
|
|
CheckForReverseEntry(candidate, null, checkInterest, recursed);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks and optionally removes reverse-response entries.
|
|
/// Mirrors Go <c>(a *Account) checkForReverseEntry(...)</c>.
|
|
/// </summary>
|
|
internal void CheckForReverseEntry(string reply, ServiceImportEntry? serviceImport, bool checkInterest) =>
|
|
CheckForReverseEntry(reply, serviceImport, checkInterest, false);
|
|
|
|
/// <summary>
|
|
/// Internal reverse-entry checker with recursion protection.
|
|
/// Mirrors Go <c>(a *Account) _checkForReverseEntry(...)</c>.
|
|
/// </summary>
|
|
internal void CheckForReverseEntry(string reply, ServiceImportEntry? serviceImport, bool checkInterest, bool recursed)
|
|
{
|
|
List<ServiceRespEntry>? responseEntries;
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Imports.ReverseResponseMap == null || Imports.ReverseResponseMap.Count == 0)
|
|
return;
|
|
|
|
if (SubscriptionIndex.SubjectHasWildcard(reply))
|
|
{
|
|
if (recursed)
|
|
return;
|
|
}
|
|
else if (!Imports.ReverseResponseMap.TryGetValue(reply, out responseEntries) || responseEntries == null)
|
|
{
|
|
return;
|
|
}
|
|
else if (checkInterest && Sublist != null && Sublist.HasInterest(reply))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
if (SubscriptionIndex.SubjectHasWildcard(reply))
|
|
{
|
|
CheckForReverseEntries(reply, checkInterest, true);
|
|
return;
|
|
}
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Imports.ReverseResponseMap == null || !Imports.ReverseResponseMap.TryGetValue(reply, out responseEntries) || responseEntries == null)
|
|
return;
|
|
|
|
if (serviceImport == null)
|
|
{
|
|
Imports.ReverseResponseMap.Remove(reply);
|
|
}
|
|
else
|
|
{
|
|
responseEntries.RemoveAll(entry => entry.MappedSubject == serviceImport.From);
|
|
|
|
if (responseEntries.Count == 0)
|
|
Imports.ReverseResponseMap.Remove(reply);
|
|
else
|
|
Imports.ReverseResponseMap[reply] = responseEntries;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when a service import is overshadowed by an existing subject key.
|
|
/// Mirrors Go <c>(a *Account) serviceImportShadowed(from string) bool</c>.
|
|
/// </summary>
|
|
internal bool ServiceImportShadowed(string from)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Imports.Services == null)
|
|
return false;
|
|
if (Imports.Services.ContainsKey(from))
|
|
return true;
|
|
|
|
foreach (var subject in Imports.Services.Keys)
|
|
{
|
|
if (SubscriptionIndex.SubjectIsSubsetMatch(from, subject))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when a service import already exists for destination account + source subject.
|
|
/// Mirrors Go <c>(a *Account) serviceImportExists(dstAccName, from string) bool</c>.
|
|
/// </summary>
|
|
internal bool ServiceImportExists(string destinationAccountName, string from)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return GetServiceImportForAccountLocked(destinationAccountName, from) != null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates (or returns existing) internal account client.
|
|
/// Lock must be held.
|
|
/// Mirrors Go <c>(a *Account) internalClient() *client</c>.
|
|
/// </summary>
|
|
internal ClientConnection? InternalAccountClient()
|
|
{
|
|
if (InternalClient == null && Server is NatsServer server)
|
|
{
|
|
InternalClient = server.CreateInternalAccountClient();
|
|
InternalClient.SetAccount(this);
|
|
}
|
|
|
|
return InternalClient;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates internal account-scoped subscription.
|
|
/// Mirrors Go <c>(a *Account) subscribeInternal(...)</c>.
|
|
/// </summary>
|
|
internal (Subscription? Sub, Exception? Error) SubscribeInternal(string subject) =>
|
|
SubscribeInternalEx(subject, false);
|
|
|
|
/// <summary>
|
|
/// Unsubscribes from an internal account subscription.
|
|
/// Mirrors Go <c>(a *Account) unsubscribeInternal(sub *subscription)</c>.
|
|
/// </summary>
|
|
internal void UnsubscribeInternal(Subscription? sub)
|
|
{
|
|
if (sub?.Sid == null)
|
|
return;
|
|
|
|
_mu.EnterReadLock();
|
|
var internalClient = InternalClient;
|
|
_mu.ExitReadLock();
|
|
internalClient?.RemoveSubBySid(sub.Sid);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates internal subscription for service-import responses.
|
|
/// Mirrors Go <c>(a *Account) subscribeServiceImportResponse(subject string)</c>.
|
|
/// </summary>
|
|
internal (Subscription? Sub, Exception? Error) SubscribeServiceImportResponse(string subject) =>
|
|
SubscribeInternalEx(subject, true);
|
|
|
|
/// <summary>
|
|
/// Extended internal subscription helper.
|
|
/// Mirrors Go <c>(a *Account) subscribeInternalEx(...)</c>.
|
|
/// </summary>
|
|
internal (Subscription? Sub, Exception? Error) SubscribeInternalEx(string subject, bool responseImport)
|
|
{
|
|
ClientConnection? client;
|
|
string sidText;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_isid++;
|
|
client = InternalAccountClient();
|
|
sidText = _isid.ToString();
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (client == null)
|
|
return (null, new InvalidOperationException("no internal account client"));
|
|
|
|
return client.ProcessSubEx(Encoding.ASCII.GetBytes(subject), null, Encoding.ASCII.GetBytes(sidText), false, false, responseImport);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an internal subscription that matches a service import's <c>from</c> subject.
|
|
/// Mirrors Go <c>(a *Account) addServiceImportSub(si *serviceImport) error</c>.
|
|
/// </summary>
|
|
internal Exception? AddServiceImportSub(ServiceImportEntry serviceImport)
|
|
{
|
|
if (serviceImport == null)
|
|
return ServerErrors.ErrMissingService;
|
|
|
|
ClientConnection? client;
|
|
string sidText;
|
|
string subject;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
client = InternalAccountClient();
|
|
if (client == null)
|
|
return null;
|
|
if (serviceImport.SubscriptionId is { Length: > 0 })
|
|
return new InvalidOperationException("duplicate call to create subscription for service import");
|
|
|
|
_isid++;
|
|
sidText = _isid.ToString();
|
|
serviceImport.SubscriptionId = Encoding.ASCII.GetBytes(sidText);
|
|
subject = serviceImport.From;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
var (_, err) = client.ProcessSubEx(Encoding.ASCII.GetBytes(subject), null, Encoding.ASCII.GetBytes(sidText), true, true, false);
|
|
return err;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes all subscriptions associated with service imports.
|
|
/// Mirrors Go <c>(a *Account) removeAllServiceImportSubs()</c>.
|
|
/// </summary>
|
|
internal void RemoveAllServiceImportSubs()
|
|
{
|
|
List<byte[]> subscriptionIds = [];
|
|
ClientConnection? internalClient;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (Imports.Services != null)
|
|
{
|
|
foreach (var imports in Imports.Services.Values)
|
|
{
|
|
foreach (var serviceImport in imports)
|
|
{
|
|
if (serviceImport.SubscriptionId is { Length: > 0 })
|
|
{
|
|
subscriptionIds.Add(serviceImport.SubscriptionId);
|
|
serviceImport.SubscriptionId = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
internalClient = InternalClient;
|
|
InternalClient = null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (internalClient == null)
|
|
return;
|
|
|
|
foreach (var sid in subscriptionIds)
|
|
internalClient.RemoveSubBySid(sid);
|
|
|
|
internalClient.CloseConnection(ClosedState.InternalClient);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds subscriptions for all registered service imports.
|
|
/// Mirrors Go <c>(a *Account) addAllServiceImportSubs()</c>.
|
|
/// </summary>
|
|
internal void AddAllServiceImportSubs()
|
|
{
|
|
List<ServiceImportEntry> imports = [];
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Imports.Services != null)
|
|
{
|
|
foreach (var entries in Imports.Services.Values)
|
|
imports.AddRange(entries);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
foreach (var serviceImport in imports)
|
|
_ = AddServiceImportSub(serviceImport);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes a service-import response routed to this account.
|
|
/// Mirrors Go <c>(a *Account) processServiceImportResponse(...)</c>.
|
|
/// </summary>
|
|
internal void ProcessServiceImportResponse(string subject, byte[] msg)
|
|
{
|
|
ServiceImportEntry? serviceImport;
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (IsExpired() || Exports.Responses == null || Exports.Responses.Count == 0)
|
|
return;
|
|
|
|
if (!Exports.Responses.TryGetValue(subject, out serviceImport))
|
|
return;
|
|
if (serviceImport == null || serviceImport.Invalid)
|
|
return;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
|
|
// The client-side response processing pipeline is still under active porting.
|
|
serviceImport.DidDeliver = msg.Length >= 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates response wildcard prefix for service replies.
|
|
/// Lock must be held by caller.
|
|
/// Mirrors Go <c>(a *Account) createRespWildcard()</c>.
|
|
/// </summary>
|
|
internal void CreateRespWildcard()
|
|
{
|
|
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
Span<byte> prefix = stackalloc byte[14];
|
|
prefix[0] = (byte)'_';
|
|
prefix[1] = (byte)'R';
|
|
prefix[2] = (byte)'_';
|
|
prefix[3] = (byte)'.';
|
|
|
|
ulong random = (ulong)Random.Shared.NextInt64();
|
|
for (var i = 4; i < prefix.Length; i++)
|
|
{
|
|
prefix[i] = (byte)alphabet[(int)(random % (ulong)alphabet.Length)];
|
|
random /= (ulong)alphabet.Length;
|
|
}
|
|
|
|
ServiceImportReply = [.. prefix, (byte)'.'];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a new service reply subject.
|
|
/// Mirrors Go <c>(a *Account) newServiceReply(tracking bool) []byte</c>.
|
|
/// </summary>
|
|
internal byte[] NewServiceReply(bool tracking)
|
|
{
|
|
bool createdPrefix = false;
|
|
byte[] replyPrefix;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (ServiceImportReply == null)
|
|
{
|
|
CreateRespWildcard();
|
|
createdPrefix = true;
|
|
}
|
|
|
|
replyPrefix = ServiceImportReply ?? Encoding.ASCII.GetBytes("_R_.");
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (createdPrefix)
|
|
_ = SubscribeServiceImportResponse(Encoding.ASCII.GetString([.. replyPrefix, (byte)'>']));
|
|
|
|
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
Span<byte> randomPart = stackalloc byte[20];
|
|
ulong random = (ulong)Random.Shared.NextInt64();
|
|
for (var i = 0; i < randomPart.Length; i++)
|
|
{
|
|
randomPart[i] = (byte)alphabet[(int)(random % (ulong)alphabet.Length)];
|
|
random /= (ulong)alphabet.Length;
|
|
}
|
|
|
|
var reply = new List<byte>(replyPrefix.Length + randomPart.Length + 2);
|
|
reply.AddRange(replyPrefix);
|
|
reply.AddRange(randomPart.ToArray());
|
|
|
|
if (tracking)
|
|
{
|
|
reply.Add((byte)'.');
|
|
reply.Add((byte)'T');
|
|
}
|
|
|
|
return [.. reply];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the response threshold for an exported service.
|
|
/// Mirrors Go <c>(a *Account) ServiceExportResponseThreshold(...)</c>.
|
|
/// </summary>
|
|
public (TimeSpan Threshold, Exception? Error) ServiceExportResponseThreshold(string export)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
var serviceExport = GetServiceExport(export);
|
|
if (serviceExport == null)
|
|
return (TimeSpan.Zero, new InvalidOperationException($"no export defined for \"{export}\""));
|
|
return (serviceExport.ResponseThreshold, null);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets max response delivery time for an exported service.
|
|
/// Mirrors Go <c>(a *Account) SetServiceExportResponseThreshold(...)</c>.
|
|
/// </summary>
|
|
public Exception? SetServiceExportResponseThreshold(string export, TimeSpan maxTime)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (IsClaimAccount())
|
|
return new InvalidOperationException("claim based accounts can not be updated directly");
|
|
|
|
var serviceExport = GetServiceExport(export);
|
|
if (serviceExport == null)
|
|
return new InvalidOperationException($"no export defined for \"{export}\"");
|
|
|
|
serviceExport.ResponseThreshold = maxTime;
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enables/disables cross-account trace propagation on a service export.
|
|
/// Mirrors Go <c>(a *Account) SetServiceExportAllowTrace(...)</c>.
|
|
/// </summary>
|
|
public Exception? SetServiceExportAllowTrace(string export, bool allowTrace)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
var serviceExport = GetServiceExport(export);
|
|
if (serviceExport == null)
|
|
return new InvalidOperationException($"no export defined for \"{export}\"");
|
|
|
|
serviceExport.AllowTrace = allowTrace;
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates internal response service import entry.
|
|
/// Mirrors Go <c>(a *Account) addRespServiceImport(...)</c>.
|
|
/// </summary>
|
|
internal ServiceImportEntry AddRespServiceImport(Account destination, string to, ServiceImportEntry originalServiceImport, bool tracking, Dictionary<string, string[]>? header)
|
|
{
|
|
var newReply = Encoding.ASCII.GetString(originalServiceImport.Account?.NewServiceReply(tracking) ?? NewServiceReply(tracking));
|
|
|
|
ServiceImportEntry responseImport;
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
responseImport = new ServiceImportEntry
|
|
{
|
|
Account = destination,
|
|
ServiceExport = originalServiceImport.ServiceExport,
|
|
From = newReply,
|
|
To = to,
|
|
ResponseType = originalServiceImport.ResponseType,
|
|
IsResponse = true,
|
|
Share = originalServiceImport.Share,
|
|
Timestamp = UtcNowUnixNanos(),
|
|
Tracking = tracking && originalServiceImport.ResponseType == ServiceRespType.Singleton,
|
|
TrackingHeader = header,
|
|
Latency = tracking && originalServiceImport.ResponseType == ServiceRespType.Singleton
|
|
? originalServiceImport.Latency
|
|
: null,
|
|
};
|
|
|
|
Exports.Responses ??= new Dictionary<string, ServiceImportEntry>(StringComparer.Ordinal);
|
|
Exports.Responses[newReply] = responseImport;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
destination.AddReverseRespMapEntry(this, to, newReply);
|
|
return responseImport;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds stream import with optional claim context.
|
|
/// Mirrors Go <c>(a *Account) AddStreamImportWithClaim(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddStreamImportWithClaim(Account account, string from, string prefix, object? importClaim) =>
|
|
AddStreamImportWithClaimInternal(account, from, prefix, false, importClaim);
|
|
|
|
/// <summary>
|
|
/// Internal stream import add helper.
|
|
/// Mirrors Go <c>(a *Account) addStreamImportWithClaim(...)</c>.
|
|
/// </summary>
|
|
internal Exception? AddStreamImportWithClaimInternal(Account account, string from, string prefix, bool allowTrace, object? importClaim)
|
|
{
|
|
if (account == null)
|
|
return ServerErrors.ErrMissingAccount;
|
|
if (!account.CheckStreamImportAuthorized(this, from, importClaim))
|
|
return ServerErrors.ErrStreamImportAuthorization;
|
|
|
|
if (!string.IsNullOrEmpty(prefix))
|
|
{
|
|
if (SubscriptionIndex.SubjectHasWildcard(prefix))
|
|
return ServerErrors.ErrStreamImportBadPrefix;
|
|
if (!prefix.EndsWith(".", StringComparison.Ordinal))
|
|
prefix += '.';
|
|
}
|
|
|
|
return AddMappedStreamImportWithClaimInternal(account, from, prefix + from, allowTrace, importClaim);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience helper for mapped stream imports without claim.
|
|
/// Mirrors Go <c>(a *Account) AddMappedStreamImport(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddMappedStreamImport(Account account, string from, string to) =>
|
|
AddMappedStreamImportWithClaim(account, from, to, null);
|
|
|
|
/// <summary>
|
|
/// Adds mapped stream import with optional claim.
|
|
/// Mirrors Go <c>(a *Account) AddMappedStreamImportWithClaim(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddMappedStreamImportWithClaim(Account account, string from, string to, object? importClaim) =>
|
|
AddMappedStreamImportWithClaimInternal(account, from, to, false, importClaim);
|
|
|
|
/// <summary>
|
|
/// Internal mapped stream import add helper.
|
|
/// Mirrors Go <c>(a *Account) addMappedStreamImportWithClaim(...)</c>.
|
|
/// </summary>
|
|
internal Exception? AddMappedStreamImportWithClaimInternal(Account account, string from, string to, bool allowTrace, object? importClaim)
|
|
{
|
|
if (account == null)
|
|
return ServerErrors.ErrMissingAccount;
|
|
if (!account.CheckStreamImportAuthorized(this, from, importClaim))
|
|
return ServerErrors.ErrStreamImportAuthorization;
|
|
|
|
if (string.IsNullOrEmpty(to))
|
|
to = from;
|
|
|
|
var cycleErr = StreamImportFormsCycle(account, to) ?? StreamImportFormsCycle(account, from);
|
|
if (cycleErr != null)
|
|
return cycleErr;
|
|
|
|
ISubjectTransformer? transform = null;
|
|
var usePublishedSubject = false;
|
|
if (SubscriptionIndex.SubjectHasWildcard(from))
|
|
{
|
|
if (to == from)
|
|
{
|
|
usePublishedSubject = true;
|
|
}
|
|
else
|
|
{
|
|
var (created, err) = SubjectTransform.New(from, to);
|
|
if (err != null)
|
|
return new InvalidOperationException($"failed to create mapping transform for stream import subject from \"{from}\" to \"{to}\": {err.Message}");
|
|
transform = created;
|
|
}
|
|
}
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (IsStreamImportDuplicate(account, from))
|
|
return ServerErrors.ErrStreamImportDuplicate;
|
|
|
|
Imports.Streams ??= [];
|
|
Imports.Streams.Add(new StreamImportEntry
|
|
{
|
|
Account = account,
|
|
From = from,
|
|
To = to,
|
|
Transform = transform,
|
|
Claim = importClaim,
|
|
UsePublishedSubject = usePublishedSubject,
|
|
AllowTrace = allowTrace,
|
|
});
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if stream import duplicate exists. Lock should be held.
|
|
/// Mirrors Go <c>(a *Account) isStreamImportDuplicate(...)</c>.
|
|
/// </summary>
|
|
internal bool IsStreamImportDuplicate(Account account, string from)
|
|
{
|
|
if (Imports.Streams == null)
|
|
return false;
|
|
|
|
foreach (var streamImport in Imports.Streams)
|
|
{
|
|
if (ReferenceEquals(streamImport.Account, account) && streamImport.From == from)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds stream import from a specific account.
|
|
/// Mirrors Go <c>(a *Account) AddStreamImport(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddStreamImport(Account account, string from, string prefix) =>
|
|
AddStreamImportWithClaimInternal(account, from, prefix, false, null);
|
|
|
|
/// <summary>
|
|
/// Adds stream export, optionally restricted to explicit accounts.
|
|
/// Mirrors Go <c>(a *Account) AddStreamExport(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddStreamExport(string subject, IReadOnlyList<Account>? accounts = null) =>
|
|
AddStreamExportWithAccountPos(subject, accounts, 0);
|
|
|
|
/// <summary>
|
|
/// Adds stream export with account-position matching.
|
|
/// Mirrors Go <c>(a *Account) addStreamExportWithAccountPos(...)</c>.
|
|
/// </summary>
|
|
public Exception? AddStreamExportWithAccountPos(string subject, IReadOnlyList<Account>? accounts, uint accountPos)
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(subject))
|
|
return ServerErrors.ErrBadSubject;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
Exports.Streams ??= new Dictionary<string, StreamExport>(StringComparer.Ordinal);
|
|
Exports.Streams.TryGetValue(subject, out var export);
|
|
export ??= new StreamExport();
|
|
|
|
if (accounts != null || accountPos > 0)
|
|
{
|
|
var authErr = SetExportAuth(export, subject, accounts, accountPos);
|
|
if (authErr != null)
|
|
return authErr;
|
|
}
|
|
|
|
Exports.Streams[subject] = export;
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks stream import authorization with account lock.
|
|
/// Mirrors Go <c>(a *Account) checkStreamImportAuthorized(...)</c>.
|
|
/// </summary>
|
|
internal bool CheckStreamImportAuthorized(Account account, string subject, object? importClaim)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return CheckStreamImportAuthorizedNoLock(account, subject, importClaim); }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks stream import authorization assuming lock is already held.
|
|
/// Mirrors Go <c>(a *Account) checkStreamImportAuthorizedNoLock(...)</c>.
|
|
/// </summary>
|
|
internal bool CheckStreamImportAuthorizedNoLock(Account account, string subject, object? importClaim)
|
|
{
|
|
if (Exports.Streams == null || !SubscriptionIndex.IsValidSubject(subject))
|
|
return false;
|
|
return CheckStreamExportApproved(account, subject, importClaim);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets wildcard-matching service export for subject.
|
|
/// Lock should be held.
|
|
/// Mirrors Go <c>(a *Account) getWildcardServiceExport(from string)</c>.
|
|
/// </summary>
|
|
internal ServiceExportEntry? GetWildcardServiceExport(string from)
|
|
{
|
|
if (Exports.Services == null)
|
|
return null;
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(from);
|
|
foreach (var (subject, serviceExport) in Exports.Services)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subject))
|
|
return serviceExport;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles stream import activation expiration.
|
|
/// Mirrors Go <c>(a *Account) streamActivationExpired(...)</c>.
|
|
/// </summary>
|
|
internal void StreamActivationExpired(Account exportAccount, string subject)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (IsExpired() || Imports.Streams == null)
|
|
return;
|
|
|
|
foreach (var streamImport in Imports.Streams)
|
|
{
|
|
if (ReferenceEquals(streamImport.Account, exportAccount) && streamImport.From == subject)
|
|
{
|
|
streamImport.Invalid = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handles service import activation expiration.
|
|
/// Mirrors Go <c>(a *Account) serviceActivationExpired(...)</c>.
|
|
/// </summary>
|
|
internal void ServiceActivationExpired(Account destinationAccount, string subject)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
if (IsExpired() || Imports.Services == null)
|
|
return;
|
|
|
|
var serviceImport = GetServiceImportForAccountLocked(destinationAccount.Name, subject);
|
|
if (serviceImport != null)
|
|
serviceImport.Invalid = true;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-evaluates import validity when an activation token expiration timer fires.
|
|
/// Mirrors Go <c>(a *Account) activationExpired(...)</c>.
|
|
/// </summary>
|
|
internal void ActivationExpired(Account exportAccount, string subject, object? kind)
|
|
{
|
|
var normalizedKind = NormalizeExportKind(kind);
|
|
if (string.Equals(normalizedKind, "stream", StringComparison.Ordinal))
|
|
{
|
|
StreamActivationExpired(exportAccount, subject);
|
|
}
|
|
else if (string.Equals(normalizedKind, "service", StringComparison.Ordinal))
|
|
{
|
|
ServiceActivationExpired(exportAccount, subject);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an import activation claim/token.
|
|
/// Mirrors Go <c>(a *Account) checkActivation(...)</c>.
|
|
/// </summary>
|
|
internal bool CheckActivation(Account importAccount, object? claim, ExportAuth? exportAuth, bool expirationTimer)
|
|
{
|
|
if (claim == null)
|
|
return false;
|
|
|
|
if (!TryReadStringMember(claim, "Token", out var token) || string.IsNullOrWhiteSpace(token))
|
|
return false;
|
|
|
|
if (!TryDecodeJwtPayload(token, out var activationPayload))
|
|
return false;
|
|
|
|
if (!IsIssuerClaimTrusted(activationPayload))
|
|
return false;
|
|
|
|
if (TryReadLongMember(activationPayload, "exp", out var expires) && expires > 0)
|
|
{
|
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
if (expires <= now)
|
|
return false;
|
|
|
|
if (expirationTimer)
|
|
{
|
|
var delay = TimeSpan.FromSeconds(expires - now);
|
|
string importSubject = ReadActivationImportSubject(activationPayload);
|
|
object? claimType = TryReadMember(claim, "Type", out var typeValue) ? typeValue : null;
|
|
|
|
_ = new Timer(
|
|
_ => importAccount.ActivationExpired(this, importSubject, claimType),
|
|
null,
|
|
delay,
|
|
Timeout.InfiniteTimeSpan);
|
|
}
|
|
}
|
|
|
|
if (exportAuth == null)
|
|
return true;
|
|
|
|
string subject = TryReadStringMember(activationPayload, "sub", out var sub) ? sub : string.Empty;
|
|
long issuedAt = TryReadLongMember(activationPayload, "iat", out var iat) ? iat : 0;
|
|
return !IsRevoked(exportAuth.ActivationsRevoked, subject, issuedAt);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when activation issuer details are trusted for this account.
|
|
/// Mirrors Go <c>(a *Account) isIssuerClaimTrusted(...)</c>.
|
|
/// </summary>
|
|
internal bool IsIssuerClaimTrusted(object? claims)
|
|
{
|
|
if (claims == null)
|
|
return false;
|
|
|
|
string issuerAccount =
|
|
TryReadStringMember(claims, "IssuerAccount", out var ia) ? ia :
|
|
TryReadStringMember(claims, "issuer_account", out var iaAlt) ? iaAlt :
|
|
string.Empty;
|
|
|
|
// If issuer-account is omitted, issuer defaults to the account itself.
|
|
if (string.IsNullOrEmpty(issuerAccount))
|
|
return true;
|
|
|
|
if (!string.Equals(Name, issuerAccount, StringComparison.Ordinal))
|
|
{
|
|
if (Server is NatsServer server)
|
|
{
|
|
string importSubject = ReadActivationImportSubject(claims);
|
|
string importType = TryReadStringMember(claims, "import_type", out var it) ? it : string.Empty;
|
|
server.Errorf(
|
|
"Invalid issuer account {0} in activation claim (subject: {1} - type: {2}) for account {3}",
|
|
issuerAccount,
|
|
importSubject,
|
|
importType,
|
|
Name);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
string issuer =
|
|
TryReadStringMember(claims, "Issuer", out var issuerValue) ? issuerValue :
|
|
TryReadStringMember(claims, "iss", out var issValue) ? issValue :
|
|
string.Empty;
|
|
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
(_, var ok) = HasIssuerNoLock(issuer);
|
|
return ok;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether another account is approved to import this service export.
|
|
/// Mirrors Go <c>(a *Account) checkServiceImportAuthorized(...)</c>.
|
|
/// </summary>
|
|
internal bool CheckServiceImportAuthorized(Account account, string subject, object? importClaim)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return CheckServiceImportAuthorizedNoLock(account, subject, importClaim); }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lock-free helper for service import authorization checks.
|
|
/// Mirrors Go <c>(a *Account) checkServiceImportAuthorizedNoLock(...)</c>.
|
|
/// </summary>
|
|
internal bool CheckServiceImportAuthorizedNoLock(Account account, string subject, object? importClaim)
|
|
{
|
|
if (Exports.Services == null)
|
|
return false;
|
|
|
|
return CheckServiceExportApproved(account, subject, importClaim);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether bearer tokens should be rejected for this account.
|
|
/// Mirrors Go <c>(a *Account) failBearer() bool</c>.
|
|
/// </summary>
|
|
internal bool FailBearer()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return DisallowBearer; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates expiration state/timer from claim data.
|
|
/// Mirrors Go <c>(a *Account) checkExpiration(...)</c>.
|
|
/// </summary>
|
|
internal void CheckExpiration(object? claimsData)
|
|
{
|
|
long expires =
|
|
claimsData != null && TryReadLongMember(claimsData, "Expires", out var exp) ? exp :
|
|
claimsData != null && TryReadLongMember(claimsData, "exp", out var expUnix) ? expUnix :
|
|
0;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
ClearExpirationTimer();
|
|
|
|
if (expires == 0)
|
|
{
|
|
Interlocked.Exchange(ref _expired, 0);
|
|
return;
|
|
}
|
|
|
|
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
if (expires <= now)
|
|
{
|
|
Interlocked.Exchange(ref _expired, 1);
|
|
return;
|
|
}
|
|
|
|
SetExpirationTimer(TimeSpan.FromSeconds(expires - now));
|
|
Interlocked.Exchange(ref _expired, 0);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns signer scope for issuer, if present.
|
|
/// Mirrors Go <c>(a *Account) hasIssuer(...)</c>.
|
|
/// </summary>
|
|
internal (object? Scope, bool Ok) HasIssuer(string issuer)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return HasIssuerNoLock(issuer); }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lock-free signer lookup.
|
|
/// Mirrors Go <c>(a *Account) hasIssuerNoLock(...)</c>.
|
|
/// </summary>
|
|
internal (object? Scope, bool Ok) HasIssuerNoLock(string issuer)
|
|
{
|
|
if (SigningKeys == null || string.IsNullOrEmpty(issuer))
|
|
return (null, false);
|
|
|
|
return SigningKeys.TryGetValue(issuer, out var scope)
|
|
? (scope, true)
|
|
: (null, false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the leaf-node loop-detection subject.
|
|
/// Mirrors Go <c>(a *Account) getLDSubject() string</c>.
|
|
/// </summary>
|
|
internal string GetLDSubject()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return LoopDetectionSubject; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns account label used in trace output.
|
|
/// Mirrors Go <c>(a *Account) traceLabel() string</c>.
|
|
/// </summary>
|
|
internal string TraceLabel()
|
|
{
|
|
if (string.IsNullOrEmpty(NameTag))
|
|
return Name;
|
|
return $"{Name}/{NameTag}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when external auth is configured.
|
|
/// Mirrors Go <c>(a *Account) hasExternalAuth() bool</c>.
|
|
/// </summary>
|
|
internal bool HasExternalAuth()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try { return ExternalAuth != null; }
|
|
finally { _mu.ExitReadLock(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true when <paramref name="userId"/> is configured as an external-auth user.
|
|
/// Mirrors Go <c>(a *Account) isExternalAuthUser(userID string) bool</c>.
|
|
/// </summary>
|
|
internal bool IsExternalAuthUser(string userId)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
foreach (var authUser in ReadStringListMember(ExternalAuth, "AuthUsers", "auth_users"))
|
|
{
|
|
if (string.Equals(userId, authUser, StringComparison.Ordinal))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns configured external-auth xkey, or empty when unset.
|
|
/// Mirrors Go <c>(a *Account) externalAuthXKey() string</c>.
|
|
/// </summary>
|
|
internal string ExternalAuthXKey()
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (TryReadStringMember(ExternalAuth, "XKey", out var xkey) && !string.IsNullOrEmpty(xkey))
|
|
return xkey;
|
|
if (TryReadStringMember(ExternalAuth, "xkey", out var xkeyAlt) && !string.IsNullOrEmpty(xkeyAlt))
|
|
return xkeyAlt;
|
|
return string.Empty;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns whether external auth allows account switching to <paramref name="account"/>.
|
|
/// Mirrors Go <c>(a *Account) isAllowedAcount(acc string) bool</c>.
|
|
/// </summary>
|
|
internal bool IsAllowedAcount(string account)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
var allowed = ReadStringListMember(ExternalAuth, "AllowedAccounts", "allowed_accounts");
|
|
if (allowed.Count == 1 && string.Equals(allowed[0], "*", StringComparison.Ordinal))
|
|
return true;
|
|
|
|
foreach (var candidate in allowed)
|
|
{
|
|
if (string.Equals(candidate, account, StringComparison.Ordinal))
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Export checks
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true if the given service subject is exported (exact or wildcard match).
|
|
/// Mirrors Go <c>(a *Account) IsExportService(service string) bool</c>.
|
|
/// </summary>
|
|
public bool IsExportService(string service)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Exports.Services == null)
|
|
return false;
|
|
|
|
if (Exports.Services.ContainsKey(service))
|
|
return true;
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(service);
|
|
foreach (var subj in Exports.Services.Keys)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subj))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the given service export has latency tracking enabled.
|
|
/// Mirrors Go <c>(a *Account) IsExportServiceTracking(service string) bool</c>.
|
|
/// </summary>
|
|
public bool IsExportServiceTracking(string service)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
if (Exports.Services == null)
|
|
return false;
|
|
|
|
if (Exports.Services.TryGetValue(service, out var ea))
|
|
{
|
|
if (ea == null) return false;
|
|
if (ea.Latency != null) return true;
|
|
}
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(service);
|
|
foreach (var (subj, se) in Exports.Services)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subj) && se?.Latency != null)
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether another account is approved to import this stream export.
|
|
/// Lock must be held on entry (read is sufficient).
|
|
/// Mirrors Go <c>(a *Account) checkStreamExportApproved(...) bool</c>.
|
|
/// </summary>
|
|
internal bool CheckStreamExportApproved(Account account, string subject, object? imClaim)
|
|
{
|
|
if (Exports.Streams == null) return false;
|
|
|
|
if (Exports.Streams.TryGetValue(subject, out var ea))
|
|
{
|
|
if (ea == null) return true;
|
|
return CheckAuth(ea, account, imClaim, null);
|
|
}
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(subject);
|
|
foreach (var (subj, se) in Exports.Streams)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subj))
|
|
{
|
|
if (se == null) return true;
|
|
return CheckAuth(se, account, imClaim, tokens);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether another account is approved to import this service export.
|
|
/// Lock must be held on entry (read is sufficient).
|
|
/// Mirrors Go <c>(a *Account) checkServiceExportApproved(...) bool</c>.
|
|
/// </summary>
|
|
internal bool CheckServiceExportApproved(Account account, string subject, object? imClaim)
|
|
{
|
|
if (Exports.Services == null) return false;
|
|
|
|
if (Exports.Services.TryGetValue(subject, out var se))
|
|
{
|
|
if (se == null) return true;
|
|
return CheckAuth(se, account, imClaim, null);
|
|
}
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(subject);
|
|
foreach (var (subj, entry) in Exports.Services)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subj))
|
|
{
|
|
if (entry == null) return true;
|
|
return CheckAuth(entry, account, imClaim, tokens);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// User revocation check
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true if the user identified by <paramref name="nkey"/> with the
|
|
/// given <paramref name="issuedAt"/> timestamp has been revoked.
|
|
/// Mirrors Go <c>(a *Account) checkUserRevoked(nkey string, issuedAt int64) bool</c>.
|
|
/// </summary>
|
|
public bool CheckUserRevoked(string nkey, long issuedAt)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
return IsRevoked(UsersRevoked, nkey, issuedAt);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Config-reload comparison helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true if this account's stream imports equal <paramref name="b"/>'s.
|
|
/// Acquires this account's read lock; <paramref name="b"/> must not be
|
|
/// concurrently accessed.
|
|
/// Mirrors Go <c>(a *Account) checkStreamImportsEqual(b *Account) bool</c>.
|
|
/// </summary>
|
|
internal bool CheckStreamImportsEqual(Account b)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
var aStreams = Imports.Streams;
|
|
var bStreams = b.Imports.Streams;
|
|
|
|
int aLen = aStreams?.Count ?? 0;
|
|
int bLen = bStreams?.Count ?? 0;
|
|
if (aLen != bLen) return false;
|
|
if (aLen == 0) return true;
|
|
|
|
// Build an index from (accName+from+to) → entry for b.
|
|
var bIndex = new Dictionary<string, StreamImportEntry>(bLen);
|
|
foreach (var bim in bStreams!)
|
|
{
|
|
string key = (bim.Account?.Name ?? string.Empty) + bim.From + bim.To;
|
|
bIndex[key] = bim;
|
|
}
|
|
|
|
foreach (var aim in aStreams!)
|
|
{
|
|
string key = (aim.Account?.Name ?? string.Empty) + aim.From + aim.To;
|
|
if (!bIndex.TryGetValue(key, out var bim))
|
|
return false;
|
|
if (aim.AllowTrace != bim.AllowTrace)
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if this account's stream exports equal <paramref name="b"/>'s.
|
|
/// Acquires this account's read lock; <paramref name="b"/> must not be
|
|
/// concurrently accessed.
|
|
/// Mirrors Go <c>(a *Account) checkStreamExportsEqual(b *Account) bool</c>.
|
|
/// </summary>
|
|
internal bool CheckStreamExportsEqual(Account b)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
var aStreams = Exports.Streams;
|
|
var bStreams = b.Exports.Streams;
|
|
|
|
int aLen = aStreams?.Count ?? 0;
|
|
int bLen = bStreams?.Count ?? 0;
|
|
if (aLen != bLen) return false;
|
|
if (aLen == 0) return true;
|
|
|
|
foreach (var (subj, aea) in aStreams!)
|
|
{
|
|
if (!bStreams!.TryGetValue(subj, out var bea))
|
|
return false;
|
|
if (!IsStreamExportEqual(aea, bea))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if this account's service exports equal <paramref name="b"/>'s.
|
|
/// Acquires this account's read lock; <paramref name="b"/> must not be
|
|
/// concurrently accessed.
|
|
/// Mirrors Go <c>(a *Account) checkServiceExportsEqual(b *Account) bool</c>.
|
|
/// </summary>
|
|
internal bool CheckServiceExportsEqual(Account b)
|
|
{
|
|
_mu.EnterReadLock();
|
|
try
|
|
{
|
|
var aServices = Exports.Services;
|
|
var bServices = b.Exports.Services;
|
|
|
|
int aLen = aServices?.Count ?? 0;
|
|
int bLen = bServices?.Count ?? 0;
|
|
if (aLen != bLen) return false;
|
|
if (aLen == 0) return true;
|
|
|
|
foreach (var (subj, aea) in aServices!)
|
|
{
|
|
if (!bServices!.TryGetValue(subj, out var bea))
|
|
return false;
|
|
if (!IsServiceExportEqual(aea, bea))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitReadLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Leaf-node helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Notifies leaf nodes of a subscription change.
|
|
/// Stub — full implementation in session 15.
|
|
/// Mirrors Go <c>(a *Account) updateLeafNodes(sub, delta)</c>.
|
|
/// </summary>
|
|
internal void UpdateLeafNodes(object sub, int delta)
|
|
{
|
|
if (delta == 0 || sub is not Subscription s || s.Subject.Length == 0)
|
|
return;
|
|
|
|
var subject = Encoding.UTF8.GetString(s.Subject);
|
|
var queue = s.Queue is { Length: > 0 } ? Encoding.UTF8.GetString(s.Queue) : string.Empty;
|
|
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
_rm ??= new Dictionary<string, int>(StringComparer.Ordinal);
|
|
if (!_rm.TryGetValue(subject, out var rc))
|
|
rc = 0;
|
|
rc += delta;
|
|
if (rc <= 0)
|
|
_rm.Remove(subject);
|
|
else
|
|
_rm[subject] = rc;
|
|
|
|
if (!string.IsNullOrEmpty(queue))
|
|
{
|
|
_lqws ??= new Dictionary<string, int>(StringComparer.Ordinal);
|
|
var key = $"{subject} {queue}";
|
|
var qw = s.Qw != 0 ? s.Qw : 1;
|
|
if (!_lqws.TryGetValue(key, out var qv))
|
|
qv = 0;
|
|
qv += delta * qw;
|
|
if (qv <= 0)
|
|
_lqws.Remove(key);
|
|
else
|
|
_lqws[key] = qv;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
List<ClientConnection> leafs;
|
|
_lmu.EnterReadLock();
|
|
try { leafs = [.. _lleafs]; }
|
|
finally { _lmu.ExitReadLock(); }
|
|
|
|
foreach (var leaf in leafs)
|
|
leaf.FlushSignal();
|
|
}
|
|
|
|
internal void UpdateLeafNodesEx(string subject, int delta, bool force = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(subject) || delta == 0)
|
|
return;
|
|
|
|
var heldWriteLock = _mu.IsWriteLockHeld;
|
|
if (!heldWriteLock)
|
|
_mu.EnterWriteLock();
|
|
|
|
try
|
|
{
|
|
_rm ??= new Dictionary<string, int>(StringComparer.Ordinal);
|
|
_rm.TryGetValue(subject, out var interest);
|
|
interest += delta;
|
|
if (interest <= 0)
|
|
_rm.Remove(subject);
|
|
else
|
|
_rm[subject] = interest;
|
|
}
|
|
finally
|
|
{
|
|
if (!heldWriteLock)
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
List<ClientConnection> leafs;
|
|
_lmu.EnterReadLock();
|
|
try { leafs = [.. _lleafs]; }
|
|
finally { _lmu.ExitReadLock(); }
|
|
|
|
foreach (var leaf in leafs)
|
|
{
|
|
if (force)
|
|
{
|
|
if (delta > 0)
|
|
leaf.ForceAddToSmap(subject);
|
|
else
|
|
leaf.ForceRemoveFromSmap(subject);
|
|
}
|
|
else
|
|
{
|
|
leaf.FlushSignal();
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// addClient / removeClient
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Registers a client with this account, updating system and leaf counters.
|
|
/// Returns the previous total client count.
|
|
/// Mirrors Go <c>(a *Account) addClient(c *client) int</c>.
|
|
/// </summary>
|
|
private int AddClientInternal(ClientConnection c)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
int prev;
|
|
try
|
|
{
|
|
_clients ??= new HashSet<ClientConnection>();
|
|
prev = _clients.Count;
|
|
|
|
if (!_clients.Add(c))
|
|
{
|
|
// Client was already present — do nothing.
|
|
return prev;
|
|
}
|
|
|
|
if (IsInternalClientKind(c.Kind))
|
|
{
|
|
_sysclients++;
|
|
}
|
|
else if (c.Kind == ClientKind.Leaf)
|
|
{
|
|
_nleafs++;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
// Add leaf to the leaf list (uses separate lock).
|
|
if (c.Kind == ClientKind.Leaf)
|
|
{
|
|
_lmu.EnterWriteLock();
|
|
try { _lleafs.Add(c); }
|
|
finally { _lmu.ExitWriteLock(); }
|
|
}
|
|
|
|
// TODO: session 12 — notify server via c.srv.accConnsUpdate(a).
|
|
|
|
return prev;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Unregisters a client from this account, updating system and leaf counters.
|
|
/// Returns the previous total client count.
|
|
/// Mirrors Go <c>(a *Account) removeClient(c *client) int</c>.
|
|
/// </summary>
|
|
private int RemoveClientInternal(ClientConnection c)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
int prev;
|
|
bool wasLeaf = false;
|
|
try
|
|
{
|
|
prev = _clients?.Count ?? 0;
|
|
if (_clients == null || !_clients.Remove(c))
|
|
return prev;
|
|
|
|
if (IsInternalClientKind(c.Kind))
|
|
{
|
|
_sysclients--;
|
|
}
|
|
else if (c.Kind == ClientKind.Leaf)
|
|
{
|
|
_nleafs--;
|
|
wasLeaf = true;
|
|
|
|
// Cluster accounting for hub leaf nodes.
|
|
if (c.IsHubLeafNode())
|
|
{
|
|
var cluster = c.RemoteCluster();
|
|
if (!string.IsNullOrWhiteSpace(cluster) && _leafClusters != null && _leafClusters.TryGetValue(cluster, out var current))
|
|
{
|
|
if (current <= 1)
|
|
_leafClusters.Remove(cluster);
|
|
else
|
|
_leafClusters[cluster] = current - 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
|
|
if (wasLeaf)
|
|
{
|
|
RemoveLeafNode(c);
|
|
}
|
|
|
|
// TODO: session 12 — notify server via c.srv.accConnsUpdate(a).
|
|
|
|
return prev;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a leaf-node client from the ordered leaf list.
|
|
/// Uses <see cref="_lmu"/> internally.
|
|
/// Mirrors Go <c>(a *Account) removeLeafNode(c *client)</c>.
|
|
/// </summary>
|
|
private void RemoveLeafNode(ClientConnection c)
|
|
{
|
|
_lmu.EnterWriteLock();
|
|
try
|
|
{
|
|
int idx = _lleafs.IndexOf(c);
|
|
if (idx < 0) return;
|
|
|
|
int last = _lleafs.Count - 1;
|
|
_lleafs[idx] = _lleafs[last];
|
|
_lleafs.RemoveAt(last);
|
|
}
|
|
finally
|
|
{
|
|
_lmu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// INatsAccount implementation
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true when the account is valid (not expired).
|
|
/// Mirrors Go <c>INatsAccount.IsValid</c>.
|
|
/// </summary>
|
|
bool INatsAccount.IsValid => !IsExpired();
|
|
|
|
/// <summary>
|
|
/// Delegates to <see cref="MaxTotalConnectionsReached"/>.
|
|
/// Mirrors Go <c>INatsAccount.MaxTotalConnectionsReached()</c>.
|
|
/// </summary>
|
|
bool INatsAccount.MaxTotalConnectionsReached() => MaxTotalConnectionsReached();
|
|
|
|
/// <summary>
|
|
/// Delegates to <see cref="MaxTotalLeafNodesReached"/>.
|
|
/// Mirrors Go <c>INatsAccount.MaxTotalLeafNodesReached()</c>.
|
|
/// </summary>
|
|
bool INatsAccount.MaxTotalLeafNodesReached() => MaxTotalLeafNodesReached();
|
|
|
|
/// <summary>
|
|
/// Registers a client connection. Returns the previous client count.
|
|
/// Mirrors Go <c>INatsAccount.AddClient(c)</c>.
|
|
/// </summary>
|
|
int INatsAccount.AddClient(ClientConnection c) => AddClientInternal(c);
|
|
|
|
/// <summary>
|
|
/// Unregisters a client connection. Returns the previous client count.
|
|
/// Mirrors Go <c>INatsAccount.RemoveClient(c)</c>.
|
|
/// </summary>
|
|
int INatsAccount.RemoveClient(ClientConnection c) => RemoveClientInternal(c);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Static helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Returns true when the user identified by <paramref name="subject"/> with
|
|
/// the given <paramref name="issuedAt"/> timestamp has been revoked.
|
|
/// Also checks the wildcard entry (jwt.All = "*").
|
|
/// Mirrors Go package-level <c>isRevoked(...) bool</c>.
|
|
/// </summary>
|
|
internal static bool IsRevoked(
|
|
Dictionary<string, long>? revocations,
|
|
string subject,
|
|
long issuedAt)
|
|
{
|
|
if (revocations == null || revocations.Count == 0)
|
|
return false;
|
|
|
|
// Check specific key.
|
|
if (revocations.TryGetValue(subject, out long ts) && ts >= issuedAt)
|
|
return true;
|
|
|
|
// Check wildcard revocation ("*" = jwt.All).
|
|
if (revocations.TryGetValue("*", out long tsAll) && tsAll >= issuedAt)
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the reply is a tracked reply (ends with ".<hash>.T").
|
|
/// Mirrors Go package-level <c>isTrackedReply(reply []byte) bool</c>.
|
|
/// </summary>
|
|
internal static bool IsTrackedReply(ReadOnlySpan<byte> reply)
|
|
{
|
|
int lreply = reply.Length - 1;
|
|
return lreply > 3 && reply[lreply - 1] == '.' && reply[lreply] == 'T';
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates a mapping destination subject without creating a full transform.
|
|
/// Mirrors Go <c>ValidateMapping(src, dest string) error</c> in sublist.go.
|
|
/// Returns null on success; an exception on failure.
|
|
/// </summary>
|
|
internal static Exception? ValidateMapping(string src, string dest)
|
|
{
|
|
if (string.IsNullOrEmpty(dest))
|
|
return null;
|
|
|
|
bool sfwc = false;
|
|
foreach (var token in dest.Split('.'))
|
|
{
|
|
int length = token.Length;
|
|
if (length == 0 || sfwc)
|
|
return new MappingDestinationException(token, ServerErrors.ErrInvalidMappingDestinationSubject);
|
|
|
|
// If it looks like a mapping function, ensure it is a known one.
|
|
if (length > 4 && token[0] == '{' && token[1] == '{' &&
|
|
token[length - 2] == '}' && token[length - 1] == '}')
|
|
{
|
|
var (tt, _, _, _, terr) = SubjectTransform.IndexPlaceHolders(token);
|
|
if (terr != null) return terr;
|
|
if (tt == TransformType.BadTransform)
|
|
return new MappingDestinationException(token, ServerErrors.ErrUnknownMappingDestinationFunction);
|
|
continue;
|
|
}
|
|
|
|
if (length == 1 && token[0] == '>')
|
|
{
|
|
sfwc = true;
|
|
}
|
|
else if (token.IndexOfAny(['\t', '\n', '\f', '\r', ' ']) >= 0)
|
|
{
|
|
return ServerErrors.ErrInvalidMappingDestinationSubject;
|
|
}
|
|
}
|
|
|
|
// Verify the full transform can be constructed.
|
|
var (_, err) = SubjectTransform.New(src, dest);
|
|
return err;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns true if the given <see cref="ClientKind"/> is an internal kind
|
|
/// (System, JetStream, or Account — not a real user connection).
|
|
/// Mirrors Go <c>isInternalClient(kind int) bool</c>.
|
|
/// </summary>
|
|
private static bool IsInternalClientKind(ClientKind kind) =>
|
|
kind is ClientKind.System or ClientKind.JetStream or ClientKind.Account;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Private helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Builds the cumulative-weight destination list from a list of raw-weight
|
|
/// <see cref="Destination"/> entries. If the total weight is less than 100
|
|
/// and the source was not explicitly listed as a destination, a pass-through
|
|
/// entry is auto-added for the remainder.
|
|
/// Mirrors Go <c>processDestinations(dests []*destination) ([]*destination, error)</c>.
|
|
/// </summary>
|
|
private static Exception? ProcessDestinations(
|
|
string src,
|
|
bool hasWildcard,
|
|
HashSet<string> seen,
|
|
List<Destination> dests)
|
|
{
|
|
byte totalWeight = 0;
|
|
foreach (var d in dests)
|
|
totalWeight += d.Weight;
|
|
|
|
bool haveSrc = seen.Contains(src);
|
|
|
|
// Auto-fill the remaining weight with a pass-through to the source.
|
|
if (totalWeight != 100 && !haveSrc)
|
|
{
|
|
string passThroughDest = src;
|
|
if (hasWildcard)
|
|
passThroughDest = SubjectTransform.TransformTokenize(src);
|
|
|
|
var (tr, err) = SubjectTransform.New(src, passThroughDest);
|
|
if (err != null) return err;
|
|
|
|
byte aw = dests.Count == 0 ? (byte)100 : (byte)(100 - totalWeight);
|
|
dests.Add(new Destination { Transform = tr, Weight = aw });
|
|
}
|
|
|
|
// Sort ascending by raw weight so the cumulative scan is correct.
|
|
dests.Sort((a, b) => a.Weight.CompareTo(b.Weight));
|
|
|
|
// Convert raw weights to cumulative weights.
|
|
byte cumulative = 0;
|
|
foreach (var d in dests)
|
|
{
|
|
cumulative += d.Weight;
|
|
d.Weight = cumulative;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a service import entry to the import map.
|
|
/// Mirrors Go <c>(a *Account) addServiceImport(...)</c>.
|
|
/// </summary>
|
|
private (ServiceImportEntry? Import, Exception? Error) AddServiceImportInternal(Account destination, string from, string to, object? claim)
|
|
{
|
|
_mu.EnterWriteLock();
|
|
try
|
|
{
|
|
Imports.Services ??= new Dictionary<string, List<ServiceImportEntry>>(StringComparer.Ordinal);
|
|
|
|
var serviceImport = new ServiceImportEntry
|
|
{
|
|
Account = destination,
|
|
Claim = claim,
|
|
From = from,
|
|
To = to,
|
|
};
|
|
|
|
if (!Imports.Services.TryGetValue(from, out var entries))
|
|
{
|
|
entries = [];
|
|
Imports.Services[from] = entries;
|
|
}
|
|
|
|
entries.Add(serviceImport);
|
|
return (serviceImport, null);
|
|
}
|
|
finally
|
|
{
|
|
_mu.ExitWriteLock();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a service export by exact or wildcard subject match.
|
|
/// Mirrors Go <c>(a *Account) getServiceExport(service string) *serviceExport</c>.
|
|
/// </summary>
|
|
private ServiceExportEntry? GetServiceExport(string service)
|
|
{
|
|
if (Exports.Services == null)
|
|
return null;
|
|
|
|
if (Exports.Services.TryGetValue(service, out var serviceExport))
|
|
return serviceExport;
|
|
|
|
var tokens = SubjectTransform.TokenizeSubject(service);
|
|
foreach (var (subject, export) in Exports.Services)
|
|
{
|
|
if (SubjectTransform.IsSubsetMatch(tokens, subject))
|
|
return export;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static ClientInfo? CreateClientInfo(ClientConnection? client, bool _)
|
|
{
|
|
if (client == null)
|
|
return null;
|
|
|
|
return new ClientInfo
|
|
{
|
|
Id = client.Cid,
|
|
Account = client.Account()?.Name ?? string.Empty,
|
|
Name = client.Opts.Name ?? string.Empty,
|
|
Rtt = client.GetRttValue(),
|
|
Start = client.Start == default ? string.Empty : client.Start.ToUniversalTime().ToString("O"),
|
|
Kind = client.Kind.ToString(),
|
|
ClientType = client.ClientType().ToString(),
|
|
};
|
|
}
|
|
|
|
private static long UtcNowUnixNanos() => TimeSpanToUnixNanos(DateTime.UtcNow - DateTime.UnixEpoch);
|
|
|
|
private static long TimeSpanToUnixNanos(TimeSpan value) => value.Ticks * 100L;
|
|
|
|
private static TimeSpan UnixNanosToTimeSpan(long unixNanos) => TimeSpan.FromTicks(unixNanos / 100L);
|
|
|
|
private static DateTime UnixNanoToDateTime(long unixNanos)
|
|
{
|
|
if (unixNanos <= 0)
|
|
return DateTime.UnixEpoch;
|
|
return DateTime.UnixEpoch.AddTicks(unixNanos / 100L);
|
|
}
|
|
|
|
private static bool TryDecodeJwtPayload(string token, out JsonElement payload)
|
|
{
|
|
payload = default;
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
return false;
|
|
|
|
var parts = token.Split('.');
|
|
if (parts.Length < 2)
|
|
return false;
|
|
|
|
string base64 = parts[1]
|
|
.Replace("-", "+", StringComparison.Ordinal)
|
|
.Replace("_", "/", StringComparison.Ordinal);
|
|
|
|
int mod = base64.Length % 4;
|
|
if (mod > 0)
|
|
base64 = base64.PadRight(base64.Length + (4 - mod), '=');
|
|
|
|
byte[] bytes;
|
|
try { bytes = Convert.FromBase64String(base64); }
|
|
catch { return false; }
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(bytes);
|
|
payload = doc.RootElement.Clone();
|
|
return payload.ValueKind == JsonValueKind.Object;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryReadMember(object source, string name, out object? value)
|
|
{
|
|
value = null;
|
|
if (source == null)
|
|
return false;
|
|
|
|
if (source is JsonElement element)
|
|
{
|
|
if (element.ValueKind != JsonValueKind.Object)
|
|
return false;
|
|
|
|
foreach (var property in element.EnumerateObject())
|
|
{
|
|
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
value = property.Value;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (source is IDictionary<string, object?> dictionary &&
|
|
dictionary.TryGetValue(name, out var dictionaryValue))
|
|
{
|
|
value = dictionaryValue;
|
|
return true;
|
|
}
|
|
|
|
if (source is IDictionary<string, string> stringDictionary &&
|
|
stringDictionary.TryGetValue(name, out var stringDictionaryValue))
|
|
{
|
|
value = stringDictionaryValue;
|
|
return true;
|
|
}
|
|
|
|
var propertyInfo = source
|
|
.GetType()
|
|
.GetProperty(name, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.IgnoreCase);
|
|
if (propertyInfo == null)
|
|
return false;
|
|
|
|
value = propertyInfo.GetValue(source);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryReadStringMember(object? source, string name, out string value)
|
|
{
|
|
value = string.Empty;
|
|
if (source == null || !TryReadMember(source, name, out var member))
|
|
return false;
|
|
|
|
if (member is JsonElement element)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
{
|
|
value = element.GetString() ?? string.Empty;
|
|
return true;
|
|
}
|
|
|
|
if (element.ValueKind == JsonValueKind.Number)
|
|
{
|
|
value = element.ToString();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
value = member?.ToString() ?? string.Empty;
|
|
return true;
|
|
}
|
|
|
|
private static bool TryReadLongMember(object source, string name, out long value)
|
|
{
|
|
value = 0;
|
|
if (!TryReadMember(source, name, out var member))
|
|
return false;
|
|
|
|
if (member is JsonElement element)
|
|
{
|
|
if (element.ValueKind == JsonValueKind.Number)
|
|
return element.TryGetInt64(out value);
|
|
|
|
if (element.ValueKind == JsonValueKind.String)
|
|
return long.TryParse(element.GetString(), out value);
|
|
|
|
return false;
|
|
}
|
|
|
|
switch (member)
|
|
{
|
|
case byte b:
|
|
value = b;
|
|
return true;
|
|
case sbyte sb:
|
|
value = sb;
|
|
return true;
|
|
case short s:
|
|
value = s;
|
|
return true;
|
|
case ushort us:
|
|
value = us;
|
|
return true;
|
|
case int i:
|
|
value = i;
|
|
return true;
|
|
case uint ui:
|
|
value = ui;
|
|
return true;
|
|
case long l:
|
|
value = l;
|
|
return true;
|
|
case ulong ul when ul <= long.MaxValue:
|
|
value = (long)ul;
|
|
return true;
|
|
case string str:
|
|
return long.TryParse(str, out value);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<string> ReadStringListMember(object? source, params string[] names)
|
|
{
|
|
if (source == null)
|
|
return [];
|
|
|
|
foreach (var name in names)
|
|
{
|
|
if (!TryReadMember(source, name, out var member) || member == null)
|
|
continue;
|
|
|
|
if (member is IEnumerable<string> enumerableStrings)
|
|
return [.. enumerableStrings];
|
|
|
|
if (member is JsonElement element && element.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var results = new List<string>();
|
|
foreach (var item in element.EnumerateArray())
|
|
{
|
|
if (item.ValueKind == JsonValueKind.String)
|
|
results.Add(item.GetString() ?? string.Empty);
|
|
else
|
|
results.Add(item.ToString());
|
|
}
|
|
return results;
|
|
}
|
|
|
|
if (member is IEnumerable<object?> objectEnumerable)
|
|
{
|
|
var results = new List<string>();
|
|
foreach (var item in objectEnumerable)
|
|
results.Add(item?.ToString() ?? string.Empty);
|
|
return results;
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private static string NormalizeExportKind(object? kind)
|
|
{
|
|
if (kind is JsonElement element)
|
|
return element.ToString().Trim().ToLowerInvariant();
|
|
|
|
return kind?.ToString()?.Trim().ToLowerInvariant() ?? string.Empty;
|
|
}
|
|
|
|
private static string ReadActivationImportSubject(object claimOrPayload)
|
|
{
|
|
if (TryReadStringMember(claimOrPayload, "ImportSubject", out var importSubject) && !string.IsNullOrEmpty(importSubject))
|
|
return importSubject;
|
|
if (TryReadStringMember(claimOrPayload, "import_subject", out var importSubjectSnake) && !string.IsNullOrEmpty(importSubjectSnake))
|
|
return importSubjectSnake;
|
|
|
|
if (claimOrPayload is JsonElement element &&
|
|
element.ValueKind == JsonValueKind.Object &&
|
|
element.TryGetProperty("nats", out var natsObj) &&
|
|
natsObj.ValueKind == JsonValueKind.Object &&
|
|
natsObj.TryGetProperty("import_subject", out var natsImportSubject) &&
|
|
natsImportSubject.ValueKind == JsonValueKind.String)
|
|
{
|
|
return natsImportSubject.GetString() ?? string.Empty;
|
|
}
|
|
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tokenises a subject string into an array, using the same split logic
|
|
/// as <c>btsep</c>-based tokenisation in the Go source.
|
|
/// </summary>
|
|
private static string[] TokenizeSubjectForMapping(string subject)
|
|
{
|
|
var parts = new List<string>();
|
|
int start = 0;
|
|
for (int i = 0; i < subject.Length; i++)
|
|
{
|
|
if (subject[i] == '.')
|
|
{
|
|
parts.Add(subject[start..i]);
|
|
start = i + 1;
|
|
}
|
|
}
|
|
parts.Add(subject[start..]);
|
|
return [.. parts];
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the cached cluster name for cluster-scoped mapping selection.
|
|
/// Delegates to the server when available; returns empty string as a stub.
|
|
/// Mirrors Go <c>a.srv.cachedClusterName()</c>.
|
|
/// TODO: session 09 — wire up Server.CachedClusterName().
|
|
/// </summary>
|
|
private string GetCachedClusterName()
|
|
{
|
|
// TODO: session 09 — Server.CachedClusterName().
|
|
return string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the connection-heartbeat timer. Caller must hold the account lock.
|
|
/// Mirrors Go <c>(a *Account) clearConnectionTimer()</c> in server/events.go.
|
|
/// </summary>
|
|
internal void ClearConnectionHeartbeatTimer()
|
|
{
|
|
ClearTimerLocked(ref _ctmr);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Starts or resets the connection-heartbeat timer.
|
|
/// Caller must hold the account lock.
|
|
/// Mirrors Go inline timer setup in <c>sendAccConnsUpdate()</c>.
|
|
/// </summary>
|
|
internal void SetConnectionHeartbeatTimer(long delayMs, Action callback)
|
|
{
|
|
if (_ctmr == null)
|
|
_ctmr = new Timer(_ => callback(), null, delayMs, Timeout.Infinite);
|
|
else
|
|
_ctmr.Change(delayMs, Timeout.Infinite);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops and nulls out a timer. Lock must be held by the caller.
|
|
/// Mirrors Go <c>clearTimer(t **time.Timer)</c>.
|
|
/// </summary>
|
|
private static void ClearTimerLocked(ref Timer? t)
|
|
{
|
|
t?.Dispose();
|
|
t = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks whether <paramref name="account"/> is authorised to use
|
|
/// <paramref name="ea"/> (either via explicit approval or token requirement).
|
|
/// Mirrors Go <c>(a *Account) checkAuth(...) bool</c>.
|
|
/// </summary>
|
|
private bool CheckAuth(
|
|
ExportAuth ea,
|
|
Account account,
|
|
object? imClaim,
|
|
string[]? tokens)
|
|
{
|
|
if (ea.Approved != null && ea.Approved.ContainsKey(account.Name))
|
|
return true;
|
|
|
|
if (ea.TokenRequired)
|
|
{
|
|
return CheckActivation(account, imClaim, ea, expirationTimer: true);
|
|
}
|
|
|
|
// No approved list and no token required → public export.
|
|
if (ea.Approved == null) return true;
|
|
|
|
// AccountPosition embedding check.
|
|
if (ea.AccountPosition > 0 && tokens != null)
|
|
{
|
|
int pos = (int)ea.AccountPosition - 1;
|
|
if (pos < tokens.Length && tokens[pos] == account.Name)
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies account-based authorization rules to an export descriptor.
|
|
/// Mirrors Go <c>setExportAuth(&se.exportAuth, ...)</c>.
|
|
/// </summary>
|
|
private static Exception? SetExportAuth(ExportAuth auth, string subject, IReadOnlyList<Account>? accounts, uint accountPos)
|
|
{
|
|
if (!SubscriptionIndex.IsValidSubject(subject))
|
|
return ServerErrors.ErrBadSubject;
|
|
|
|
auth.AccountPosition = accountPos;
|
|
|
|
if (accounts == null || accounts.Count == 0)
|
|
{
|
|
auth.Approved = null;
|
|
return null;
|
|
}
|
|
|
|
var approved = new Dictionary<string, Account>(accounts.Count, StringComparer.Ordinal);
|
|
foreach (var account in accounts)
|
|
{
|
|
if (account == null)
|
|
continue;
|
|
approved[account.Name] = account;
|
|
}
|
|
|
|
auth.Approved = approved;
|
|
return null;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Export equality helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
private static bool IsStreamExportEqual(StreamExport? a, StreamExport? b)
|
|
{
|
|
if (a == null && b == null) return true;
|
|
if ((a == null) != (b == null)) return false;
|
|
return IsExportAuthEqual(a!, b!);
|
|
}
|
|
|
|
private static bool IsServiceExportEqual(ServiceExportEntry? a, ServiceExportEntry? b)
|
|
{
|
|
if (a == null && b == null) return true;
|
|
if ((a == null) != (b == null)) return false;
|
|
if (!IsExportAuthEqual(a!, b!)) return false;
|
|
if ((a!.Account?.Name ?? string.Empty) != (b!.Account?.Name ?? string.Empty)) return false;
|
|
if (a.ResponseType != b.ResponseType) return false;
|
|
if (a.AllowTrace != b.AllowTrace) return false;
|
|
|
|
// Latency comparison.
|
|
if ((a.Latency == null) != (b.Latency == null)) return false;
|
|
if (a.Latency != null)
|
|
{
|
|
if (a.Latency.Sampling != b.Latency!.Sampling) return false;
|
|
if (a.Latency.Subject != b.Latency.Subject) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static bool IsExportAuthEqual(ExportAuth a, ExportAuth b)
|
|
{
|
|
if (a.TokenRequired != b.TokenRequired) return false;
|
|
if (a.AccountPosition != b.AccountPosition) return false;
|
|
|
|
int aApproved = a.Approved?.Count ?? 0;
|
|
int bApproved = b.Approved?.Count ?? 0;
|
|
if (aApproved != bApproved) return false;
|
|
|
|
if (a.Approved != null)
|
|
{
|
|
foreach (var (ak, av) in a.Approved)
|
|
{
|
|
if (!b.Approved!.TryGetValue(ak, out var bv) ||
|
|
av.Name != bv.Name)
|
|
return false;
|
|
}
|
|
}
|
|
|
|
int aRevoked = a.ActivationsRevoked?.Count ?? 0;
|
|
int bRevoked = b.ActivationsRevoked?.Count ?? 0;
|
|
if (aRevoked != bRevoked) return false;
|
|
|
|
if (a.ActivationsRevoked != null)
|
|
{
|
|
foreach (var (ak, av) in a.ActivationsRevoked)
|
|
{
|
|
if (!b.ActivationsRevoked!.TryGetValue(ak, out var bv) || av != bv)
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|