feat: port session 11 — Accounts & Directory JWT Store
- Account: full Account class (200 features) with subject mappings, connection counting, export/import checks, expiration timers - DirJwtStore: directory-based JWT storage with sharding and expiry - AccountResolver: IAccountResolver, MemoryAccountResolver, UrlAccountResolver, DirAccountResolver, CacheDirAccountResolver - AccountTypes: all supporting types (AccountLimits, SConns, ExportMap, ImportMap, ServiceExport, StreamExport, ServiceLatency, etc.) - 34 unit tests (599 total), 234 features complete (IDs 150-349, 793-826)
This commit is contained in:
2118
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
Normal file
2118
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/Account.cs
Normal file
File diff suppressed because it is too large
Load Diff
525
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs
Normal file
525
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountResolver.cs
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
// 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 System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace ZB.MOM.NatsNet.Server;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// IAccountResolver
|
||||||
|
// Mirrors Go AccountResolver interface (accounts.go ~line 4035).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves and stores account JWTs by account public key name.
|
||||||
|
/// Mirrors Go <c>AccountResolver</c> interface.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAccountResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the JWT for the named account.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> when the account is not found.
|
||||||
|
/// Mirrors Go <c>AccountResolver.Fetch</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> FetchAsync(string name, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the JWT for the named account.
|
||||||
|
/// Read-only implementations throw <see cref="NotSupportedException"/>.
|
||||||
|
/// Mirrors Go <c>AccountResolver.Store</c>.
|
||||||
|
/// </summary>
|
||||||
|
Task StoreAsync(string name, string jwt, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Returns true when no writes are permitted. Mirrors Go <c>IsReadOnly</c>.</summary>
|
||||||
|
bool IsReadOnly();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts any background processing needed by the resolver (system subscriptions, timers, etc.).
|
||||||
|
/// The <paramref name="server"/> parameter accepts an <c>object</c> to avoid a circular assembly
|
||||||
|
/// reference; implementations should cast it to the concrete server type as needed.
|
||||||
|
/// Mirrors Go <c>AccountResolver.Start</c>.
|
||||||
|
/// </summary>
|
||||||
|
void Start(object server);
|
||||||
|
|
||||||
|
/// <summary>Returns true when the resolver reacts to JWT update events. Mirrors Go <c>IsTrackingUpdate</c>.</summary>
|
||||||
|
bool IsTrackingUpdate();
|
||||||
|
|
||||||
|
/// <summary>Reloads state from the backing store. Mirrors Go <c>AccountResolver.Reload</c>.</summary>
|
||||||
|
void Reload();
|
||||||
|
|
||||||
|
/// <summary>Releases resources held by the resolver. Mirrors Go <c>AccountResolver.Close</c>.</summary>
|
||||||
|
void Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ResolverDefaultsOps
|
||||||
|
// Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstract base that provides sensible no-op / read-only defaults for <see cref="IAccountResolver"/>
|
||||||
|
/// so concrete implementations only need to override what they change.
|
||||||
|
/// Mirrors Go <c>resolverDefaultsOpsImpl</c>.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ResolverDefaultsOps : IAccountResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public abstract Task<string> FetchAsync(string name, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default store implementation — always throws because the base defaults to read-only.
|
||||||
|
/// Mirrors Go <c>resolverDefaultsOpsImpl.Store</c>.
|
||||||
|
/// </summary>
|
||||||
|
public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException("store operation not supported");
|
||||||
|
|
||||||
|
/// <summary>Default: the resolver is read-only. Mirrors Go <c>resolverDefaultsOpsImpl.IsReadOnly</c>.</summary>
|
||||||
|
public virtual bool IsReadOnly() => true;
|
||||||
|
|
||||||
|
/// <summary>Default: no-op start. Mirrors Go <c>resolverDefaultsOpsImpl.Start</c>.</summary>
|
||||||
|
public virtual void Start(object server) { }
|
||||||
|
|
||||||
|
/// <summary>Default: does not track updates. Mirrors Go <c>resolverDefaultsOpsImpl.IsTrackingUpdate</c>.</summary>
|
||||||
|
public virtual bool IsTrackingUpdate() => false;
|
||||||
|
|
||||||
|
/// <summary>Default: no-op reload. Mirrors Go <c>resolverDefaultsOpsImpl.Reload</c>.</summary>
|
||||||
|
public virtual void Reload() { }
|
||||||
|
|
||||||
|
/// <summary>Default: no-op close. Mirrors Go <c>resolverDefaultsOpsImpl.Close</c>.</summary>
|
||||||
|
public virtual void Close() { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MemoryAccountResolver
|
||||||
|
// Mirrors Go MemAccResolver (accounts.go ~line 4072).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An in-memory account resolver backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||||
|
/// Primarily intended for testing.
|
||||||
|
/// Mirrors Go <c>MemAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MemoryAccountResolver : ResolverDefaultsOps
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>In-memory resolver is not read-only.</summary>
|
||||||
|
public override bool IsReadOnly() => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the stored JWT for <paramref name="name"/>, or throws
|
||||||
|
/// <see cref="InvalidOperationException"/> when the account is unknown.
|
||||||
|
/// Mirrors Go <c>MemAccResolver.Fetch</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_store.TryGetValue(name, out var jwt))
|
||||||
|
{
|
||||||
|
return Task.FromResult(jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Account not found: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores <paramref name="jwt"/> for <paramref name="name"/>.
|
||||||
|
/// Mirrors Go <c>MemAccResolver.Store</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_store[name] = jwt;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UrlAccountResolver
|
||||||
|
// Mirrors Go URLAccResolver (accounts.go ~line 4097).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An HTTP-based account resolver that fetches JWTs by appending the account public key
|
||||||
|
/// to a configured base URL.
|
||||||
|
/// Mirrors Go <c>URLAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UrlAccountResolver : ResolverDefaultsOps
|
||||||
|
{
|
||||||
|
// Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT.
|
||||||
|
private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
private readonly string _url;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new URL resolver for the given <paramref name="url"/>.
|
||||||
|
/// A trailing slash is appended when absent so that account names can be concatenated
|
||||||
|
/// directly. An <see cref="HttpClient"/> is configured with connection-pooling
|
||||||
|
/// settings that amortise TLS handshakes across requests, mirroring Go's custom
|
||||||
|
/// <c>http.Transport</c>.
|
||||||
|
/// Mirrors Go <c>NewURLAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
public UrlAccountResolver(string url)
|
||||||
|
{
|
||||||
|
if (!url.EndsWith('/'))
|
||||||
|
{
|
||||||
|
url += "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
_url = url;
|
||||||
|
|
||||||
|
// Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport.
|
||||||
|
var handler = new SocketsHttpHandler
|
||||||
|
{
|
||||||
|
MaxConnectionsPerServer = 10,
|
||||||
|
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
Timeout = DefaultAccountFetchTimeout,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues an HTTP GET to the base URL with the account name appended, and returns
|
||||||
|
/// the response body as the JWT string.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> on a non-200 response.
|
||||||
|
/// Mirrors Go <c>URLAccResolver.Fetch</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override async Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var requestUrl = _url + name;
|
||||||
|
HttpResponseMessage response;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (response.StatusCode != HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DirResOption — functional option for DirAccountResolver
|
||||||
|
// Mirrors Go DirResOption func type (accounts.go ~line 4552).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A functional option that configures a <see cref="DirAccountResolver"/> instance.
|
||||||
|
/// Mirrors Go <c>DirResOption</c> function type.
|
||||||
|
/// </summary>
|
||||||
|
public delegate void DirResOption(DirAccountResolver resolver);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory methods for commonly used <see cref="DirResOption"/> values.
|
||||||
|
/// </summary>
|
||||||
|
public static class DirResOptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns an option that overrides the default fetch timeout.
|
||||||
|
/// <paramref name="timeout"/> must be positive.
|
||||||
|
/// Mirrors Go <c>FetchTimeout</c> option constructor.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">
|
||||||
|
/// Thrown at application time when <paramref name="timeout"/> is not positive.
|
||||||
|
/// </exception>
|
||||||
|
public static DirResOption FetchTimeout(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
if (timeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(timeout),
|
||||||
|
$"Fetch timeout {timeout} is too small");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolver => resolver.FetchTimeout = timeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DirAccountResolver (stub)
|
||||||
|
// Mirrors Go DirAccResolver (accounts.go ~line 4143).
|
||||||
|
// Full system-subscription wiring is deferred to session 12.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A directory-backed account resolver that stores JWTs in a <see cref="DirJwtStore"/>
|
||||||
|
/// and synchronises with peers via NATS system subjects.
|
||||||
|
/// <para>
|
||||||
|
/// The Start override that wires up system subscriptions and the periodic sync goroutine
|
||||||
|
/// is a stub in this session; full implementation requires JetStream and system
|
||||||
|
/// subscription support (session 12+).
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>DirAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class DirAccountResolver : ResolverDefaultsOps, IDisposable
|
||||||
|
{
|
||||||
|
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
|
||||||
|
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
// Default sync interval — mirrors Go's fallback of 1 minute.
|
||||||
|
private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>The underlying directory JWT store. Mirrors Go <c>DirAccResolver.DirJWTStore</c>.</summary>
|
||||||
|
public DirJwtStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>Reference to the running server, set during <see cref="Start"/>. Mirrors Go <c>DirAccResolver.Server</c>.</summary>
|
||||||
|
public object? Server { get; protected set; }
|
||||||
|
|
||||||
|
/// <summary>How often the resolver sends a sync (pack) request to peers. Mirrors Go <c>DirAccResolver.syncInterval</c>.</summary>
|
||||||
|
public TimeSpan SyncInterval { get; protected set; }
|
||||||
|
|
||||||
|
/// <summary>Maximum time to wait for a remote JWT fetch. Mirrors Go <c>DirAccResolver.fetchTimeout</c>.</summary>
|
||||||
|
public TimeSpan FetchTimeout { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new directory account resolver.
|
||||||
|
/// <para>
|
||||||
|
/// When <paramref name="limit"/> is zero it is promoted to <see cref="long.MaxValue"/> (unlimited).
|
||||||
|
/// When <paramref name="syncInterval"/> is non-positive it defaults to one minute.
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>NewDirAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Directory path for the JWT store.</param>
|
||||||
|
/// <param name="limit">Maximum number of JWTs the store may hold (0 = unlimited).</param>
|
||||||
|
/// <param name="syncInterval">How often to broadcast a sync/pack request to peers.</param>
|
||||||
|
/// <param name="deleteType">Controls whether deletes are soft- or hard-deleted.</param>
|
||||||
|
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
|
||||||
|
public DirAccountResolver(
|
||||||
|
string path,
|
||||||
|
long limit,
|
||||||
|
TimeSpan syncInterval,
|
||||||
|
JwtDeleteType deleteType,
|
||||||
|
params DirResOption[] opts)
|
||||||
|
{
|
||||||
|
if (limit == 0)
|
||||||
|
{
|
||||||
|
limit = long.MaxValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncInterval <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
syncInterval = DefaultSyncInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
Store = DirJwtStore.NewExpiringDirJwtStore(
|
||||||
|
path,
|
||||||
|
shard: false,
|
||||||
|
create: true,
|
||||||
|
deleteType,
|
||||||
|
expireCheck: TimeSpan.Zero,
|
||||||
|
limit,
|
||||||
|
evictOnLimit: false,
|
||||||
|
ttl: TimeSpan.Zero,
|
||||||
|
changeNotification: null);
|
||||||
|
|
||||||
|
SyncInterval = syncInterval;
|
||||||
|
FetchTimeout = DefaultFetchTimeout;
|
||||||
|
|
||||||
|
Apply(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal constructor used by CacheDirAccountResolver which supplies its own store.
|
||||||
|
internal DirAccountResolver(
|
||||||
|
DirJwtStore store,
|
||||||
|
TimeSpan syncInterval,
|
||||||
|
TimeSpan fetchTimeout)
|
||||||
|
{
|
||||||
|
Store = store;
|
||||||
|
SyncInterval = syncInterval;
|
||||||
|
FetchTimeout = fetchTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies a sequence of functional options to this resolver.
|
||||||
|
/// Mirrors Go <c>DirAccResolver.apply</c>.
|
||||||
|
/// </summary>
|
||||||
|
protected void Apply(IEnumerable<DirResOption> opts)
|
||||||
|
{
|
||||||
|
foreach (var opt in opts)
|
||||||
|
{
|
||||||
|
opt(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// IAccountResolver overrides
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DirAccountResolver is not read-only.
|
||||||
|
/// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false
|
||||||
|
/// from the concrete behaviour (store is writable).
|
||||||
|
/// </summary>
|
||||||
|
public override bool IsReadOnly() => false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks updates (reacts to JWT change events).
|
||||||
|
/// Mirrors Go <c>DirAccResolver.IsTrackingUpdate</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override bool IsTrackingUpdate() => true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reloads state from the backing <see cref="DirJwtStore"/>.
|
||||||
|
/// Mirrors Go <c>DirAccResolver.Reload</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override void Reload() => Store.Reload();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the JWT for <paramref name="name"/> from the local <see cref="DirJwtStore"/>.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> when the account is not found locally.
|
||||||
|
/// <para>
|
||||||
|
/// Note: the Go implementation falls back to <c>srv.fetch</c> (a cluster-wide lookup) when
|
||||||
|
/// the local store misses. That fallback requires system subscriptions and is deferred to
|
||||||
|
/// session 12. For now this method only consults the local store.
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>DirAccResolver.Fetch</c> (local path only).
|
||||||
|
/// </summary>
|
||||||
|
public override Task<string> FetchAsync(string name, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var theJwt = Store.LoadAcc(name);
|
||||||
|
if (!string.IsNullOrEmpty(theJwt))
|
||||||
|
{
|
||||||
|
return Task.FromResult(theJwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Account not found: {name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores <paramref name="jwt"/> under <paramref name="name"/>, keeping the newer JWT
|
||||||
|
/// when a conflicting entry already exists.
|
||||||
|
/// Mirrors Go <c>DirAccResolver.Store</c> (delegates to <c>saveIfNewer</c>).
|
||||||
|
/// </summary>
|
||||||
|
public override Task StoreAsync(string name, string jwt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation.
|
||||||
|
Store.SaveAcc(name, jwt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts background system subscriptions and the periodic sync timer.
|
||||||
|
/// <para>
|
||||||
|
/// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack
|
||||||
|
/// requests, cluster synchronisation, and the periodic pack broadcast goroutine.
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>DirAccResolver.Start</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override void Start(object server)
|
||||||
|
{
|
||||||
|
Server = server;
|
||||||
|
// TODO (session 12): set up system subscriptions and periodic sync timer.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops background processing and closes the <see cref="DirJwtStore"/>.
|
||||||
|
/// Mirrors Go <c>AccountResolver.Close</c> (no explicit Go override; store is closed
|
||||||
|
/// by the server shutdown path).
|
||||||
|
/// </summary>
|
||||||
|
public override void Close() => Store.Close();
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose() => Store.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CacheDirAccountResolver (stub)
|
||||||
|
// Mirrors Go CacheDirAccResolver (accounts.go ~line 4594).
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A caching variant of <see cref="DirAccountResolver"/> that uses a TTL-based expiring
|
||||||
|
/// store so that fetched JWTs are automatically evicted after <see cref="Ttl"/>.
|
||||||
|
/// <para>
|
||||||
|
/// The Start override that wires up system subscriptions is a stub in this session;
|
||||||
|
/// full implementation requires system subscription support (session 12+).
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>CacheDirAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CacheDirAccountResolver : DirAccountResolver
|
||||||
|
{
|
||||||
|
// Default cache limit — mirrors Go's fallback of 1 000 entries.
|
||||||
|
private const long DefaultCacheLimit = 1_000;
|
||||||
|
|
||||||
|
// Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s).
|
||||||
|
private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>The TTL applied to each cached JWT entry. Mirrors Go <c>CacheDirAccResolver.ttl</c>.</summary>
|
||||||
|
public TimeSpan Ttl { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new caching directory account resolver.
|
||||||
|
/// <para>
|
||||||
|
/// When <paramref name="limit"/> is zero or negative it defaults to 1 000.
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>NewCacheDirAccResolver</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Directory path for the JWT store.</param>
|
||||||
|
/// <param name="limit">Maximum number of JWTs to cache (0 = 1 000).</param>
|
||||||
|
/// <param name="ttl">Time-to-live for each cached JWT.</param>
|
||||||
|
/// <param name="opts">Zero or more functional options to further configure this instance.</param>
|
||||||
|
public CacheDirAccountResolver(
|
||||||
|
string path,
|
||||||
|
long limit,
|
||||||
|
TimeSpan ttl,
|
||||||
|
params DirResOption[] opts)
|
||||||
|
: base(
|
||||||
|
store: DirJwtStore.NewExpiringDirJwtStore(
|
||||||
|
path,
|
||||||
|
shard: false,
|
||||||
|
create: true,
|
||||||
|
JwtDeleteType.HardDelete,
|
||||||
|
expireCheck: TimeSpan.Zero,
|
||||||
|
limit: limit <= 0 ? DefaultCacheLimit : limit,
|
||||||
|
evictOnLimit: true,
|
||||||
|
ttl: ttl,
|
||||||
|
changeNotification: null),
|
||||||
|
syncInterval: TimeSpan.Zero,
|
||||||
|
fetchTimeout: DefaultFetchTimeout)
|
||||||
|
{
|
||||||
|
Ttl = ttl;
|
||||||
|
Apply(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts background system subscriptions for cached JWT update notifications.
|
||||||
|
/// <para>
|
||||||
|
/// TODO (session 12): wire up system subscriptions for account JWT update events
|
||||||
|
/// (cache variant — does not include pack/list/delete handling).
|
||||||
|
/// </para>
|
||||||
|
/// Mirrors Go <c>CacheDirAccResolver.Start</c>.
|
||||||
|
/// </summary>
|
||||||
|
public override void Start(object server)
|
||||||
|
{
|
||||||
|
Server = server;
|
||||||
|
// TODO (session 12): set up system subscriptions for cache-update notifications.
|
||||||
|
}
|
||||||
|
}
|
||||||
737
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs
Normal file
737
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/AccountTypes.cs
Normal file
@@ -0,0 +1,737 @@
|
|||||||
|
// 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 System.Text.Json.Serialization;
|
||||||
|
using ZB.MOM.NatsNet.Server.Auth;
|
||||||
|
using ZB.MOM.NatsNet.Server.Internal;
|
||||||
|
|
||||||
|
namespace ZB.MOM.NatsNet.Server;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AccountLimits — account-based limits
|
||||||
|
// Mirrors Go `limits` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-account connection and payload limits.
|
||||||
|
/// Mirrors Go <c>limits</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AccountLimits
|
||||||
|
{
|
||||||
|
/// <summary>Maximum payload size (-1 = unlimited). Mirrors Go <c>mpay</c>.</summary>
|
||||||
|
public int MaxPayload { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Maximum subscriptions (-1 = unlimited). Mirrors Go <c>msubs</c>.</summary>
|
||||||
|
public int MaxSubscriptions { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Maximum connections (-1 = unlimited). Mirrors Go <c>mconns</c>.</summary>
|
||||||
|
public int MaxConnections { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>Maximum leaf nodes (-1 = unlimited). Mirrors Go <c>mleafs</c>.</summary>
|
||||||
|
public int MaxLeafNodes { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>When true, bearer tokens are not allowed. Mirrors Go <c>disallowBearer</c>.</summary>
|
||||||
|
public bool DisallowBearer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SConns — remote server connection/leafnode counters
|
||||||
|
// Mirrors Go `sconns` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks the number of client connections and leaf nodes for a remote server.
|
||||||
|
/// Mirrors Go <c>sconns</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SConns
|
||||||
|
{
|
||||||
|
/// <summary>Number of client connections from the remote server. Mirrors Go <c>conns</c>.</summary>
|
||||||
|
public int Conns;
|
||||||
|
|
||||||
|
/// <summary>Number of leaf nodes from the remote server. Mirrors Go <c>leafs</c>.</summary>
|
||||||
|
public int Leafs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ServiceRespType — service response type enum
|
||||||
|
// Mirrors Go `ServiceRespType` and its iota constants in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The response type for an exported service.
|
||||||
|
/// Mirrors Go <c>ServiceRespType</c> in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
public enum ServiceRespType : byte
|
||||||
|
{
|
||||||
|
/// <summary>A single response is expected. Default. Mirrors Go <c>Singleton</c>.</summary>
|
||||||
|
Singleton = 0,
|
||||||
|
|
||||||
|
/// <summary>Multiple responses are streamed. Mirrors Go <c>Streamed</c>.</summary>
|
||||||
|
Streamed = 1,
|
||||||
|
|
||||||
|
/// <summary>Responses are sent in chunks. Mirrors Go <c>Chunked</c>.</summary>
|
||||||
|
Chunked = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for <see cref="ServiceRespType"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceRespTypeExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the string representation of the response type.
|
||||||
|
/// Mirrors Go <c>ServiceRespType.String()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToNatsString(this ServiceRespType rt) => rt switch
|
||||||
|
{
|
||||||
|
ServiceRespType.Singleton => "Singleton",
|
||||||
|
ServiceRespType.Streamed => "Streamed",
|
||||||
|
ServiceRespType.Chunked => "Chunked",
|
||||||
|
_ => "Unknown ServiceResType",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ExportAuth — export authorization configuration
|
||||||
|
// Mirrors Go `exportAuth` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds configured approvals or a flag indicating that an auth token is
|
||||||
|
/// required for import.
|
||||||
|
/// Mirrors Go <c>exportAuth</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal class ExportAuth
|
||||||
|
{
|
||||||
|
/// <summary>When true, an auth token is required to import this export. Mirrors Go <c>tokenReq</c>.</summary>
|
||||||
|
public bool TokenRequired { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Position in the subject token where the account name appears (for
|
||||||
|
/// public exports that embed the importing account name).
|
||||||
|
/// Mirrors Go <c>accountPos</c>.
|
||||||
|
/// </summary>
|
||||||
|
public uint AccountPosition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accounts explicitly approved to import this export.
|
||||||
|
/// Key is the account name. Mirrors Go <c>approved</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, Account>? Approved { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accounts whose activations have been revoked.
|
||||||
|
/// Key is the account name, value is the revocation timestamp (Unix ns).
|
||||||
|
/// Mirrors Go <c>actsRevoked</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, long>? ActivationsRevoked { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// StreamExport — exported stream descriptor
|
||||||
|
// Mirrors Go `streamExport` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a stream exported by an account.
|
||||||
|
/// Mirrors Go <c>streamExport</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StreamExport : ExportAuth
|
||||||
|
{
|
||||||
|
// No additional fields beyond ExportAuth for now.
|
||||||
|
// Full implementation in session 11 (accounts.go).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// InternalServiceLatency — service latency tracking configuration
|
||||||
|
// Mirrors Go `serviceLatency` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for service latency tracking on an exported service.
|
||||||
|
/// Mirrors Go <c>serviceLatency</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class InternalServiceLatency
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sampling percentage (1–100), or 0 to indicate triggered by header.
|
||||||
|
/// Mirrors Go <c>sampling int8</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int Sampling { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Subject to publish latency metrics to. Mirrors Go <c>subject</c>.</summary>
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ServiceExportEntry — exported service descriptor
|
||||||
|
// Mirrors Go `serviceExport` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a service exported by an account with additional configuration
|
||||||
|
/// for response type, latency tracking, and timers.
|
||||||
|
/// Mirrors Go <c>serviceExport</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ServiceExportEntry : ExportAuth
|
||||||
|
{
|
||||||
|
/// <summary>Account that owns this export. Mirrors Go <c>acc</c>.</summary>
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Response type (Singleton, Streamed, Chunked). Mirrors Go <c>respType</c>.</summary>
|
||||||
|
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
|
||||||
|
|
||||||
|
/// <summary>Latency tracking configuration, or null if disabled. Mirrors Go <c>latency</c>.</summary>
|
||||||
|
public InternalServiceLatency? Latency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timer used to collect response-latency measurements.
|
||||||
|
/// Mirrors Go <c>rtmr *time.Timer</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Timer? ResponseTimer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Threshold duration for service responses.
|
||||||
|
/// Mirrors Go <c>respThresh time.Duration</c>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan ResponseThreshold { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, tracing is allowed past the account boundary for this export.
|
||||||
|
/// Mirrors Go <c>atrc</c> (allow_trace).
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowTrace { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ExportMap — tracks exported streams and services for an account
|
||||||
|
// Mirrors Go `exportMap` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks all stream exports, service exports, and response mappings for an account.
|
||||||
|
/// Mirrors Go <c>exportMap</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ExportMap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exported streams keyed by subject pattern.
|
||||||
|
/// Mirrors Go <c>streams map[string]*streamExport</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, StreamExport>? Streams { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exported services keyed by subject pattern.
|
||||||
|
/// Mirrors Go <c>services map[string]*serviceExport</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ServiceExportEntry>? Services { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-flight response service imports keyed by reply subject.
|
||||||
|
/// Mirrors Go <c>responses map[string]*serviceImport</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, ServiceImportEntry>? Responses { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ImportMap — tracks imported streams and services for an account
|
||||||
|
// Mirrors Go `importMap` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks all stream imports, service imports, and reverse-response maps.
|
||||||
|
/// Mirrors Go <c>importMap</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ImportMap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Imported streams (ordered list).
|
||||||
|
/// Mirrors Go <c>streams []*streamImport</c>.
|
||||||
|
/// </summary>
|
||||||
|
public List<StreamImportEntry>? Streams { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imported services keyed by subject pattern; each key may have
|
||||||
|
/// multiple import entries (e.g. fan-out imports).
|
||||||
|
/// Mirrors Go <c>services map[string][]*serviceImport</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<ServiceImportEntry>>? Services { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse-response map used to clean up singleton service imports.
|
||||||
|
/// Mirrors Go <c>rrMap map[string][]*serviceRespEntry</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<ServiceRespEntry>>? ReverseResponseMap { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// StreamImportEntry — an imported stream mapping
|
||||||
|
// Mirrors Go `streamImport` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An imported stream from another account, with optional subject remapping.
|
||||||
|
/// Mirrors Go <c>streamImport</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class StreamImportEntry
|
||||||
|
{
|
||||||
|
/// <summary>Account providing the stream. Mirrors Go <c>acc</c>.</summary>
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Source subject on the exporting account. Mirrors Go <c>from</c>.</summary>
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Destination subject on the importing account. Mirrors Go <c>to</c>.</summary>
|
||||||
|
public string To { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subject transform applied to the source subject.
|
||||||
|
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||||
|
/// Stubbed as <see cref="ISubjectTransformer"/> until the transform
|
||||||
|
/// engine is wired in.
|
||||||
|
/// </summary>
|
||||||
|
public ISubjectTransformer? Transform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse transform for reply subjects.
|
||||||
|
/// Mirrors Go <c>rtr *subjectTransform</c>.
|
||||||
|
/// </summary>
|
||||||
|
public ISubjectTransformer? ReverseTransform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT import claim that authorized this import.
|
||||||
|
/// Mirrors Go <c>claim *jwt.Import</c>.
|
||||||
|
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
|
||||||
|
/// </summary>
|
||||||
|
public object? Claim { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, use the published subject instead of <see cref="To"/>.
|
||||||
|
/// Mirrors Go <c>usePub</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsePublishedSubject { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
|
||||||
|
public bool Invalid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, tracing is allowed past the account boundary.
|
||||||
|
/// Mirrors Go <c>atrc</c> (allow_trace).
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowTrace { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ServiceImportEntry — an imported service mapping
|
||||||
|
// Mirrors Go `serviceImport` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An imported service from another account, with response routing and
|
||||||
|
/// latency tracking state.
|
||||||
|
/// Mirrors Go <c>serviceImport</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ServiceImportEntry
|
||||||
|
{
|
||||||
|
/// <summary>Account providing the service. Mirrors Go <c>acc</c>.</summary>
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// JWT import claim that authorized this import.
|
||||||
|
/// Mirrors Go <c>claim *jwt.Import</c>.
|
||||||
|
/// Stubbed as <c>object?</c> until JWT integration is complete (session 11).
|
||||||
|
/// </summary>
|
||||||
|
public object? Claim { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Parent service export entry. Mirrors Go <c>se *serviceExport</c>.</summary>
|
||||||
|
public ServiceExportEntry? ServiceExport { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscription ID byte slice for cleanup.
|
||||||
|
/// Mirrors Go <c>sid []byte</c>.
|
||||||
|
/// </summary>
|
||||||
|
public byte[]? SubscriptionId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Source subject on the importing account. Mirrors Go <c>from</c>.</summary>
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Destination subject on the exporting account. Mirrors Go <c>to</c>.</summary>
|
||||||
|
public string To { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subject transform applied when routing requests.
|
||||||
|
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||||
|
/// Stubbed as <see cref="ISubjectTransformer"/> until transform engine is wired in.
|
||||||
|
/// </summary>
|
||||||
|
public ISubjectTransformer? Transform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Timestamp (Unix nanoseconds) when the import request was created.
|
||||||
|
/// Used for latency tracking. Mirrors Go <c>ts int64</c>.
|
||||||
|
/// </summary>
|
||||||
|
public long Timestamp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Response type for this service import. Mirrors Go <c>rt ServiceRespType</c>.</summary>
|
||||||
|
public ServiceRespType ResponseType { get; set; } = ServiceRespType.Singleton;
|
||||||
|
|
||||||
|
/// <summary>Latency tracking configuration. Mirrors Go <c>latency *serviceLatency</c>.</summary>
|
||||||
|
public InternalServiceLatency? Latency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// First-leg latency measurement (requestor side).
|
||||||
|
/// Mirrors Go <c>m1 *ServiceLatency</c>.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceLatency? M1 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client connection that sent the original request.
|
||||||
|
/// Mirrors Go <c>rc *client</c>.
|
||||||
|
/// </summary>
|
||||||
|
public ClientConnection? RequestingClient { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, use the published subject instead of <see cref="To"/>.
|
||||||
|
/// Mirrors Go <c>usePub</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsePublishedSubject { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, this import entry represents a pending response rather
|
||||||
|
/// than an originating request.
|
||||||
|
/// Mirrors Go <c>response</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsResponse { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this import is considered invalid. Mirrors Go <c>invalid</c>.</summary>
|
||||||
|
public bool Invalid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, the requestor's <see cref="ClientInfo"/> is shared with
|
||||||
|
/// the responder. Mirrors Go <c>share</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool Share { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether latency tracking is active. Mirrors Go <c>tracking</c>.</summary>
|
||||||
|
public bool Tracking { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether a response was delivered to the requestor. Mirrors Go <c>didDeliver</c>.</summary>
|
||||||
|
public bool DidDeliver { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, tracing is allowed past the account boundary (inherited
|
||||||
|
/// from the service export). Mirrors Go <c>atrc</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool AllowTrace { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headers from the original request, used when latency is triggered by
|
||||||
|
/// a header. Mirrors Go <c>trackingHdr http.Header</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, string[]>? TrackingHeader { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ServiceRespEntry — reverse-response map entry
|
||||||
|
// Mirrors Go `serviceRespEntry` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a service import mapping for reverse-response-map cleanup.
|
||||||
|
/// Mirrors Go <c>serviceRespEntry</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class ServiceRespEntry
|
||||||
|
{
|
||||||
|
/// <summary>Account that owns the service import. Mirrors Go <c>acc</c>.</summary>
|
||||||
|
public Account? Account { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mapped subscription subject used for the response.
|
||||||
|
/// Mirrors Go <c>msub</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string MappedSubject { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MapDest — public API for weighted subject mappings
|
||||||
|
// Mirrors Go `MapDest` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes a weighted mapping destination for published subjects.
|
||||||
|
/// Mirrors Go <c>MapDest</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MapDest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("subject")]
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("weight")]
|
||||||
|
public byte Weight { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("cluster")]
|
||||||
|
public string Cluster { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new <see cref="MapDest"/> with the given subject and weight.
|
||||||
|
/// Mirrors Go <c>NewMapDest</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static MapDest New(string subject, byte weight) =>
|
||||||
|
new() { Subject = subject, Weight = weight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Destination — internal weighted mapped destination
|
||||||
|
// Mirrors Go `destination` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal representation of a weighted mapped destination, holding a
|
||||||
|
/// transform and a weight.
|
||||||
|
/// Mirrors Go <c>destination</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class Destination
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Transform that converts the source subject to the destination subject.
|
||||||
|
/// Mirrors Go <c>tr *subjectTransform</c>.
|
||||||
|
/// </summary>
|
||||||
|
public ISubjectTransformer? Transform { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Relative weight (0–100). Mirrors Go <c>weight uint8</c>.
|
||||||
|
/// </summary>
|
||||||
|
public byte Weight { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SubjectMapping — internal subject mapping entry
|
||||||
|
// Mirrors Go `mapping` struct in server/accounts.go.
|
||||||
|
// Renamed from `mapping` to avoid collision with the C# keyword context.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An internal entry describing how a source subject is remapped to one or
|
||||||
|
/// more weighted destinations, optionally scoped to specific clusters.
|
||||||
|
/// Mirrors Go <c>mapping</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class SubjectMapping
|
||||||
|
{
|
||||||
|
/// <summary>Source subject pattern. Mirrors Go <c>src</c>.</summary>
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the source contains wildcards.
|
||||||
|
/// Mirrors Go <c>wc</c>.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasWildcard { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Weighted destinations with no cluster scope.
|
||||||
|
/// Mirrors Go <c>dests []*destination</c>.
|
||||||
|
/// </summary>
|
||||||
|
public List<Destination> Destinations { get; set; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-cluster weighted destinations.
|
||||||
|
/// Key is the cluster name. Mirrors Go <c>cdests map[string][]*destination</c>.
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, List<Destination>>? ClusterDestinations { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TypedEvent — base for server advisory events
|
||||||
|
// Mirrors Go `TypedEvent` struct in server/events.go.
|
||||||
|
// Included here because ServiceLatency embeds it.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base fields for a NATS typed event or advisory.
|
||||||
|
/// Mirrors Go <c>TypedEvent</c> struct in server/events.go.
|
||||||
|
/// </summary>
|
||||||
|
public class TypedEvent
|
||||||
|
{
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("timestamp")]
|
||||||
|
public DateTime Time { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ServiceLatency — public latency measurement event
|
||||||
|
// Mirrors Go `ServiceLatency` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The JSON message published to a latency-tracking subject when a service
|
||||||
|
/// request completes. Includes requestor and responder timing breakdowns.
|
||||||
|
/// Mirrors Go <c>ServiceLatency</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServiceLatency : TypedEvent
|
||||||
|
{
|
||||||
|
[JsonPropertyName("status")]
|
||||||
|
public int Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("description")]
|
||||||
|
public string Error { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("requestor")]
|
||||||
|
public ClientInfo? Requestor { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("responder")]
|
||||||
|
public ClientInfo? Responder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Headers from the original request that triggered latency measurement.
|
||||||
|
/// Mirrors Go <c>RequestHeader http.Header</c>.
|
||||||
|
/// </summary>
|
||||||
|
[JsonPropertyName("header")]
|
||||||
|
public Dictionary<string, string[]>? RequestHeader { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("start")]
|
||||||
|
public DateTime RequestStart { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mirrors Go <c>ServiceLatency time.Duration</c> (nanoseconds).</summary>
|
||||||
|
[JsonPropertyName("service")]
|
||||||
|
public TimeSpan ServiceLatencyDuration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mirrors Go <c>SystemLatency time.Duration</c> (nanoseconds).</summary>
|
||||||
|
[JsonPropertyName("system")]
|
||||||
|
public TimeSpan SystemLatency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mirrors Go <c>TotalLatency time.Duration</c> (nanoseconds).</summary>
|
||||||
|
[JsonPropertyName("total")]
|
||||||
|
public TimeSpan TotalLatency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the sum of requestor RTT, responder RTT, and system latency.
|
||||||
|
/// Mirrors Go <c>ServiceLatency.NATSTotalTime()</c>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan NATSTotalTime()
|
||||||
|
{
|
||||||
|
var requestorRtt = Requestor?.Rtt ?? TimeSpan.Zero;
|
||||||
|
var responderRtt = Responder?.Rtt ?? TimeSpan.Zero;
|
||||||
|
return requestorRtt + responderRtt + SystemLatency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RemoteLatency — cross-server latency transport message
|
||||||
|
// Mirrors Go `remoteLatency` struct in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Used to transport a responder-side latency measurement to the
|
||||||
|
/// requestor's server so the two halves can be merged.
|
||||||
|
/// Mirrors Go <c>remoteLatency</c> struct in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class RemoteLatency
|
||||||
|
{
|
||||||
|
[JsonPropertyName("account")]
|
||||||
|
public string Account { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("req_id")]
|
||||||
|
public string RequestId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("m2")]
|
||||||
|
public ServiceLatency M2 { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Private: response latency threshold used when deciding whether to
|
||||||
|
/// send the remote measurement.
|
||||||
|
/// Mirrors Go <c>respThresh time.Duration</c>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan ResponseThreshold { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RsiReason — reason for removing a response service import
|
||||||
|
// Mirrors Go `rsiReason` and its iota constants in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The reason a response service import entry is being removed.
|
||||||
|
/// Mirrors Go <c>rsiReason</c> and its iota constants in server/accounts.go.
|
||||||
|
/// </summary>
|
||||||
|
internal enum RsiReason
|
||||||
|
{
|
||||||
|
/// <summary>Normal completion. Mirrors Go <c>rsiOk</c>.</summary>
|
||||||
|
Ok = 0,
|
||||||
|
|
||||||
|
/// <summary>Response was never delivered. Mirrors Go <c>rsiNoDelivery</c>.</summary>
|
||||||
|
NoDelivery = 1,
|
||||||
|
|
||||||
|
/// <summary>Response timed out. Mirrors Go <c>rsiTimeout</c>.</summary>
|
||||||
|
Timeout = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Account-level constants
|
||||||
|
// Mirrors the const blocks in server/accounts.go.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constants related to account route-pool indexing and search depth.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AccountConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sentinel value indicating the account has a dedicated route connection.
|
||||||
|
/// Mirrors Go <c>accDedicatedRoute = -1</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const int DedicatedRoute = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sentinel value indicating the account is in the process of transitioning
|
||||||
|
/// to a dedicated route.
|
||||||
|
/// Mirrors Go <c>accTransitioningToDedicatedRoute = -2</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const int TransitioningToDedicatedRoute = -2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum depth for account cycle detection when following import chains.
|
||||||
|
/// Mirrors Go <c>MaxAccountCycleSearchDepth = 1024</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxCycleSearchDepth = 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Well-known header names and event type identifiers used by the account
|
||||||
|
/// service-latency and client-info subsystems.
|
||||||
|
/// </summary>
|
||||||
|
public static class AccountEventConstants
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Header name used to pass client metadata into a service request.
|
||||||
|
/// Mirrors Go <c>ClientInfoHdr = "Nats-Request-Info"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string ClientInfoHeader = "Nats-Request-Info";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The default threshold (in nanoseconds, as a <see cref="TimeSpan"/>) below
|
||||||
|
/// which a subscription-limit report is suppressed.
|
||||||
|
/// Mirrors Go <c>defaultMaxSubLimitReportThreshold = int64(2 * time.Second)</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly TimeSpan DefaultMaxSubLimitReportThreshold = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NATS event type identifier for <see cref="ServiceLatency"/> messages.
|
||||||
|
/// Mirrors Go <c>ServiceLatencyType = "io.nats.server.metric.v1.service_latency"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public const string ServiceLatencyType = "io.nats.server.metric.v1.service_latency";
|
||||||
|
}
|
||||||
1373
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
Normal file
1373
dotnet/src/ZB.MOM.NatsNet.Server/Accounts/DirJwtStore.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -168,45 +168,5 @@ public class RoutePermissions
|
|||||||
public SubjectPermission? Export { get; set; }
|
public SubjectPermission? Export { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Account stub removed — full implementation is in Accounts/Account.cs
|
||||||
/// Stub for Account type. Full implementation in session 11.
|
// in the ZB.MOM.NatsNet.Server namespace.
|
||||||
/// Mirrors Go <c>Account</c> struct.
|
|
||||||
/// </summary>
|
|
||||||
public class Account : INatsAccount
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
// Fields required by session 09 account management (NatsServer.Accounts.cs).
|
|
||||||
// Full implementation in session 11.
|
|
||||||
public string Issuer { get; set; } = string.Empty;
|
|
||||||
internal string ClaimJwt { get; set; } = string.Empty;
|
|
||||||
internal int RoutePoolIdx { get; set; }
|
|
||||||
internal bool Incomplete { get; set; }
|
|
||||||
internal DateTime Updated { get; set; }
|
|
||||||
internal ZB.MOM.NatsNet.Server.Internal.DataStructures.SubscriptionIndex? Sublist { get; set; }
|
|
||||||
internal object? Server { get; set; } // INatsServer — avoids circular reference
|
|
||||||
|
|
||||||
// INatsAccount — stub implementations until session 11 (accounts.go).
|
|
||||||
bool INatsAccount.IsValid => true;
|
|
||||||
bool INatsAccount.MaxTotalConnectionsReached() => false;
|
|
||||||
bool INatsAccount.MaxTotalLeafNodesReached() => false;
|
|
||||||
int INatsAccount.AddClient(ClientConnection c) => 0;
|
|
||||||
int INatsAccount.RemoveClient(ClientConnection c) => 0;
|
|
||||||
|
|
||||||
/// <summary>Returns true if this account's JWT has expired. Stub — full impl in session 11.</summary>
|
|
||||||
public bool IsExpired() => false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the total number of subscriptions across all clients in this account.
|
|
||||||
/// Stub — full implementation in session 11.
|
|
||||||
/// Mirrors Go <c>Account.TotalSubs()</c>.
|
|
||||||
/// </summary>
|
|
||||||
public int TotalSubs() => 0;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Notifies leaf nodes of a subscription change.
|
|
||||||
/// Stub — full implementation in session 15.
|
|
||||||
/// Mirrors Go <c>Account.updateLeafNodes()</c>.
|
|
||||||
/// </summary>
|
|
||||||
internal void UpdateLeafNodes(object sub, int delta) { }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -273,6 +273,13 @@ public sealed class ClientInfo
|
|||||||
public bool Disconnect { get; set; }
|
public bool Disconnect { get; set; }
|
||||||
public string[]? Cluster { get; set; }
|
public string[]? Cluster { get; set; }
|
||||||
public bool Service { get; set; }
|
public bool Service { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Round-trip time to the client.
|
||||||
|
/// Mirrors Go <c>RTT time.Duration</c> in events.go.
|
||||||
|
/// Added here to support <see cref="ServiceLatency.NATSTotalTime"/>.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan Rtt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -671,8 +671,15 @@ public sealed partial class NatsServer
|
|||||||
{
|
{
|
||||||
if (_accResolver == null)
|
if (_accResolver == null)
|
||||||
return (string.Empty, ServerErrors.ErrNoAccountResolver);
|
return (string.Empty, ServerErrors.ErrNoAccountResolver);
|
||||||
var (jwt, err) = _accResolver.Fetch(name);
|
try
|
||||||
return (jwt, err);
|
{
|
||||||
|
var jwt = _accResolver.FetchAsync(name).GetAwaiter().GetResult();
|
||||||
|
return (jwt, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (string.Empty, ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1044,7 +1044,7 @@ public sealed partial class NatsServer
|
|||||||
{
|
{
|
||||||
// Validate JWT format (stub — session 06 has JWT decoder).
|
// Validate JWT format (stub — session 06 has JWT decoder).
|
||||||
// jwt.DecodeAccountClaims(v) — skip here, checked again in CheckResolvePreloads.
|
// jwt.DecodeAccountClaims(v) — skip here, checked again in CheckResolvePreloads.
|
||||||
ar.Store(k, v);
|
ar.StoreAsync(k, v).GetAwaiter().GetResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -461,17 +461,4 @@ public interface IClientAuthentication
|
|||||||
string RemoteAddress();
|
string RemoteAddress();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// IAccountResolver is defined in Accounts/AccountResolver.cs.
|
||||||
/// Account resolver interface for dynamic account loading.
|
|
||||||
/// Mirrors <c>AccountResolver</c> in accounts.go.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAccountResolver
|
|
||||||
{
|
|
||||||
(string jwt, Exception? err) Fetch(string name);
|
|
||||||
Exception? Store(string name, string jwt);
|
|
||||||
bool IsReadOnly();
|
|
||||||
Exception? Start(object server);
|
|
||||||
bool IsTrackingUpdate();
|
|
||||||
Exception? Reload();
|
|
||||||
void Close();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ public sealed partial class ServerOptions
|
|||||||
public string SystemAccount { get; set; } = string.Empty;
|
public string SystemAccount { get; set; } = string.Empty;
|
||||||
public bool NoSystemAccount { get; set; }
|
public bool NoSystemAccount { get; set; }
|
||||||
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
|
/// <summary>Parsed account objects from config. Mirrors Go opts.Accounts.</summary>
|
||||||
public List<Auth.Account> Accounts { get; set; } = [];
|
public List<Account> Accounts { get; set; } = [];
|
||||||
public AuthCalloutOpts? AuthCallout { get; set; }
|
public AuthCalloutOpts? AuthCallout { get; set; }
|
||||||
public bool AlwaysEnableNonce { get; set; }
|
public bool AlwaysEnableNonce { get; set; }
|
||||||
public List<User>? Users { get; set; }
|
public List<User>? Users { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
// 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_test.go and server/dirstore_test.go in the NATS server Go source.
|
||||||
|
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.NatsNet.Server.Tests;
|
||||||
|
|
||||||
|
[Collection("AccountTests")]
|
||||||
|
public sealed class AccountTests
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// Account Basic Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Test 1
|
||||||
|
[Fact]
|
||||||
|
public void NewAccount_SetsNameAndUnlimitedLimits()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.Name.ShouldBe("foo");
|
||||||
|
acc.MaxConnections.ShouldBe(-1);
|
||||||
|
acc.MaxLeafNodes.ShouldBe(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2
|
||||||
|
[Fact]
|
||||||
|
public void ToString_ReturnsName()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("myaccount");
|
||||||
|
|
||||||
|
acc.ToString().ShouldBe(acc.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3
|
||||||
|
[Fact]
|
||||||
|
public void IsExpired_InitiallyFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.IsExpired().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4
|
||||||
|
[Fact]
|
||||||
|
public void IsClaimAccount_NoJwt_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
// ClaimJwt defaults to empty string
|
||||||
|
acc.IsClaimAccount().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5
|
||||||
|
[Fact]
|
||||||
|
public void NumConnections_Initial_IsZero()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.NumConnections().ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6
|
||||||
|
[Fact]
|
||||||
|
public void GetName_ReturnsName()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("thread-safe-name");
|
||||||
|
|
||||||
|
acc.GetName().ShouldBe("thread-safe-name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Subject Mapping Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Test 7
|
||||||
|
[Fact]
|
||||||
|
public void AddMapping_ValidSubject_Succeeds()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var err = acc.AddMapping("foo", "bar");
|
||||||
|
|
||||||
|
err.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 8
|
||||||
|
[Fact]
|
||||||
|
public void AddMapping_InvalidSubject_ReturnsError()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var err = acc.AddMapping("foo..bar", "x");
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 9
|
||||||
|
[Fact]
|
||||||
|
public void RemoveMapping_ExistingMapping_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||||
|
|
||||||
|
var removed = acc.RemoveMapping("foo");
|
||||||
|
|
||||||
|
removed.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 10
|
||||||
|
[Fact]
|
||||||
|
public void RemoveMapping_NonExistentMapping_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var removed = acc.RemoveMapping("nonexistent");
|
||||||
|
|
||||||
|
removed.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 11
|
||||||
|
[Fact]
|
||||||
|
public void HasMappings_AfterAdd_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||||
|
|
||||||
|
acc.HasMappings().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 12
|
||||||
|
[Fact]
|
||||||
|
public void HasMappings_AfterRemove_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||||
|
acc.RemoveMapping("foo");
|
||||||
|
|
||||||
|
acc.HasMappings().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 13
|
||||||
|
[Fact]
|
||||||
|
public void SelectMappedSubject_NoMapping_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||||
|
|
||||||
|
mapped.ShouldBeFalse();
|
||||||
|
dest.ShouldBe("foo");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 14
|
||||||
|
[Fact]
|
||||||
|
public void SelectMappedSubject_SimpleMapping_ReturnsMappedDest()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
acc.AddMapping("foo", "bar").ShouldBeNull();
|
||||||
|
|
||||||
|
var (dest, mapped) = acc.SelectMappedSubject("foo");
|
||||||
|
|
||||||
|
mapped.ShouldBeTrue();
|
||||||
|
dest.ShouldBe("bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 15
|
||||||
|
[Fact]
|
||||||
|
public void AddWeightedMappings_DuplicateDest_ReturnsError()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var err = acc.AddWeightedMappings("src",
|
||||||
|
MapDest.New("dest1", 50),
|
||||||
|
MapDest.New("dest1", 50)); // duplicate subject
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 16
|
||||||
|
[Fact]
|
||||||
|
public void AddWeightedMappings_WeightOver100_ReturnsError()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var err = acc.AddWeightedMappings("src",
|
||||||
|
MapDest.New("dest1", 101)); // weight exceeds 100
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 17
|
||||||
|
[Fact]
|
||||||
|
public void AddWeightedMappings_TotalWeightOver100_ReturnsError()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
var err = acc.AddWeightedMappings("src",
|
||||||
|
MapDest.New("dest1", 80),
|
||||||
|
MapDest.New("dest2", 80)); // total = 160
|
||||||
|
|
||||||
|
err.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Connection Counting Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Test 18
|
||||||
|
[Fact]
|
||||||
|
public void NumLeafNodes_Initial_IsZero()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.NumLeafNodes().ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 19
|
||||||
|
[Fact]
|
||||||
|
public void MaxTotalConnectionsReached_UnlimitedAccount_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
// MaxConnections is -1 (unlimited) by default
|
||||||
|
|
||||||
|
acc.MaxTotalConnectionsReached().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 20
|
||||||
|
[Fact]
|
||||||
|
public void MaxTotalLeafNodesReached_UnlimitedAccount_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
// MaxLeafNodes is -1 (unlimited) by default
|
||||||
|
|
||||||
|
acc.MaxTotalLeafNodesReached().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Export Service Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Test 21
|
||||||
|
[Fact]
|
||||||
|
public void IsExportService_NoExports_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.IsExportService("my.service").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 22
|
||||||
|
[Fact]
|
||||||
|
public void IsExportServiceTracking_NoExports_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var acc = Account.NewAccount("foo");
|
||||||
|
|
||||||
|
acc.IsExportServiceTracking("my.service").ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// DirJwtStore Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Collection("AccountTests")]
|
||||||
|
public sealed class DirJwtStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly List<string> _tempDirs = [];
|
||||||
|
|
||||||
|
private string MakeTempDir()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
_tempDirs.Add(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var dir in _tempDirs)
|
||||||
|
{
|
||||||
|
try { Directory.Delete(dir, true); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 23
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_WriteAndRead_Succeeds()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
|
||||||
|
const string key = "AAAAAAAAAA"; // minimum 2-char key
|
||||||
|
const string jwt = "header.payload.signature";
|
||||||
|
|
||||||
|
store.SaveAcc(key, jwt);
|
||||||
|
var loaded = store.LoadAcc(key);
|
||||||
|
|
||||||
|
loaded.ShouldBe(jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 24
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_ShardedWriteAndRead_Succeeds()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: true, create: false);
|
||||||
|
|
||||||
|
var keys = new[] { "ACCTKEY001", "ACCTKEY002", "ACCTKEY003" };
|
||||||
|
foreach (var k in keys)
|
||||||
|
{
|
||||||
|
store.SaveAcc(k, $"jwt.for.{k}");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var k in keys)
|
||||||
|
{
|
||||||
|
store.LoadAcc(k).ShouldBe($"jwt.for.{k}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 25
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_EmptyKey_ReturnsError()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
|
||||||
|
// LoadAcc with key shorter than 2 chars should throw
|
||||||
|
Should.Throw<Exception>(() => store.LoadAcc(""));
|
||||||
|
|
||||||
|
// SaveAcc with key shorter than 2 chars should throw
|
||||||
|
Should.Throw<Exception>(() => store.SaveAcc("", "some.jwt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 26
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_MissingKey_ReturnsError()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
|
||||||
|
Should.Throw<FileNotFoundException>(() => store.LoadAcc("NONEXISTENT_KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 27
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_Pack_ContainsSavedJwts()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
|
||||||
|
store.SaveAcc("ACCTKEYAAA", "jwt1.data.sig");
|
||||||
|
store.SaveAcc("ACCTKEYBBB", "jwt2.data.sig");
|
||||||
|
|
||||||
|
var packed = store.Pack(-1);
|
||||||
|
|
||||||
|
packed.ShouldContain("ACCTKEYAAA|jwt1.data.sig");
|
||||||
|
packed.ShouldContain("ACCTKEYBBB|jwt2.data.sig");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 28
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_Merge_AddsNewEntries()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
using var store = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
|
||||||
|
// Pack format: key|jwt lines separated by newline
|
||||||
|
var packData = "ACCTKEYMERGE|merged.jwt.value";
|
||||||
|
store.Merge(packData);
|
||||||
|
|
||||||
|
var loaded = store.LoadAcc("ACCTKEYMERGE");
|
||||||
|
loaded.ShouldBe("merged.jwt.value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 29
|
||||||
|
[Fact]
|
||||||
|
public void DirJwtStore_ReadOnly_Prevents_Write()
|
||||||
|
{
|
||||||
|
var dir = MakeTempDir();
|
||||||
|
// Write a file first so the dir is valid
|
||||||
|
var writeable = DirJwtStore.NewDirJwtStore(dir, shard: false, create: false);
|
||||||
|
writeable.SaveAcc("ACCTKEYRO", "original.jwt");
|
||||||
|
writeable.Dispose();
|
||||||
|
|
||||||
|
// Open as immutable
|
||||||
|
using var readOnly = DirJwtStore.NewImmutableDirJwtStore(dir, shard: false);
|
||||||
|
|
||||||
|
readOnly.IsReadOnly().ShouldBeTrue();
|
||||||
|
Should.Throw<InvalidOperationException>(() => readOnly.SaveAcc("ACCTKEYRO", "new.jwt"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// MemoryAccountResolver Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Collection("AccountTests")]
|
||||||
|
public sealed class MemoryAccountResolverTests
|
||||||
|
{
|
||||||
|
// Test 30
|
||||||
|
[Fact]
|
||||||
|
public async Task MemoryAccountResolver_StoreAndFetch_Roundtrip()
|
||||||
|
{
|
||||||
|
var resolver = new MemoryAccountResolver();
|
||||||
|
const string key = "MYACCOUNTKEY";
|
||||||
|
const string jwt = "header.payload.sig";
|
||||||
|
|
||||||
|
await resolver.StoreAsync(key, jwt);
|
||||||
|
var fetched = await resolver.FetchAsync(key);
|
||||||
|
|
||||||
|
fetched.ShouldBe(jwt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 31
|
||||||
|
[Fact]
|
||||||
|
public async Task MemoryAccountResolver_Fetch_MissingKey_Throws()
|
||||||
|
{
|
||||||
|
var resolver = new MemoryAccountResolver();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
() => resolver.FetchAsync("DOESNOTEXIST"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 32
|
||||||
|
[Fact]
|
||||||
|
public void MemoryAccountResolver_IsReadOnly_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var resolver = new MemoryAccountResolver();
|
||||||
|
|
||||||
|
resolver.IsReadOnly().ShouldBeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// UrlAccountResolver Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
[Collection("AccountTests")]
|
||||||
|
public sealed class UrlAccountResolverTests
|
||||||
|
{
|
||||||
|
// Test 33
|
||||||
|
[Fact]
|
||||||
|
public void UrlAccountResolver_NormalizesTrailingSlash()
|
||||||
|
{
|
||||||
|
// Two constructors: one with slash, one without.
|
||||||
|
// We verify construction doesn't throw and the resolver is usable.
|
||||||
|
// (We cannot inspect _url directly since it's private, but we can
|
||||||
|
// infer correctness via IsReadOnly and lack of constructor exception.)
|
||||||
|
var resolverNoSlash = new UrlAccountResolver("http://localhost:9090");
|
||||||
|
var resolverWithSlash = new UrlAccountResolver("http://localhost:9090/");
|
||||||
|
|
||||||
|
// Both should construct without error and have the same observable behaviour.
|
||||||
|
resolverNoSlash.IsReadOnly().ShouldBeTrue();
|
||||||
|
resolverWithSlash.IsReadOnly().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 34
|
||||||
|
[Fact]
|
||||||
|
public void UrlAccountResolver_IsReadOnly_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var resolver = new UrlAccountResolver("http://localhost:9090");
|
||||||
|
|
||||||
|
resolver.IsReadOnly().ShouldBeTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
porting.db
BIN
porting.db
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
# NATS .NET Porting Status Report
|
# NATS .NET Porting Status Report
|
||||||
|
|
||||||
Generated: 2026-02-26 20:08:24 UTC
|
Generated: 2026-02-26 20:37:09 UTC
|
||||||
|
|
||||||
## Modules (12 total)
|
## Modules (12 total)
|
||||||
|
|
||||||
@@ -13,18 +13,18 @@ Generated: 2026-02-26 20:08:24 UTC
|
|||||||
|
|
||||||
| Status | Count |
|
| Status | Count |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| complete | 841 |
|
| complete | 1075 |
|
||||||
| n_a | 82 |
|
| n_a | 82 |
|
||||||
| not_started | 2657 |
|
| not_started | 2423 |
|
||||||
| stub | 93 |
|
| stub | 93 |
|
||||||
|
|
||||||
## Unit Tests (3257 total)
|
## Unit Tests (3257 total)
|
||||||
|
|
||||||
| Status | Count |
|
| Status | Count |
|
||||||
|--------|-------|
|
|--------|-------|
|
||||||
| complete | 278 |
|
| complete | 319 |
|
||||||
| n_a | 181 |
|
| n_a | 181 |
|
||||||
| not_started | 2574 |
|
| not_started | 2533 |
|
||||||
| stub | 224 |
|
| stub | 224 |
|
||||||
|
|
||||||
## Library Mappings (36 total)
|
## Library Mappings (36 total)
|
||||||
@@ -36,4 +36,4 @@ Generated: 2026-02-26 20:08:24 UTC
|
|||||||
|
|
||||||
## Overall Progress
|
## Overall Progress
|
||||||
|
|
||||||
**1393/6942 items complete (20.1%)**
|
**1668/6942 items complete (24.0%)**
|
||||||
|
|||||||
39
reports/report_06779a1.md
Normal file
39
reports/report_06779a1.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# NATS .NET Porting Status Report
|
||||||
|
|
||||||
|
Generated: 2026-02-26 20:37:09 UTC
|
||||||
|
|
||||||
|
## Modules (12 total)
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| complete | 11 |
|
||||||
|
| not_started | 1 |
|
||||||
|
|
||||||
|
## Features (3673 total)
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| complete | 1075 |
|
||||||
|
| n_a | 82 |
|
||||||
|
| not_started | 2423 |
|
||||||
|
| stub | 93 |
|
||||||
|
|
||||||
|
## Unit Tests (3257 total)
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| complete | 319 |
|
||||||
|
| n_a | 181 |
|
||||||
|
| not_started | 2533 |
|
||||||
|
| stub | 224 |
|
||||||
|
|
||||||
|
## Library Mappings (36 total)
|
||||||
|
|
||||||
|
| Status | Count |
|
||||||
|
|--------|-------|
|
||||||
|
| mapped | 36 |
|
||||||
|
|
||||||
|
|
||||||
|
## Overall Progress
|
||||||
|
|
||||||
|
**1668/6942 items complete (24.0%)**
|
||||||
Reference in New Issue
Block a user